@simplix-react/contract 0.0.1 → 0.0.2

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.
package/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # @simplix-react/contract
2
+
3
+ Define type-safe API contracts with Zod schemas. A single contract drives your HTTP client, React Query hooks, and MSW mock handlers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @simplix-react/contract
9
+ ```
10
+
11
+ **Peer dependency:** `zod >= 4.0.0`
12
+
13
+ ```bash
14
+ pnpm add zod
15
+ ```
16
+
17
+ ## Quick Example
18
+
19
+ ```ts
20
+ import { z } from "zod";
21
+ import { defineApi, simpleQueryBuilder } from "@simplix-react/contract";
22
+
23
+ // 1. Define your contract
24
+ const projectApi = defineApi({
25
+ domain: "project",
26
+ basePath: "/api/v1",
27
+ entities: {
28
+ task: {
29
+ path: "/tasks",
30
+ schema: z.object({
31
+ id: z.string(),
32
+ title: z.string(),
33
+ status: z.enum(["open", "closed"]),
34
+ }),
35
+ createSchema: z.object({
36
+ title: z.string(),
37
+ }),
38
+ updateSchema: z.object({
39
+ title: z.string().optional(),
40
+ status: z.enum(["open", "closed"]).optional(),
41
+ }),
42
+ },
43
+ },
44
+ queryBuilder: simpleQueryBuilder,
45
+ });
46
+
47
+ // 2. Use the type-safe client
48
+ const tasks = await projectApi.client.task.list();
49
+ const task = await projectApi.client.task.get("task-1");
50
+ const created = await projectApi.client.task.create({ title: "New task" });
51
+ const updated = await projectApi.client.task.update("task-1", { status: "closed" });
52
+ await projectApi.client.task.delete("task-1");
53
+
54
+ // 3. Use query keys for cache management
55
+ projectApi.queryKeys.task.all; // ["project", "task"]
56
+ projectApi.queryKeys.task.lists(); // ["project", "task", "list"]
57
+ projectApi.queryKeys.task.list({ status: "open" }); // ["project", "task", "list", { status: "open" }]
58
+ projectApi.queryKeys.task.detail("task-1"); // ["project", "task", "detail", "task-1"]
59
+ ```
60
+
61
+ ## API Overview
62
+
63
+ | Export | Kind | Description |
64
+ | --- | --- | --- |
65
+ | `defineApi` | Function | Creates a contract with client and query keys from a config |
66
+ | `deriveClient` | Function | Generates a type-safe HTTP client from a contract config |
67
+ | `deriveQueryKeys` | Function | Generates TanStack Query key factories from a contract config |
68
+ | `buildPath` | Function | Substitutes `:param` placeholders in URL templates |
69
+ | `defaultFetch` | Function | Built-in fetch with JSON content-type and `{ data }` envelope unwrapping |
70
+ | `ApiError` | Class | Error type for non-2xx HTTP responses |
71
+ | `simpleQueryBuilder` | Object | Ready-made `QueryBuilder` for common REST query string patterns |
72
+ | `camelToKebab` | Function | Converts camelCase to kebab-case |
73
+ | `camelToSnake` | Function | Converts camelCase to snake_case |
74
+
75
+ ### Type Exports
76
+
77
+ | Export | Description |
78
+ | --- | --- |
79
+ | `EntityDefinition` | Describes a CRUD entity with Zod schemas |
80
+ | `EntityParent` | Parent resource for nested URL construction |
81
+ | `EntityQuery` | Named query scope filtering by parent relationship |
82
+ | `OperationDefinition` | Describes a custom non-CRUD API operation |
83
+ | `HttpMethod` | Union of supported HTTP methods |
84
+ | `ApiContractConfig` | Full configuration input for `defineApi` |
85
+ | `ApiContract` | Return type of `defineApi` |
86
+ | `EntityClient` | CRUD client interface for a single entity |
87
+ | `QueryKeyFactory` | Structured query key generators for TanStack Query |
88
+ | `FetchFn` | Custom fetch function signature |
89
+ | `ListParams` | Filters, sort, and pagination parameters for list queries |
90
+ | `SortParam` | Sort field and direction |
91
+ | `PaginationParam` | Offset-based or cursor-based pagination |
92
+ | `PageInfo` | Server-returned pagination metadata |
93
+ | `QueryBuilder` | Interface for serializing list params to URL search params |
94
+
95
+ ## Key Concepts
96
+
97
+ ### Zod Schema → Type Inference
98
+
99
+ Every contract type is inferred from Zod schemas at compile time. You define schemas once; TypeScript infers the rest:
100
+
101
+ ```ts
102
+ import { z } from "zod";
103
+
104
+ const taskSchema = z.object({
105
+ id: z.string(),
106
+ title: z.string(),
107
+ status: z.enum(["open", "closed"]),
108
+ });
109
+
110
+ // z.infer<typeof taskSchema> → { id: string; title: string; status: "open" | "closed" }
111
+ ```
112
+
113
+ The framework uses these inferred types throughout the client, hooks, and mock handlers, so your API types are always in sync with validation logic.
114
+
115
+ ### EntityDefinition
116
+
117
+ An `EntityDefinition` is the building block for CRUD resources. It bundles three schemas:
118
+
119
+ - **`schema`** — The full entity shape returned by the API
120
+ - **`createSchema`** — The payload required to create a new entity
121
+ - **`updateSchema`** — The payload for partial updates
122
+
123
+ ```ts
124
+ const taskEntity = {
125
+ path: "/tasks",
126
+ schema: taskSchema,
127
+ createSchema: z.object({ title: z.string() }),
128
+ updateSchema: z.object({ title: z.string().optional() }),
129
+ };
130
+ ```
131
+
132
+ This single definition drives `list`, `get`, `create`, `update`, and `delete` methods on the client.
133
+
134
+ ### OperationDefinition
135
+
136
+ For endpoints that don't fit the CRUD pattern (file uploads, RPC-style calls, batch operations), use `OperationDefinition`:
137
+
138
+ ```ts
139
+ import { z } from "zod";
140
+ import { defineApi } from "@simplix-react/contract";
141
+
142
+ const api = defineApi({
143
+ domain: "project",
144
+ basePath: "/api/v1",
145
+ entities: { task: taskEntity },
146
+ operations: {
147
+ assignTask: {
148
+ method: "POST",
149
+ path: "/tasks/:taskId/assign",
150
+ input: z.object({ userId: z.string() }),
151
+ output: z.object({ id: z.string(), assigneeId: z.string() }),
152
+ },
153
+ exportReport: {
154
+ method: "GET",
155
+ path: "/projects/:projectId/export",
156
+ input: z.object({}),
157
+ output: z.any(),
158
+ responseType: "blob",
159
+ },
160
+ },
161
+ });
162
+
163
+ // Path params are positional arguments, input is the last argument
164
+ await api.client.assignTask("task-1", { userId: "user-42" });
165
+ ```
166
+
167
+ ### Nested Entities (Parent Relationships)
168
+
169
+ Entities can declare a `parent` for nested URL construction:
170
+
171
+ ```ts
172
+ const taskEntity = {
173
+ path: "/tasks",
174
+ schema: taskSchema,
175
+ createSchema: createTaskSchema,
176
+ updateSchema: updateTaskSchema,
177
+ parent: { param: "projectId", path: "/projects" },
178
+ };
179
+
180
+ // Client adjusts URLs based on parent
181
+ await api.client.task.list("project-1");
182
+ // GET /api/v1/projects/project-1/tasks
183
+
184
+ await api.client.task.create("project-1", { title: "New task" });
185
+ // POST /api/v1/projects/project-1/tasks
186
+ ```
187
+
188
+ ### Query Keys
189
+
190
+ The contract automatically generates TanStack Query-compatible key factories with hierarchical structure:
191
+
192
+ ```
193
+ task.all → ["project", "task"] (broadest)
194
+ task.lists() → ["project", "task", "list"]
195
+ task.list({ ... }) → ["project", "task", "list", { ... }]
196
+ task.details() → ["project", "task", "detail"]
197
+ task.detail("id") → ["project", "task", "detail", "id"] (most specific)
198
+ ```
199
+
200
+ Invalidating a broader key automatically invalidates all more-specific keys beneath it.
201
+
202
+ ## Guides
203
+
204
+ ### Custom Fetch Function
205
+
206
+ Replace the built-in HTTP client with custom logic for authentication, logging, or retry:
207
+
208
+ ```ts
209
+ import { defineApi, defaultFetch } from "@simplix-react/contract";
210
+
211
+ const api = defineApi(config, {
212
+ fetchFn: async (path, options) => {
213
+ const token = await getAuthToken();
214
+ return defaultFetch(path, {
215
+ ...options,
216
+ headers: {
217
+ ...options?.headers,
218
+ Authorization: `Bearer ${token}`,
219
+ },
220
+ });
221
+ },
222
+ });
223
+ ```
224
+
225
+ ### Custom Query Builder
226
+
227
+ Implement the `QueryBuilder` interface to match your API's query string conventions:
228
+
229
+ ```ts
230
+ import { defineApi } from "@simplix-react/contract";
231
+ import type { QueryBuilder } from "@simplix-react/contract";
232
+
233
+ const springQueryBuilder: QueryBuilder = {
234
+ buildSearchParams(params) {
235
+ const sp = new URLSearchParams();
236
+ if (params.pagination?.type === "offset") {
237
+ // Spring uses 0-based page indexing
238
+ sp.set("page", String(params.pagination.page - 1));
239
+ sp.set("size", String(params.pagination.limit));
240
+ }
241
+ if (params.sort) {
242
+ const sorts = Array.isArray(params.sort) ? params.sort : [params.sort];
243
+ for (const s of sorts) {
244
+ sp.append("sort", `${s.field},${s.direction}`);
245
+ }
246
+ }
247
+ return sp;
248
+ },
249
+ };
250
+
251
+ const api = defineApi({
252
+ domain: "project",
253
+ basePath: "/api/v1",
254
+ entities: { task: taskEntity },
255
+ queryBuilder: springQueryBuilder,
256
+ });
257
+ ```
258
+
259
+ ### Multipart File Uploads
260
+
261
+ Use `contentType: "multipart"` in an operation definition for file upload endpoints:
262
+
263
+ ```ts
264
+ const api = defineApi({
265
+ domain: "project",
266
+ basePath: "/api/v1",
267
+ entities: {},
268
+ operations: {
269
+ uploadAvatar: {
270
+ method: "POST",
271
+ path: "/users/:userId/avatar",
272
+ input: z.object({ file: z.instanceof(File) }),
273
+ output: z.object({ url: z.string() }),
274
+ contentType: "multipart",
275
+ },
276
+ },
277
+ });
278
+
279
+ await api.client.uploadAvatar("user-1", { file: selectedFile });
280
+ ```
281
+
282
+ ### Error Handling
283
+
284
+ All client methods throw `ApiError` for non-2xx responses:
285
+
286
+ ```ts
287
+ import { ApiError } from "@simplix-react/contract";
288
+
289
+ try {
290
+ await api.client.task.get("nonexistent");
291
+ } catch (error) {
292
+ if (error instanceof ApiError) {
293
+ console.log(error.status); // 404
294
+ console.log(error.body); // Raw response body
295
+ }
296
+ }
297
+ ```
298
+
299
+ ## Related Packages
300
+
301
+ | Package | Description |
302
+ | --- | --- |
303
+ | `@simplix-react/react` | Derives React Query hooks from the contract |
304
+ | `@simplix-react/mock` | Generates MSW handlers and PGlite repositories from the contract |
305
+ | `@simplix-react/testing` | Test utilities built on top of the contract and mock packages |
306
+
307
+ ---
308
+
309
+ **Next Step** → [`@simplix-react/react`](../react/README.md) — Turn your contract into React Query hooks.
package/dist/index.d.ts CHANGED
@@ -1,62 +1,470 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ /**
4
+ * Describes a single sort directive with field name and direction.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import type { SortParam } from "@simplix-react/contract";
9
+ *
10
+ * const sort: SortParam = { field: "createdAt", direction: "desc" };
11
+ * ```
12
+ */
13
+ interface SortParam {
14
+ /** The field name to sort by. */
15
+ field: string;
16
+ /** Sort direction: ascending or descending. */
17
+ direction: "asc" | "desc";
18
+ }
19
+ /**
20
+ * Describes pagination strategy, supporting both offset-based and cursor-based patterns.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import type { PaginationParam } from "@simplix-react/contract";
25
+ *
26
+ * // Offset-based
27
+ * const offset: PaginationParam = { type: "offset", page: 1, limit: 20 };
28
+ *
29
+ * // Cursor-based
30
+ * const cursor: PaginationParam = { type: "cursor", cursor: "abc123", limit: 20 };
31
+ * ```
32
+ */
33
+ type PaginationParam = {
34
+ type: "offset";
35
+ page: number;
36
+ limit: number;
37
+ } | {
38
+ type: "cursor";
39
+ cursor: string;
40
+ limit: number;
41
+ };
42
+ /**
43
+ * Encapsulates all list query parameters: filters, sorting, and pagination.
44
+ *
45
+ * Passed to entity `list()` methods and serialized into URL search params
46
+ * by a {@link QueryBuilder}.
47
+ *
48
+ * @typeParam TFilters - Shape of the filter object, defaults to `Record<string, unknown>`.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * import type { ListParams } from "@simplix-react/contract";
53
+ *
54
+ * const params: ListParams = {
55
+ * filters: { status: "active" },
56
+ * sort: { field: "title", direction: "asc" },
57
+ * pagination: { type: "offset", page: 1, limit: 10 },
58
+ * };
59
+ *
60
+ * await api.client.task.list(params);
61
+ * ```
62
+ */
63
+ interface ListParams<TFilters = Record<string, unknown>> {
64
+ /** Optional filter criteria applied to the list query. */
65
+ filters?: TFilters;
66
+ /** Single sort directive or array of sort directives. */
67
+ sort?: SortParam | SortParam[];
68
+ /** Pagination strategy and parameters. */
69
+ pagination?: PaginationParam;
70
+ }
71
+ /**
72
+ * Describes pagination metadata returned from the server.
73
+ *
74
+ * Used by {@link QueryBuilder.parsePageInfo} to extract pagination state
75
+ * from API responses, enabling infinite scroll and paginated UIs.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import type { PageInfo } from "@simplix-react/contract";
80
+ *
81
+ * const pageInfo: PageInfo = {
82
+ * total: 42,
83
+ * hasNextPage: true,
84
+ * nextCursor: "cursor-xyz",
85
+ * };
86
+ * ```
87
+ */
88
+ interface PageInfo {
89
+ /** Total number of items across all pages (if the server provides it). */
90
+ total?: number;
91
+ /** Whether more items exist beyond the current page. */
92
+ hasNextPage: boolean;
93
+ /** Cursor value to fetch the next page (cursor-based pagination only). */
94
+ nextCursor?: string;
95
+ }
96
+ /**
97
+ * Defines how list parameters are serialized to URL search params and how
98
+ * pagination metadata is extracted from API responses.
99
+ *
100
+ * Implement this interface to adapt the framework to your API's query string
101
+ * conventions. Use {@link simpleQueryBuilder} as a ready-made implementation
102
+ * for common REST patterns.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import type { QueryBuilder } from "@simplix-react/contract";
107
+ *
108
+ * const customQueryBuilder: QueryBuilder = {
109
+ * buildSearchParams(params) {
110
+ * const sp = new URLSearchParams();
111
+ * if (params.pagination?.type === "offset") {
112
+ * sp.set("offset", String((params.pagination.page - 1) * params.pagination.limit));
113
+ * sp.set("limit", String(params.pagination.limit));
114
+ * }
115
+ * return sp;
116
+ * },
117
+ * parsePageInfo(response) {
118
+ * const { total, nextCursor } = response as any;
119
+ * return { total, hasNextPage: !!nextCursor, nextCursor };
120
+ * },
121
+ * };
122
+ * ```
123
+ *
124
+ * @see {@link simpleQueryBuilder} for the built-in implementation.
125
+ */
126
+ interface QueryBuilder {
127
+ /** Converts structured list parameters into URL search params. */
128
+ buildSearchParams(params: ListParams): URLSearchParams;
129
+ /** Extracts pagination metadata from an API response (optional). */
130
+ parsePageInfo?(response: unknown): PageInfo;
131
+ }
132
+
133
+ /**
134
+ * Describes the parent resource in a nested entity relationship.
135
+ *
136
+ * Enables hierarchical URL construction such as `/projects/:projectId/tasks`.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * const parent: EntityParent = {
141
+ * param: "projectId",
142
+ * path: "/projects",
143
+ * };
144
+ * // Produces: /projects/:projectId/tasks
145
+ * ```
146
+ *
147
+ * @see {@link EntityDefinition} for the entity that references this parent.
148
+ */
3
149
  interface EntityParent {
150
+ /** Route parameter name used to identify the parent resource (e.g. `"projectId"`). */
4
151
  param: string;
152
+ /** Base path segment for the parent resource (e.g. `"/projects"`). */
5
153
  path: string;
6
154
  }
