@lokalise/api-contracts 6.10.0 → 6.12.0

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 CHANGED
@@ -1,420 +1,380 @@
1
1
  # api-contracts
2
2
 
3
- Key idea behind API contracts: backend owns entire definition for the route, including its path, HTTP method used and
4
- response structure expectations, and exposes it as a part of its API schemas. Then frontend consumes that definition
5
- instead of forming full request configuration manually on the client side.
3
+ API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend.
4
+ The contract describes a route — its path, HTTP method, and request/response schemas and serves as the single source of truth for both sides.
6
5
 
7
- This reduces amount of assumptions FE needs to make about the behaviour of BE, reduces amount of code that needs to be
8
- written on FE, and makes the code more type-safe (as path parameter setting is handled by logic exposed by BE, in a
9
- type-safe way).
6
+ The backend implements the route against the contract.
7
+ The client uses the same contract to make type-safe requests without duplicating configuration.
8
+ This eliminates assumptions across the boundary and keeps documentation, validation, and types in sync.
10
9
 
11
- ## Universal Contract Builder
10
+ ## Defining contracts
12
11
 
13
- Use `buildContract` as a single entry point for creating any type of API contract. It automatically delegates to the appropriate specialized builder based on the configuration:
14
-
15
- | `serverSentEventSchemas` | Contract Type |
16
- |--------------------------|---------------|
17
- | ❌ | REST contract (GET, POST, PUT, PATCH, DELETE) |
18
- | ✅ | SSE or Dual-mode contract |
12
+ ### REST routes
19
13
 
20
14
  ```ts
21
- import { buildContract } from '@lokalise/api-contracts'
22
- import { z } from 'zod'
15
+ import { defineApiContract, noBodyResponse } from '@lokalise/api-contracts'
16
+ import { z } from 'zod/v4'
17
+
18
+ // GET with path params
19
+ const getUser = defineApiContract({
20
+ method: 'get',
21
+ requestPathParamsSchema: z.object({ userId: z.uuid() }),
22
+ pathResolver: ({ userId }) => `/users/${userId}`,
23
+ responsesByStatusCode: {
24
+ 200: z.object({ id: z.string(), name: z.string() }),
25
+ },
26
+ })
23
27
 
24
- // REST GET route
25
- const getUsers = buildContract({
26
- successResponseBodySchema: z.array(userSchema),
27
- pathResolver: () => '/api/users',
28
+ // POST
29
+ const createUser = defineApiContract({
30
+ method: 'post',
31
+ pathResolver: () => '/users',
32
+ requestBodySchema: z.object({ name: z.string() }),
33
+ responsesByStatusCode: {
34
+ 201: z.object({ id: z.string(), name: z.string() }),
35
+ },
28
36
  })
29
37
 
30
- // REST POST route
31
- const createUser = buildContract({
32
- method: 'post',
33
- requestBodySchema: createUserSchema,
34
- successResponseBodySchema: userSchema,
35
- pathResolver: () => '/api/users',
38
+ // DELETE with no response body
39
+ const deleteUser = defineApiContract({
40
+ method: 'delete',
41
+ requestPathParamsSchema: z.object({ userId: z.uuid() }),
42
+ pathResolver: ({ userId }) => `/users/${userId}`,
43
+ responsesByStatusCode: {
44
+ 204: noBodyResponse(),
45
+ },
36
46
  })
47
+ ```
37
48
 
