@simplix-react/contract 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+
3
+ interface EntityParent {
4
+ param: string;
5
+ path: string;
6
+ }
7
+ interface EntityQuery {
8
+ parent: string;
9
+ param: string;
10
+ }
11
+ interface EntityDefinition<TSchema extends z.ZodType = z.ZodType, TCreate extends z.ZodType = z.ZodType, TUpdate extends z.ZodType = z.ZodType> {
12
+ path: string;
13
+ schema: TSchema;
14
+ createSchema: TCreate;
15
+ updateSchema: TUpdate;
16
+ parent?: EntityParent;
17
+ queries?: Record<string, EntityQuery>;
18
+ }
19
+ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
20
+ interface OperationDefinition<TInput extends z.ZodType = z.ZodType, TOutput extends z.ZodType = z.ZodType> {
21
+ method: HttpMethod;
22
+ path: string;
23
+ input: TInput;
24
+ output: TOutput;
25
+ invalidates?: (queryKeys: Record<string, QueryKeyFactory>, params: Record<string, string>) => readonly unknown[][];
26
+ }
27
+ interface ApiContractConfig<TEntities extends Record<string, EntityDefinition<any, any, any>> = Record<string, EntityDefinition>, TOperations extends Record<string, OperationDefinition<any, any>> = Record<string, OperationDefinition>> {
28
+ domain: string;
29
+ basePath: string;
30
+ entities: TEntities;
31
+ operations?: TOperations;
32
+ }
33
+ interface QueryKeyFactory {
34
+ all: readonly unknown[];
35
+ lists: () => readonly unknown[];
36
+ list: (params: Record<string, unknown>) => readonly unknown[];
37
+ details: () => readonly unknown[];
38
+ detail: (id: string) => readonly unknown[];
39
+ }
40
+ interface EntityClient<TSchema extends z.ZodType, TCreate extends z.ZodType, TUpdate extends z.ZodType> {
41
+ list: (parentId?: string) => Promise<z.infer<TSchema>[]>;
42
+ get: (id: string) => Promise<z.infer<TSchema>>;
43
+ create: (parentIdOrDto: string | z.infer<TCreate>, dto?: z.infer<TCreate>) => Promise<z.infer<TSchema>>;
44
+ update: (id: string, dto: z.infer<TUpdate>) => Promise<z.infer<TSchema>>;
45
+ delete: (id: string) => Promise<void>;
46
+ }
47
+ type FetchFn = <T>(path: string, options?: RequestInit) => Promise<T>;
48
+ interface ApiContract<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>>> {
49
+ config: ApiContractConfig<TEntities, TOperations>;
50
+ client: {
51
+ [K in keyof TEntities]: EntityClient<TEntities[K]["schema"], TEntities[K]["createSchema"], TEntities[K]["updateSchema"]>;
52
+ } & {
53
+ [K in keyof TOperations]: TOperations[K] extends OperationDefinition<infer _TInput, infer TOutput> ? (...args: unknown[]) => Promise<z.infer<TOutput>> : never;
54
+ };
55
+ queryKeys: {
56
+ [K in keyof TEntities]: QueryKeyFactory;
57
+ };
58
+ }
59
+
60
+ declare function defineApi<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>> = Record<string, never>>(config: ApiContractConfig<TEntities, TOperations>, options?: {
61
+ fetchFn?: FetchFn;
62
+ }): {
63
+ config: ApiContractConfig<TEntities, TOperations>;
64
+ client: Record<string, unknown>;
65
+ queryKeys: { [K in keyof TEntities]: QueryKeyFactory; };
66
+ };
67
+
68
+ declare function deriveClient<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>>>(config: ApiContractConfig<TEntities, TOperations>, fetchFn?: FetchFn): Record<string, unknown>;
69
+
70
+ declare function deriveQueryKeys<TEntities extends Record<string, EntityDefinition<any, any, any>>>(config: Pick<ApiContractConfig<TEntities>, "domain" | "entities">): {
71
+ [K in keyof TEntities]: QueryKeyFactory;
72
+ };
73
+
74
+ /**
75
+ * Build URL path with parameter substitution.
76
+ * buildPath("/topologies/:topologyId/controllers", { topologyId: "abc" })
77
+ * -> "/topologies/abc/controllers"
78
+ */
79
+ declare function buildPath(template: string, params?: Record<string, string>): string;
80
+
81
+ declare class ApiError extends Error {
82
+ readonly status: number;
83
+ readonly body: string;
84
+ constructor(status: number, body: string);
85
+ }
86
+ /**
87
+ * Default fetch function that unwraps { data: T } envelope.
88
+ */
89
+ declare function defaultFetch<T>(path: string, options?: RequestInit): Promise<T>;
90
+
91
+ /**
92
+ * Convert camelCase to kebab-case.
93
+ * "doorReader" -> "door-reader"
94
+ */
95
+ declare function camelToKebab(str: string): string;
96
+ /**
97
+ * Convert camelCase to snake_case.
98
+ * "doorReader" -> "door_reader"
99
+ */
100
+ declare function camelToSnake(str: string): string;
101
+
102
+ export { type ApiContract, type ApiContractConfig, ApiError, type EntityClient, type EntityDefinition, type EntityParent, type EntityQuery, type FetchFn, type HttpMethod, type OperationDefinition, type QueryKeyFactory, buildPath, camelToKebab, camelToSnake, defaultFetch, defineApi, deriveClient, deriveQueryKeys };
package/dist/index.js ADDED
@@ -0,0 +1,148 @@
1
+ // src/helpers/path-builder.ts
2
+ function buildPath(template, params = {}) {
3
+ let result = template;
4
+ for (const [key, value] of Object.entries(params)) {
5
+ result = result.replace(`:${key}`, encodeURIComponent(value));
6
+ }
7
+ return result;
8
+ }
9
+
10
+ // src/helpers/fetch.ts
11
+ var ApiError = class extends Error {
12
+ constructor(status, body) {
13
+ super(`API Error ${status}: ${body}`);
14
+ this.status = status;
15
+ this.body = body;
16
+ this.name = "ApiError";
17
+ }
18
+ };
19
+ async function defaultFetch(path, options) {
20
+ const res = await fetch(path, {
21
+ ...options,
22
+ headers: {
23
+ "Content-Type": "application/json",
24
+ ...options?.headers
25
+ }
26
+ });
27
+ if (!res.ok) {
28
+ throw new ApiError(res.status, await res.text());
29
+ }
30
+ if (res.status === 204) {
31
+ return void 0;
32
+ }
33
+ const json = await res.json();
34
+ return json.data !== void 0 ? json.data : json;
35
+ }
36
+
37
+ // src/derive/client.ts
38
+ function deriveClient(config, fetchFn = defaultFetch) {
39
+ const { basePath, entities, operations } = config;
40
+ const result = {};
41
+ for (const [name, entity] of Object.entries(entities)) {
42
+ result[name] = createEntityClient(basePath, entity, fetchFn);
43
+ }
44
+ if (operations) {
45
+ for (const [name, operation] of Object.entries(operations)) {
46
+ result[name] = createOperationClient(basePath, operation, fetchFn);
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+ function createEntityClient(basePath, entity, fetchFn) {
52
+ const { path, parent } = entity;
53
+ return {
54
+ list(parentId) {
55
+ const url = parent && parentId ? `${basePath}${parent.path}/${parentId}${path}` : `${basePath}${path}`;
56
+ return fetchFn(url);
57
+ },
58
+ get(id) {
59
+ return fetchFn(`${basePath}${path}/${id}`);
60
+ },
61
+ create(parentIdOrDto, dto) {
62
+ if (parent && typeof parentIdOrDto === "string") {
63
+ const url2 = `${basePath}${parent.path}/${parentIdOrDto}${path}`;
64
+ return fetchFn(url2, {
65
+ method: "POST",
66
+ body: JSON.stringify(dto)
67
+ });
68
+ }
69
+ const url = `${basePath}${path}`;
70
+ return fetchFn(url, {
71
+ method: "POST",
72
+ body: JSON.stringify(parentIdOrDto)
73
+ });
74
+ },
75
+ update(id, dto) {
76
+ return fetchFn(`${basePath}${path}/${id}`, {
77
+ method: "PATCH",
78
+ body: JSON.stringify(dto)
79
+ });
80
+ },
81
+ delete(id) {
82
+ return fetchFn(`${basePath}${path}/${id}`, { method: "DELETE" });
83
+ }
84
+ };
85
+ }
86
+ function createOperationClient(basePath, operation, fetchFn) {
87
+ return (...args) => {
88
+ const pathParams = {};
89
+ const paramNames = (operation.path.match(/:(\w+)/g) ?? []).map(
90
+ (p) => p.slice(1)
91
+ );
92
+ let inputData = void 0;
93
+ let argIndex = 0;
94
+ for (const paramName of paramNames) {
95
+ if (argIndex < args.length) {
96
+ pathParams[paramName] = String(args[argIndex]);
97
+ argIndex++;
98
+ }
99
+ }
100
+ if (argIndex < args.length) {
101
+ inputData = args[argIndex];
102
+ }
103
+ const url = `${basePath}${buildPath(operation.path, pathParams)}`;
104
+ const options = { method: operation.method };
105
+ if (inputData !== void 0 && operation.method !== "GET") {
106
+ options.body = JSON.stringify(inputData);
107
+ }
108
+ return fetchFn(url, options);
109
+ };
110
+ }
111
+
112
+ // src/derive/query-keys.ts
113
+ function deriveQueryKeys(config) {
114
+ const { domain, entities } = config;
115
+ const result = {};
116
+ for (const entityName of Object.keys(entities)) {
117
+ result[entityName] = createQueryKeyFactory(domain, entityName);
118
+ }
119
+ return result;
120
+ }
121
+ function createQueryKeyFactory(domain, entity) {
122
+ return {
123
+ all: [domain, entity],
124
+ lists: () => [domain, entity, "list"],
125
+ list: (params) => [domain, entity, "list", params],
126
+ details: () => [domain, entity, "detail"],
127
+ detail: (id) => [domain, entity, "detail", id]
128
+ };
129
+ }
130
+
131
+ // src/define-api.ts
132
+ function defineApi(config, options) {
133
+ return {
134
+ config,
135
+ client: deriveClient(config, options?.fetchFn),
136
+ queryKeys: deriveQueryKeys(config)
137
+ };
138
+ }
139
+
140
+ // src/helpers/case-transform.ts
141
+ function camelToKebab(str) {
142
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
143
+ }
144
+ function camelToSnake(str) {
145
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
146
+ }
147
+
148
+ export { ApiError, buildPath, camelToKebab, camelToSnake, defaultFetch, defineApi, deriveClient, deriveQueryKeys };
@@ -0,0 +1,14 @@
1
+ import { E as EntityDefinition, O as OperationDefinition, A as ApiContractConfig } from './types-tFXBXgJP.js';
2
+ import 'zod';
3
+
4
+ interface MCPToolDefinition {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: Record<string, unknown>;
8
+ }
9
+ /**
10
+ * Derive MCP tool definitions from contract config.
11
+ */
12
+ declare function deriveMCPTools<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>>>(config: ApiContractConfig<TEntities, TOperations>): MCPToolDefinition[];
13
+
14
+ export { type MCPToolDefinition, deriveMCPTools };
@@ -0,0 +1,62 @@
1
+ // src/derive/mcp-tools.ts
2
+ function deriveMCPTools(config) {
3
+ const tools = [];
4
+ for (const [name, entity] of Object.entries(config.entities)) {
5
+ const capitalName = name.charAt(0).toUpperCase() + name.slice(1);
6
+ tools.push(
7
+ {
8
+ name: `list${capitalName}s`,
9
+ description: `List all ${name} entities`,
10
+ inputSchema: entity.parent ? {
11
+ type: "object",
12
+ properties: { [entity.parent.param]: { type: "string" } },
13
+ required: [entity.parent.param]
14
+ } : { type: "object", properties: {} }
15
+ },
16
+ {
17
+ name: `get${capitalName}`,
18
+ description: `Get a single ${name} by ID`,
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: { id: { type: "string" } },
22
+ required: ["id"]
23
+ }
24
+ },
25
+ {
26
+ name: `create${capitalName}`,
27
+ description: `Create a new ${name}`,
28
+ inputSchema: { type: "object", properties: {} }
29
+ },
30
+ {
31
+ name: `update${capitalName}`,
32
+ description: `Update an existing ${name}`,
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: { id: { type: "string" } },
36
+ required: ["id"]
37
+ }
38
+ },
39
+ {
40
+ name: `delete${capitalName}`,
41
+ description: `Delete a ${name}`,
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { id: { type: "string" } },
45
+ required: ["id"]
46
+ }
47
+ }
48
+ );
49
+ }
50
+ if (config.operations) {
51
+ for (const [name, op] of Object.entries(config.operations)) {
52
+ tools.push({
53
+ name,
54
+ description: `${op.method} ${op.path}`,
55
+ inputSchema: { type: "object", properties: {} }
56
+ });
57
+ }
58
+ }
59
+ return tools;
60
+ }
61
+
62
+ export { deriveMCPTools };
@@ -0,0 +1,21 @@
1
+ import { E as EntityDefinition, O as OperationDefinition, A as ApiContractConfig } from './types-tFXBXgJP.js';
2
+ import 'zod';
3
+
4
+ interface OpenAPISpec {
5
+ openapi: "3.1.0";
6
+ info: {
7
+ title: string;
8
+ version: string;
9
+ };
10
+ paths: Record<string, unknown>;
11
+ }
12
+ /**
13
+ * Derive OpenAPI 3.1 specification from contract config.
14
+ * Full implementation requires zod-to-json-schema.
15
+ */
16
+ declare function deriveOpenAPI<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>>>(config: ApiContractConfig<TEntities, TOperations>, options?: {
17
+ title?: string;
18
+ version?: string;
19
+ }): OpenAPISpec;
20
+
21
+ export { type OpenAPISpec, deriveOpenAPI };
@@ -0,0 +1,54 @@
1
+ // src/derive/openapi.ts
2
+ function deriveOpenAPI(config, options) {
3
+ const title = options?.title ?? config.domain;
4
+ const version = options?.version ?? "0.1.0";
5
+ const paths = {};
6
+ for (const [name, entity] of Object.entries(config.entities)) {
7
+ const entityPath = entity.parent ? `${config.basePath}${entity.parent.path}/{${entity.parent.param}}${entity.path}` : `${config.basePath}${entity.path}`;
8
+ const capitalName = name.charAt(0).toUpperCase() + name.slice(1);
9
+ paths[entityPath] = {
10
+ get: {
11
+ summary: `List ${name}`,
12
+ operationId: `list${capitalName}`,
13
+ responses: { "200": { description: "Success" } }
14
+ },
15
+ post: {
16
+ summary: `Create ${name}`,
17
+ operationId: `create${capitalName}`,
18
+ responses: { "201": { description: "Created" } }
19
+ }
20
+ };
21
+ paths[`${config.basePath}${entity.path}/{id}`] = {
22
+ get: {
23
+ summary: `Get ${name}`,
24
+ operationId: `get${capitalName}`,
25
+ responses: { "200": { description: "Success" } }
26
+ },
27
+ patch: {
28
+ summary: `Update ${name}`,
29
+ operationId: `update${capitalName}`,
30
+ responses: { "200": { description: "Success" } }
31
+ },
32
+ delete: {
33
+ summary: `Delete ${name}`,
34
+ operationId: `delete${capitalName}`,
35
+ responses: { "204": { description: "Deleted" } }
36
+ }
37
+ };
38
+ }
39
+ if (config.operations) {
40
+ for (const [name, op] of Object.entries(config.operations)) {
41
+ const opPath = `${config.basePath}${op.path}`;
42
+ paths[opPath] = {
43
+ [op.method.toLowerCase()]: {
44
+ summary: name,
45
+ operationId: name,
46
+ responses: { "200": { description: "Success" } }
47
+ }
48
+ };
49
+ }
50
+ }
51
+ return { openapi: "3.1.0", info: { title, version }, paths };
52
+ }
53
+
54
+ export { deriveOpenAPI };
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+
3
+ interface EntityParent {
4
+ param: string;
5
+ path: string;
6
+ }
7
+ interface EntityQuery {
8
+ parent: string;
9
+ param: string;
10
+ }
11
+ interface EntityDefinition<TSchema extends z.ZodType = z.ZodType, TCreate extends z.ZodType = z.ZodType, TUpdate extends z.ZodType = z.ZodType> {
12
+ path: string;
13
+ schema: TSchema;
14
+ createSchema: TCreate;
15
+ updateSchema: TUpdate;
16
+ parent?: EntityParent;
17
+ queries?: Record<string, EntityQuery>;
18
+ }
19
+ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
20
+ interface OperationDefinition<TInput extends z.ZodType = z.ZodType, TOutput extends z.ZodType = z.ZodType> {
21
+ method: HttpMethod;
22
+ path: string;
23
+ input: TInput;
24
+ output: TOutput;
25
+ invalidates?: (queryKeys: Record<string, QueryKeyFactory>, params: Record<string, string>) => readonly unknown[][];
26
+ }
27
+ interface ApiContractConfig<TEntities extends Record<string, EntityDefinition<any, any, any>> = Record<string, EntityDefinition>, TOperations extends Record<string, OperationDefinition<any, any>> = Record<string, OperationDefinition>> {
28
+ domain: string;
29
+ basePath: string;
30
+ entities: TEntities;
31
+ operations?: TOperations;
32
+ }
33
+ interface QueryKeyFactory {
34
+ all: readonly unknown[];
35
+ lists: () => readonly unknown[];
36
+ list: (params: Record<string, unknown>) => readonly unknown[];
37
+ details: () => readonly unknown[];
38
+ detail: (id: string) => readonly unknown[];
39
+ }
40
+
41
+ export type { ApiContractConfig as A, EntityDefinition as E, OperationDefinition as O };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@simplix-react/contract",
3
+ "version": "0.0.1",
4
+ "description": "Define type-safe API contracts with Zod schemas",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./openapi": {
12
+ "types": "./dist/openapi.d.ts",
13
+ "import": "./dist/openapi.js"
14
+ },
15
+ "./mcp": {
16
+ "types": "./dist/mcp-tools.d.ts",
17
+ "import": "./dist/mcp-tools.js"
18
+ }
19
+ },
20
+ "files": ["dist"],
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "dev": "tsup --watch",
24
+ "typecheck": "tsc --noEmit",
25
+ "lint": "eslint src",
26
+ "test": "vitest run --passWithNoTests",
27
+ "clean": "rm -rf dist .turbo"
28
+ },
29
+ "peerDependencies": {
30
+ "zod": ">=4.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@simplix-react/config-typescript": "workspace:*",
34
+ "eslint": "^9.39.2",
35
+ "tsup": "^8.5.1",
36
+ "typescript": "^5.9.3",
37
+ "vitest": "^3.0.0",
38
+ "zod": "^4.0.0"
39
+ }
40
+ }