@ontrails/http 0.2.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 +570 -0
- package/README.md +169 -0
- package/package.json +59 -0
- package/src/blob-output.ts +31 -0
- package/src/build.ts +1552 -0
- package/src/bun.ts +270 -0
- package/src/fetch.ts +1047 -0
- package/src/index.ts +28 -0
- package/src/method.ts +68 -0
- package/src/openapi.ts +383 -0
- package/src/query-coercion.ts +150 -0
- package/src/testing.ts +378 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Build (framework-agnostic)
|
|
2
|
+
export {
|
|
3
|
+
deriveHttpRoutes,
|
|
4
|
+
type DeriveHttpRoutesOptions,
|
|
5
|
+
type HttpExecutionContext,
|
|
6
|
+
type HttpHeaderSource,
|
|
7
|
+
type HttpLayerInputRendering,
|
|
8
|
+
type HttpRouteDefinition,
|
|
9
|
+
type ResolveHttpPermit,
|
|
10
|
+
type ResolveHttpPermitInput,
|
|
11
|
+
} from './build.js';
|
|
12
|
+
export {
|
|
13
|
+
deriveHttpInputSource,
|
|
14
|
+
deriveHttpMethod,
|
|
15
|
+
deriveHttpOperationMethod,
|
|
16
|
+
httpMethodByIntent,
|
|
17
|
+
} from './method.js';
|
|
18
|
+
export type { HttpMethod, HttpOperationMethod, InputSource } from './method.js';
|
|
19
|
+
export {
|
|
20
|
+
createFetchHandler,
|
|
21
|
+
createRouteHandler,
|
|
22
|
+
type CreateFetchHandlerOptions,
|
|
23
|
+
type CreateRouteHandlerOptions,
|
|
24
|
+
} from './fetch.js';
|
|
25
|
+
|
|
26
|
+
// OpenAPI
|
|
27
|
+
export { deriveOpenApiSpec } from './openapi.js';
|
|
28
|
+
export type { OpenApiOptions, OpenApiSpec, OpenApiServer } from './openapi.js';
|
package/src/method.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Intent, WebhookMethod } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
export type HttpMethod = 'GET' | 'POST' | 'DELETE' | WebhookMethod;
|
|
4
|
+
|
|
5
|
+
export type HttpOperationMethod = Lowercase<HttpMethod>;
|
|
6
|
+
|
|
7
|
+
export type InputSource = 'query' | 'body' | 'webhook';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owner table for rendering trail intent onto HTTP methods.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { httpMethodByIntent } from '@ontrails/http';
|
|
15
|
+
*
|
|
16
|
+
* const readMethod = httpMethodByIntent.read;
|
|
17
|
+
* // readMethod === 'GET'
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const httpMethodByIntent = {
|
|
21
|
+
destroy: 'DELETE',
|
|
22
|
+
read: 'GET',
|
|
23
|
+
write: 'POST',
|
|
24
|
+
} as const satisfies Record<Intent, HttpMethod>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive the HTTP method used for a trail intent.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { deriveHttpMethod } from '@ontrails/http';
|
|
32
|
+
*
|
|
33
|
+
* const method = deriveHttpMethod('read');
|
|
34
|
+
* // method === 'GET'
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export const deriveHttpMethod = (intent: Intent): HttpMethod =>
|
|
38
|
+
(httpMethodByIntent as Partial<Record<string, HttpMethod>>)[intent] ?? 'POST';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Derive the lowercase OpenAPI operation method for a trail intent.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { deriveHttpOperationMethod } from '@ontrails/http';
|
|
46
|
+
*
|
|
47
|
+
* const operationMethod = deriveHttpOperationMethod('destroy');
|
|
48
|
+
* // operationMethod === 'delete'
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const deriveHttpOperationMethod = (
|
|
52
|
+
intent: Intent
|
|
53
|
+
): HttpOperationMethod =>
|
|
54
|
+
deriveHttpMethod(intent).toLowerCase() as HttpOperationMethod;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive where request input should be read from for an HTTP method.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { deriveHttpInputSource } from '@ontrails/http';
|
|
62
|
+
*
|
|
63
|
+
* const source = deriveHttpInputSource('GET');
|
|
64
|
+
* // source === 'query'
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export const deriveHttpInputSource = (method: HttpMethod): InputSource =>
|
|
68
|
+
method === 'GET' ? 'query' : 'body';
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive an OpenAPI 3.1 specification from a Topo.
|
|
3
|
+
*
|
|
4
|
+
* Converts each trail into an HTTP operation, deriving paths, methods,
|
|
5
|
+
* parameters, and response schemas from the trail contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ValidationError,
|
|
10
|
+
filterSurfaceTrails,
|
|
11
|
+
renderErrorClassSurface,
|
|
12
|
+
validateSurfaceTopo,
|
|
13
|
+
zodToJsonSchema,
|
|
14
|
+
} from '@ontrails/core';
|
|
15
|
+
import type {
|
|
16
|
+
SurfaceSelectionOptions,
|
|
17
|
+
SurfaceValidationOptions,
|
|
18
|
+
Topo,
|
|
19
|
+
Trail,
|
|
20
|
+
} from '@ontrails/core';
|
|
21
|
+
|
|
22
|
+
import { isBlobOutputSchema } from './blob-output.js';
|
|
23
|
+
import { deriveHttpOperationMethod } from './method.js';
|
|
24
|
+
|
|
25
|
+
type JsonSchema = Readonly<Record<string, unknown>>;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface OpenApiServer {
|
|
32
|
+
readonly url: string;
|
|
33
|
+
readonly description?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OpenApiOptions
|
|
37
|
+
extends SurfaceSelectionOptions, SurfaceValidationOptions {
|
|
38
|
+
/** Default: `graph.name` */
|
|
39
|
+
readonly title?: string | undefined;
|
|
40
|
+
/** Default: `'1.0.0'` */
|
|
41
|
+
readonly version?: string | undefined;
|
|
42
|
+
readonly description?: string | undefined;
|
|
43
|
+
readonly servers?: readonly OpenApiServer[] | undefined;
|
|
44
|
+
/** Prefix for all paths. Default: `''` */
|
|
45
|
+
readonly basePath?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal OpenAPI 3.1 spec shape — intentionally plain objects, no heavy library. */
|
|
49
|
+
export interface OpenApiSpec {
|
|
50
|
+
readonly openapi: '3.1.0';
|
|
51
|
+
readonly info: {
|
|
52
|
+
readonly title: string;
|
|
53
|
+
readonly version: string;
|
|
54
|
+
readonly description?: string | undefined;
|
|
55
|
+
};
|
|
56
|
+
readonly servers?: readonly OpenApiServer[] | undefined;
|
|
57
|
+
readonly paths: Record<string, Record<string, unknown>>;
|
|
58
|
+
readonly components: { readonly schemas: Record<string, unknown> };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** `entity.show` → `/entity/show` */
|
|
66
|
+
const trailIdToPath = (id: string, basePath: string): string =>
|
|
67
|
+
`${basePath}/${id.split('.').join('/')}`;
|
|
68
|
+
|
|
69
|
+
/** First segment of a dotted ID, used as an OpenAPI tag. */
|
|
70
|
+
const tagFromId = (id: string): string => id.split('.')[0] ?? id;
|
|
71
|
+
|
|
72
|
+
/** Convert a Zod schema to JSON Schema via the core helper. */
|
|
73
|
+
const toJsonSchema = (schema: unknown): JsonSchema =>
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
zodToJsonSchema(schema as any) as JsonSchema;
|
|
76
|
+
|
|
77
|
+
/** Build query parameters from a JSON Schema `properties` object. */
|
|
78
|
+
const buildQueryParameters = (
|
|
79
|
+
jsonSchema: JsonSchema
|
|
80
|
+
): Record<string, unknown>[] => {
|
|
81
|
+
const properties = jsonSchema['properties'] as
|
|
82
|
+
| Record<string, Record<string, unknown>>
|
|
83
|
+
| undefined;
|
|
84
|
+
if (!properties) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const required = new Set(
|
|
89
|
+
Array.isArray(jsonSchema['required'])
|
|
90
|
+
? (jsonSchema['required'] as string[])
|
|
91
|
+
: []
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return Object.entries(properties).map(([name, schema]) => ({
|
|
95
|
+
in: 'query',
|
|
96
|
+
name,
|
|
97
|
+
required: required.has(name),
|
|
98
|
+
schema,
|
|
99
|
+
}));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Map a single error example to a status code entry, or undefined if not mappable. */
|
|
103
|
+
const errorExampleToEntry = (
|
|
104
|
+
errorName: string,
|
|
105
|
+
seen: Set<number>
|
|
106
|
+
): [string, { description: string }] | undefined => {
|
|
107
|
+
const rendering = renderErrorClassSurface('http', errorName);
|
|
108
|
+
if (rendering === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { code } = rendering;
|
|
113
|
+
if (seen.has(code)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
seen.add(code);
|
|
117
|
+
return [String(code), { description: errorName }];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Extract error status codes from trail examples that have an `error` field. */
|
|
121
|
+
const errorResponsesFromExamples = (
|
|
122
|
+
examples: readonly { error?: string | undefined }[]
|
|
123
|
+
): Record<string, { description: string }> => {
|
|
124
|
+
const responses: Record<string, { description: string }> = {};
|
|
125
|
+
const seen = new Set<number>();
|
|
126
|
+
|
|
127
|
+
for (const ex of examples) {
|
|
128
|
+
if (!ex.error) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const entry = errorExampleToEntry(ex.error, seen);
|
|
132
|
+
if (entry) {
|
|
133
|
+
const [code, value] = entry;
|
|
134
|
+
responses[code] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return responses;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Operation builder — split into focused helpers
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** True when the body is required — non-object schemas are always required,
|
|
146
|
+
* object schemas are required only when they have at least one required property. */
|
|
147
|
+
const isBodyRequired = (schema: JsonSchema): boolean => {
|
|
148
|
+
if (schema['type'] !== 'object') {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return (
|
|
152
|
+
Array.isArray(schema['required']) &&
|
|
153
|
+
(schema['required'] as unknown[]).length > 0
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Build the input portion of an operation (parameters or requestBody). */
|
|
158
|
+
const buildInputSpec = (
|
|
159
|
+
t: Trail<unknown, unknown, unknown>,
|
|
160
|
+
method: string
|
|
161
|
+
): Record<string, unknown> => {
|
|
162
|
+
if (!t.input) {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
const inputSchema = toJsonSchema(t.input);
|
|
166
|
+
|
|
167
|
+
if (method === 'get') {
|
|
168
|
+
const params = buildQueryParameters(inputSchema);
|
|
169
|
+
return params.length > 0 ? { parameters: params } : {};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const properties = inputSchema['properties'] as
|
|
173
|
+
| Record<string, unknown>
|
|
174
|
+
| undefined;
|
|
175
|
+
if (properties !== undefined && Object.keys(properties).length === 0) {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
requestBody: {
|
|
181
|
+
content: { 'application/json': { schema: inputSchema } },
|
|
182
|
+
required: isBodyRequired(inputSchema),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Wrap a raw output schema in the `{ data: ... }` envelope the HTTP adapter uses. */
|
|
188
|
+
const wrapInDataEnvelope = (outputSchema: JsonSchema): JsonSchema => ({
|
|
189
|
+
properties: { data: outputSchema },
|
|
190
|
+
required: ['data'],
|
|
191
|
+
type: 'object',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/** Shared error response body schema: `{ error: { message, code, category } }`. */
|
|
195
|
+
const errorResponseSchema: JsonSchema = {
|
|
196
|
+
properties: {
|
|
197
|
+
error: {
|
|
198
|
+
properties: {
|
|
199
|
+
category: { type: 'string' },
|
|
200
|
+
code: { type: 'string' },
|
|
201
|
+
message: { type: 'string' },
|
|
202
|
+
},
|
|
203
|
+
required: ['message', 'code', 'category'],
|
|
204
|
+
type: 'object',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
required: ['error'],
|
|
208
|
+
type: 'object',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Build the 200 response entry. */
|
|
212
|
+
const buildSuccessResponse = (
|
|
213
|
+
t: Trail<unknown, unknown, unknown>
|
|
214
|
+
): Record<string, unknown> => {
|
|
215
|
+
if (!t.output) {
|
|
216
|
+
return { '200': { description: 'Success' } };
|
|
217
|
+
}
|
|
218
|
+
if (isBlobOutputSchema(t.output)) {
|
|
219
|
+
// BlobRef routes stream raw bytes at runtime (`fetch.ts` serves the
|
|
220
|
+
// blob's declared mimeType and Content-Length), so the spec documents
|
|
221
|
+
// a binary body instead of the JSON data envelope. The concrete
|
|
222
|
+
// Content-Type is per-blob runtime data, hence the `*/*` range.
|
|
223
|
+
return {
|
|
224
|
+
'200': {
|
|
225
|
+
content: {
|
|
226
|
+
'*/*': { schema: { format: 'binary', type: 'string' } },
|
|
227
|
+
},
|
|
228
|
+
description:
|
|
229
|
+
"Binary content: raw blob bytes served with the blob's declared mimeType",
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const outputSchema = toJsonSchema(t.output);
|
|
234
|
+
return {
|
|
235
|
+
'200': {
|
|
236
|
+
content: {
|
|
237
|
+
'application/json': { schema: wrapInDataEnvelope(outputSchema) },
|
|
238
|
+
},
|
|
239
|
+
description: 'Success',
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Build the default 400 validation error response. */
|
|
245
|
+
const validationErrorResponse: Record<
|
|
246
|
+
string,
|
|
247
|
+
{ content: Record<string, unknown>; description: string }
|
|
248
|
+
> = {
|
|
249
|
+
'400': {
|
|
250
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
251
|
+
description: 'Validation error',
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Build all responses (success + error) for a trail. */
|
|
256
|
+
const buildResponses = (
|
|
257
|
+
t: Trail<unknown, unknown, unknown>
|
|
258
|
+
): Record<string, unknown> => {
|
|
259
|
+
const examples = (t.examples ?? []) as readonly {
|
|
260
|
+
error?: string | undefined;
|
|
261
|
+
}[];
|
|
262
|
+
return {
|
|
263
|
+
...buildSuccessResponse(t),
|
|
264
|
+
...errorResponsesFromExamples(examples),
|
|
265
|
+
...validationErrorResponse,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const operationIdFromTrailId = (trailId: string): string =>
|
|
270
|
+
trailId.replaceAll('.', '_');
|
|
271
|
+
|
|
272
|
+
/** Build a complete OpenAPI operation for a trail. */
|
|
273
|
+
const buildOperation = (
|
|
274
|
+
t: Trail<unknown, unknown, unknown>,
|
|
275
|
+
method: string
|
|
276
|
+
): Record<string, unknown> => ({
|
|
277
|
+
operationId: operationIdFromTrailId(t.id),
|
|
278
|
+
responses: buildResponses(t),
|
|
279
|
+
tags: [tagFromId(t.id)],
|
|
280
|
+
...(t.description ? { summary: t.description } : {}),
|
|
281
|
+
...buildInputSpec(t, method),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Path collection
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** Collect all paths from public trails in the graph. */
|
|
289
|
+
const collectPaths = (
|
|
290
|
+
graph: Topo,
|
|
291
|
+
basePath: string,
|
|
292
|
+
options?: OpenApiOptions
|
|
293
|
+
): Record<string, Record<string, unknown>> => {
|
|
294
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
295
|
+
const seenRoutes = new Map<string, string>();
|
|
296
|
+
const seenOperationIds = new Map<string, string>();
|
|
297
|
+
|
|
298
|
+
for (const t of filterSurfaceTrails(graph.list(), {
|
|
299
|
+
exclude: options?.exclude,
|
|
300
|
+
include: options?.include,
|
|
301
|
+
intent: options?.intent,
|
|
302
|
+
})) {
|
|
303
|
+
const method = deriveHttpOperationMethod(t.intent);
|
|
304
|
+
const path = trailIdToPath(t.id, basePath);
|
|
305
|
+
const routeKey = `${method.toUpperCase()} ${path}`;
|
|
306
|
+
const existingId = seenRoutes.get(routeKey);
|
|
307
|
+
if (existingId !== undefined) {
|
|
308
|
+
throw new ValidationError(
|
|
309
|
+
`HTTP route collision: trails "${existingId}" and "${t.id}" both derive ${routeKey}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
seenRoutes.set(routeKey, t.id);
|
|
313
|
+
|
|
314
|
+
const operationId = operationIdFromTrailId(t.id);
|
|
315
|
+
const existingOperationId = seenOperationIds.get(operationId);
|
|
316
|
+
if (existingOperationId !== undefined) {
|
|
317
|
+
throw new ValidationError(
|
|
318
|
+
`OpenAPI operationId collision: trails "${existingOperationId}" and "${t.id}" both derive operationId "${operationId}"`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
seenOperationIds.set(operationId, t.id);
|
|
322
|
+
|
|
323
|
+
paths[path] ??= {};
|
|
324
|
+
paths[path][method] = buildOperation(t, method);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return paths;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Build the info object from options and graph name. */
|
|
331
|
+
const buildInfo = (
|
|
332
|
+
graphName: string,
|
|
333
|
+
options?: OpenApiOptions
|
|
334
|
+
): OpenApiSpec['info'] => ({
|
|
335
|
+
title: options?.title ?? graphName,
|
|
336
|
+
version: options?.version ?? '1.0.0',
|
|
337
|
+
...(options?.description ? { description: options.description } : {}),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Public API
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Derive an OpenAPI 3.1 specification from a Topo.
|
|
346
|
+
*
|
|
347
|
+
* Iterates all trails, skipping signals and internal trails, and produces
|
|
348
|
+
* paths, operations, parameters, and response schemas derived from
|
|
349
|
+
* the trail contract.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* import { deriveOpenApiSpec } from '@ontrails/http';
|
|
354
|
+
*
|
|
355
|
+
* const spec = deriveOpenApiSpec(graph, {
|
|
356
|
+
* basePath: '/api',
|
|
357
|
+
* title: 'Demo API',
|
|
358
|
+
* });
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
export const deriveOpenApiSpec = (
|
|
362
|
+
graph: Topo,
|
|
363
|
+
options?: OpenApiOptions
|
|
364
|
+
): OpenApiSpec => {
|
|
365
|
+
const validated = validateSurfaceTopo(graph, options);
|
|
366
|
+
if (validated.isErr()) {
|
|
367
|
+
throw validated.error;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
components: { schemas: {} },
|
|
372
|
+
info: buildInfo(graph.name, options),
|
|
373
|
+
openapi: '3.1.0',
|
|
374
|
+
paths: collectPaths(
|
|
375
|
+
graph,
|
|
376
|
+
(options?.basePath ?? '').replace(/\/+$/, ''),
|
|
377
|
+
options
|
|
378
|
+
),
|
|
379
|
+
...(options?.servers && options.servers.length > 0
|
|
380
|
+
? { servers: options.servers }
|
|
381
|
+
: {}),
|
|
382
|
+
};
|
|
383
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
interface QueryZodInternals {
|
|
2
|
+
readonly _zod: {
|
|
3
|
+
readonly def: Readonly<Record<string, unknown>>;
|
|
4
|
+
};
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface QueryLayerFieldMetadata {
|
|
8
|
+
readonly knownFields: ReadonlySet<string>;
|
|
9
|
+
readonly preserveRawFields: ReadonlySet<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface QueryLayerRendering {
|
|
13
|
+
readonly routing: ReadonlyMap<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const queryLayerFieldMetadata = new WeakMap<
|
|
17
|
+
ReadonlyMap<string, string>,
|
|
18
|
+
QueryLayerFieldMetadata
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
22
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
|
|
24
|
+
const queryZodDef = (
|
|
25
|
+
schema: unknown
|
|
26
|
+
): Readonly<Record<string, unknown>> | undefined => {
|
|
27
|
+
if (!isRecord(schema)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const internals = (schema as Partial<QueryZodInternals>)._zod;
|
|
31
|
+
return internals !== undefined && isRecord(internals.def)
|
|
32
|
+
? internals.def
|
|
33
|
+
: undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const querySchemaContainsCoercion = (
|
|
37
|
+
schema: unknown,
|
|
38
|
+
seen = new Set<unknown>()
|
|
39
|
+
): boolean => {
|
|
40
|
+
const def = queryZodDef(schema);
|
|
41
|
+
if (def === undefined) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (def['coerce'] === true) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (seen.has(schema)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
seen.add(schema);
|
|
51
|
+
|
|
52
|
+
const { element, innerType, options, type } = def;
|
|
53
|
+
if (type === 'array') {
|
|
54
|
+
return querySchemaContainsCoercion(element, seen);
|
|
55
|
+
}
|
|
56
|
+
if (type === 'union') {
|
|
57
|
+
return (
|
|
58
|
+
Array.isArray(options) &&
|
|
59
|
+
options.some((option) => querySchemaContainsCoercion(option, seen))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
type === 'default' ||
|
|
64
|
+
type === 'nullable' ||
|
|
65
|
+
type === 'optional' ||
|
|
66
|
+
type === 'readonly'
|
|
67
|
+
) {
|
|
68
|
+
return querySchemaContainsCoercion(innerType, seen);
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const coercingQueryFields = (schema: unknown): ReadonlySet<string> => {
|
|
74
|
+
const fields = new Set<string>();
|
|
75
|
+
const visit = (candidate: unknown, seen: Set<unknown>): void => {
|
|
76
|
+
const def = queryZodDef(candidate);
|
|
77
|
+
if (def === undefined || seen.has(candidate)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
seen.add(candidate);
|
|
81
|
+
|
|
82
|
+
const { innerType, options, shape, type } = def;
|
|
83
|
+
if (type === 'object') {
|
|
84
|
+
if (!isRecord(shape)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const [name, fieldSchema] of Object.entries(shape)) {
|
|
88
|
+
if (querySchemaContainsCoercion(fieldSchema)) {
|
|
89
|
+
fields.add(name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (type === 'union') {
|
|
95
|
+
if (!Array.isArray(options)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const option of options) {
|
|
99
|
+
visit(option, seen);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
type === 'default' ||
|
|
105
|
+
type === 'nullable' ||
|
|
106
|
+
type === 'optional' ||
|
|
107
|
+
type === 'readonly'
|
|
108
|
+
) {
|
|
109
|
+
visit(innerType, seen);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
visit(schema, new Set());
|
|
113
|
+
return fields;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const recordQueryLayerCoercion = (
|
|
117
|
+
inputSchema: unknown,
|
|
118
|
+
routing: ReadonlyMap<string, string>
|
|
119
|
+
): void => {
|
|
120
|
+
const coercingFields = coercingQueryFields(inputSchema);
|
|
121
|
+
const preserveRawFields = new Set<string>();
|
|
122
|
+
for (const [renderedName, originalName] of routing) {
|
|
123
|
+
if (coercingFields.has(originalName)) {
|
|
124
|
+
preserveRawFields.add(renderedName);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
queryLayerFieldMetadata.set(routing, {
|
|
128
|
+
knownFields: new Set(routing.keys()),
|
|
129
|
+
preserveRawFields,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const preservedLayerQueryFields = (
|
|
134
|
+
renderings: readonly QueryLayerRendering[]
|
|
135
|
+
): ReadonlySet<string> => {
|
|
136
|
+
const preserveRawFields = new Set<string>();
|
|
137
|
+
for (const { routing } of renderings) {
|
|
138
|
+
const metadata = queryLayerFieldMetadata.get(routing);
|
|
139
|
+
for (const renderedName of routing.keys()) {
|
|
140
|
+
if (
|
|
141
|
+
metadata === undefined ||
|
|
142
|
+
!metadata.knownFields.has(renderedName) ||
|
|
143
|
+
metadata.preserveRawFields.has(renderedName)
|
|
144
|
+
) {
|
|
145
|
+
preserveRawFields.add(renderedName);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return preserveRawFields;
|
|
150
|
+
};
|