@ontrails/schema 1.0.0-beta.6 → 1.0.0-beta.8

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.
@@ -1,3 +1,3 @@
1
1
  $ oxlint ./src
2
2
  Found 0 warnings and 0 errors.
3
- Finished in 19ms on 10 files with 93 rules using 24 threads.
3
+ Finished in 30ms on 12 files with 93 rules using 24 threads.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @ontrails/schema
2
2
 
3
+ ## 1.0.0-beta.8
4
+
5
+ ### Patch Changes
6
+
7
+ - Restructure HTTP package and fix Codex review findings.
8
+
9
+ **http**: BREAKING — `blaze()` moved to `@ontrails/http/hono` subpath. Hono is now a peer dependency. `buildHttpRoutes()` is framework-agnostic. Fixed: malformed JSON → 400, execute() never throws, query parsing preserves raw strings and supports arrays.
10
+
11
+ **schema**: OpenAPI 200 response wraps in `{ data }` envelope matching wire format. Always includes 400 ValidationError with error schema. basePath trailing slash normalized.
12
+
13
+ - @ontrails/core@1.0.0-beta.8
14
+
15
+ ## 1.0.0-beta.7
16
+
17
+ ### Minor Changes
18
+
19
+ - HTTP surface and OpenAPI generation.
20
+
21
+ **http**: New `@ontrails/http` package — Hono-based HTTP adapter. `blaze()` derives routes from trail IDs, maps intent to HTTP verbs (read→GET, write→POST, destroy→DELETE), and maps error taxonomy to status codes. Returns the Hono instance.
22
+
23
+ **schema**: Add `generateOpenApiSpec(topo)` — generates a complete OpenAPI 3.1 spec from the topo. Each trail becomes an operation with path, method, schemas, and error responses derived from the contract.
24
+
25
+ **trails**: `trails survey --openapi` outputs the OpenAPI spec for any Trails app.
26
+
27
+ ### Patch Changes
28
+
29
+ - @ontrails/core@1.0.0-beta.7
30
+
3
31
  ## 1.0.0-beta.6
4
32
 
5
33
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { generateSurfaceMap } from './generate.js';
2
2
  export { hashSurfaceMap } from './hash.js';
3
3
  export { diffSurfaceMaps } from './diff.js';
4
+ export { generateOpenApiSpec } from './openapi.js';
5
+ export type { OpenApiOptions, OpenApiSpec, OpenApiServer } from './openapi.js';
4
6
  export { writeSurfaceMap, readSurfaceMap, writeSurfaceLock, readSurfaceLock, } from './io.js';
5
7
  export type { SurfaceMap, SurfaceMapEntry, DiffEntry, DiffResult, JsonSchema, WriteOptions, ReadOptions, } from './types.js';
6
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAG5C,OAAO,EACL,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,GAChB,MAAM,SAAS,CAAC;AAGjB,YAAY,EACV,UAAU,EACV,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,YAAY,EACZ,WAAW,GACZ,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAG5C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG/E,OAAO,EACL,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,GAChB,MAAM,SAAS,CAAC;AAGjB,YAAY,EACV,UAAU,EACV,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,YAAY,EACZ,WAAW,GACZ,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  export { generateSurfaceMap } from './generate.js';
3
3
  export { hashSurfaceMap } from './hash.js';
4
4
  export { diffSurfaceMaps } from './diff.js';
5
+ // OpenAPI
6
+ export { generateOpenApiSpec } from './openapi.js';
5
7
  // File I/O
6
8
  export { writeSurfaceMap, readSurfaceMap, writeSurfaceLock, readSurfaceLock, } from './io.js';
