@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 +286 -326
- package/dist/apiContracts.d.ts +19 -13
- package/dist/apiContracts.js +19 -13
- package/dist/apiContracts.js.map +1 -1
- package/dist/contractBuilder.d.ts +20 -0
- package/dist/contractBuilder.js.map +1 -1
- package/dist/new/contractResponse.d.ts +18 -5
- package/dist/new/contractResponse.js +14 -5
- package/dist/new/contractResponse.js.map +1 -1
- package/dist/new/defineApiContract.d.ts +2 -0
- package/dist/new/defineApiContract.js +5 -2
- package/dist/new/defineApiContract.js.map +1 -1
- package/dist/rest/restContractBuilder.d.ts +20 -0
- package/dist/rest/restContractBuilder.js.map +1 -1
- package/dist/sse/sseContractBuilders.js +37 -0
- package/dist/sse/sseContractBuilders.js.map +1 -1
- package/package.json +57 -57
package/README.md
CHANGED
|
@@ -1,420 +1,380 @@
|
|
|
1
1
|
# api-contracts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
##
|
|
10
|
+
## Defining contracts
|
|
12
11
|
|
|
13
|
-
|
|
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 {
|
|
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
|
-
//
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
75
|
+
Use `blobResponse` for binary responses (images, PDFs, etc.):
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
```ts
|
|
78
|
+
import { defineApiContract, blobResponse } from '@lokalise/api-contracts'
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
const downloadPhoto = defineApiContract({
|
|
81
|
+
method: 'get',
|
|
82
|
+
pathResolver: () => '/photo.png',
|
|
83
|
+
responsesByStatusCode: { 200: blobResponse('image/png') },
|
|
84
|
+
})
|
|
85
|
+
```
|
|
72
86
|
|
|
73
|
-
|
|
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
|
-
|
|
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 {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
162
|
+
import { getSseSchemaByEventName } from '@lokalise/api-contracts'
|
|
163
|
+
|
|
164
|
+
getSseSchemaByEventName(notifications)
|
|
165
|
+
// { notification: ZodObject<...> }
|
|
122
166
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
getSseSchemaByEventName(chatCompletion)
|
|
168
|
+
// { chunk: ZodObject<...>, done: ZodObject<...> }
|
|
169
|
+
```
|
|
126
170
|
|
|
127
|
-
|
|
128
|
-
import { buildRestContract } from '@lokalise/api-contracts'
|
|
171
|
+
### All fields
|
|
129
172
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
**`HasAnySseSuccessResponse<T>`** — `true` if any 2xx entry is a `TypedSseResponse` or an `AnyOfResponses` containing one.
|
|
161
240
|
|
|
162
|
-
|
|
241
|
+
**`HasAnyJsonSuccessResponse<T>`** — `true` if any 2xx entry is a JSON Zod schema or an `AnyOfResponses` containing one.
|
|
163
242
|
|
|
164
|
-
|
|
243
|
+
**`HasAnyNonSseSuccessResponse<T>`** — `true` if any 2xx entry is a non-SSE response (JSON, text, blob, or no-body).
|
|
165
244
|
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
261
|
+
### Client types
|
|
206
262
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
**`DefaultStreaming<T>`** — `true` for SSE-only contracts, `false` for everything else.
|
|
232
272
|
|
|
233
273
|
```ts
|
|
234
|
-
import {
|
|
274
|
+
import type { ClientRequestParams, InferNonSseClientResponse } from '@lokalise/api-contracts'
|
|
235
275
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
//
|
|
279
|
+
type GetUserResponse = InferNonSseClientResponse<typeof getUser>
|
|
280
|
+
// { statusCode: 200; headers: Record<string, string>; body: { id: string; name: string } }
|
|
244
281
|
```
|
|
245
282
|
|
|
246
|
-
|
|
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
|
-
|
|
285
|
+
**`ApiContract`** — union of all contract variants (`GetApiContract | DeleteApiContract | PayloadApiContract`). Use this to type function parameters that accept any contract.
|
|
252
286
|
|
|
253
|
-
|
|
287
|
+
**`GetApiContract`**, **`DeleteApiContract`**, **`PayloadApiContract`** — individual contract variants if you need to narrow the type.
|
|
254
288
|
|
|
255
|
-
|
|
289
|
+
**`RequestPathParamsSchema`**, **`RequestQuerySchema`**, **`RequestHeaderSchema`**, **`ResponseHeaderSchema`** — type aliases for `z.ZodObject`. Use these to constrain schema arguments in generic helpers.
|
|
256
290
|
|
|
257
|
-
|
|
258
|
-
import { describeContract, buildRestContract } from '@lokalise/api-contracts'
|
|
291
|
+
### Utility functions
|
|
259
292
|
|
|
260
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
278
|
-
console.log(describeContract(postContract)) // "POST /orgs/:orgId/users/:userId"
|
|
298
|
+
mapApiContractToPath(getUser) // "/users/:userId"
|
|
279
299
|
```
|
|
280
300
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
+
```ts
|
|
312
|
+
import { getSuccessResponseSchema } from '@lokalise/api-contracts'
|
|
290
313
|
|
|
291
|
-
|
|
314
|
+
getSuccessResponseSchema(getUser) // ZodObject
|
|
315
|
+
getSuccessResponseSchema(deleteUser) // null
|
|
316
|
+
```
|
|
292
317
|
|
|
293
|
-
|
|
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 {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
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
|
-
|
|
327
|
+
**`hasAnySuccessSseResponse`** — `true` when any 2xx entry is an SSE response (including inside `anyOfResponses`).
|
|
341
328
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
- `Accept: text/event-stream` → SSE streaming
|
|
329
|
+
```ts
|
|
330
|
+
import { hasAnySuccessSseResponse } from '@lokalise/api-contracts'
|
|
345
331
|
|
|
346
|
-
|
|
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 {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
//
|
|
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
|
-
|
|
346
|
+
## Module augmentation
|
|
390
347
|
|
|
391
|
-
|
|
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
|
-
```
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
366
|
+
For Fastify backends, use `@lokalise/fastify-api-contracts` to simplify route definition using contracts as the single source of truth.
|
|
414
367
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
```
|