38
- // REST DELETE route
39
- const deleteUser = buildContract({
40
- method: 'delete',
41
- requestPathParamsSchema: z.object({ userId: z.string() }),
42
- pathResolver: (params) => `/api/users/${params.userId}`,
49
+ ### Non-JSON responses
50
+
51
+ Use `textResponse` for text-based responses (plain text, CSV, HTML, XML, YAML, etc.):
52
+
53
+ ```ts
54
+ import { defineApiContract, textResponse } from '@lokalise/api-contracts'
55
+
56
+ const exportCsv = defineApiContract({
57
+ method: 'get',
58
+ pathResolver: () => '/export.csv',
59
+ responsesByStatusCode: { 200: textResponse('text/csv') },
43
60
  })
44
61
 
45
- // SSE-only streaming endpoint
46
- const notifications = buildContract({
47
- method: 'get',
48
- pathResolver: () => '/api/notifications/stream',
49
- serverSentEventSchemas: {
50
- notification: z.object({ id: z.string(), message: z.string() }),
51
- },
62
+ const getPage = defineApiContract({
63
+ method: 'get',
64
+ pathResolver: () => '/page',
65
+ responsesByStatusCode: { 200: textResponse('text/html') },
52
66
  })
53
67
 
54
- // Dual-mode endpoint (supports both JSON and SSE)
55
- const chatCompletion = buildContract({
56
- method: 'post',
57
- pathResolver: () => '/api/chat/completions',
58
- requestBodySchema: z.object({ message: z.string() }),
59
- successResponseBodySchema: z.object({ reply: z.string() }),
60
- serverSentEventSchemas: {
61
- chunk: z.object({ delta: z.string() }),
62
- done: z.object({ usage: z.object({ tokens: z.number() }) }),
63
- },
68
+ const getDocument = defineApiContract({
69
+ method: 'get',
70
+ pathResolver: () => '/document',
71
+ responsesByStatusCode: { 200: textResponse('application/xml') },
64
72
  })
65
73
  ```
66
74
 
67
- You can also use the specialized builders directly (`buildRestContract`, `buildSseContract`) if you prefer explicit control over contract types.
75
+ Use `blobResponse` for binary responses (images, PDFs, etc.):
68
76
 
69
- ## REST Contracts
77
+ ```ts
78
+ import { defineApiContract, blobResponse } from '@lokalise/api-contracts'
70
79
 
71
- Use `buildRestContract` to create REST API contracts. The contract type is automatically determined based on the configuration:
80
+ const downloadPhoto = defineApiContract({
81
+ method: 'get',
82
+ pathResolver: () => '/photo.png',
83
+ responsesByStatusCode: { 200: blobResponse('image/png') },
84
+ })
85
+ ```
72
86
 
73
- | `method` | `requestBodySchema` | Result |
74
- |----------|---------------------|--------|
75
- | omitted/undefined | ❌ | GET route |
76
- | `'delete'` | ❌ | DELETE route |
77
- | `'post'`/`'put'`/`'patch'` | ✅ | Payload route |
87
+ ### SSE and dual-mode routes
78
88
 
79
- Usage examples:
89
+ Use `sseResponse()` inside `responsesByStatusCode` to define SSE event schemas.
90
+ For endpoints that can respond with either JSON or an SSE stream depending on the `Accept` header, use `anyOfResponses()` to declare both options on the same status code.
80
91
 
81
92
  ```ts
82
- import { buildRestContract } from '@lokalise/api-contracts'
83
-
84
- // GET route - method is inferred automatically
85
- const getContract = buildRestContract({
86
- successResponseBodySchema: RESPONSE_BODY_SCHEMA,
87
- requestPathParamsSchema: REQUEST_PATH_PARAMS_SCHEMA,
88
- requestQuerySchema: REQUEST_QUERY_SCHEMA,
89
- requestHeaderSchema: REQUEST_HEADER_SCHEMA,
90
- responseHeaderSchema: RESPONSE_HEADER_SCHEMA,
91
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
92
- summary: 'Route summary',
93
- metadata: { allowedRoles: ['admin'] },
93
+ import { defineApiContract, sseResponse, anyOfResponses } from '@lokalise/api-contracts'
94
+ import { z } from 'zod/v4'
95
+
96
+ // SSE-only
97
+ const notifications = defineApiContract({
98
+ method: 'get',
99
+ pathResolver: () => '/notifications/stream',
100
+ responsesByStatusCode: {
101
+ 200: sseResponse({
102
+ notification: z.object({ id: z.string(), message: z.string() }),
103
+ }),
104
+ },
94
105
  })
95
106
 
96
- // POST route - requires method and requestBodySchema
97
- const postContract = buildRestContract({
98
- method: 'post', // can also be 'put' or 'patch'
99
- successResponseBodySchema: RESPONSE_BODY_SCHEMA,
100
- requestBodySchema: REQUEST_BODY_SCHEMA,
101
- pathResolver: () => '/',
102
- summary: 'Route summary',
103
- metadata: { allowedPermission: ['edit'] },
107
+ // Dual-mode: JSON response or SSE stream depending on Accept header
108
+ const chatCompletion = defineApiContract({
109
+ method: 'post',
110
+ pathResolver: () => '/chat/completions',
111
+ requestBodySchema: z.object({ message: z.string() }),
112
+ responsesByStatusCode: {
113
+ 200: anyOfResponses([
114
+ sseResponse({
115
+ chunk: z.object({ delta: z.string() }),
116
+ done: z.object({ finish_reason: z.string() }),
117
+ }),
118
+ z.object({ text: z.string() }),
119
+ ]),
120
+ },
104
121
  })
122
+ ```
105
123
 
106
- // DELETE route - method is 'delete', no body, defaults isEmptyResponseExpected to true
107
- const deleteContract = buildRestContract({
108
- method: 'delete',
109
- successResponseBodySchema: RESPONSE_BODY_SCHEMA,
110
- requestPathParamsSchema: REQUEST_PATH_PARAMS_SCHEMA,
111
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
124
+ ### OpenAPI response descriptions
125
+
126
+ All response factories accept an optional `ResponseOptions` object as their last argument.
127
+
128
+ ```ts
129
+ import {
130
+ defineApiContract,
131
+ noBodyResponse,
132
+ textResponse,
133
+ blobResponse,
134
+ sseResponse,
135
+ anyOfResponses,
136
+ } from '@lokalise/api-contracts'
137
+ import { z } from 'zod/v4'
138
+
139
+ const contract = defineApiContract({
140
+ method: 'post',
141
+ pathResolver: () => '/files',
142
+ requestBodySchema: z.object({ name: z.string() }),
143
+ responsesByStatusCode: {
144
+ 201: z.object({ id: z.string() }).describe('Created resource'),
145
+ 204: noBodyResponse({ description: 'Deleted — no content returned' }),
146
+ 200: anyOfResponses(
147
+ [
148
+ z.object({ id: z.string() }).describe('JSON representation'),
149
+ textResponse('text/csv', { description: 'CSV export' }),
150
+ blobResponse('application/pdf', { description: 'PDF report' }),
151
+ sseResponse({ update: z.object({ id: z.string() }) }, { description: 'Live update stream' }),
152
+ ],
153
+ { description: 'Multiple response formats available' },
154
+ ),
155
+ },
112
156
  })
113
157
  ```
114
158
 
115
- ### Deprecated Builders
116
-
117
- The individual builders `buildGetRoute`, `buildPayloadRoute`, and `buildDeleteRoute` are deprecated. Use `buildRestContract` instead:
159
+ `getSseSchemaByEventName(contract)` extracts SSE event schemas from a contract:
118
160
 
119
161
  ```ts
120
- // Before (deprecated):
121
- import { buildGetRoute, buildPayloadRoute, buildDeleteRoute } from '@lokalise/api-contracts'
162
+ import { getSseSchemaByEventName } from '@lokalise/api-contracts'
163
+
164
+ getSseSchemaByEventName(notifications)
165
+ // { notification: ZodObject<...> }
122
166
 
123
- const getContract = buildGetRoute({ ... })
124
- const postContract = buildPayloadRoute({ method: 'post', ... })
125
- const deleteContract = buildDeleteRoute({ ... })
167
+ getSseSchemaByEventName(chatCompletion)
168
+ // { chunk: ZodObject<...>, done: ZodObject<...> }
169
+ ```
126
170
 
127
- // After (recommended):
128
- import { buildRestContract } from '@lokalise/api-contracts'
171
+ ### All fields
129
172
 
130
- const getContract = buildRestContract({ ... }) // method inferred as 'get'
131
- const postContract = buildRestContract({ method: 'post', ... })
132
- const deleteContract = buildRestContract({ method: 'delete', ... })
173
+ ```ts
174
+ defineApiContract({
175
+ // Required
176
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
177
+ pathResolver: (pathParams) => string,
178
+ responsesByStatusCode: {
179
+ [statusCode]: z.ZodType | NoBodyResponse | TypedTextResponse | TypedBlobResponse | TypedSseResponse | AnyOfResponses
180
+ },
181
+
182
+ // Path params — links pathResolver parameter type to the schema
183
+ requestPathParamsSchema: z.ZodObject,
184
+
185
+ // Request
186
+ requestBodySchema: z.ZodType | ContractNoBody, // required for POST / PUT / PATCH, forbidden otherwise
187
+ requestQuerySchema: z.ZodObject,
188
+ requestHeaderSchema: z.ZodObject,
189
+
190
+ // Response
191
+ responseHeaderSchema: z.ZodObject,
192
+
193
+ // Documentation
194
+ summary: string,
195
+ description: string,
196
+ tags: readonly string[],
197
+ metadata: Record<string, unknown>,
198
+ })
133
199
  ```
134
200
 
135
- In the previous example, the `metadata` property is an optional, free-form field that allows you to store any additional
136
- information related to the route. If you require more precise type definitions for the `metadata` field, you can utilize
137
- TypeScript's module augmentation mechanism to enforce stricter typing. This allows for more controlled and type-safe
138
- usage in your route definitions.
201
+ ### Header schemas
139
202
 
140
- Here is how you can apply strict typing to the `metadata` property using TypeScript module augmentation:
141
- ```typescript
142
- // file -> apiContracts.d.ts
143
- // Import the existing module to ensure TypeScript recognizes the original definitions
144
- import '@lokalise/api-contracts';
203
+ ```ts
204
+ const contract = defineApiContract({
205
+ method: 'get',
206
+ pathResolver: () => '/api/data',
207
+ requestHeaderSchema: z.object({
208
+ authorization: z.string(),
209
+ 'x-api-key': z.string(),
210
+ }),
211
+ responseHeaderSchema: z.object({
212
+ 'x-ratelimit-remaining': z.string(),
213
+ 'cache-control': z.string(),
214
+ }),
215
+ responsesByStatusCode: {
216
+ 200: dataSchema,
217
+ },
218
+ })
219
+ ```
145
220
 
146
- // Augment the module to extend the interface with specific properties
147
- declare module '@lokalise/api-contracts' {
148
- interface CommonRouteDefinitionMetadata {
149
- myTestProp?: string[];
150
- mySecondTestProp?: number;
151
- }
152
- }
221
+ ### Type utilities
222
+
223
+ **`InferNonSseSuccessResponses<T>`** TypeScript output type of all non-SSE 2xx responses. JSON schemas → `z.output<T>`, `textResponse` → `string`, `blobResponse` → `Blob`, `ContractNoBody`/`NoBodyResponse` → `undefined`, `sseResponse` → `never` (excluded). `anyOfResponses` entries are unpacked before mapping.
224
+
225
+ ```ts
226
+ import type { InferNonSseSuccessResponses } from '@lokalise/api-contracts'
227
+
228
+ type UserResponse = InferNonSseSuccessResponses<typeof getUser['responsesByStatusCode']>
229
+ // { id: string; name: string }
230
+
231
+ type CsvResponse = InferNonSseSuccessResponses<typeof exportCsv['responsesByStatusCode']>
232
+ // string
153
233
  ```
154
234
 
155
- Note that in order to make contract-based requests, you need to use a compatible HTTP client
156
- (`@lokalise/frontend-http-client` or `@lokalise/backend-http-client`)
235
+ **`InferJsonSuccessResponses<T>`** union of Zod schema types for all JSON 2xx entries. Text, Blob, SSE, and `ContractNoBody` entries are excluded.
157
236
 
158
- In case you are using fastify on the backend, you can also use `@lokalise/fastify-api-contracts` in order to simplify definition of your fastify routes, utilizing contracts as the single source of truth.
237
+ **`InferSseSuccessResponses<T>`** extracts the SSE event schema map type from a `responsesByStatusCode` map. Returns `never` when no SSE schemas are present.
159
238
 
160
- ## Header Schemas
239
+ **`HasAnySseSuccessResponse<T>`** `true` if any 2xx entry is a `TypedSseResponse` or an `AnyOfResponses` containing one.
161
240
 
162
- ### Request Headers (`requestHeaderSchema`)
241
+ **`HasAnyJsonSuccessResponse<T>`** `true` if any 2xx entry is a JSON Zod schema or an `AnyOfResponses` containing one.
163
242
 
164
- Use `requestHeaderSchema` to define and validate headers that the client must send with the request. This is useful for authentication headers, API keys, content negotiation, and other request-specific headers.
243
+ **`HasAnyNonSseSuccessResponse<T>`** `true` if any 2xx entry is a non-SSE response (JSON, text, blob, or no-body).
165
244
 
166
- ```ts
167
- import { buildRestContract } from '@lokalise/api-contracts'
168
- import { z } from 'zod'
169
-
170
- const contract = buildRestContract({
171
- successResponseBodySchema: DATA_SCHEMA,
172
- requestHeaderSchema: z.object({
173
- 'authorization': z.string(),
174
- 'x-api-key': z.string(),
175
- 'accept-language': z.string().optional(),
176
- }),
177
- pathResolver: () => '/api/data',
178
- })
179
- ```
245
+ **`IsNoBodySuccessResponse<T>`** — `true` when all 2xx entries are `ContractNoBody`/`NoBodyResponse` or no 2xx status codes are defined.
180
246
 
181
- ### Response Headers (`responseHeaderSchema`)
247
+ **`ContractResponseMode<T>`** classifies a contract into `'dual'` (SSE + non-SSE), `'sse'` (SSE-only), or `'non-sse'` (JSON/text/blob/no-body).
182
248
 
183
- Use `responseHeaderSchema` to define and validate headers that the server will send in the response. This is particularly useful for documenting:
184
- - Rate limiting headers
185
- - Pagination headers
186
- - Cache control headers
187
- - Custom API metadata headers
249
+ **`AvailableResponseModes<T>`** union of mode literals available for a contract: `'json' | 'sse' | 'blob' | 'text' | 'noContent'`.
250
+
251
+ **`SseEventOf<S>`** discriminated union of SSE events inferred from a `schemaByEventName` map. Aligns with the browser `MessageEvent` shape: `{ type, data, lastEventId, retry }`.
188
252
 
189
253
  ```ts
190
- import { buildRestContract } from '@lokalise/api-contracts'
191
- import { z } from 'zod'
192
-
193
- const contract = buildRestContract({
194
- successResponseBodySchema: DATA_SCHEMA,
195
- responseHeaderSchema: z.object({
196
- 'x-ratelimit-limit': z.string(),
197
- 'x-ratelimit-remaining': z.string(),
198
- 'x-ratelimit-reset': z.string(),
199
- 'cache-control': z.string(),
200
- }),
201
- pathResolver: () => '/api/data',
202
- })
254
+ import type { SseEventOf, InferSseSuccessResponses } from '@lokalise/api-contracts'
255
+
256
+ type NotificationEvents = InferSseSuccessResponses<typeof notifications['responsesByStatusCode']>
257
+ type NotificationEvent = SseEventOf<NotificationEvents>
258
+ // { type: 'notification'; data: { id: string; message: string }; lastEventId: string; retry: number | undefined }
203
259
  ```
204
260
 
205
- Both header schemas can be used together in a single contract:
261
+ ### Client types
206
262
 
207
- ```ts
208
- const contract = buildRestContract({
209
- successResponseBodySchema: DATA_SCHEMA,
210
- requestHeaderSchema: z.object({
211
- 'authorization': z.string(),
212
- }),
213
- responseHeaderSchema: z.object({
214
- 'x-ratelimit-limit': z.string(),
215
- 'x-ratelimit-remaining': z.string(),
216
- }),
217
- pathResolver: () => '/api/data',
218
- })
219
- ```
263
+ These types are primarily consumed by HTTP client implementations.
220
264
 
221
- These header schemas are primarily used for:
222
- - OpenAPI/Swagger documentation generation
223
- - Client-side validation of response headers
224
- - Type-safe header access in TypeScript
225
- - Contract testing between frontend and backend
265
+ **`ClientRequestParams<TApiContract, TIsStreaming>`** infers the request parameter object for a contract. Includes `pathParams`, `body`, `queryParams`, `headers` (required when the corresponding schema is defined), `pathPrefix` (always optional), and `streaming` (required for dual-mode contracts, forbidden otherwise).
226
266
 
227
- ## Utility Functions
267
+ **`InferSseClientResponse<TApiContract>`** discriminated union of `{ statusCode, headers, body }` for SSE mode. Success status codes yield `AsyncIterable<SseEventOf<...>>`; error codes yield the declared body type.
228
268
 
229
- ### `mapRouteToPath`
269
+ **`InferNonSseClientResponse<TApiContract>`** — same shape as above for non-SSE mode. Success status codes yield JSON / `string` / `Blob` / `null`; SSE entries are excluded (`never`).
230
270
 
231
- Converts a route definition to its corresponding path pattern with parameter placeholders.
271
+ **`DefaultStreaming<T>`** `true` for SSE-only contracts, `false` for everything else.
232
272
 
233
273
  ```ts
234
- import { mapRouteToPath, buildRestContract } from '@lokalise/api-contracts'
274
+ import type { ClientRequestParams, InferNonSseClientResponse } from '@lokalise/api-contracts'
235
275
 
236
- const userContract = buildRestContract({
237
- requestPathParamsSchema: z.object({ userId: z.string() }),
238
- successResponseBodySchema: USER_SCHEMA,
239
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
240
- })
276
+ type GetUserParams = ClientRequestParams<typeof getUser, false>
277
+ // { pathParams: { userId: string }; pathPrefix?: string }
241
278
 
242
- const pathPattern = mapRouteToPath(userContract)
243
- // Returns: "/users/:userId"
279
+ type GetUserResponse = InferNonSseClientResponse<typeof getUser>
280
+ // { statusCode: 200; headers: Record<string, string>; body: { id: string; name: string } }
244
281
  ```
245
282
 
246
- This function is useful when you need to:
247
- - Generate OpenAPI/Swagger documentation
248
- - Create route patterns for server-side routing frameworks
249
- - Display route information in debugging or logging
283
+ ### Contract type aliases
250
284
 
251
- The function replaces actual path parameters with placeholder syntax (`:paramName`), making it compatible with Express-style route patterns.
285
+ **`ApiContract`** union of all contract variants (`GetApiContract | DeleteApiContract | PayloadApiContract`). Use this to type function parameters that accept any contract.
252
286
 
253
- ### `describeContract`
287
+ **`GetApiContract`**, **`DeleteApiContract`**, **`PayloadApiContract`** — individual contract variants if you need to narrow the type.
254
288
 
255
- Generates a human-readable description of a route contract, combining the HTTP method with the route path.
289
+ **`RequestPathParamsSchema`**, **`RequestQuerySchema`**, **`RequestHeaderSchema`**, **`ResponseHeaderSchema`** type aliases for `z.ZodObject`. Use these to constrain schema arguments in generic helpers.
256
290
 
257
- ```ts
258
- import { describeContract, buildRestContract } from '@lokalise/api-contracts'
291
+ ### Utility functions
259
292
 
260
- const getContract = buildRestContract({
261
- requestPathParamsSchema: z.object({ userId: z.string() }),
262
- successResponseBodySchema: USER_SCHEMA,
263
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
264
- })
293
+ **`mapApiContractToPath`** Express/Fastify-style path pattern.
265
294
 
266
- const postContract = buildRestContract({
267
- method: 'post',
268
- requestPathParamsSchema: z.object({
269
- orgId: z.string(),
270
- userId: z.string()
271
- }),
272
- requestBodySchema: CREATE_USER_SCHEMA,
273
- successResponseBodySchema: USER_SCHEMA,
274
- pathResolver: (pathParams) => `/orgs/${pathParams.orgId}/users/${pathParams.userId}`,
275
- })
295
+ ```ts
296
+ import { mapApiContractToPath } from '@lokalise/api-contracts'
276
297
 
277
- console.log(describeContract(getContract)) // "GET /users/:userId"
278
- console.log(describeContract(postContract)) // "POST /orgs/:orgId/users/:userId"
298
+ mapApiContractToPath(getUser) // "/users/:userId"
279
299
  ```
280
300
 
281
- This function is particularly useful for:
282
- - Logging and debugging API calls
283
- - Generating documentation or route summaries
284
- - Error messages that need to reference specific endpoints
285
- - Test descriptions and assertions
301
+ **`describeApiContract`** human-readable `"METHOD /path"` string.
302
+
303
+ ```ts
304
+ import { describeApiContract } from '@lokalise/api-contracts'
305
+
306
+ describeApiContract(getUser) // "GET /users/:userId"
307
+ ```
286
308
 
287
- ## SSE and Dual-Mode Contracts
309
+ **`getSuccessResponseSchema`** *(deprecated — no known consumers, will be removed in a future release)* — merged Zod schema from all 2xx JSON entries. `ContractNoBody`/`NoBodyResponse` and non-JSON entries are excluded. Returns `null` when no schema is present.
288
310
 
289
- Use `buildSseContract` for endpoints that support Server-Sent Events (SSE) streaming. This builder supports two contract types:
311
+ ```ts
312
+ import { getSuccessResponseSchema } from '@lokalise/api-contracts'
290
313
 
291
- ### SSE-Only Contracts
314
+ getSuccessResponseSchema(getUser) // ZodObject
315
+ getSuccessResponseSchema(deleteUser) // null
316
+ ```
292
317
 
293
- Pure streaming endpoints that only return SSE events. Use these for real-time notifications, live feeds, or any endpoint that only streams data.
318
+ **`getIsEmptyResponseExpected`** *(deprecated no known consumers, will be removed in a future release)* `true` when no Zod schema exists among 2xx entries.
294
319
 
295
320
  ```ts
296
- import { buildSseContract } from '@lokalise/api-contracts'
297
- import { z } from 'zod'
298
-
299
- // GET SSE endpoint for live notifications
300
- // requestPathParamsSchema, requestQuerySchema, requestHeaderSchema are optional
301
- const notificationsStream = buildSseContract({
302
- method: 'get',
303
- pathResolver: () => '/api/notifications/stream',
304
- requestQuerySchema: z.object({ userId: z.string().optional() }),
305
- serverSentEventSchemas: {
306
- notification: z.object({ id: z.string(), message: z.string() }),
307
- },
308
- })
309
- // Result: isSSE: true
310
-
311
- // POST SSE endpoint (e.g., streaming file processing)
312
- const processStream = buildSseContract({
313
- method: 'post',
314
- pathResolver: () => '/api/process/stream',
315
- requestBodySchema: z.object({ fileId: z.string() }),
316
- serverSentEventSchemas: {
317
- progress: z.object({ percent: z.number() }),
318
- done: z.object({ result: z.string() }),
319
- },
320
- })
321
- // Result: isSSE: true
322
-
323
- // SSE endpoint with error schemas (for errors before streaming starts)
324
- const channelStream = buildSseContract({
325
- method: 'get',
326
- pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
327
- requestPathParamsSchema: z.object({ channelId: z.string() }),
328
- requestHeaderSchema: z.object({ authorization: z.string() }),
329
- serverSentEventSchemas: {
330
- message: z.object({ text: z.string() }),
331
- },
332
- // Errors returned before streaming begins
333
- responseBodySchemasByStatusCode: {
334
- 401: z.object({ error: z.literal('Unauthorized') }),
335
- 404: z.object({ error: z.literal('Channel not found') }),
336
- },
337
- })
321
+ import { getIsEmptyResponseExpected } from '@lokalise/api-contracts'
322
+
323
+ getIsEmptyResponseExpected(deleteUser) // true
324
+ getIsEmptyResponseExpected(getUser) // false
338
325
  ```
339
326
 
340
- ### Dual-Mode (Hybrid) Contracts
327
+ **`hasAnySuccessSseResponse`** `true` when any 2xx entry is an SSE response (including inside `anyOfResponses`).
341
328
 
342
- Hybrid endpoints that support **both** synchronous JSON responses **and** SSE streaming from the same URL. The response mode is determined by the client's `Accept` header:
343
- - `Accept: application/json` Synchronous JSON response
344
- - `Accept: text/event-stream` → SSE streaming
329
+ ```ts
330
+ import { hasAnySuccessSseResponse } from '@lokalise/api-contracts'
345
331
 
346
- This pattern is ideal for AI/LLM APIs (like OpenAI's Chat Completions API) where clients can choose between getting the full response at once or streaming it token-by-token.
332
+ hasAnySuccessSseResponse(notifications) // true
333
+ hasAnySuccessSseResponse(getUser) // false
334
+ hasAnySuccessSseResponse(chatCompletion) // true (dual-mode)
335
+ ```
336
+
337
+ **`getSseSchemaByEventName`** — extracts SSE event schemas from a contract. Returns `null` when no SSE schemas are present.
347
338
 
348
339
  ```ts
349
- import { buildSseContract } from '@lokalise/api-contracts'
350
- import { z } from 'zod'
351
-
352
- // OpenAI-style chat completion endpoint
353
- // - Accept: application/json → returns { reply, usage } immediately
354
- // - Accept: text/event-stream → streams chunk events, then done event
355
- const chatCompletion = buildSseContract({
356
- method: 'post',
357
- pathResolver: () => '/api/chat/completions',
358
- requestBodySchema: z.object({ message: z.string() }),
359
- // Adding successResponseBodySchema makes it dual-mode
360
- successResponseBodySchema: z.object({
361
- reply: z.string(),
362
- usage: z.object({ tokens: z.number() }),
363
- }),
364
- serverSentEventSchemas: {
365
- chunk: z.object({ delta: z.string() }),
366
- done: z.object({ usage: z.object({ totalTokens: z.number() }) }),
367
- },
368
- })
369
- // Result: isDualMode: true
370
-
371
- // GET dual-mode endpoint for job status (poll or stream)
372
- const jobStatus = buildSseContract({
373
- method: 'get',
374
- pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
375
- requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
376
- requestQuerySchema: z.object({ verbose: z.string().optional() }),
377
- successResponseBodySchema: z.object({
378
- status: z.enum(['pending', 'running', 'completed', 'failed']),
379
- progress: z.number(),
380
- }),
381
- serverSentEventSchemas: {
382
- progress: z.object({ percent: z.number() }),
383
- done: z.object({ result: z.string() }),
384
- },
385
- })
386
- // Result: isDualMode: true
340
+ import { getSseSchemaByEventName } from '@lokalise/api-contracts'
341
+
342
+ getSseSchemaByEventName(notifications) // { notification: ZodObject<...> }
343
+ getSseSchemaByEventName(getUser) // null
387
344
  ```
388
345
 
389
- ### Response Schemas by Status Code
346
+ ## Module augmentation
390
347
 
391
- Both SSE-only and dual-mode contracts support `responseBodySchemasByStatusCode` for defining different response shapes for errors that occur **before streaming starts** (e.g., authentication failures, validation errors, resource not found):
348
+ If you require more precise type definitions for the `metadata` field, you can utilize TypeScript's module augmentation mechanism to enforce stricter typing:
392
349
 
393
- ```ts
394
- const chatCompletion = buildSseContract({
395
- method: 'post',
396
- pathResolver: () => '/api/chat/completions',
397
- requestHeaderSchema: z.object({ authorization: z.string() }),
398
- requestBodySchema: z.object({ message: z.string() }),
399
- successResponseBodySchema: z.object({ reply: z.string() }),
400
- serverSentEventSchemas: {
401
- chunk: z.object({ delta: z.string() }),
402
- },
403
- responseBodySchemasByStatusCode: {
404
- 400: z.object({ error: z.string(), details: z.array(z.string()) }),
405
- 401: z.object({ error: z.literal('Unauthorized') }),
406
- 429: z.object({ error: z.string(), retryAfter: z.number() }),
407
- },
408
- })
350
+ ```typescript
351
+ // file -> apiContracts.d.ts
352
+ import '@lokalise/api-contracts';
353
+
354
+ declare module '@lokalise/api-contracts' {
355
+ interface CommonRouteDefinitionMetadata {
356
+ myTestProp?: string[];
357
+ mySecondTestProp?: number;
358
+ }
359
+ }
409
360
  ```
410
361
 
411
- ### Contract Type Detection
362
+ ## HTTP clients
363
+
364
+ To make contract-based requests, use a compatible HTTP client (`@lokalise/frontend-http-client` or `@lokalise/backend-http-client`).
412
365
 
413
- `buildSseContract` automatically determines the contract type based on the presence of `successResponseBodySchema`:
366
+ For Fastify backends, use `@lokalise/fastify-api-contracts` to simplify route definition using contracts as the single source of truth.
414
367
 
415
- | `successResponseBodySchema` | `requestBodySchema` | Result |
416
- |----------------------------|---------------------|--------|
417
- | | | SSE-only GET |
418
- | ❌ | ✅ | SSE-only POST/PUT/PATCH |
419
- | ✅ | ❌ | Dual-mode GET |
420
- | ✅ | ✅ | Dual-mode POST/PUT/PATCH |
368
+ ## Future: request body content type
369
+
370
+ Currently, HTTP clients default to `application/json` when a request body is present. The planned improvement is a `requestBodyContentType` field on `defineApiContract`:
371
+
372
+ ```ts
373
+ defineApiContract({
374
+ method: 'post',
375
+ pathResolver: () => '/upload',
376
+ requestBodySchema: z.object({ file: z.unknown() }),
377
+ requestBodyContentType: 'multipart/form-data',
378
+ responsesByStatusCode: { 200: z.object({ url: z.string() }) },
379
+ })
380
+ ```