7
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,aAAa;AACb,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,WAAW;AACX,OAAO,EACL,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,GAChB,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,aAAa;AACb,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,UAAU;AACV,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGnD,WAAW;AACX,OAAO,EACL,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,eAAe,GAChB,MAAM,SAAS,CAAC"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Generate 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
+ import type { Topo } from '@ontrails/core';
8
+ export interface OpenApiServer {
9
+ readonly url: string;
10
+ readonly description?: string | undefined;
11
+ }
12
+ export interface OpenApiOptions {
13
+ /** Default: `app.name` */
14
+ readonly title?: string | undefined;
15
+ /** Default: `'1.0.0'` */
16
+ readonly version?: string | undefined;
17
+ readonly description?: string | undefined;
18
+ readonly servers?: readonly OpenApiServer[] | undefined;
19
+ /** Prefix for all paths. Default: `''` */
20
+ readonly basePath?: string | undefined;
21
+ }
22
+ /** Minimal OpenAPI 3.1 spec shape — intentionally plain objects, no heavy library. */
23
+ export interface OpenApiSpec {
24
+ readonly openapi: '3.1.0';
25
+ readonly info: {
26
+ readonly title: string;
27
+ readonly version: string;
28
+ readonly description?: string | undefined;
29
+ };
30
+ readonly servers?: readonly OpenApiServer[] | undefined;
31
+ readonly paths: Record<string, Record<string, unknown>>;
32
+ readonly components: {
33
+ readonly schemas: Record<string, unknown>;
34
+ };
35
+ }
36
+ /**
37
+ * Generate an OpenAPI 3.1 specification from a Topo.
38
+ *
39
+ * Iterates all trails, skipping events and internal trails, and produces
40
+ * paths, operations, parameters, and response schemas derived from
41
+ * the trail contract.
42
+ */
43
+ export declare const generateOpenApiSpec: (app: Topo, options?: OpenApiOptions) => OpenApiSpec;
44
+ //# sourceMappingURL=openapi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,IAAI,EAAS,MAAM,gBAAgB,CAAC;AAQlD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,yBAAyB;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,CAAC;IACxD,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AAED,sFAAsF;AACtF,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KAC3C,CAAC;IACF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,CAAC;IACxD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACxD,QAAQ,CAAC,UAAU,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;CACpE;AAuQD;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC9B,KAAK,IAAI,EACT,UAAU,cAAc,KACvB,WAQD,CAAC"}
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Generate 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
+ import { statusCodeMap, zodToJsonSchema } from '@ontrails/core';
8
+ // ---------------------------------------------------------------------------
9
+ // Error name → category lookup
10
+ // ---------------------------------------------------------------------------
11
+ const errorNameToCategory = {
12
+ AlreadyExistsError: 'conflict',
13
+ AmbiguousError: 'validation',
14
+ AssertionError: 'internal',
15
+ AuthError: 'auth',
16
+ CancelledError: 'cancelled',
17
+ ConflictError: 'conflict',
18
+ InternalError: 'internal',
19
+ NetworkError: 'network',
20
+ NotFoundError: 'not_found',
21
+ PermissionError: 'permission',
22
+ RateLimitError: 'rate_limit',
23
+ TimeoutError: 'timeout',
24
+ ValidationError: 'validation',
25
+ };
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+ const intentToMethod = {
30
+ destroy: 'delete',
31
+ read: 'get',
32
+ write: 'post',
33
+ };
34
+ /** `entity.show` → `/entity/show` */
35
+ const trailIdToPath = (id, basePath) => `${basePath}/${id.split('.').join('/')}`;
36
+ /** First segment of a dotted ID, used as an OpenAPI tag. */
37
+ const tagFromId = (id) => id.split('.')[0] ?? id;
38
+ /** Convert a Zod schema to JSON Schema via the core helper. */
39
+ const toJsonSchema = (schema) =>
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ zodToJsonSchema(schema);
42
+ /** Build query parameters from a JSON Schema `properties` object. */
43
+ const buildQueryParameters = (jsonSchema) => {
44
+ const properties = jsonSchema['properties'];
45
+ if (!properties) {
46
+ return [];
47
+ }
48
+ const required = new Set(Array.isArray(jsonSchema['required'])
49
+ ? jsonSchema['required']
50
+ : []);
51
+ return Object.entries(properties).map(([name, schema]) => ({
52
+ in: 'query',
53
+ name,
54
+ required: required.has(name),
55
+ schema,
56
+ }));
57
+ };
58
+ /** Map a single error example to a status code entry, or undefined if not mappable. */
59
+ const errorExampleToEntry = (errorName, seen) => {
60
+ const category = errorNameToCategory[errorName];
61
+ if (!category) {
62
+ return undefined;
63
+ }
64
+ const code = statusCodeMap[category];
65
+ if (seen.has(code)) {
66
+ return undefined;
67
+ }
68
+ seen.add(code);
69
+ return [String(code), { description: errorName }];
70
+ };
71
+ /** Extract error status codes from trail examples that have an `error` field. */
72
+ const errorResponsesFromExamples = (examples) => {
73
+ const responses = {};
74
+ const seen = new Set();
75
+ for (const ex of examples) {
76
+ if (!ex.error) {
77
+ continue;
78
+ }
79
+ const entry = errorExampleToEntry(ex.error, seen);
80
+ if (entry) {
81
+ const [code, value] = entry;
82
+ responses[code] = value;
83
+ }
84
+ }
85
+ return responses;
86
+ };
87
+ // ---------------------------------------------------------------------------
88
+ // Operation builder — split into focused helpers
89
+ // ---------------------------------------------------------------------------
90
+ /** Build the input portion of an operation (parameters or requestBody). */
91
+ const buildInputSpec = (t, method) => {
92
+ if (!t.input) {
93
+ return {};
94
+ }
95
+ const inputSchema = toJsonSchema(t.input);
96
+ if (method === 'get') {
97
+ const params = buildQueryParameters(inputSchema);
98
+ return params.length > 0 ? { parameters: params } : {};
99
+ }
100
+ return {
101
+ requestBody: {
102
+ content: { 'application/json': { schema: inputSchema } },
103
+ required: true,
104
+ },
105
+ };
106
+ };
107
+ /** Wrap a raw output schema in the `{ data: ... }` envelope the HTTP adapter uses. */
108
+ const wrapInDataEnvelope = (outputSchema) => ({
109
+ properties: { data: outputSchema },
110
+ required: ['data'],
111
+ type: 'object',
112
+ });
113
+ /** Shared error response body schema: `{ error: { message, code, category } }`. */
114
+ const errorResponseSchema = {
115
+ properties: {
116
+ error: {
117
+ properties: {
118
+ category: { type: 'string' },
119
+ code: { type: 'string' },
120
+ message: { type: 'string' },
121
+ },
122
+ required: ['message', 'code', 'category'],
123
+ type: 'object',
124
+ },
125
+ },
126
+ required: ['error'],
127
+ type: 'object',
128
+ };
129
+ /** Build the 200 response entry. */
130
+ const buildSuccessResponse = (t) => {
131
+ if (!t.output) {
132
+ return { '200': { description: 'Success' } };
133
+ }
134
+ const outputSchema = toJsonSchema(t.output);
135
+ return {
136
+ '200': {
137
+ content: {
138
+ 'application/json': { schema: wrapInDataEnvelope(outputSchema) },
139
+ },
140
+ description: 'Success',
141
+ },
142
+ };
143
+ };
144
+ /** Build the default 400 validation error response. */
145
+ const validationErrorResponse = {
146
+ '400': {
147
+ content: { 'application/json': { schema: errorResponseSchema } },
148
+ description: 'Validation error',
149
+ },
150
+ };
151
+ /** Build all responses (success + error) for a trail. */
152
+ const buildResponses = (t) => {
153
+ const examples = (t.examples ?? []);
154
+ return {
155
+ ...buildSuccessResponse(t),
156
+ ...validationErrorResponse,
157
+ ...errorResponsesFromExamples(examples),
158
+ };
159
+ };
160
+ /** Build a complete OpenAPI operation for a trail. */
161
+ const buildOperation = (t, method) => ({
162
+ operationId: t.id.replaceAll('.', '_'),
163
+ responses: buildResponses(t),
164
+ tags: [tagFromId(t.id)],
165
+ ...(t.description ? { summary: t.description } : {}),
166
+ ...buildInputSpec(t, method),
167
+ });
168
+ // ---------------------------------------------------------------------------
169
+ // Path collection
170
+ // ---------------------------------------------------------------------------
171
+ /** Check whether a trail should be included in the spec. */
172
+ const isPublicTrail = (t) => {
173
+ if (t.kind !== 'trail') {
174
+ return false;
175
+ }
176
+ const { metadata } = t;
177
+ return metadata?.['internal'] !== true;
178
+ };
179
+ /** Collect all paths from public trails in the topo. */
180
+ const collectPaths = (app, basePath) => {
181
+ const paths = {};
182
+ for (const item of app.list()) {
183
+ const t = item;
184
+ if (!isPublicTrail(t)) {
185
+ continue;
186
+ }
187
+ const method = intentToMethod[t.intent] ?? 'post';
188
+ paths[trailIdToPath(t.id, basePath)] = {
189
+ [method]: buildOperation(t, method),
190
+ };
191
+ }
192
+ return paths;
193
+ };
194
+ /** Build the info object from options and app name. */
195
+ const buildInfo = (appName, options) => ({
196
+ title: options?.title ?? appName,
197
+ version: options?.version ?? '1.0.0',
198
+ ...(options?.description ? { description: options.description } : {}),
199
+ });
200
+ // ---------------------------------------------------------------------------
201
+ // Public API
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Generate an OpenAPI 3.1 specification from a Topo.
205
+ *
206
+ * Iterates all trails, skipping events and internal trails, and produces
207
+ * paths, operations, parameters, and response schemas derived from
208
+ * the trail contract.
209
+ */
210
+ export const generateOpenApiSpec = (app, options) => ({
211
+ components: { schemas: {} },
212
+ info: buildInfo(app.name, options),
213
+ openapi: '3.1.0',
214
+ paths: collectPaths(app, (options?.basePath ?? '').replace(/\/+$/, '')),
215
+ ...(options?.servers && options.servers.length > 0
216
+ ? { servers: options.servers }
217
+ : {}),
218
+ });
219
+ //# sourceMappingURL=openapi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.js","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/schema",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "clean": "rm -rf dist *.tsbuildinfo"
15
15
  },
