Skip to content

Commit fcca08f

Browse files
author
Mateusz Tkacz
committed
feat: add new way of defining api contracts
1 parent 427181b commit fcca08f

File tree

11 files changed

+1545
-0
lines changed

11 files changed

+1545
-0
lines changed

packages/app/api-contracts/src/HttpStatusCodes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,9 @@ export type HttpStatusCode =
6262
| 508
6363
| 510
6464
| 511
65+
66+
export const SUCCESSFUL_HTTP_STATUS_CODES = [
67+
200, 201, 202, 203, 204, 205, 206, 207, 208, 226,
68+
] as const
69+
70+
export type SuccessfulHttpStatusCode = (typeof SUCCESSFUL_HTTP_STATUS_CODES)[number]

packages/app/api-contracts/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ export * from './contractBuilder.ts'
44
export * from './HttpStatusCodes.ts'
55
export * from './pathUtils.ts'
66
export * from './rest/restContractBuilder.ts'
7+
export * from './new/constants.ts'
8+
export * from './new/contractResponse.ts'
9+
export * from './new/defineApiContract.ts'
10+
export * from './new/inferTypes.ts'
711
// Dual-mode (hybrid) contracts
812
export * from './sse/dualModeContracts.ts'
913
// Contract builders
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# api-contracts
2+
3+
API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route — its path, HTTP method, and request/response schemas — and serves as the single source of truth for both sides.
4+
5+
The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This eliminates assumptions across the boundary and keeps documentation, validation, and types in sync.
6+
7+
## Defining contracts
8+
9+
### REST routes
10+
11+
```ts
12+
import { defineApiContract, ContractNoBody } from '@lokalise/api-contracts'
13+
import { z } from 'zod/v4'
14+
15+
// GET with path params
16+
const getUser = defineApiContract({
17+
method: 'get',
18+
requestPathParamsSchema: z.object({ userId: z.uuid() }),
19+
pathResolver: ({ userId }) => `/users/${userId}`,
20+
responseSchemasByStatusCode: {
21+
200: z.object({ id: z.string(), name: z.string() }),
22+
},
23+
})
24+
25+
// POST
26+
const createUser = defineApiContract({
27+
method: 'post',
28+
pathResolver: () => '/users',
29+
requestBodySchema: z.object({ name: z.string() }),
30+
responseSchemasByStatusCode: {
31+
201: z.object({ id: z.string(), name: z.string() }),
32+
},
33+
})
34+
35+
// DELETE with no response body
36+
const deleteUser = defineApiContract({
37+
method: 'delete',
38+
requestPathParamsSchema: z.object({ userId: z.uuid() }),
39+
pathResolver: ({ userId }) => `/users/${userId}`,
40+
responseSchemasByStatusCode: {
41+
204: ContractNoBody,
42+
},
43+
})
44+
```
45+
46+
### Non-JSON responses
47+
48+
Use `textResponse` for plain-text or CSV responses, and `blobResponse` for binary responses (images, PDFs, etc.). Both carry the content type.
49+
50+
```ts
51+
import { defineApiContract, textResponse, blobResponse } from '@lokalise/api-contracts'
52+
53+
const exportCsv = defineApiContract({
54+
method: 'get',
55+
pathResolver: () => '/export.csv',
56+
responseSchemasByStatusCode: {
57+
200: textResponse('text/csv'),
58+
},
59+
})
60+
61+
const downloadPhoto = defineApiContract({
62+
method: 'get',
63+
pathResolver: () => '/photo.png',
64+
responseSchemasByStatusCode: {
65+
200: blobResponse('image/png'),
66+
},
67+
})
68+
```
69+
70+
### SSE and dual-mode routes
71+
72+
Use `sseResponse()` inside `responseSchemasByStatusCode` to define SSE event schemas. 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.
73+
74+
```ts
75+
import { defineApiContract, sseResponse, anyOfResponses } from '@lokalise/api-contracts'
76+
import { z } from 'zod/v4'
77+
78+
// SSE-only
79+
const notifications = defineApiContract({
80+
method: 'get',
81+
pathResolver: () => '/notifications/stream',
82+
responseSchemasByStatusCode: {
83+
200: sseResponse({
84+
notification: z.object({ id: z.string(), message: z.string() }),
85+
}),
86+
},
87+
})
88+
89+
// Dual-mode: JSON response or SSE stream depending on Accept header
90+
const chatCompletion = defineApiContract({
91+
method: 'post',
92+
pathResolver: () => '/chat/completions',
93+
requestBodySchema: z.object({ message: z.string() }),
94+
responseSchemasByStatusCode: {
95+
200: anyOfResponses([
96+
sseResponse({
97+
chunk: z.object({ delta: z.string() }),
98+
done: z.object({ finish_reason: z.string() }),
99+
}),
100+
z.object({ text: z.string() }),
101+
]),
102+
},
103+
})
104+
```
105+
106+
`getSseSchemaByEventName(contract)` extracts SSE event schemas from a contract:
107+
108+
```ts
109+
import { getSseSchemaByEventName } from '@lokalise/api-contracts'
110+
111+
getSseSchemaByEventName(notifications)
112+
// { notification: ZodObject<...> }
113+
114+
getSseSchemaByEventName(chatCompletion)
115+
// { chunk: ZodObject<...>, done: ZodObject<...> }
116+
```
117+
118+
### All fields
119+
120+
```ts
121+
defineApiContract({
122+
// Required
123+
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
124+
pathResolver: (pathParams) => string,
125+
responseSchemasByStatusCode: {
126+
[statusCode]: z.ZodType | ContractNoBody | TypedTextResponse | TypedBlobResponse | TypedSseResponse | AnyOfResponses
127+
},
128+
129+
// Path params — links pathResolver parameter type to the schema
130+
requestPathParamsSchema: z.ZodType,
131+
132+
// Request
133+
requestBodySchema: z.ZodType | ContractNoBody, // POST / PUT / PATCH only
134+
requestQuerySchema: z.ZodType,
135+
requestHeaderSchema: z.ZodType,
136+
137+
// Response
138+
responseHeaderSchema: z.ZodType,
139+
140+
// Documentation
141+
summary: string,
142+
description: string,
143+
tags: readonly string[],
144+
metadata: Record<string, unknown>,
145+
})
146+
```
147+
148+
### Header schemas
149+
150+
```ts
151+
const contract = defineApiContract({
152+
method: 'get',
153+
pathResolver: () => '/api/data',
154+
requestHeaderSchema: z.object({
155+
authorization: z.string(),
156+
'x-api-key': z.string(),
157+
}),
158+
responseHeaderSchema: z.object({
159+
'x-ratelimit-remaining': z.string(),
160+
'cache-control': z.string(),
161+
}),
162+
responseSchemasByStatusCode: {
163+
200: dataSchema,
164+
},
165+
})
166+
```
167+
168+
### Type utilities
169+
170+
**`InferNonSseSuccessResponses<T>`** — TypeScript output type of all non-SSE 2xx responses. JSON schemas → `z.output<T>`, `textResponse``string`, `blobResponse``Blob`, `ContractNoBody``undefined`, `sseResponse``never` (excluded). `anyOfResponses` entries are unpacked before mapping.
171+
172+
```ts
173+
import type { InferNonSseSuccessResponses } from '@lokalise/api-contracts'
174+
175+
type UserResponse = InferNonSseSuccessResponses<typeof getUser['responseSchemasByStatusCode']>
176+
// { id: string; name: string }
177+
178+
type CsvResponse = InferNonSseSuccessResponses<typeof exportCsv['responseSchemasByStatusCode']>
179+
// string
180+
```
181+
182+
**`InferJsonSuccessResponses<T>`** — union of Zod schema types for all JSON 2xx entries. Text, Blob, SSE, and `ContractNoBody` entries are excluded.
183+
184+
**`InferSseSuccessResponses<T>`** — extracts the SSE event schema map type from a `responseSchemasByStatusCode` map. Returns `never` when no SSE schemas are present.
185+
186+
**`HasAnySseSuccessResponse<T>`** — `true` if any 2xx entry is a `TypedSseResponse` or an `AnyOfResponses` containing one.
187+
188+
**`HasAnyJsonSuccessResponse<T>`** — `true` if any 2xx entry is a JSON Zod schema or an `AnyOfResponses` containing one.
189+
190+
**`IsNoBodySuccessResponse<T>`** — `true` when all 2xx entries are `ContractNoBody` or no 2xx status codes are defined.
191+
192+
### Utility functions
193+
194+
**`mapApiContractToPath`** — Express/Fastify-style path pattern.
195+
196+
```ts
197+
import { mapApiContractToPath } from '@lokalise/api-contracts'
198+
199+
mapApiContractToPath(getUser) // "/users/:userId"
200+
```
201+
202+
**`describeApiContract`** — human-readable `"METHOD /path"` string.
203+
204+
```ts
205+
import { describeApiContract } from '@lokalise/api-contracts'
206+
207+
describeApiContract(getUser) // "GET /users/:userId"
208+
```
209+
210+
**`getSuccessResponseSchema`** — merged Zod schema from all 2xx JSON entries. `ContractNoBody` and non-JSON entries are excluded. Returns `null` when no schema is present.
211+
212+
```ts
213+
import { getSuccessResponseSchema } from '@lokalise/api-contracts'
214+
215+
getSuccessResponseSchema(getUser) // ZodObject
216+
getSuccessResponseSchema(deleteUser) // null
217+
```
218+
219+
**`getIsEmptyResponseExpected`**`true` when no Zod schema exists among 2xx entries.
220+
221+
```ts
222+
import { getIsEmptyResponseExpected } from '@lokalise/api-contracts'
223+
224+
getIsEmptyResponseExpected(deleteUser) // true
225+
getIsEmptyResponseExpected(getUser) // false
226+
```
227+
228+
**`getSseSchemaByEventName`** — extracts SSE event schemas from a contract. Returns `null` when no SSE schemas are present.
229+
230+
```ts
231+
import { getSseSchemaByEventName } from '@lokalise/api-contracts'
232+
233+
getSseSchemaByEventName(notifications) // { notification: ZodObject<...> }
234+
getSseSchemaByEventName(getUser) // null
235+
```
236+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const ContractNoBody = Symbol.for('ContractNoBody')
2+
export type ContractNoBodyType = typeof ContractNoBody
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { z } from 'zod/v4'
3+
import { ContractNoBody } from './constants.ts'
4+
import {
5+
anyOfResponses,
6+
blobResponse,
7+
resolveContractResponse,
8+
sseResponse,
9+
textResponse,
10+
} from './contractResponse.ts'
11+
12+
describe('resolveContractResponse', () => {
13+
describe('ContractNoBody', () => {
14+
it('returns noContent regardless of content-type', () => {
15+
expect(resolveContractResponse(ContractNoBody, 'application/json')).toEqual({
16+
kind: 'noContent',
17+
})
18+
expect(resolveContractResponse(ContractNoBody, undefined)).toEqual({ kind: 'noContent' })
19+
})
20+
})
21+
22+
describe('missing content-type', () => {
23+
it('returns null for typed responses when content-type is absent', () => {
24+
expect(resolveContractResponse(z.object({ id: z.string() }), undefined)).toBeNull()
25+
expect(resolveContractResponse(textResponse('text/csv'), undefined)).toBeNull()
26+
expect(resolveContractResponse(blobResponse('image/png'), undefined)).toBeNull()
27+
})
28+
})
29+
30+
describe('JSON (ZodType)', () => {
31+
it('resolves to json for application/json content-type', () => {
32+
const schema = z.object({ id: z.string() })
33+
const result = resolveContractResponse(schema, 'application/json')
34+
expect(result).toEqual({ kind: 'json', schema })
35+
})
36+
37+
it('returns null for non-json content-type', () => {
38+
const schema = z.object({ id: z.string() })
39+
expect(resolveContractResponse(schema, 'text/plain')).toBeNull()
40+
})
41+
})
42+
43+
describe('textResponse', () => {
44+
it('resolves to text when content-type matches', () => {
45+
expect(resolveContractResponse(textResponse('text/csv'), 'text/csv; charset=utf-8')).toEqual({
46+
kind: 'text',
47+
})
48+
})
49+
50+
it('returns null when content-type does not match', () => {
51+
expect(resolveContractResponse(textResponse('text/csv'), 'application/json')).toBeNull()
52+
})
53+
})
54+
55+
describe('blobResponse', () => {
56+
it('resolves to blob when content-type matches', () => {
57+
expect(resolveContractResponse(blobResponse('image/png'), 'image/png')).toEqual({
58+
kind: 'blob',
59+
})
60+
})
61+
62+
it('returns null when content-type does not match', () => {
63+
expect(resolveContractResponse(blobResponse('image/png'), 'application/json')).toBeNull()
64+
})
65+
})
66+
67+
describe('sseResponse', () => {
68+
it('resolves to sse for text/event-stream content-type', () => {
69+
const schema = { update: z.object({ id: z.string() }) }
70+
const result = resolveContractResponse(sseResponse(schema), 'text/event-stream')
71+
expect(result).toEqual({ kind: 'sse', schemaByEventName: schema })
72+
})
73+
74+
it('returns null for non-sse content-type', () => {
75+
expect(
76+
resolveContractResponse(sseResponse({ update: z.string() }), 'application/json'),
77+
).toBeNull()
78+
})
79+
})
80+
81+
describe('anyOfResponses', () => {
82+
it('resolves to the first matching entry by content-type', () => {
83+
const schema = z.object({ id: z.string() })
84+
const entry = anyOfResponses([textResponse('text/csv'), schema])
85+
86+
expect(resolveContractResponse(entry, 'text/csv')).toEqual({ kind: 'text' })
87+
expect(resolveContractResponse(entry, 'application/json')).toEqual({ kind: 'json', schema })
88+
})
89+
90+
it('resolves SSE entry inside anyOfResponses', () => {
91+
const sseSchema = { tick: z.object({ count: z.number() }) }
92+
const entry = anyOfResponses([sseResponse(sseSchema), z.object({ total: z.number() })])
93+
94+
expect(resolveContractResponse(entry, 'text/event-stream')).toEqual({
95+
kind: 'sse',
96+
schemaByEventName: sseSchema,
97+
})
98+
})
99+
100+
it('returns null when no entry matches content-type', () => {
101+
const entry = anyOfResponses([textResponse('text/csv'), blobResponse('image/png')])
102+
expect(resolveContractResponse(entry, 'application/json')).toBeNull()
103+
})
104+
})
105+
})

0 commit comments

Comments
 (0)