155
+ /**
156
+ * Represents a named query scope that filters entities by a parent relationship.
157
+ *
158
+ * Allows defining reusable query patterns like "tasks by project" that can be
159
+ * referenced throughout the application.
160
+ *
161
+ * @see {@link EntityDefinition.queries} where these are declared.
162
+ */
7
163
  interface EntityQuery {
164
+ /** Name of the parent entity this query filters by (e.g. `"project"`). */
8
165
  parent: string;
166
+ /** Route parameter name used to scope the query (e.g. `"projectId"`). */
9
167
  param: string;
10
168
  }
169
+ /**
170
+ * Defines a CRUD-capable API entity with Zod schemas for type-safe validation.
171
+ *
172
+ * Serves as the single source of truth for an entity's shape, creation payload,
173
+ * update payload, and URL structure. The framework derives API clients, React Query
174
+ * hooks, and MSW handlers from this definition.
175
+ *
176
+ * @typeParam TSchema - Zod schema for the entity's response shape.
177
+ * @typeParam TCreate - Zod schema for the creation payload.
178
+ * @typeParam TUpdate - Zod schema for the update (partial) payload.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * import { z } from "zod";
183
+ * import type { EntityDefinition } from "@simplix-react/contract";
184
+ *
185
+ * const taskEntity: EntityDefinition = {
186
+ * path: "/tasks",
187
+ * schema: z.object({ id: z.string(), title: z.string() }),
188
+ * createSchema: z.object({ title: z.string() }),
189
+ * updateSchema: z.object({ title: z.string().optional() }),
190
+ * parent: { param: "projectId", path: "/projects" },
191
+ * };
192
+ * ```
193
+ *
194
+ * @see {@link OperationDefinition} for non-CRUD custom operations.
195
+ * @see {@link @simplix-react/react!deriveHooks | deriveHooks} for deriving React Query hooks.
196
+ * @see {@link @simplix-react/mock!deriveMockHandlers | deriveMockHandlers} for deriving MSW handlers.
197
+ */
11
198
  interface EntityDefinition<TSchema extends z.ZodType = z.ZodType, TCreate extends z.ZodType = z.ZodType, TUpdate extends z.ZodType = z.ZodType> {
199
+ /** URL path segment for this entity (e.g. `"/tasks"`). */
12
200
  path: string;
201
+ /** Zod schema describing the full entity shape returned by the API. */
13
202
  schema: TSchema;
203
+ /** Zod schema describing the payload required to create a new entity. */
14
204
  createSchema: TCreate;
205
+ /** Zod schema describing the payload for updating an existing entity. */
15
206
  updateSchema: TUpdate;
207
+ /** Optional parent resource for nested URL construction. */
16
208
  parent?: EntityParent;
209
+ /** Named query scopes for filtering entities by parent relationships. */
17
210
  queries?: Record<string, EntityQuery>;
211
+ /** Optional Zod schema for validating list filter parameters. */
212
+ filterSchema?: z.ZodType;
18
213
  }
