@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/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
+ };