16
16
  "peerDependencies": {
17
- "@ontrails/core": "^1.0.0-beta.0",
17
+ "@ontrails/core": "^1.0.0-beta.6",
18
18
  "zod": "^4.3.5"
19
19
  }
20
20
  }
@@ -0,0 +1,447 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { Result, topo, trail } from '@ontrails/core';
4
+ import type { Topo } from '@ontrails/core';
5
+ import { z } from 'zod';
6
+
7
+ import { generateOpenApiSpec } from '../openapi.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const topoFrom = (...modules: Record<string, unknown>[]): Topo =>
14
+ topo('test-app', ...modules);
15
+
16
+ const noop = () => Result.ok(null as unknown);
17
+
18
+ /** Extract an operation from a spec by path and method. */
19
+ const getOperation = (
20
+ spec: ReturnType<typeof generateOpenApiSpec>,
21
+ path: string,
22
+ method: string
23
+ ): Record<string, unknown> =>
24
+ spec.paths[path]?.[method] as Record<string, unknown>;
25
+
26
+ /** Drill into a response to get the JSON schema. */
27
+ const getJsonSchema = (
28
+ response: Record<string, unknown>
29
+ ): Record<string, unknown> => {
30
+ const content = response['content'] as Record<string, unknown>;
31
+ const json = content['application/json'] as Record<string, unknown>;
32
+ return json['schema'] as Record<string, unknown>;
33
+ };
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Tests
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('generateOpenApiSpec', () => {
40
+ describe('path and method derivation', () => {
41
+ test('dotted trail ID becomes a path', () => {
42
+ const t = trail('entity.show', {
43
+ input: z.object({ id: z.string() }),
44
+ intent: 'read',
45
+ run: noop,
46
+ });
47
+ const spec = generateOpenApiSpec(topoFrom({ t }));
48
+
49
+ expect(spec.paths['/entity/show']).toBeDefined();
50
+ });
51
+
52
+ test('single-segment trail ID becomes a root path', () => {
53
+ const t = trail('search', {
54
+ input: z.object({ q: z.string() }),
55
+ intent: 'read',
56
+ run: noop,
57
+ });
58
+ const spec = generateOpenApiSpec(topoFrom({ t }));
59
+
60
+ expect(spec.paths['/search']).toBeDefined();
61
+ });
62
+
63
+ test('intent read → GET', () => {
64
+ const t = trail('entity.show', {
65
+ input: z.object({ id: z.string() }),
66
+ intent: 'read',
67
+ run: noop,
68
+ });
69
+ const spec = generateOpenApiSpec(topoFrom({ t }));
70
+
71
+ expect(spec.paths['/entity/show']?.['get']).toBeDefined();
72
+ });
73
+
74
+ test('intent destroy → DELETE', () => {
75
+ const t = trail('entity.remove', {
76
+ input: z.object({ id: z.string() }),
77
+ intent: 'destroy',
78
+ run: noop,
79
+ });
80
+ const spec = generateOpenApiSpec(topoFrom({ t }));
81
+
82
+ expect(spec.paths['/entity/remove']?.['delete']).toBeDefined();
83
+ });
84
+
85
+ test('intent write (default) → POST', () => {
86
+ const t = trail('entity.create', {
87
+ input: z.object({ name: z.string() }),
88
+ run: noop,
89
+ });
90
+ const spec = generateOpenApiSpec(topoFrom({ t }));
91
+
92
+ expect(spec.paths['/entity/create']?.['post']).toBeDefined();
93
+ });
94
+
95
+ test('basePath is prepended to all paths', () => {
96
+ const t = trail('entity.show', {
97
+ input: z.object({ id: z.string() }),
98
+ intent: 'read',
99
+ run: noop,
100
+ });
101
+ const spec = generateOpenApiSpec(topoFrom({ t }), {
102
+ basePath: '/api/v1',
103
+ });
104
+
105
+ expect(spec.paths['/api/v1/entity/show']).toBeDefined();
106
+ });
107
+
108
+ test('basePath trailing slash is normalized', () => {
109
+ const t = trail('entity.show', {
110
+ input: z.object({ id: z.string() }),
111
+ intent: 'read',
112
+ run: noop,
113
+ });
114
+ const spec = generateOpenApiSpec(topoFrom({ t }), {
115
+ basePath: '/api/v1/',
116
+ });
117
+
118
+ expect(spec.paths['/api/v1/entity/show']).toBeDefined();
119
+ expect(spec.paths['/api/v1//entity/show']).toBeUndefined();
120
+ });
121
+ });
122
+
123
+ describe('GET query parameters', () => {
124
+ const buildReadSpec = () => {
125
+ const t = trail('entity.show', {
126
+ input: z.object({ id: z.string(), verbose: z.boolean().optional() }),
127
+ intent: 'read',
128
+ run: noop,
129
+ });
130
+ const spec = generateOpenApiSpec(topoFrom({ t }));
131
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
132
+ return op['parameters'] as Record<string, unknown>[];
133
+ };
134
+
135
+ test('produces one parameter per input field', () => {
136
+ expect(buildReadSpec()).toHaveLength(2);
137
+ });
138
+
139
+ test('required field is marked required with in=query', () => {
140
+ const idParam = buildReadSpec().find((p) => p['name'] === 'id');
141
+ expect(idParam?.['in']).toBe('query');
142
+ expect(idParam?.['required']).toBe(true);
143
+ });
144
+
145
+ test('optional field is marked not required', () => {
146
+ const verboseParam = buildReadSpec().find((p) => p['name'] === 'verbose');
147
+ expect(verboseParam).toBeDefined();
148
+ expect(verboseParam?.['required']).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe('request body', () => {
153
+ test('POST input schema becomes requestBody', () => {
154
+ const t = trail('entity.create', {
155
+ input: z.object({ name: z.string() }),
156
+ run: noop,
157
+ });
158
+ const spec = generateOpenApiSpec(topoFrom({ t }));
159
+ const op = spec.paths['/entity/create']?.['post'] as Record<
160
+ string,
161
+ unknown
162
+ >;
163
+ const body = op['requestBody'] as Record<string, unknown>;
164
+
165
+ expect(body['required']).toBe(true);
166
+ expect(body['content']).toBeDefined();
167
+ const content = body['content'] as Record<string, unknown>;
168
+ expect(content['application/json']).toBeDefined();
169
+ });
170
+ });
171
+
172
+ describe('responses', () => {
173
+ test('trail with output schema → 200 response wrapped in { data }', () => {
174
+ const t = trail('entity.show', {
175
+ input: z.object({ id: z.string() }),
176
+ intent: 'read',
177
+ output: z.object({ id: z.string(), name: z.string() }),
178
+ run: noop,
179
+ });
180
+ const spec = generateOpenApiSpec(topoFrom({ t }));
181
+ const success = (
182
+ getOperation(spec, '/entity/show', 'get')['responses'] as Record<
183
+ string,
184
+ unknown
185
+ >
186
+ )['200'] as Record<string, unknown>;
187
+ const schema = getJsonSchema(success);
188
+
189
+ expect(success['description']).toBe('Success');
190
+ expect(schema['type']).toBe('object');
191
+ expect(schema['required']).toEqual(['data']);
192
+ const dataSchema = (schema['properties'] as Record<string, unknown>)[
193
+ 'data'
194
+ ] as Record<string, unknown>;
195
+ expect(dataSchema['type']).toBe('object');
196
+ });
197
+
198
+ test('trail without output → 200 with no schema', () => {
199
+ const t = trail('fire.forget', {
200
+ input: z.object({ msg: z.string() }),
201
+ run: noop,
202
+ });
203
+ const spec = generateOpenApiSpec(topoFrom({ t }));
204
+ const op = spec.paths['/fire/forget']?.['post'] as Record<
205
+ string,
206
+ unknown
207
+ >;
208
+ const responses = op['responses'] as Record<string, unknown>;
209
+ const success = responses['200'] as Record<string, unknown>;
210
+
211
+ expect(success['description']).toBe('Success');
212
+ expect(success['content']).toBeUndefined();
213
+ });
214
+
215
+ test('trail with error examples → appropriate error responses', () => {
216
+ const t = trail('entity.show', {
217
+ examples: [
218
+ { input: { id: '123' }, name: 'found' },
219
+ {
220
+ error: 'NotFoundError',
221
+ input: { id: 'missing' },
222
+ name: 'not found',
223
+ },
224
+ ],
225
+ input: z.object({ id: z.string() }),
226
+ intent: 'read',
227
+ output: z.object({ id: z.string() }),
228
+ run: noop,
229
+ });
230
+ const spec = generateOpenApiSpec(topoFrom({ t }));
231
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
232
+ const responses = op['responses'] as Record<string, unknown>;
233
+
234
+ expect(responses['404']).toEqual({ description: 'NotFoundError' });
235
+ });
236
+
237
+ test('every trail includes a default 400 validation error response', () => {
238
+ const t = trail('entity.show', {
239
+ input: z.object({ id: z.string() }),
240
+ intent: 'read',
241
+ output: z.object({ id: z.string() }),
242
+ run: noop,
243
+ });
244
+ const spec = generateOpenApiSpec(topoFrom({ t }));
245
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
246
+ const fourHundred = (op['responses'] as Record<string, unknown>)[
247
+ '400'
248
+ ] as Record<string, unknown>;
249
+
250
+ expect(fourHundred['description']).toBe('Validation error');
251
+ expect(fourHundred['content']).toBeDefined();
252
+ const schema = getJsonSchema(fourHundred);
253
+ const errorProp = (schema['properties'] as Record<string, unknown>)[
254
+ 'error'
255
+ ] as Record<string, unknown>;
256
+ expect(errorProp['type']).toBe('object');
257
+ });
258
+
259
+ test('example-derived 400 does not override the default 400', () => {
260
+ const t = trail('entity.show', {
261
+ examples: [{ error: 'ValidationError', input: {}, name: 'bad input' }],
262
+ input: z.object({ id: z.string() }),
263
+ intent: 'read',
264
+ output: z.object({ id: z.string() }),
265
+ run: noop,
266
+ });
267
+ const spec = generateOpenApiSpec(topoFrom({ t }));
268
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
269
+ const responses = op['responses'] as Record<string, unknown>;
270
+
271
+ // The example-derived 400 (description: 'ValidationError') overrides the default
272
+ expect(responses['400']).toEqual({ description: 'ValidationError' });
273
+ });
274
+ });
275
+
276
+ describe('operationId', () => {
277
+ test('dots replaced with underscores', () => {
278
+ const t = trail('entity.show', {
279
+ input: z.object({ id: z.string() }),
280
+ intent: 'read',
281
+ run: noop,
282
+ });
283
+ const spec = generateOpenApiSpec(topoFrom({ t }));
284
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
285
+
286
+ expect(op['operationId']).toBe('entity_show');
287
+ });
288
+
289
+ test('single segment ID preserved', () => {
290
+ const t = trail('search', {
291
+ input: z.object({ q: z.string() }),
292
+ intent: 'read',
293
+ run: noop,
294
+ });
295
+ const spec = generateOpenApiSpec(topoFrom({ t }));
296
+ const op = spec.paths['/search']?.['get'] as Record<string, unknown>;
297
+
298
+ expect(op['operationId']).toBe('search');
299
+ });
300
+ });
301
+
302
+ describe('tags', () => {
303
+ test('tag is first segment of dotted ID', () => {
304
+ const t = trail('entity.show', {
305
+ input: z.object({ id: z.string() }),
306
+ intent: 'read',
307
+ run: noop,
308
+ });
309
+ const spec = generateOpenApiSpec(topoFrom({ t }));
310
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
311
+
312
+ expect(op['tags']).toEqual(['entity']);
313
+ });
314
+
315
+ test('single-segment ID uses itself as tag', () => {
316
+ const t = trail('search', {
317
+ input: z.object({ q: z.string() }),
318
+ intent: 'read',
319
+ run: noop,
320
+ });
321
+ const spec = generateOpenApiSpec(topoFrom({ t }));
322
+ const op = spec.paths['/search']?.['get'] as Record<string, unknown>;
323
+
324
+ expect(op['tags']).toEqual(['search']);
325
+ });
326
+ });
327
+
328
+ describe('multiple trails', () => {
329
+ test('all trails populate paths', () => {
330
+ const a = trail('entity.create', {
331
+ input: z.object({ name: z.string() }),
332
+ run: noop,
333
+ });
334
+ const b = trail('entity.show', {
335
+ input: z.object({ id: z.string() }),
336
+ intent: 'read',
337
+ run: noop,
338
+ });
339
+ const c = trail('entity.remove', {
340
+ input: z.object({ id: z.string() }),
341
+ intent: 'destroy',
342
+ run: noop,
343
+ });
344
+ const spec = generateOpenApiSpec(topoFrom({ a, b, c }));
345
+
346
+ expect(Object.keys(spec.paths)).toHaveLength(3);
347
+ expect(spec.paths['/entity/create']?.['post']).toBeDefined();
348
+ expect(spec.paths['/entity/show']?.['get']).toBeDefined();
349
+ expect(spec.paths['/entity/remove']?.['delete']).toBeDefined();
350
+ });
351
+ });
352
+
353
+ describe('internal trails', () => {
354
+ test('trails with metadata.internal are skipped', () => {
355
+ const pub = trail('entity.show', {
356
+ input: z.object({ id: z.string() }),
357
+ intent: 'read',
358
+ run: noop,
359
+ });
360
+ const internal = trail('internal.helper', {
361
+ input: z.object({}),
362
+ metadata: { internal: true },
363
+ run: noop,
364
+ });
365
+ const spec = generateOpenApiSpec(topoFrom({ internal, pub }));
366
+
367
+ expect(Object.keys(spec.paths)).toHaveLength(1);
368
+ expect(spec.paths['/entity/show']).toBeDefined();
369
+ expect(spec.paths['/internal/helper']).toBeUndefined();
370
+ });
371
+ });
372
+
373
+ describe('spec structure', () => {
374
+ test('openapi version is 3.1.0', () => {
375
+ const spec = generateOpenApiSpec(topoFrom({}));
376
+
377
+ expect(spec.openapi).toBe('3.1.0');
378
+ });
379
+
380
+ test('info defaults from topo name', () => {
381
+ const spec = generateOpenApiSpec(topoFrom({}));
382
+
383
+ expect(spec.info.title).toBe('test-app');
384
+ expect(spec.info.version).toBe('1.0.0');
385
+ });
386
+
387
+ test('info uses options when provided', () => {
388
+ const spec = generateOpenApiSpec(topoFrom({}), {
389
+ description: 'Test API',
390
+ title: 'My API',
391
+ version: '2.0.0',
392
+ });
393
+
394
+ expect(spec.info.title).toBe('My API');
395
+ expect(spec.info.version).toBe('2.0.0');
396
+ expect(spec.info.description).toBe('Test API');
397
+ });
398
+
399
+ test('servers included when provided', () => {
400
+ const spec = generateOpenApiSpec(topoFrom({}), {
401
+ servers: [{ description: 'Local', url: 'http://localhost:3000' }],
402
+ });
403
+
404
+ expect(spec.servers).toHaveLength(1);
405
+ expect(spec.servers?.[0]?.url).toBe('http://localhost:3000');
406
+ });
407
+
408
+ test('servers omitted when not provided', () => {
409
+ const spec = generateOpenApiSpec(topoFrom({}));
410
+
411
+ expect(spec.servers).toBeUndefined();
412
+ });
413
+
414
+ test('components.schemas is present (empty)', () => {
415
+ const spec = generateOpenApiSpec(topoFrom({}));
416
+
417
+ expect(spec.components.schemas).toEqual({});
418
+ });
419
+ });
420
+
421
+ describe('summary from description', () => {
422
+ test('trail description becomes operation summary', () => {
423
+ const t = trail('entity.show', {
424
+ description: 'Show an entity by ID',
425
+ input: z.object({ id: z.string() }),
426
+ intent: 'read',
427
+ run: noop,
428
+ });
429
+ const spec = generateOpenApiSpec(topoFrom({ t }));
430
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
431
+
432
+ expect(op['summary']).toBe('Show an entity by ID');
433
+ });
434
+
435
+ test('trail without description has no summary', () => {
436
+ const t = trail('entity.show', {
437
+ input: z.object({ id: z.string() }),
438
+ intent: 'read',
439
+ run: noop,
440
+ });
441
+ const spec = generateOpenApiSpec(topoFrom({ t }));
442
+ const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
443
+
444
+ expect(op['summary']).toBeUndefined();
445
+ });
446
+ });
447
+ });
package/src/index.ts CHANGED
@@ -3,6 +3,10 @@ export { generateSurfaceMap } from './generate.js';
3
3
  export { hashSurfaceMap } from './hash.js';
4
4
  export { diffSurfaceMaps } from './diff.js';
5
5
 
6
+ // OpenAPI
7
+ export { generateOpenApiSpec } from './openapi.js';
8
+ export type { OpenApiOptions, OpenApiSpec, OpenApiServer } from './openapi.js';
9
+
6
10
  // File I/O
7
11
  export {
8
12
  writeSurfaceMap,
package/src/openapi.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Generate 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 { statusCodeMap, zodToJsonSchema } from '@ontrails/core';
9
+ import type { Topo, Trail } from '@ontrails/core';
10
+
11
+ import type { JsonSchema } from './types.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Public types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface OpenApiServer {
18
+ readonly url: string;
19
+ readonly description?: string | undefined;
20
+ }
21
+
22
+ export interface OpenApiOptions {
23
+ /** Default: `app.name` */
24
+ readonly title?: string | undefined;
25
+ /** Default: `'1.0.0'` */
26
+ readonly version?: string | undefined;
27
+ readonly description?: string | undefined;
28
+ readonly servers?: readonly OpenApiServer[] | undefined;
29
+ /** Prefix for all paths. Default: `''` */
30
+ readonly basePath?: string | undefined;
31
+ }
32
+
33
+ /** Minimal OpenAPI 3.1 spec shape — intentionally plain objects, no heavy library. */
34
+ export interface OpenApiSpec {
35
+ readonly openapi: '3.1.0';
36
+ readonly info: {
37
+ readonly title: string;
38
+ readonly version: string;
39
+ readonly description?: string | undefined;
40
+ };
41
+ readonly servers?: readonly OpenApiServer[] | undefined;
42
+ readonly paths: Record<string, Record<string, unknown>>;
43
+ readonly components: { readonly schemas: Record<string, unknown> };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Error name → category lookup
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const errorNameToCategory: Record<string, keyof typeof statusCodeMap> = {
51
+ AlreadyExistsError: 'conflict',
52
+ AmbiguousError: 'validation',
53
+ AssertionError: 'internal',
54
+ AuthError: 'auth',
55
+ CancelledError: 'cancelled',
56
+ ConflictError: 'conflict',
57
+ InternalError: 'internal',
58
+ NetworkError: 'network',
59
+ NotFoundError: 'not_found',
60
+ PermissionError: 'permission',
61
+ RateLimitError: 'rate_limit',
62
+ TimeoutError: 'timeout',
63
+ ValidationError: 'validation',
64
+ };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const intentToMethod: Record<string, string> = {
71
+ destroy: 'delete',
72
+ read: 'get',
73
+ write: 'post',
74
+ };
75
+
76
+ /** `entity.show` → `/entity/show` */
77
+ const trailIdToPath = (id: string, basePath: string): string =>
78
+ `${basePath}/${id.split('.').join('/')}`;
79
+
80
+ /** First segment of a dotted ID, used as an OpenAPI tag. */
81
+ const tagFromId = (id: string): string => id.split('.')[0] ?? id;
82
+
83
+ /** Convert a Zod schema to JSON Schema via the core helper. */
84
+ const toJsonSchema = (schema: unknown): JsonSchema =>
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ zodToJsonSchema(schema as any) as JsonSchema;
87
+
88
+ /** Build query parameters from a JSON Schema `properties` object. */
89
+ const buildQueryParameters = (
90
+ jsonSchema: JsonSchema
91
+ ): Record<string, unknown>[] => {
92
+ const properties = jsonSchema['properties'] as
93
+ | Record<string, Record<string, unknown>>
94
+ | undefined;
95
+ if (!properties) {
96
+ return [];
97
+ }
98
+
99
+ const required = new Set(
100
+ Array.isArray(jsonSchema['required'])
101
+ ? (jsonSchema['required'] as string[])
102
+ : []
103
+ );
104
+
105
+ return Object.entries(properties).map(([name, schema]) => ({
106
+ in: 'query',
107
+ name,
108
+ required: required.has(name),
109
+ schema,
110
+ }));
111
+ };
112
+
113
+ /** Map a single error example to a status code entry, or undefined if not mappable. */
114
+ const errorExampleToEntry = (
115
+ errorName: string,
116
+ seen: Set<number>
117
+ ): [string, { description: string }] | undefined => {
118
+ const category = errorNameToCategory[errorName];
119
+ if (!category) {
120
+ return undefined;
121
+ }
122
+ const code = statusCodeMap[category];
123
+ if (seen.has(code)) {
124
+ return undefined;
125
+ }
126
+ seen.add(code);
127
+ return [String(code), { description: errorName }];
128
+ };
129
+
130
+ /** Extract error status codes from trail examples that have an `error` field. */
131
+ const errorResponsesFromExamples = (
132
+ examples: readonly { error?: string | undefined }[]
133
+ ): Record<string, { description: string }> => {
134
+ const responses: Record<string, { description: string }> = {};
135
+ const seen = new Set<number>();
136
+
137
+ for (const ex of examples) {
138
+ if (!ex.error) {
139
+ continue;
140
+ }
141
+ const entry = errorExampleToEntry(ex.error, seen);
142
+ if (entry) {
143
+ const [code, value] = entry;
144
+ responses[code] = value;
145
+ }
146
+ }
147
+
148
+ return responses;
149
+ };
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Operation builder — split into focused helpers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** Build the input portion of an operation (parameters or requestBody). */
156
+ const buildInputSpec = (
157
+ t: Trail<unknown, unknown>,
158
+ method: string
159
+ ): Record<string, unknown> => {
160
+ if (!t.input) {
161
+ return {};
162
+ }
163
+ const inputSchema = toJsonSchema(t.input);
164
+
165
+ if (method === 'get') {
166
+ const params = buildQueryParameters(inputSchema);
167
+ return params.length > 0 ? { parameters: params } : {};
168
+ }
169
+
170
+ return {
171
+ requestBody: {
172
+ content: { 'application/json': { schema: inputSchema } },
173
+ required: true,
174
+ },
175
+ };
176
+ };
177
+
178
+ /** Wrap a raw output schema in the `{ data: ... }` envelope the HTTP adapter uses. */
179
+ const wrapInDataEnvelope = (outputSchema: JsonSchema): JsonSchema => ({
180
+ properties: { data: outputSchema },
181
+ required: ['data'],
182
+ type: 'object',
183
+ });
184
+
185
+ /** Shared error response body schema: `{ error: { message, code, category } }`. */
186
+ const errorResponseSchema: JsonSchema = {
187
+ properties: {
188
+ error: {
189
+ properties: {
190
+ category: { type: 'string' },
191
+ code: { type: 'string' },
192
+ message: { type: 'string' },
193
+ },
194
+ required: ['message', 'code', 'category'],
195
+ type: 'object',
196
+ },
197
+ },
198
+ required: ['error'],
199
+ type: 'object',
200
+ };
201
+
202
+ /** Build the 200 response entry. */
203
+ const buildSuccessResponse = (
204
+ t: Trail<unknown, unknown>
205
+ ): Record<string, unknown> => {
206
+ if (!t.output) {
207
+ return { '200': { description: 'Success' } };
208
+ }
209
+ const outputSchema = toJsonSchema(t.output);
210
+ return {
211
+ '200': {
212
+ content: {
213
+ 'application/json': { schema: wrapInDataEnvelope(outputSchema) },
214
+ },
215
+ description: 'Success',
216
+ },
217
+ };
218
+ };
219
+
220
+ /** Build the default 400 validation error response. */
221
+ const validationErrorResponse: Record<
222
+ string,
223
+ { content: Record<string, unknown>; description: string }
224
+ > = {
225
+ '400': {
226
+ content: { 'application/json': { schema: errorResponseSchema } },
227
+ description: 'Validation error',
228
+ },
229
+ };
230
+
231
+ /** Build all responses (success + error) for a trail. */
232
+ const buildResponses = (
233
+ t: Trail<unknown, unknown>
234
+ ): Record<string, unknown> => {
235
+ const examples = (t.examples ?? []) as readonly {
236
+ error?: string | undefined;
237
+ }[];
238
+ return {
239
+ ...buildSuccessResponse(t),
240
+ ...validationErrorResponse,
241
+ ...errorResponsesFromExamples(examples),
242
+ };
243
+ };
244
+
245
+ /** Build a complete OpenAPI operation for a trail. */
246
+ const buildOperation = (
247
+ t: Trail<unknown, unknown>,
248
+ method: string
249
+ ): Record<string, unknown> => ({
250
+ operationId: t.id.replaceAll('.', '_'),
251
+ responses: buildResponses(t),
252
+ tags: [tagFromId(t.id)],
253
+ ...(t.description ? { summary: t.description } : {}),
254
+ ...buildInputSpec(t, method),
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Path collection
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /** Check whether a trail should be included in the spec. */
262
+ const isPublicTrail = (t: Trail<unknown, unknown>): boolean => {
263
+ if (t.kind !== 'trail') {
264
+ return false;
265
+ }
266
+ const { metadata } = t as unknown as {
267
+ metadata?: Record<string, unknown>;
268
+ };
269
+ return metadata?.['internal'] !== true;
270
+ };
271
+
272
+ /** Collect all paths from public trails in the topo. */
273
+ const collectPaths = (
274
+ app: Topo,
275
+ basePath: string
276
+ ): Record<string, Record<string, unknown>> => {
277
+ const paths: Record<string, Record<string, unknown>> = {};
278
+
279
+ for (const item of app.list()) {
280
+ const t = item as Trail<unknown, unknown>;
281
+ if (!isPublicTrail(t)) {
282
+ continue;
283
+ }
284
+ const method = intentToMethod[t.intent] ?? 'post';
285
+ paths[trailIdToPath(t.id, basePath)] = {
286
+ [method]: buildOperation(t, method),
287
+ };
288
+ }
289
+
290
+ return paths;
291
+ };
292
+
293
+ /** Build the info object from options and app name. */
294
+ const buildInfo = (
295
+ appName: string,
296
+ options?: OpenApiOptions
297
+ ): OpenApiSpec['info'] => ({
298
+ title: options?.title ?? appName,
299
+ version: options?.version ?? '1.0.0',
300
+ ...(options?.description ? { description: options.description } : {}),
301
+ });
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Public API
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * Generate an OpenAPI 3.1 specification from a Topo.
309
+ *
310
+ * Iterates all trails, skipping events and internal trails, and produces
311
+ * paths, operations, parameters, and response schemas derived from
312
+ * the trail contract.
313
+ */
314
+ export const generateOpenApiSpec = (
315
+ app: Topo,
316
+ options?: OpenApiOptions
317
+ ): OpenApiSpec => ({
318
+ components: { schemas: {} },
319
+ info: buildInfo(app.name, options),
320
+ openapi: '3.1.0',
321
+ paths: collectPaths(app, (options?.basePath ?? '').replace(/\/+$/, '')),
322
+ ...(options?.servers && options.servers.length > 0
323
+ ? { servers: options.servers }
324
+ : {}),
325
+ });
@@ -1 +1 @@
1
- {"root":["./src/diff.ts","./src/generate.ts","./src/hash.ts","./src/index.ts","./src/io.ts","./src/types.ts"],"version":"5.9.3"}
1
+ {"root":["./src/diff.ts","./src/generate.ts","./src/hash.ts","./src/index.ts","./src/io.ts","./src/openapi.ts","./src/types.ts"],"version":"5.9.3"}