214
+ /**
215
+ * Supported HTTP methods for API operations.
216
+ */
19
217
  type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
218
+ /**
219
+ * Defines a custom (non-CRUD) API operation with typed input and output.
220
+ *
221
+ * Covers endpoints that do not fit the standard entity CRUD pattern, such as
222
+ * file uploads, batch operations, or RPC-style calls. Path parameters use
223
+ * the `:paramName` syntax and are positionally mapped to function arguments.
224
+ *
225
+ * @typeParam TInput - Zod schema for the request payload.
226
+ * @typeParam TOutput - Zod schema for the response payload.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * import { z } from "zod";
231
+ * import type { OperationDefinition } from "@simplix-react/contract";
232
+ *
233
+ * const assignTask: OperationDefinition = {
234
+ * method: "POST",
235
+ * path: "/tasks/:taskId/assign",
236
+ * input: z.object({ userId: z.string() }),
237
+ * output: z.object({ id: z.string(), assigneeId: z.string() }),
238
+ * };
239
+ * ```
240
+ *
241
+ * @see {@link EntityDefinition} for standard CRUD entities.
242
+ */
20
243
  interface OperationDefinition<TInput extends z.ZodType = z.ZodType, TOutput extends z.ZodType = z.ZodType> {
244
+ /** HTTP method for this operation. */
21
245
  method: HttpMethod;
246
+ /** URL path with optional `:paramName` placeholders (e.g. `"/tasks/:taskId/assign"`). */
22
247
  path: string;
248
+ /** Zod schema validating the request payload. */
23
249
  input: TInput;
250
+ /** Zod schema validating the response payload. */
24
251
  output: TOutput;
252
+ /** Content type for the request body. Defaults to `"json"`. */
253
+ contentType?: "json" | "multipart";
254
+ /** Expected response format. Defaults to `"json"`. */
255
+ responseType?: "json" | "blob";
256
+ /**
257
+ * Returns query key arrays that should be invalidated after this operation succeeds.
258
+ * Enables automatic cache invalidation in `@simplix-react/react`.
259
+ */
25
260
  invalidates?: (queryKeys: Record<string, QueryKeyFactory>, params: Record<string, string>) => readonly unknown[][];
26
261
  }
