opinionated-machine 7.0.0 → 9.0.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/CHANGELOG.md +49 -0
- package/README.md +14 -1
- package/dist/lib/api-contracts/AbstractApiController.d.ts +6 -3
- package/dist/lib/api-contracts/AbstractApiController.js +6 -3
- package/dist/lib/api-contracts/AbstractApiController.js.map +1 -1
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +57 -15
- package/dist/lib/api-contracts/apiRouteBuilder.js +14 -347
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
- package/dist/lib/api-contracts/index.d.ts +2 -2
- package/dist/lib/api-contracts/index.js.map +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.d.ts +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.js +1 -1
- package/dist/lib/gateway/gatewayMetadata.d.ts +0 -5
- package/dist/lib/gateway/gatewayMetadata.js +0 -1
- package/dist/lib/gateway/gatewayMetadata.js.map +1 -1
- package/dist/lib/gateway/manifest/manifestSchema.d.ts +0 -10
- package/dist/lib/routes/fastifyRouteBuilder.js +24 -13
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +1 -1
- package/dist/lib/sse/AbstractSSEController.js +1 -1
- package/package.json +85 -89
- package/dist/lib/api-contracts/apiHandlerTypes.d.ts +0 -161
- package/dist/lib/api-contracts/apiHandlerTypes.js +0 -2
- package/dist/lib/api-contracts/apiHandlerTypes.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# opinionated-machine
|
|
2
|
+
|
|
3
|
+
## 9.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- d6da03e: Adopt mandatory contract visibility (`@lokalise/api-contracts` v8):
|
|
8
|
+
|
|
9
|
+
- Raise peer dependency floors to `@lokalise/api-contracts` >= 8.0.0 (visibility is now a required
|
|
10
|
+
field of every contract builder config) and `@lokalise/fastify-api-contracts` >= 7.0.0.
|
|
11
|
+
- Derive the fastify-swagger `hide` flag from contract `visibility` in the SSE and dual-mode route
|
|
12
|
+
builders, failing closed: only `visibility: 'public'` contracts appear in generated OpenAPI docs.
|
|
13
|
+
Anything else — `'internal'`, or a contract that lacks the field at runtime because it was compiled
|
|
14
|
+
against a pre-visibility `@lokalise/api-contracts` — sets `schema.hide: true` and is excluded,
|
|
15
|
+
matching `@lokalise/fastify-api-contracts`. The same builders now also map the contract's
|
|
16
|
+
`description`, `summary` and `tags` to the route schema; previously these fields were dropped and
|
|
17
|
+
the routes appeared undocumented.
|
|
18
|
+
- Remove the unused `visibility` field from the gateway metadata schema. No generator consumed it
|
|
19
|
+
and it collided with the (unrelated) contract `visibility` — a docs-internal BFF route can still
|
|
20
|
+
be gateway-public, so the two concepts cannot be derived from each other. If gateway-level
|
|
21
|
+
exposure classification is needed later, it will be reintroduced under a non-colliding name
|
|
22
|
+
(e.g. `exposure`). The metadata schema is strict, so passing `visibility` now fails loudly.
|
|
23
|
+
|
|
24
|
+
## 8.0.0
|
|
25
|
+
|
|
26
|
+
### Major Changes
|
|
27
|
+
|
|
28
|
+
- de12db0: Replace the local ApiContract route builder with `buildFastifyApiRoute` from `@lokalise/fastify-api-contracts` (>=6.0.0, now the source of the route-building, handler inference, response validation, and SSE streaming logic). `buildApiRoute` remains as a thin wrapper that only adds the contract-narrowed `gatewayMetadata` option.
|
|
29
|
+
|
|
30
|
+
Breaking changes:
|
|
31
|
+
|
|
32
|
+
- Handlers now use the package's unified `(request, reply, context) => { status, body }` shape for every response mode. SSE streaming is driven via `context.sse.start(...)` (or by returning an `AsyncIterable` body for an SSE status); dual-mode `{ nonSse, sse }` handler objects and `sse.respond()` / `sse.sendHeaders()` are gone — branch on `context.expectedContentType` and return `{ status, body }` for early HTTP responses.
|
|
33
|
+
- `ApiNonSseHandler`, `ApiSseHandler`, `InferApiRequest`, and `InferApiStatusResponse` are removed; `InferApiHandler`, `InferApiHandlerRequest`, `InferApiHandlerResult`, `ApiHandlerContext`, and `ApiHandlerReply` are re-exported from `@lokalise/fastify-api-contracts` instead.
|
|
34
|
+
- `buildApiRoute` options: `defaultMode` is removed (use `context.expectedContentType`). `heartbeatInterval` is removed — per-route heartbeat intervals are no longer possible: the package's `heartbeat` option is a boolean that only enables/disables the heartbeat for a route, and the interval itself is configured once for all routes at `@fastify/sse` plugin registration (`heartbeatInterval`, default 30000 ms).
|
|
35
|
+
- Response body validation is delegated to the `fastify-type-provider-zod` serializer compiler — apps must register `validatorCompiler` / `serializerCompiler`. This applies to every ApiContract route, not just JSON ones: the package emits a Zod response schema for every declared status (including `sseBody()` and `noBodyResponse()`, which previously produced no response schema), so an app missing `setSerializerCompiler` now fails at boot during route registration — even for SSE-only contracts — with `FST_ERR_SCH_SERIALIZATION_BUILD: Failed building the serialization schema … schema is invalid: data/required must be array`. If you hit that error, register the zod compilers. Peer ranges bumped: `@lokalise/fastify-api-contracts` >=6.0.0, `fastify-type-provider-zod` >=7.0.0.
|
|
36
|
+
- Error handling is delegated to the app's global `fastify.setErrorHandler` (per the `@lokalise/fastify-api-contracts` README), including for SSE routes: the route builder no longer maps the node-core `httpStatusCode` error convention (`PublicNonRecoverableError`, `InternalError`, …) onto responses — such errors now reach the error handler unmapped and default to 500 — and no longer emits a terminal SSE `error` event when a handler throws after `sse.start()`. Note the resulting cross-system split: legacy `buildFastifyRoute` / SSE / dual-mode routes still honor `httpStatusCode` internally, so an app mixing both route families maps the same error to different statuses. Install a global error handler that maps `httpStatusCode` (and, if your clients rely on it, emits the terminal SSE `error` event) before upgrading.
|
|
37
|
+
- The `SSESession` / `SSEContext` / `SSESessionMode` / `FastifySSERouteOptions` types exported from the package root still resolve to the legacy `lib/routes` types and no longer match what `buildApiRoute` handlers and lifecycle hooks (`onConnect` / `onClose`) actually receive — the package's session has no `rooms` / `eventSchemas`, an optional `context`, adds `close()`, and its context has no `respond()` / `sendHeaders()`. When typing `buildApiRoute` sessions, contexts, or hooks explicitly, import these types from `@lokalise/fastify-api-contracts` directly; the root exports keep typing the legacy `AbstractSSEController` / `AbstractDualModeController` routes.
|
|
38
|
+
|
|
39
|
+
## 7.0.0
|
|
40
|
+
|
|
41
|
+
### Major Changes
|
|
42
|
+
|
|
43
|
+
- e3a05b6: Require `@lokalise/api-contracts` >= 7.0.0. The route builder no longer handles the legacy response entries removed in api-contracts 7 (`anyOfResponses`, `sseResponse`/`blobResponse`/`textResponse` tagged objects, `ContractNoBody` as a response) — declare responses with bare Zod schemas, `noBodyResponse()`, or content maps (`{ content: { 'text/event-stream': sseBody(...) } }`). Handler body types are now also inferred from content-map entries (JSON media types resolve to their Zod output type, blob to `Blob`, `allowNoBody` to `undefined`).
|
|
44
|
+
|
|
45
|
+
## 6.20.3
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 22a290b: Migrate release automation to Changesets.
|
package/README.md
CHANGED
|
@@ -475,6 +475,7 @@ const PATH_PARAMS_SCHEMA = z.object({
|
|
|
475
475
|
})
|
|
476
476
|
|
|
477
477
|
const contract = buildRestContract({
|
|
478
|
+
visibility: 'public',
|
|
478
479
|
method: 'delete',
|
|
479
480
|
successResponseBodySchema: BODY_SCHEMA,
|
|
480
481
|
requestPathParamsSchema: PATH_PARAMS_SCHEMA,
|
|
@@ -741,6 +742,7 @@ import { buildSseContract } from '@lokalise/api-contracts'
|
|
|
741
742
|
|
|
742
743
|
// GET-based SSE stream with path params
|
|
743
744
|
export const channelStreamContract = buildSseContract({
|
|
745
|
+
visibility: 'public',
|
|
744
746
|
method: 'get',
|
|
745
747
|
pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
|
|
746
748
|
requestPathParamsSchema: z.object({ channelId: z.string() }),
|
|
@@ -753,6 +755,7 @@ export const channelStreamContract = buildSseContract({
|
|
|
753
755
|
|
|
754
756
|
// GET-based SSE stream without path params
|
|
755
757
|
export const notificationsContract = buildSseContract({
|
|
758
|
+
visibility: 'public',
|
|
756
759
|
method: 'get',
|
|
757
760
|
pathResolver: () => '/api/notifications/stream',
|
|
758
761
|
requestPathParamsSchema: z.object({}),
|
|
@@ -768,6 +771,7 @@ export const notificationsContract = buildSseContract({
|
|
|
768
771
|
|
|
769
772
|
// POST-based SSE stream (e.g., AI chat completions)
|
|
770
773
|
export const chatCompletionContract = buildSseContract({
|
|
774
|
+
visibility: 'public',
|
|
771
775
|
method: 'post',
|
|
772
776
|
pathResolver: () => '/api/chat/completions',
|
|
773
777
|
requestPathParamsSchema: z.object({}),
|
|
@@ -1128,6 +1132,7 @@ The mapper can return any of: `config`, `bodyLimit`, `onRequest`, `preParsing`,
|
|
|
1128
1132
|
```ts
|
|
1129
1133
|
// In the contract definition
|
|
1130
1134
|
const adminStreamContract = buildSseContract({
|
|
1135
|
+
visibility: 'public',
|
|
1131
1136
|
method: 'get',
|
|
1132
1137
|
pathResolver: () => '/api/admin/stream',
|
|
1133
1138
|
// ...schemas...
|
|
@@ -1521,6 +1526,7 @@ import { z } from 'zod'
|
|
|
1521
1526
|
import { injectSSE } from 'opinionated-machine'
|
|
1522
1527
|
|
|
1523
1528
|
const streamContract = buildSseContract({
|
|
1529
|
+
visibility: 'public',
|
|
1524
1530
|
method: 'get',
|
|
1525
1531
|
pathResolver: () => '/api/stream',
|
|
1526
1532
|
requestQuerySchema: z.object({}),
|
|
@@ -2262,6 +2268,7 @@ import { buildSseContract } from '@lokalise/api-contracts'
|
|
|
2262
2268
|
|
|
2263
2269
|
// GET dual-mode route (polling or streaming job status)
|
|
2264
2270
|
export const jobStatusContract = buildSseContract({
|
|
2271
|
+
visibility: 'public',
|
|
2265
2272
|
method: 'get',
|
|
2266
2273
|
pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
|
|
2267
2274
|
requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
|
|
@@ -2280,6 +2287,7 @@ export const jobStatusContract = buildSseContract({
|
|
|
2280
2287
|
|
|
2281
2288
|
// POST dual-mode route (OpenAI-style chat completion)
|
|
2282
2289
|
export const chatCompletionContract = buildSseContract({
|
|
2290
|
+
visibility: 'public',
|
|
2283
2291
|
method: 'post',
|
|
2284
2292
|
pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
|
|
2285
2293
|
requestPathParamsSchema: z.object({ chatId: z.string().uuid() }),
|
|
@@ -2305,6 +2313,7 @@ Dual-mode contracts support an optional `responseHeaderSchema` to define and val
|
|
|
2305
2313
|
|
|
2306
2314
|
```ts
|
|
2307
2315
|
export const rateLimitedContract = buildSseContract({
|
|
2316
|
+
visibility: 'public',
|
|
2308
2317
|
method: 'post',
|
|
2309
2318
|
pathResolver: () => '/api/rate-limited',
|
|
2310
2319
|
requestPathParamsSchema: z.object({}),
|
|
@@ -2350,6 +2359,7 @@ Dual-mode and SSE contracts support `responseBodySchemasByStatusCode` to define
|
|
|
2350
2359
|
|
|
2351
2360
|
```ts
|
|
2352
2361
|
export const resourceContract = buildSseContract({
|
|
2362
|
+
visibility: 'public',
|
|
2353
2363
|
method: 'post',
|
|
2354
2364
|
pathResolver: (params) => `/api/resources/${params.id}`,
|
|
2355
2365
|
requestPathParamsSchema: z.object({ id: z.string() }),
|
|
@@ -2876,12 +2886,14 @@ import {
|
|
|
2876
2886
|
import { z } from 'zod/v4'
|
|
2877
2887
|
|
|
2878
2888
|
const getUser = buildRestContract({
|
|
2889
|
+
visibility: 'public',
|
|
2879
2890
|
method: 'get',
|
|
2880
2891
|
successResponseBodySchema: z.object({ id: z.string() }),
|
|
2881
2892
|
requestPathParamsSchema: z.object({ userId: z.string() }),
|
|
2882
2893
|
pathResolver: (p) => `/users/${p.userId}`,
|
|
2883
2894
|
})
|
|
2884
2895
|
const createUser = buildRestContract({
|
|
2896
|
+
visibility: 'public',
|
|
2885
2897
|
method: 'post',
|
|
2886
2898
|
requestBodySchema: z.object({ name: z.string() }),
|
|
2887
2899
|
successResponseBodySchema: z.object({ id: z.string() }),
|
|
@@ -3031,6 +3043,7 @@ become compile errors before you ever ship a config:
|
|
|
3031
3043
|
|
|
3032
3044
|
```ts
|
|
3033
3045
|
const getUser = buildRestContract({
|
|
3046
|
+
visibility: 'public',
|
|
3034
3047
|
method: 'get',
|
|
3035
3048
|
successResponseBodySchema: ResponseBody,
|
|
3036
3049
|
requestHeaderSchema: z.object({ 'x-trace-id': z.string() }),
|
|
@@ -3088,7 +3101,7 @@ time.
|
|
|
3088
3101
|
| `rewrite` | `{ stripPrefix: '/v2' }` or `{ replacePrefix: { from: '/v1', to: '/v2' } }` | |
|
|
3089
3102
|
| `traffic` | `{ weights: [{ upstream: 'a', weight: 80 }, { upstream: 'b', weight: 20 }] }` | Also `shadow: { upstream, percent }` |
|
|
3090
3103
|
| `headers` | `{ request: { add: { 'x-internal': 'true' }, remove: ['cookie'] }, response: … }` | Free-form keys; typically infra headers not in the contract |
|
|
3091
|
-
| `tags
|
|
3104
|
+
| `tags` | `tags: ['users']` | Documentation / partitioning |
|
|
3092
3105
|
| `extensions` | `{ envoy: { … }, krakend: { … }, kong: { … } }` | Vendor escape hatch; merged onto the generated route last |
|
|
3093
3106
|
|
|
3094
3107
|
### Generating Gateway Configs
|
|
@@ -20,9 +20,12 @@ import type { GatewayMetadataValue } from '../gateway/gatewayMetadata.ts';
|
|
|
20
20
|
* status: 200,
|
|
21
21
|
* body: { id: req.params.id },
|
|
22
22
|
* })),
|
|
23
|
-
* streamUpdates: buildApiRoute(
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* streamUpdates: buildApiRoute(
|
|
24
|
+
* UserController.contracts.streamUpdates,
|
|
25
|
+
* async (_req, _reply, { sse }) => {
|
|
26
|
+
* sse.start('keepAlive')
|
|
27
|
+
* },
|
|
28
|
+
* ),
|
|
26
29
|
* }
|
|
27
30
|
* }
|
|
28
31
|
* ```
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* status: 200,
|
|
18
18
|
* body: { id: req.params.id },
|
|
19
19
|
* })),
|
|
20
|
-
* streamUpdates: buildApiRoute(
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* streamUpdates: buildApiRoute(
|
|
21
|
+
* UserController.contracts.streamUpdates,
|
|
22
|
+
* async (_req, _reply, { sse }) => {
|
|
23
|
+
* sse.start('keepAlive')
|
|
24
|
+
* },
|
|
25
|
+
* ),
|
|
23
26
|
* }
|
|
24
27
|
* }
|
|
25
28
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AbstractApiController.js","sourceRoot":"","sources":["../../../lib/api-contracts/AbstractApiController.ts"],"names":[],"mappings":"AAIA
|
|
1
|
+
{"version":3,"file":"AbstractApiController.js","sourceRoot":"","sources":["../../../lib/api-contracts/AbstractApiController.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAgB,qBAAqB;IAGzC;;;;;;;OAOG;IACa,eAAe,CAAuB;CACvD"}
|
|
@@ -1,23 +1,65 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import { type ApiRouteOptions as FastifyApiRouteOptions, type InferApiHandler } from '@lokalise/fastify-api-contracts';
|
|
2
3
|
import type { RouteOptions } from 'fastify';
|
|
3
|
-
import type {
|
|
4
|
+
import type { GatewayMetadata } from '../gateway/gatewayTypes.ts';
|
|
5
|
+
/**
|
|
6
|
+
* Options for configuring an ApiContract route.
|
|
7
|
+
*
|
|
8
|
+
* All options from `@lokalise/fastify-api-contracts` (any Fastify route field
|
|
9
|
+
* minus the ones the contract provides, SSE lifecycle hooks, and
|
|
10
|
+
* `contractMetadataToRouteMapper`) pass through to `buildFastifyApiRoute`
|
|
11
|
+
* unchanged.
|
|
12
|
+
*
|
|
13
|
+
* Generic in `Contract` so `gatewayMetadata.match.headers` / `match.query`
|
|
14
|
+
* keys are narrowed to the contract's request schemas. The generic is always
|
|
15
|
+
* inferred from the contract argument at the `buildApiRoute` call site, so
|
|
16
|
+
* direct references should write `ApiRouteOptions<typeof myContract>` when
|
|
17
|
+
* gateway metadata typing is needed.
|
|
18
|
+
*/
|
|
19
|
+
export type ApiRouteOptions<Contract extends ApiContract> = FastifyApiRouteOptions & {
|
|
20
|
+
/**
|
|
21
|
+
* Per-route gateway metadata. `match.headers` / `match.query` keys are
|
|
22
|
+
* narrowed to the contract's request schemas; `customHeaders` /
|
|
23
|
+
* `customQuery` remain the escape hatch for headers and params not
|
|
24
|
+
* declared on the contract. Validated at runtime against the same Zod
|
|
25
|
+
* schema used by `withGatewayMetadata` and stamped on the route via the
|
|
26
|
+
* shared `GATEWAY_METADATA_SYMBOL`.
|
|
27
|
+
*
|
|
28
|
+
* Equivalent to wrapping the result with `withGatewayMetadata` — keep
|
|
29
|
+
* to one form per route. If both are used on the same route, the later
|
|
30
|
+
* call (typically `withGatewayMetadata`) overwrites the inline value;
|
|
31
|
+
* there is no merge.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* buildApiRoute(MyController.contracts.getItem, this.getItem, {
|
|
36
|
+
* gatewayMetadata: {
|
|
37
|
+
* cache: { ttl: '60s' },
|
|
38
|
+
* match: {
|
|
39
|
+
* // narrowed to keys of the contract's requestHeaderSchema:
|
|
40
|
+
* headers: { 'x-trace-id': { regex: '^[a-f0-9]+$' } },
|
|
41
|
+
* // escape hatch for headers not declared on the contract:
|
|
42
|
+
* customHeaders: { 'x-tenant-id': { regex: '^t_' } },
|
|
43
|
+
* },
|
|
44
|
+
* },
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
gatewayMetadata?: GatewayMetadata<Contract>;
|
|
49
|
+
};
|
|
4
50
|
/**
|
|
5
51
|
* Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
|
|
6
52
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
53
|
+
* Thin wrapper around `buildFastifyApiRoute` from
|
|
54
|
+
* `@lokalise/fastify-api-contracts` — the handler shape, response mode
|
|
55
|
+
* inference, SSE streaming, and validation semantics are all the package's.
|
|
56
|
+
* See its docs for the `(request, reply, context) => { status, body }`
|
|
57
|
+
* handler model and `context.sse` streaming.
|
|
11
58
|
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `heartbeatInterval`) — applied for `'sse'` and `'dual'` contracts only,
|
|
17
|
-
* - `defaultMode` for `'dual'` contracts when the `Accept` header is ambiguous,
|
|
18
|
-
* - `gatewayMetadata` — per-route gateway policy with header / query keys
|
|
19
|
-
* narrowed to the contract; equivalent to wrapping the result with
|
|
20
|
-
* `withGatewayMetadata`. See `ApiRouteOptions` for full details.
|
|
59
|
+
* On top of the package builder this adds one option: `gatewayMetadata` —
|
|
60
|
+
* per-route gateway policy with header / query keys narrowed to the
|
|
61
|
+
* contract; equivalent to wrapping the result with `withGatewayMetadata`.
|
|
62
|
+
* See `ApiRouteOptions` for full details.
|
|
21
63
|
*
|
|
22
64
|
* @returns Fastify `RouteOptions` ready to pass to `app.route()`
|
|
23
65
|
*/
|
|
@@ -1,358 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ContractNoBody, getSseSchemaByEventName, hasAnySuccessSseResponse, isContentResponseEntry, isSseBody, mapApiContractToPath, resolveContractResponse, SUCCESSFUL_HTTP_STATUS_CODES, } from '@lokalise/api-contracts';
|
|
3
|
-
import { InternalError } from '@lokalise/node-core';
|
|
4
|
-
import { isErrorLike } from "../errorUtils.js";
|
|
1
|
+
import { buildFastifyApiRoute, } from '@lokalise/fastify-api-contracts';
|
|
5
2
|
import { attachGatewayMetadata } from "../gateway/withGatewayMetadata.js";
|
|
6
|
-
import { determineMode, hasHttpStatusCode } from "../routes/fastifyRouteUtils.js";
|
|
7
|
-
function isSuccessResponseDual(value) {
|
|
8
|
-
if (isContentResponseEntry(value)) {
|
|
9
|
-
// A content-map entry offers a non-SSE representation when it allows an empty
|
|
10
|
-
// body or declares any non-SSE media type descriptor.
|
|
11
|
-
if (value.allowNoBody || !value.content)
|
|
12
|
-
return true;
|
|
13
|
-
return Object.values(value.content).some((descriptor) => !isSseBody(descriptor));
|
|
14
|
-
}
|
|
15
|
-
// A bare Zod schema is a JSON response, which always has a non-SSE representation.
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
function getContractResponseMode(contract) {
|
|
19
|
-
if (!hasAnySuccessSseResponse(contract))
|
|
20
|
-
return 'non-sse';
|
|
21
|
-
for (const code of SUCCESSFUL_HTTP_STATUS_CODES) {
|
|
22
|
-
const value = contract.responsesByStatusCode[code];
|
|
23
|
-
if (value && isSuccessResponseDual(value))
|
|
24
|
-
return 'dual';
|
|
25
|
-
}
|
|
26
|
-
return 'sse';
|
|
27
|
-
}
|
|
28
|
-
function buildSSERouteConfig(options) {
|
|
29
|
-
if (!options?.serializer && options?.heartbeatInterval === undefined)
|
|
30
|
-
return true;
|
|
31
|
-
const sseConfig = {};
|
|
32
|
-
if (options.serializer)
|
|
33
|
-
sseConfig.serializer = options.serializer;
|
|
34
|
-
if (options.heartbeatInterval !== undefined)
|
|
35
|
-
sseConfig.heartbeatInterval = options.heartbeatInterval;
|
|
36
|
-
return sseConfig;
|
|
37
|
-
}
|
|
38
|
-
// ============================================================================
|
|
39
|
-
// Internal Helpers — Sync Route
|
|
40
|
-
// ============================================================================
|
|
41
|
-
function getSchemaForStatusCode(contract, status) {
|
|
42
|
-
const entry = contract.responsesByStatusCode[status];
|
|
43
|
-
if (!entry)
|
|
44
|
-
return null;
|
|
45
|
-
// Resolve the JSON representation for this status code, covering both bare Zod
|
|
46
|
-
// schemas and content-map entries. Non-JSON responses (blob, SSE, no-body) are
|
|
47
|
-
// not validated here.
|
|
48
|
-
const resolved = resolveContractResponse(entry, 'application/json', false);
|
|
49
|
-
return resolved?.kind === 'json' ? resolved.schema : null;
|
|
50
|
-
}
|
|
51
|
-
function validateApiResponseHeaders(contract, reply) {
|
|
52
|
-
const schema = contract.responseHeaderSchema;
|
|
53
|
-
if (!schema) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const result = schema.safeParse(reply.getHeaders());
|
|
57
|
-
if (!result.success) {
|
|
58
|
-
throw new InternalError({
|
|
59
|
-
message: 'Internal Server Error',
|
|
60
|
-
errorCode: 'RESPONSE_HEADERS_VALIDATION_FAILED',
|
|
61
|
-
details: { validationError: result.error.message },
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
async function handleApiSyncRoute(contract,
|
|
66
|
-
// biome-ignore lint/suspicious/noExplicitAny: Handler types are validated by InferApiHandler at the call site
|
|
67
|
-
handler,
|
|
68
|
-
// biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
|
|
69
|
-
request, reply) {
|
|
70
|
-
const { status, body } = await handler(request, reply);
|
|
71
|
-
if (reply.sent) {
|
|
72
|
-
request.log.warn({
|
|
73
|
-
msg: 'Sync handler sent response directly, bypassing response validation',
|
|
74
|
-
tag: 'response_sent_directly',
|
|
75
|
-
method: request.method,
|
|
76
|
-
url: request.url,
|
|
77
|
-
});
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
const schema = getSchemaForStatusCode(contract, status);
|
|
82
|
-
if (schema) {
|
|
83
|
-
const result = schema.safeParse(body);
|
|
84
|
-
if (!result.success) {
|
|
85
|
-
throw new InternalError({
|
|
86
|
-
message: 'Internal Server Error',
|
|
87
|
-
errorCode: 'RESPONSE_VALIDATION_FAILED',
|
|
88
|
-
details: { validationError: result.error.message },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch (err) {
|
|
94
|
-
reply.code(500);
|
|
95
|
-
throw err;
|
|
96
|
-
}
|
|
97
|
-
validateApiResponseHeaders(contract, reply);
|
|
98
|
-
if (!reply.hasHeader('content-type')) {
|
|
99
|
-
reply.type('application/json');
|
|
100
|
-
}
|
|
101
|
-
return reply.code(status).send(body);
|
|
102
|
-
}
|
|
103
|
-
// ============================================================================
|
|
104
|
-
// Internal Helpers — SSE Route (no controller, uses reply.sse directly)
|
|
105
|
-
// ============================================================================
|
|
106
|
-
function buildApiSSEContext(
|
|
107
|
-
// biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
|
|
108
|
-
request, reply, eventSchemas, options) {
|
|
109
|
-
let started = false;
|
|
110
|
-
let responseData;
|
|
111
|
-
const sseReply = reply;
|
|
112
|
-
const sseContext = {
|
|
113
|
-
start: (mode, startOptions) => {
|
|
114
|
-
started = true;
|
|
115
|
-
if (mode === 'keepAlive') {
|
|
116
|
-
sseReply.sse.keepAlive();
|
|
117
|
-
}
|
|
118
|
-
// sendHeaders() calls writeHead(200) but only queues headers in the buffer.
|
|
119
|
-
// flushHeaders() forces them onto the wire so the client's fetch() returns.
|
|
120
|
-
sseReply.sse.sendHeaders();
|
|
121
|
-
reply.raw.flushHeaders();
|
|
122
|
-
const connectionId = randomUUID();
|
|
123
|
-
const send = async (eventName, data, sendOptions) => {
|
|
124
|
-
const schema = eventSchemas[eventName];
|
|
125
|
-
if (schema) {
|
|
126
|
-
const result = schema.safeParse(data);
|
|
127
|
-
if (!result.success) {
|
|
128
|
-
throw new InternalError({
|
|
129
|
-
message: `SSE event validation failed for event "${eventName}": ${result.error.message}`,
|
|
130
|
-
errorCode: 'RESPONSE_VALIDATION_FAILED',
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
await sseReply.sse.send({
|
|
136
|
-
event: eventName,
|
|
137
|
-
data,
|
|
138
|
-
id: sendOptions?.id,
|
|
139
|
-
retry: sendOptions?.retry,
|
|
140
|
-
});
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
const session = {
|
|
148
|
-
id: connectionId,
|
|
149
|
-
request,
|
|
150
|
-
reply,
|
|
151
|
-
context: (startOptions?.context ?? {}),
|
|
152
|
-
connectedAt: new Date(),
|
|
153
|
-
// biome-ignore lint/suspicious/noExplicitAny: SSEEventSender generic is satisfied at handler call site
|
|
154
|
-
send: send,
|
|
155
|
-
isConnected: () => sseReply.sse.isConnected,
|
|
156
|
-
getStream: () => sseReply.sse.stream(),
|
|
157
|
-
sendStream: async (messages) => {
|
|
158
|
-
for await (const message of messages) {
|
|
159
|
-
await send(message.event, message.data, { id: message.id, retry: message.retry });
|
|
160
|
-
}
|
|
161
|
-
},
|
|
162
|
-
rooms: { join: () => { }, leave: () => { } },
|
|
163
|
-
eventSchemas,
|
|
164
|
-
};
|
|
165
|
-
if (options?.onConnect) {
|
|
166
|
-
void Promise.resolve(options.onConnect(session)).catch(() => { });
|
|
167
|
-
}
|
|
168
|
-
if (options?.onClose) {
|
|
169
|
-
const onClose = options.onClose;
|
|
170
|
-
sseReply.sse.onClose(() => {
|
|
171
|
-
void Promise.resolve(onClose(session, 'client')).catch(() => { });
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
if (options?.onReconnect && sseReply.sse.lastEventId) {
|
|
175
|
-
const onReconnect = options.onReconnect;
|
|
176
|
-
const lastEventId = sseReply.sse.lastEventId;
|
|
177
|
-
void sseReply.sse.replay(async () => {
|
|
178
|
-
const replay = await onReconnect(session, lastEventId);
|
|
179
|
-
if (replay) {
|
|
180
|
-
for await (const msg of replay) {
|
|
181
|
-
await sseReply.sse.send(msg);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
return session;
|
|
187
|
-
},
|
|
188
|
-
respond: ((code, body) => {
|
|
189
|
-
if (started) {
|
|
190
|
-
throw new Error('Cannot call sse.respond() after sse.start() — the SSE stream is already open.');
|
|
191
|
-
}
|
|
192
|
-
responseData = { code, body };
|
|
193
|
-
return { _type: 'respond', code, body };
|
|
194
|
-
// biome-ignore lint/suspicious/noExplicitAny: respond typing is enforced by contract at call site
|
|
195
|
-
}),
|
|
196
|
-
sendHeaders: () => {
|
|
197
|
-
sseReply.sse.sendHeaders();
|
|
198
|
-
},
|
|
199
|
-
reply,
|
|
200
|
-
};
|
|
201
|
-
return {
|
|
202
|
-
sseContext,
|
|
203
|
-
isStarted: () => started,
|
|
204
|
-
hasResponse: () => responseData !== undefined,
|
|
205
|
-
getResponseData: () => responseData,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Core SSE handler coordinates context, error handling, and lifecycle
|
|
209
|
-
async function handleApiSseRoute(
|
|
210
|
-
// biome-ignore lint/suspicious/noExplicitAny: SSE handler types are validated by InferApiHandler at call site
|
|
211
|
-
sseHandler, eventSchemas, options,
|
|
212
|
-
// biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
|
|
213
|
-
request, reply) {
|
|
214
|
-
const { sseContext, isStarted, hasResponse, getResponseData } = buildApiSSEContext(request, reply, eventSchemas, options);
|
|
215
|
-
try {
|
|
216
|
-
await sseHandler(request, sseContext);
|
|
217
|
-
if (!isStarted() && !hasResponse()) {
|
|
218
|
-
throw new Error('SSE handler must either send a response (sse.respond()) ' +
|
|
219
|
-
'or start streaming (sse.start()). Handler returned without doing either.');
|
|
220
|
-
}
|
|
221
|
-
const responseData = getResponseData();
|
|
222
|
-
if (responseData) {
|
|
223
|
-
// Early HTTP response (sse.respond() was called before streaming)
|
|
224
|
-
reply.removeHeader('cache-control');
|
|
225
|
-
reply.removeHeader('x-accel-buffering');
|
|
226
|
-
reply.type('application/json').code(responseData.code).send(responseData.body);
|
|
227
|
-
}
|
|
228
|
-
// If started, @fastify/sse manages the rest of the connection lifecycle
|
|
229
|
-
}
|
|
230
|
-
catch (err) {
|
|
231
|
-
if (isStarted()) {
|
|
232
|
-
// Headers already sent — can't change status code; try to send error event
|
|
233
|
-
const sseReply = reply;
|
|
234
|
-
if (sseReply.sse.isConnected) {
|
|
235
|
-
try {
|
|
236
|
-
await sseReply.sse.send({
|
|
237
|
-
event: 'error',
|
|
238
|
-
data: { message: isErrorLike(err) ? err.message : 'Internal Server Error' },
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
// Ignore send failures during error handling
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
throw err;
|
|
246
|
-
}
|
|
247
|
-
// Streaming not started — send HTTP error response
|
|
248
|
-
const message = isErrorLike(err) ? err.message : 'Internal Server Error';
|
|
249
|
-
const statusCode = hasHttpStatusCode(err) ? err.httpStatusCode : 500;
|
|
250
|
-
const statusText = statusCode >= 500 ? 'Internal Server Error' : 'Error';
|
|
251
|
-
reply.code(statusCode).type('application/json').send({ statusCode, error: statusText, message });
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
// ============================================================================
|
|
255
|
-
// Internal Helpers — Schema
|
|
256
|
-
// ============================================================================
|
|
257
|
-
function buildResponseSchemas(contract) {
|
|
258
|
-
return Object.keys(contract.responsesByStatusCode).reduce((acc, statusCode) => {
|
|
259
|
-
const schema = getSchemaForStatusCode(contract, Number(statusCode));
|
|
260
|
-
if (schema) {
|
|
261
|
-
acc[Number(statusCode)] = schema;
|
|
262
|
-
}
|
|
263
|
-
return acc;
|
|
264
|
-
}, {});
|
|
265
|
-
}
|
|
266
|
-
function buildBaseSchema(contract) {
|
|
267
|
-
const schema = {};
|
|
268
|
-
if (contract.requestPathParamsSchema)
|
|
269
|
-
schema.params = contract.requestPathParamsSchema;
|
|
270
|
-
if (contract.requestQuerySchema)
|
|
271
|
-
schema.querystring = contract.requestQuerySchema;
|
|
272
|
-
if (contract.requestHeaderSchema)
|
|
273
|
-
schema.headers = contract.requestHeaderSchema;
|
|
274
|
-
if (contract.requestBodySchema !== undefined && contract.requestBodySchema !== ContractNoBody) {
|
|
275
|
-
schema.body = contract.requestBodySchema;
|
|
276
|
-
}
|
|
277
|
-
schema.response = buildResponseSchemas(contract);
|
|
278
|
-
return schema;
|
|
279
|
-
}
|
|
280
|
-
// ============================================================================
|
|
281
|
-
// Public API
|
|
282
|
-
// ============================================================================
|
|
283
3
|
/**
|
|
284
4
|
* Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
|
|
285
5
|
*
|
|
286
|
-
*
|
|
287
|
-
* -
|
|
288
|
-
*
|
|
289
|
-
*
|
|
6
|
+
* Thin wrapper around `buildFastifyApiRoute` from
|
|
7
|
+
* `@lokalise/fastify-api-contracts` — the handler shape, response mode
|
|
8
|
+
* inference, SSE streaming, and validation semantics are all the package's.
|
|
9
|
+
* See its docs for the `(request, reply, context) => { status, body }`
|
|
10
|
+
* handler model and `context.sse` streaming.
|
|
290
11
|
*
|
|
291
|
-
*
|
|
292
|
-
* -
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* `heartbeatInterval`) — applied for `'sse'` and `'dual'` contracts only,
|
|
296
|
-
* - `defaultMode` for `'dual'` contracts when the `Accept` header is ambiguous,
|
|
297
|
-
* - `gatewayMetadata` — per-route gateway policy with header / query keys
|
|
298
|
-
* narrowed to the contract; equivalent to wrapping the result with
|
|
299
|
-
* `withGatewayMetadata`. See `ApiRouteOptions` for full details.
|
|
12
|
+
* On top of the package builder this adds one option: `gatewayMetadata` —
|
|
13
|
+
* per-route gateway policy with header / query keys narrowed to the
|
|
14
|
+
* contract; equivalent to wrapping the result with `withGatewayMetadata`.
|
|
15
|
+
* See `ApiRouteOptions` for full details.
|
|
300
16
|
*
|
|
301
17
|
* @returns Fastify `RouteOptions` ready to pass to `app.route()`
|
|
302
18
|
*/
|
|
303
19
|
export function buildApiRoute(contract, handler, options) {
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
const mode = getContractResponseMode(contract);
|
|
309
|
-
const eventSchemas = getSseSchemaByEventName(contract) ?? {};
|
|
310
|
-
const baseSchema = buildBaseSchema(contract);
|
|
311
|
-
const contractMetadata = contractMetadataToRouteMapper?.(contract.metadata) ?? {};
|
|
312
|
-
const finalize = (route) => gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route;
|
|
313
|
-
if (mode === 'non-sse') {
|
|
314
|
-
// biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
|
|
315
|
-
const syncHandler = handler;
|
|
316
|
-
return finalize({
|
|
317
|
-
...fastifyOptions,
|
|
318
|
-
...contractMetadata,
|
|
319
|
-
method: contract.method,
|
|
320
|
-
url,
|
|
321
|
-
schema: baseSchema,
|
|
322
|
-
handler: async (request, reply) => handleApiSyncRoute(contract, syncHandler, request, reply),
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
if (mode === 'dual') {
|
|
326
|
-
const resolvedDefaultMode = defaultMode ?? 'json';
|
|
327
|
-
// biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
|
|
328
|
-
const dualHandlers = handler;
|
|
329
|
-
return finalize({
|
|
330
|
-
...fastifyOptions,
|
|
331
|
-
...contractMetadata,
|
|
332
|
-
method: contract.method,
|
|
333
|
-
url,
|
|
334
|
-
sse: buildSSERouteConfig(options),
|
|
335
|
-
schema: baseSchema,
|
|
336
|
-
handler: (request, reply) => {
|
|
337
|
-
const responseMode = determineMode(request.headers.accept, resolvedDefaultMode);
|
|
338
|
-
if (responseMode === 'json') {
|
|
339
|
-
return handleApiSyncRoute(contract, dualHandlers.nonSse, request, reply);
|
|
340
|
-
}
|
|
341
|
-
return handleApiSseRoute(dualHandlers.sse, eventSchemas, options, request, reply);
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
// SSE-only
|
|
346
|
-
// biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
|
|
347
|
-
const sseHandler = handler;
|
|
348
|
-
return finalize({
|
|
349
|
-
...fastifyOptions,
|
|
350
|
-
...contractMetadata,
|
|
351
|
-
method: contract.method,
|
|
352
|
-
url,
|
|
353
|
-
sse: buildSSERouteConfig(options),
|
|
354
|
-
schema: baseSchema,
|
|
355
|
-
handler: async (request, reply) => handleApiSseRoute(sseHandler, eventSchemas, options, request, reply),
|
|
356
|
-
});
|
|
20
|
+
// Gateway metadata is stamped via Symbol, not spread into Fastify options.
|
|
21
|
+
const { gatewayMetadata, ...fastifyOptions } = options ?? {};
|
|
22
|
+
const route = buildFastifyApiRoute(contract, handler, fastifyOptions);
|
|
23
|
+
return gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route;
|
|
357
24
|
}
|
|
358
25
|
//# sourceMappingURL=apiRouteBuilder.js.map
|