262
+ /**
263
+ * Configures a complete API contract with entities, operations, and shared settings.
264
+ *
265
+ * Serves as the input to {@link defineApi}, grouping all entity and operation
266
+ * definitions under a single domain namespace with a shared base path.
267
+ *
268
+ * @typeParam TEntities - Map of entity names to their definitions.
269
+ * @typeParam TOperations - Map of operation names to their definitions.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * import { z } from "zod";
274
+ * import { simpleQueryBuilder } from "@simplix-react/contract";
275
+ * import type { ApiContractConfig } from "@simplix-react/contract";
276
+ *
277
+ * const config: ApiContractConfig = {
278
+ * domain: "project",
279
+ * basePath: "/api/v1",
280
+ * entities: {
281
+ * task: {
282
+ * path: "/tasks",
283
+ * schema: z.object({ id: z.string(), title: z.string() }),
284
+ * createSchema: z.object({ title: z.string() }),
285
+ * updateSchema: z.object({ title: z.string().optional() }),
286
+ * },
287
+ * },
288
+ * queryBuilder: simpleQueryBuilder,
289
+ * };
290
+ * ```
291
+ *
292
+ * @see {@link defineApi} for constructing a contract from this config.
293
+ */
27
294
  interface ApiContractConfig<TEntities extends Record<string, EntityDefinition<any, any, any>> = Record<string, EntityDefinition>, TOperations extends Record<string, OperationDefinition<any, any>> = Record<string, OperationDefinition>> {
295
+ /** Logical domain name used as the root segment in query keys (e.g. `"project"`). */
28
296
  domain: string;
297
+ /** Base URL path prepended to all entity and operation paths (e.g. `"/api/v1"`). */
29
298
  basePath: string;
299
+ /** Map of entity names to their CRUD definitions. */
30
300
  entities: TEntities;
301
+ /** Optional map of custom operation names to their definitions. */
31
302
  operations?: TOperations;
303
+ /** Strategy for serializing list parameters (filters, sort, pagination) into URL search params. */
304
+ queryBuilder?: QueryBuilder;
32
305
  }
306
+ /**
307
+ * Provides structured query key generators for a single entity, following the
308
+ * query key factory pattern recommended by TanStack Query.
309
+ *
310
+ * Generated automatically by {@link deriveQueryKeys} and used by `@simplix-react/react`
311
+ * to manage cache granularity and invalidation.
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * import { defineApi } from "@simplix-react/contract";
316
+ *
317
+ * const api = defineApi(config);
318
+ *
319
+ * api.queryKeys.task.all; // ["project", "task"]
320
+ * api.queryKeys.task.lists(); // ["project", "task", "list"]
321
+ * api.queryKeys.task.list({ status: "open" }); // ["project", "task", "list", { status: "open" }]
322
+ * api.queryKeys.task.details(); // ["project", "task", "detail"]
323
+ * api.queryKeys.task.detail("abc"); // ["project", "task", "detail", "abc"]
324
+ * ```
325
+ *
326
+ * @see {@link deriveQueryKeys} for the factory function.
327
+ */
33
328
  interface QueryKeyFactory {
329
+ /** Root key matching all queries for this entity: `[domain, entity]`. */
34
330
  all: readonly unknown[];
331
+ /** Returns key matching all list queries: `[domain, entity, "list"]`. */
35
332
  lists: () => readonly unknown[];
333
+ /** Returns key matching a specific list query with parameters. */
36
334
  list: (params: Record<string, unknown>) => readonly unknown[];
335
+ /** Returns key matching all detail queries: `[domain, entity, "detail"]`. */
37
336
  details: () => readonly unknown[];
337
+ /** Returns key matching a specific detail query by ID. */
38
338
  detail: (id: string) => readonly unknown[];
39
339
  }
340
+ /**
341
+ * Provides a type-safe CRUD client for a single entity, derived from its
342
+ * {@link EntityDefinition} schemas.
343
+ *
344
+ * All methods infer request/response types directly from the Zod schemas,
345
+ * ensuring compile-time safety without manual type annotations.
346
+ *
347
+ * @typeParam TSchema - Zod schema for the entity's response shape.
348
+ * @typeParam TCreate - Zod schema for the creation payload.
349
+ * @typeParam TUpdate - Zod schema for the update payload.
350
+ *
351
+ * @example
352
+ * ```ts
353
+ * import { defineApi } from "@simplix-react/contract";
354
+ *
355
+ * const api = defineApi(config);
356
+ *
357
+ * // All methods are fully typed based on entity schemas
358
+ * const tasks = await api.client.task.list();
359
+ * const task = await api.client.task.get("task-1");
360
+ * const created = await api.client.task.create({ title: "New task" });
361
+ * const updated = await api.client.task.update("task-1", { title: "Updated" });
362
+ * await api.client.task.delete("task-1");
363
+ * ```
364
+ *
365
+ * @see {@link deriveClient} for the factory function.
366
+ */
40
367
  interface EntityClient<TSchema extends z.ZodType, TCreate extends z.ZodType, TUpdate extends z.ZodType> {
41
- list: (parentId?: string) => Promise<z.infer<TSchema>[]>;
368
+ /** Fetches a list of entities, optionally scoped by parent ID and/or list parameters. */
369
+ list: (parentIdOrParams?: string | ListParams, params?: ListParams) => Promise<z.infer<TSchema>[]>;
370
+ /** Fetches a single entity by its ID. */
42
371
  get: (id: string) => Promise<z.infer<TSchema>>;
372
+ /** Creates a new entity, optionally under a parent resource. */
43
373
  create: (parentIdOrDto: string | z.infer<TCreate>, dto?: z.infer<TCreate>) => Promise<z.infer<TSchema>>;
374
+ /** Partially updates an existing entity by ID. */
44
375
  update: (id: string, dto: z.infer<TUpdate>) => Promise<z.infer<TSchema>>;
376
+ /** Deletes an entity by its ID. */
45
377
  delete: (id: string) => Promise<void>;
46
378
  }
379
+ /**
380
+ * Represents a customizable fetch function signature.
381
+ *
382
+ * Allows replacing the default HTTP client with a custom implementation
383
+ * (e.g. for authentication headers, retry logic, or testing).
384
+ *
385
+ * @typeParam T - The expected response type after deserialization.
386
+ *
387
+ * @see {@link defaultFetch} for the built-in implementation.
388
+ * @see {@link defineApi} where this is provided via `options.fetchFn`.
389
+ */
47
390
  type FetchFn = <T>(path: string, options?: RequestInit) => Promise<T>;
391
+ /**
392
+ * Represents the fully constructed API contract returned by {@link defineApi}.
393
+ *
394
+ * Contains the original configuration, a type-safe HTTP client, and query key
395
+ * factories for all registered entities. This is the primary interface consumed
396
+ * by `@simplix-react/react` and `@simplix-react/mock`.
397
+ *
398
+ * @typeParam TEntities - Map of entity names to their definitions.
399
+ * @typeParam TOperations - Map of operation names to their definitions.
400
+ *
401
+ * @see {@link defineApi} for constructing this contract.
402
+ * @see {@link @simplix-react/react!deriveHooks | deriveHooks} for deriving React hooks.
403
+ * @see {@link @simplix-react/mock!deriveMockHandlers | deriveMockHandlers} for deriving mock handlers.
404
+ */
48
405
  interface ApiContract<TEntities extends Record<string, EntityDefinition<any, any, any>>, TOperations extends Record<string, OperationDefinition<any, any>>> {
406
+ /** The original contract configuration. */
49
407
  config: ApiContractConfig<TEntities, TOperations>;
408
+ /** Type-safe HTTP client with methods for each entity and operation. */
50
409
  client: {
51
410
  [K in keyof TEntities]: EntityClient<TEntities[K]["schema"], TEntities[K]["createSchema"], TEntities[K]["updateSchema"]>;
52
411
  } & {
53
412
  [K in keyof TOperations]: TOperations[K] extends OperationDefinition<infer _TInput, infer TOutput> ? (...args: unknown[]) => Promise<z.infer<TOutput>> : never;
54
413
  };
414
+ /** Query key factories for cache management, one per entity. */
55
415
  queryKeys: {
56
416
  [K in keyof TEntities]: QueryKeyFactory;
57
417
  };
58
418
  }
59
419
 
420
+ /**
421
+ * Creates a fully-typed API contract from an {@link ApiContractConfig}.
422
+ *
423
+ * Serves as the main entry point for `@simplix-react/contract`. Takes a
424
+ * declarative config of entities and operations, then derives a type-safe
425
+ * HTTP client and query key factories. The returned contract is consumed
426
+ * by `@simplix-react/react` for hooks and `@simplix-react/mock` for MSW handlers.
427
+ *
428
+ * @typeParam TEntities - Map of entity names to their {@link EntityDefinition}s.
429
+ * @typeParam TOperations - Map of operation names to their {@link OperationDefinition}s.
430
+ *
431
+ * @param config - The API contract configuration defining entities, operations, and shared settings.
432
+ * @param options - Optional settings for customizing the contract.
433
+ * @param options.fetchFn - Custom fetch function replacing the built-in {@link defaultFetch}.
434
+ * @returns An {@link ApiContract} containing `config`, `client`, and `queryKeys`.
435
+ *
436
+ * @example
437
+ * ```ts
438
+ * import { z } from "zod";
439
+ * import { defineApi, simpleQueryBuilder } from "@simplix-react/contract";
440
+ *
441
+ * const projectApi = defineApi({
442
+ * domain: "project",
443
+ * basePath: "/api/v1",
444
+ * entities: {
445
+ * task: {
446
+ * path: "/tasks",
447
+ * schema: z.object({ id: z.string(), title: z.string() }),
448
+ * createSchema: z.object({ title: z.string() }),
449
+ * updateSchema: z.object({ title: z.string().optional() }),
450
+ * },
451
+ * },
452
+ * queryBuilder: simpleQueryBuilder,
453
+ * });
454
+ *
455
+ * // Type-safe client usage
456
+ * const tasks = await projectApi.client.task.list();
457
+ * const task = await projectApi.client.task.get("task-1");
458
+ *
459
+ * // Query keys for TanStack Query
460
+ * projectApi.queryKeys.task.all; // ["project", "task"]
461
+ * projectApi.queryKeys.task.detail("task-1"); // ["project", "task", "detail", "task-1"]
462
+ * ```
463
+ *
464
+ * @see {@link ApiContractConfig} for the full config shape.
465
+ * @see {@link @simplix-react/react!deriveHooks | deriveHooks} for deriving React Query hooks.
466
+ * @see {@link @simplix-react/mock!deriveMockHandlers | deriveMockHandlers} for deriving MSW handlers.
467
+ */
60
468
  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
469
  fetchFn?: FetchFn;
62
470
  }): {
@@ -65,38 +473,219 @@ declare function defineApi<TEntities extends Record<string, EntityDefinition<any
65
473
  queryKeys: { [K in keyof TEntities]: QueryKeyFactory; };
66
474
  };
67
475
 
476
+ /**
477
+ * Derives a type-safe HTTP client from an {@link ApiContractConfig}.
478
+ *
479
+ * Iterates over all entities and operations in the config and generates
480
+ * corresponding CRUD methods and operation functions. Each entity produces
481
+ * `list`, `get`, `create`, `update`, and `delete` methods. Each operation
482
+ * produces a callable function with positional path parameter arguments.
483
+ *
484
+ * Typically called internally by {@link defineApi} rather than used directly.
485
+ *
486
+ * @typeParam TEntities - Map of entity names to their definitions.
487
+ * @typeParam TOperations - Map of operation names to their definitions.
488
+ * @param config - The API contract configuration.
489
+ * @param fetchFn - Custom fetch function; defaults to {@link defaultFetch}.
490
+ * @returns A client object with typed methods for each entity and operation.
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * import { deriveClient } from "@simplix-react/contract";
495
+ *
496
+ * const client = deriveClient(config);
497
+ * const tasks = await client.task.list();
498
+ * ```
499
+ *
500
+ * @see {@link defineApi} for the recommended high-level API.
501
+ */
68
502
  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
503
 
504
+ /**
505
+ * Derives a set of {@link QueryKeyFactory} instances for all entities in a contract.
506
+ *
507
+ * Generates structured query keys following the factory pattern recommended
508
+ * by TanStack Query. Each entity receives keys scoped by `[domain, entityName]`,
509
+ * enabling granular cache invalidation (e.g. invalidate all task lists without
510
+ * affecting task details).
511
+ *
512
+ * Typically called internally by {@link defineApi} rather than used directly.
513
+ *
514
+ * @typeParam TEntities - Map of entity names to their definitions.
515
+ * @param config - Subset of the API contract config containing `domain` and `entities`.
516
+ * @returns A map of entity names to their {@link QueryKeyFactory} instances.
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * import { deriveQueryKeys } from "@simplix-react/contract";
521
+ *
522
+ * const queryKeys = deriveQueryKeys({ domain: "project", entities: { task: taskEntity } });
523
+ *
524
+ * queryKeys.task.all; // ["project", "task"]
525
+ * queryKeys.task.lists(); // ["project", "task", "list"]
526
+ * queryKeys.task.detail("abc"); // ["project", "task", "detail", "abc"]
527
+ * ```
528
+ *
529
+ * @see {@link defineApi} for the recommended high-level API.
530
+ * @see {@link QueryKeyFactory} for the generated key structure.
531
+ */
70
532
  declare function deriveQueryKeys<TEntities extends Record<string, EntityDefinition<any, any, any>>>(config: Pick<ApiContractConfig<TEntities>, "domain" | "entities">): {
71
533
  [K in keyof TEntities]: QueryKeyFactory;
72
534
  };
73
535
 
74
536
  /**
75
- * Build URL path with parameter substitution.
76
- * buildPath("/topologies/:topologyId/controllers", { topologyId: "abc" })
77
- * -> "/topologies/abc/controllers"
537
+ * Substitutes `:paramName` placeholders in a URL path template with actual values.
538
+ *
539
+ * Values are URI-encoded to ensure safe inclusion in URLs. Used internally by
540
+ * {@link deriveClient} for building operation URLs, and available as a public
541
+ * utility for custom URL construction.
542
+ *
543
+ * @param template - URL path template with `:paramName` placeholders.
544
+ * @param params - Map of parameter names to their string values.
545
+ * @returns The resolved URL path with all placeholders replaced.
546
+ *
547
+ * @example
548
+ * ```ts
549
+ * import { buildPath } from "@simplix-react/contract";
550
+ *
551
+ * buildPath("/projects/:projectId/tasks", { projectId: "abc" });
552
+ * // "/projects/abc/tasks"
553
+ *
554
+ * buildPath("/tasks/:taskId/assign", { taskId: "task-1" });
555
+ * // "/tasks/task-1/assign"
556
+ * ```
78
557
  */
79
558
  declare function buildPath(template: string, params?: Record<string, string>): string;
80
559
 
560
+ /**
561
+ * Represents an HTTP error response from the API.
562
+ *
563
+ * Thrown by {@link defaultFetch} and the internal multipart/blob fetchers when
564
+ * the server responds with a non-2xx status code. Captures both the HTTP status
565
+ * and the raw response body for debugging.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * import { ApiError } from "@simplix-react/contract";
570
+ *
571
+ * try {
572
+ * await api.client.task.get("nonexistent");
573
+ * } catch (error) {
574
+ * if (error instanceof ApiError) {
575
+ * console.log(error.status); // 404
576
+ * console.log(error.body); // "Not Found"
577
+ * }
578
+ * }
579
+ * ```
580
+ */
81
581
  declare class ApiError extends Error {
582
+ /** HTTP status code of the failed response. */
82
583
  readonly status: number;
584
+ /** Raw response body text. */
83
585
  readonly body: string;
84
- constructor(status: number, body: string);
586
+ constructor(
587
+ /** HTTP status code of the failed response. */
588
+ status: number,
589
+ /** Raw response body text. */
590
+ body: string);
85
591
  }
86
592
  /**
87
- * Default fetch function that unwraps { data: T } envelope.
593
+ * Performs an HTTP request with automatic JSON content-type headers and
594
+ * `{ data: T }` envelope unwrapping.
595
+ *
596
+ * Serves as the built-in fetch implementation used by {@link deriveClient}
597
+ * when no custom `fetchFn` is provided. Returns `undefined` for 204 No Content
598
+ * responses and throws {@link ApiError} for non-2xx status codes.
599
+ *
600
+ * @typeParam T - The expected deserialized response type.
601
+ * @param path - The full URL path to fetch.
602
+ * @param options - Standard `RequestInit` options forwarded to the native `fetch`.
603
+ * @returns The unwrapped response data.
604
+ * @throws {@link ApiError} When the response status is not OK.
605
+ *
606
+ * @example
607
+ * ```ts
608
+ * import { defineApi, defaultFetch } from "@simplix-react/contract";
609
+ *
610
+ * // Use as-is (default behavior)
611
+ * const api = defineApi(config);
612
+ *
613
+ * // Or provide a custom wrapper
614
+ * const api = defineApi(config, {
615
+ * fetchFn: async (path, options) => {
616
+ * // Add auth header, then delegate to defaultFetch
617
+ * return defaultFetch(path, {
618
+ * ...options,
619
+ * headers: { ...options?.headers, Authorization: `Bearer ${token}` },
620
+ * });
621
+ * },
622
+ * });
623
+ * ```
88
624
  */
89
625
  declare function defaultFetch<T>(path: string, options?: RequestInit): Promise<T>;
90
626
 
91
627
  /**
92
- * Convert camelCase to kebab-case.
93
- * "doorReader" -> "door-reader"
628
+ * Converts a camelCase string to kebab-case.
629
+ *
630
+ * Used internally for transforming entity names into URL-friendly path segments.
631
+ *
632
+ * @param str - The camelCase string to convert.
633
+ * @returns The kebab-case equivalent.
634
+ *
635
+ * @example
636
+ * ```ts
637
+ * import { camelToKebab } from "@simplix-react/contract";
638
+ *
639
+ * camelToKebab("doorReader"); // "door-reader"
640
+ * camelToKebab("myEntity"); // "my-entity"
641
+ * ```
94
642
  */
95
643
  declare function camelToKebab(str: string): string;
96
644
  /**
97
- * Convert camelCase to snake_case.
98
- * "doorReader" -> "door_reader"
645
+ * Converts a camelCase string to snake_case.
646
+ *
647
+ * Used internally for transforming entity names into database-friendly column names.
648
+ *
649
+ * @param str - The camelCase string to convert.
650
+ * @returns The snake_case equivalent.
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * import { camelToSnake } from "@simplix-react/contract";
655
+ *
656
+ * camelToSnake("doorReader"); // "door_reader"
657
+ * camelToSnake("myEntity"); // "my_entity"
658
+ * ```
99
659
  */
100
660
  declare function camelToSnake(str: string): string;
101
661
 
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 };
662
+ /**
663
+ * Provides a straightforward {@link QueryBuilder} implementation for common REST APIs.
664
+ *
665
+ * Serializes filters as flat key-value pairs, sort as `field:direction` comma-separated
666
+ * values, and pagination as `page`/`limit` (offset) or `cursor`/`limit` (cursor-based).
667
+ *
668
+ * @example
669
+ * ```ts
670
+ * import { defineApi, simpleQueryBuilder } from "@simplix-react/contract";
671
+ *
672
+ * const api = defineApi({
673
+ * domain: "project",
674
+ * basePath: "/api/v1",
675
+ * entities: { task: taskEntity },
676
+ * queryBuilder: simpleQueryBuilder,
677
+ * });
678
+ *
679
+ * // Produces: /api/v1/tasks?status=pending&sort=name:asc&page=1&limit=10
680
+ * await api.client.task.list({
681
+ * filters: { status: "pending" },
682
+ * sort: { field: "name", direction: "asc" },
683
+ * pagination: { type: "offset", page: 1, limit: 10 },
684
+ * });
685
+ * ```
686
+ *
687
+ * @see {@link QueryBuilder} for implementing custom serialization strategies.
688
+ */
689
+ declare const simpleQueryBuilder: QueryBuilder;
690
+
691
+ export { type ApiContract, type ApiContractConfig, ApiError, type EntityClient, type EntityDefinition, type EntityParent, type EntityQuery, type FetchFn, type HttpMethod, type ListParams, type OperationDefinition, type PageInfo, type PaginationParam, type QueryBuilder, type QueryKeyFactory, type SortParam, buildPath, camelToKebab, camelToSnake, defaultFetch, defineApi, deriveClient, deriveQueryKeys, simpleQueryBuilder };
package/dist/index.js CHANGED
@@ -36,10 +36,10 @@ async function defaultFetch(path, options) {
36
36
 
37
37
  // src/derive/client.ts
38
38
  function deriveClient(config, fetchFn = defaultFetch) {
39
- const { basePath, entities, operations } = config;
39
+ const { basePath, entities, operations, queryBuilder } = config;
40
40
  const result = {};
41
41
  for (const [name, entity] of Object.entries(entities)) {
42
- result[name] = createEntityClient(basePath, entity, fetchFn);
42
+ result[name] = createEntityClient(basePath, entity, fetchFn, queryBuilder);
43
43
  }
44
44
  if (operations) {
45
45
  for (const [name, operation] of Object.entries(operations)) {
@@ -48,11 +48,24 @@ function deriveClient(config, fetchFn = defaultFetch) {
48
48
  }
49
49
  return result;
50
50
  }
51
- function createEntityClient(basePath, entity, fetchFn) {
51
+ function createEntityClient(basePath, entity, fetchFn, queryBuilder) {
52
52
  const { path, parent } = entity;
53
53
  return {
54
- list(parentId) {
55
- const url = parent && parentId ? `${basePath}${parent.path}/${parentId}${path}` : `${basePath}${path}`;
54
+ list(parentIdOrParams, params) {
55
+ let parentId;
56
+ let listParams;
57
+ if (typeof parentIdOrParams === "string") {
58
+ parentId = parentIdOrParams;
59
+ listParams = params;
60
+ } else {
61
+ listParams = parentIdOrParams;
62
+ }
63
+ let url = parent && parentId ? `${basePath}${parent.path}/${parentId}${path}` : `${basePath}${path}`;
64
+ if (listParams && queryBuilder) {
65
+ const sp = queryBuilder.buildSearchParams(listParams);
66
+ const qs = sp.toString();
67
+ if (qs) url += `?${qs}`;
68
+ }
56
69
  return fetchFn(url);
57
70
  },
58
71
  get(id) {
@@ -101,6 +114,12 @@ function createOperationClient(basePath, operation, fetchFn) {
101
114
  inputData = args[argIndex];
102
115
  }
103
116
  const url = `${basePath}${buildPath(operation.path, pathParams)}`;
117
+ if (operation.contentType === "multipart" && inputData !== void 0) {
118
+ return multipartFetch(url, operation.method, toFormData(inputData), operation.responseType);
119
+ }
120
+ if (operation.responseType === "blob") {
121
+ return blobFetch(url, operation.method, inputData);
122
+ }
104
123
  const options = { method: operation.method };
105
124
  if (inputData !== void 0 && operation.method !== "GET") {
106
125
  options.body = JSON.stringify(inputData);
@@ -108,6 +127,42 @@ function createOperationClient(basePath, operation, fetchFn) {
108
127
  return fetchFn(url, options);
109
128
  };
110
129
  }
130
+ function toFormData(data) {
131
+ const formData = new FormData();
132
+ if (data && typeof data === "object") {
133
+ for (const [key, value] of Object.entries(data)) {
134
+ if (value instanceof File || value instanceof Blob) {
135
+ formData.append(key, value);
136
+ } else if (value !== void 0 && value !== null) {
137
+ formData.append(key, String(value));
138
+ }
139
+ }
140
+ }
141
+ return formData;
142
+ }
143
+ async function multipartFetch(url, method, formData, responseType) {
144
+ const res = await fetch(url, { method, body: formData });
145
+ if (!res.ok) {
146
+ throw new ApiError(res.status, await res.text());
147
+ }
148
+ if (responseType === "blob") {
149
+ return res.blob();
150
+ }
151
+ const json = await res.json();
152
+ return json.data !== void 0 ? json.data : json;
153
+ }
154
+ async function blobFetch(url, method, inputData) {
155
+ const options = { method };
156
+ if (inputData !== void 0 && method !== "GET") {
157
+ options.body = JSON.stringify(inputData);
158
+ options.headers = { "Content-Type": "application/json" };
159
+ }
160
+ const res = await fetch(url, options);
161
+ if (!res.ok) {
162
+ throw new ApiError(res.status, await res.text());
163
+ }
164
+ return res.blob();
165
+ }
111
166
 
112
167
  // src/derive/query-keys.ts
113
168
  function deriveQueryKeys(config) {
@@ -145,4 +200,33 @@ function camelToSnake(str) {
145
200
  return str.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
146
201
  }
147
202
 
148
- export { ApiError, buildPath, camelToKebab, camelToSnake, defaultFetch, defineApi, deriveClient, deriveQueryKeys };
203
+ // src/helpers/query-builders.ts
204
+ var simpleQueryBuilder = {
205
+ buildSearchParams(params) {
206
+ const sp = new URLSearchParams();
207
+ if (params.filters) {
208
+ for (const [k, v] of Object.entries(params.filters)) {
209
+ if (v !== void 0 && v !== null) sp.set(k, String(v));
210
+ }
211
+ }
212
+ if (params.sort) {
213
+ const sorts = Array.isArray(params.sort) ? params.sort : [params.sort];
214
+ sp.set(
215
+ "sort",
216
+ sorts.map((s) => `${s.field}:${s.direction}`).join(",")
217
+ );
218
+ }
219
+ if (params.pagination) {
220
+ if (params.pagination.type === "offset") {
221
+ sp.set("page", String(params.pagination.page));
222
+ sp.set("limit", String(params.pagination.limit));
223
+ } else {
224
+ sp.set("cursor", params.pagination.cursor);
225
+ sp.set("limit", String(params.pagination.limit));
226
+ }
227
+ }
228
+ return sp;
229
+ }
230
+ };
231
+
232
+ export { ApiError, buildPath, camelToKebab, camelToSnake, defaultFetch, defineApi, deriveClient, deriveQueryKeys, simpleQueryBuilder };
package/package.json CHANGED
@@ -1,23 +1,20 @@
1
1
  {
2
2
  "name": "@simplix-react/contract",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Define type-safe API contracts with Zod schemas",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
8
  "types": "./dist/index.d.ts",
9
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
10
  }
19
11
  },
20
- "files": ["dist"],
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
21
18
  "scripts": {
22
19
  "build": "tsup",
23
20
  "dev": "tsup --watch",
@@ -30,6 +27,7 @@
30
27
  "zod": ">=4.0.0"
31
28
  },
32
29
  "devDependencies": {
30
+ "@simplix-react/config-eslint": "workspace:*",
33
31
  "@simplix-react/config-typescript": "workspace:*",
34
32
  "eslint": "^9.39.2",
35
33
  "tsup": "^8.5.1",
@@ -1,14 +0,0 @@
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 };
package/dist/mcp-tools.js DELETED
@@ -1,62 +0,0 @@
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 };
package/dist/openapi.d.ts DELETED
@@ -1,21 +0,0 @@
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 };
package/dist/openapi.js DELETED
@@ -1,54 +0,0 @@
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 };
@@ -1,41 +0,0 @@
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 };