@ontrails/schema 1.0.0-beta.6 → 1.0.0-beta.7
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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/openapi.d.ts +44 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +187 -0
- package/dist/openapi.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/openapi.test.ts +390 -0
- package/src/index.ts +4 -0
- package/src/openapi.ts +287 -0
- package/tsconfig.tsbuildinfo +1 -1
package/.turbo/turbo-lint.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @ontrails/schema
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.7
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- HTTP surface and OpenAPI generation.
|
|
8
|
+
|
|
9
|
+
**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.
|
|
10
|
+
|
|
11
|
+
**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.
|
|
12
|
+
|
|
13
|
+
**trails**: `trails survey --openapi` outputs the OpenAPI spec for any Trails app.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- @ontrails/core@1.0.0-beta.7
|
|
18
|
+
|
|
3
19
|
## 1.0.0-beta.6
|
|
4
20
|
|
|
5
21
|
### 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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;AAiOD;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC9B,KAAK,IAAI,EACT,UAAU,cAAc,KACvB,WAQD,CAAC"}
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
/** Build the 200 response entry. */
|
|
108
|
+
const buildSuccessResponse = (t) => {
|
|
109
|
+
if (!t.output) {
|
|
110
|
+
return { '200': { description: 'Success' } };
|
|
111
|
+
}
|
|
112
|
+
const outputSchema = toJsonSchema(t.output);
|
|
113
|
+
return {
|
|
114
|
+
'200': {
|
|
115
|
+
content: { 'application/json': { schema: outputSchema } },
|
|
116
|
+
description: 'Success',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
/** Build all responses (success + error) for a trail. */
|
|
121
|
+
const buildResponses = (t) => {
|
|
122
|
+
const examples = (t.examples ?? []);
|
|
123
|
+
return {
|
|
124
|
+
...buildSuccessResponse(t),
|
|
125
|
+
...errorResponsesFromExamples(examples),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
/** Build a complete OpenAPI operation for a trail. */
|
|
129
|
+
const buildOperation = (t, method) => ({
|
|
130
|
+
operationId: t.id.replaceAll('.', '_'),
|
|
131
|
+
responses: buildResponses(t),
|
|
132
|
+
tags: [tagFromId(t.id)],
|
|
133
|
+
...(t.description ? { summary: t.description } : {}),
|
|
134
|
+
...buildInputSpec(t, method),
|
|
135
|
+
});
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Path collection
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/** Check whether a trail should be included in the spec. */
|
|
140
|
+
const isPublicTrail = (t) => {
|
|
141
|
+
if (t.kind !== 'trail') {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const { metadata } = t;
|
|
145
|
+
return metadata?.['internal'] !== true;
|
|
146
|
+
};
|
|
147
|
+
/** Collect all paths from public trails in the topo. */
|
|
148
|
+
const collectPaths = (app, basePath) => {
|
|
149
|
+
const paths = {};
|
|
150
|
+
for (const item of app.list()) {
|
|
151
|
+
const t = item;
|
|
152
|
+
if (!isPublicTrail(t)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const method = intentToMethod[t.intent] ?? 'post';
|
|
156
|
+
paths[trailIdToPath(t.id, basePath)] = {
|
|
157
|
+
[method]: buildOperation(t, method),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return paths;
|
|
161
|
+
};
|
|
162
|
+
/** Build the info object from options and app name. */
|
|
163
|
+
const buildInfo = (appName, options) => ({
|
|
164
|
+
title: options?.title ?? appName,
|
|
165
|
+
version: options?.version ?? '1.0.0',
|
|
166
|
+
...(options?.description ? { description: options.description } : {}),
|
|
167
|
+
});
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Public API
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
/**
|
|
172
|
+
* Generate an OpenAPI 3.1 specification from a Topo.
|
|
173
|
+
*
|
|
174
|
+
* Iterates all trails, skipping events and internal trails, and produces
|
|
175
|
+
* paths, operations, parameters, and response schemas derived from
|
|
176
|
+
* the trail contract.
|
|
177
|
+
*/
|
|
178
|
+
export const generateOpenApiSpec = (app, options) => ({
|
|
179
|
+
components: { schemas: {} },
|
|
180
|
+
info: buildInfo(app.name, options),
|
|
181
|
+
openapi: '3.1.0',
|
|
182
|
+
paths: collectPaths(app, options?.basePath ?? ''),
|
|
183
|
+
...(options?.servers && options.servers.length > 0
|
|
184
|
+
? { servers: options.servers }
|
|
185
|
+
: {}),
|
|
186
|
+
});
|
|
187
|
+
//# sourceMappingURL=openapi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.js","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAsChE,8EAA8E;AAC9E,+BAA+B;AAC/B,8EAA8E;AAE9E,MAAM,mBAAmB,GAA+C;IACtE,kBAAkB,EAAE,UAAU;IAC9B,cAAc,EAAE,YAAY;IAC5B,cAAc,EAAE,UAAU;IAC1B,SAAS,EAAE,MAAM;IACjB,cAAc,EAAE,WAAW;IAC3B,aAAa,EAAE,UAAU;IACzB,aAAa,EAAE,UAAU;IACzB,YAAY,EAAE,SAAS;IACvB,aAAa,EAAE,WAAW;IAC1B,eAAe,EAAE,YAAY;IAC7B,cAAc,EAAE,YAAY;IAC5B,YAAY,EAAE,SAAS;IACvB,eAAe,EAAE,YAAY;CAC9B,CAAC;AAEF,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,MAAM,cAAc,GAA2B;IAC7C,OAAO,EAAE,QAAQ;IACjB,IAAI,EAAE,KAAK;IACX,KAAK,EAAE,MAAM;CACd,CAAC;AAEF,qCAAqC;AACrC,MAAM,aAAa,GAAG,CAAC,EAAU,EAAE,QAAgB,EAAU,EAAE,CAC7D,GAAG,QAAQ,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;AAE3C,4DAA4D;AAC5D,MAAM,SAAS,GAAG,CAAC,EAAU,EAAU,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAEjE,+DAA+D;AAC/D,MAAM,YAAY,GAAG,CAAC,MAAe,EAAc,EAAE;AACnD,8DAA8D;AAC9D,eAAe,CAAC,MAAa,CAAe,CAAC;AAE/C,qEAAqE;AACrE,MAAM,oBAAoB,GAAG,CAC3B,UAAsB,EACK,EAAE;IAC7B,MAAM,UAAU,GAAG,UAAU,CAAC,YAAY,CAE7B,CAAC;IACd,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,GAAG,CACtB,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;QACnC,CAAC,CAAE,UAAU,CAAC,UAAU,CAAc;QACtC,CAAC,CAAC,EAAE,CACP,CAAC;IAEF,OAAO,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;QACzD,EAAE,EAAE,OAAO;QACX,IAAI;QACJ,QAAQ,EAAE,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;QAC5B,MAAM;KACP,CAAC,CAAC,CAAC;AACN,CAAC,CAAC;AAEF,uFAAuF;AACvF,MAAM,mBAAmB,GAAG,CAC1B,SAAiB,EACjB,IAAiB,EAC8B,EAAE;IACjD,MAAM,QAAQ,GAAG,mBAAmB,CAAC,SAAS,CAAC,CAAC;IAChD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IACrC,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;QACnB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACf,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC;AAEF,iFAAiF;AACjF,MAAM,0BAA0B,GAAG,CACjC,QAAmD,EACV,EAAE;IAC3C,MAAM,SAAS,GAA4C,EAAE,CAAC;IAC9D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAE/B,KAAK,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;QAC1B,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC;YACd,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QAClD,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,KAAK,CAAC;YAC5B,SAAS,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;QAC1B,CAAC;IACH,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC,CAAC;AAEF,8EAA8E;AAC9E,iDAAiD;AACjD,8EAA8E;AAE9E,2EAA2E;AAC3E,MAAM,cAAc,GAAG,CACrB,CAA0B,EAC1B,MAAc,EACW,EAAE;IAC3B,IAAI,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,MAAM,WAAW,GAAG,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAE1C,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;QACrB,MAAM,MAAM,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC;QACjD,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACzD,CAAC;IAED,OAAO;QACL,WAAW,EAAE;YACX,OAAO,EAAE,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE;YACxD,QAAQ,EAAE,IAAI;SACf;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,oCAAoC;AACpC,MAAM,oBAAoB,GAAG,CAC3B,CAA0B,EACD,EAAE;IAC3B,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;QACd,OAAO,EAAE,KAAK,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,EAAE,CAAC;IAC/C,CAAC;IACD,MAAM,YAAY,GAAG,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IAC5C,OAAO;QACL,KAAK,EAAE;YACL,OAAO,EAAE,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE;YACzD,WAAW,EAAE,SAAS;SACvB;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,yDAAyD;AACzD,MAAM,cAAc,GAAG,CACrB,CAA0B,EACD,EAAE;IAC3B,MAAM,QAAQ,GAAG,CAAC,CAAC,CAAC,QAAQ,IAAI,EAAE,CAE/B,CAAC;IACJ,OAAO;QACL,GAAG,oBAAoB,CAAC,CAAC,CAAC;QAC1B,GAAG,0BAA0B,CAAC,QAAQ,CAAC;KACxC,CAAC;AACJ,CAAC,CAAC;AAEF,sDAAsD;AACtD,MAAM,cAAc,GAAG,CACrB,CAA0B,EAC1B,MAAc,EACW,EAAE,CAAC,CAAC;IAC7B,WAAW,EAAE,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC;IACtC,SAAS,EAAE,cAAc,CAAC,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACvB,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACpD,GAAG,cAAc,CAAC,CAAC,EAAE,MAAM,CAAC;CAC7B,CAAC,CAAC;AAEH,8EAA8E;AAC9E,kBAAkB;AAClB,8EAA8E;AAE9E,4DAA4D;AAC5D,MAAM,aAAa,GAAG,CAAC,CAA0B,EAAW,EAAE;IAC5D,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;QACvB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,EAAE,QAAQ,EAAE,GAAG,CAEpB,CAAC;IACF,OAAO,QAAQ,EAAE,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC;AACzC,CAAC,CAAC;AAEF,wDAAwD;AACxD,MAAM,YAAY,GAAG,CACnB,GAAS,EACT,QAAgB,EACyB,EAAE;IAC3C,MAAM,KAAK,GAA4C,EAAE,CAAC;IAE1D,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;QAC9B,MAAM,CAAC,GAAG,IAA+B,CAAC;QAC1C,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC;YACtB,SAAS;QACX,CAAC;QACD,MAAM,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;QAClD,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,EAAE,QAAQ,CAAC,CAAC,GAAG;YACrC,CAAC,MAAM,CAAC,EAAE,cAAc,CAAC,CAAC,EAAE,MAAM,CAAC;SACpC,CAAC;IACJ,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC,CAAC;AAEF,uDAAuD;AACvD,MAAM,SAAS,GAAG,CAChB,OAAe,EACf,OAAwB,EACH,EAAE,CAAC,CAAC;IACzB,KAAK,EAAE,OAAO,EAAE,KAAK,IAAI,OAAO;IAChC,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,OAAO;IACpC,GAAG,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;CACtE,CAAC,CAAC;AAEH,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CACjC,GAAS,EACT,OAAwB,EACX,EAAE,CAAC,CAAC;IACjB,UAAU,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;IAC3B,IAAI,EAAE,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC;IAClC,OAAO,EAAE,OAAO;IAChB,KAAK,EAAE,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,IAAI,EAAE,CAAC;IACjD,GAAG,CAAC,OAAO,EAAE,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;QAChD,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE;QAC9B,CAAC,CAAC,EAAE,CAAC;CACR,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/schema",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.7",
|
|
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.
|
|
17
|
+
"@ontrails/core": "^1.0.0-beta.6",
|
|
18
18
|
"zod": "^4.3.5"
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
|
|
109
|
+
describe('GET query parameters', () => {
|
|
110
|
+
const buildReadSpec = () => {
|
|
111
|
+
const t = trail('entity.show', {
|
|
112
|
+
input: z.object({ id: z.string(), verbose: z.boolean().optional() }),
|
|
113
|
+
intent: 'read',
|
|
114
|
+
run: noop,
|
|
115
|
+
});
|
|
116
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
117
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
118
|
+
return op['parameters'] as Record<string, unknown>[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
test('produces one parameter per input field', () => {
|
|
122
|
+
expect(buildReadSpec()).toHaveLength(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('required field is marked required with in=query', () => {
|
|
126
|
+
const idParam = buildReadSpec().find((p) => p['name'] === 'id');
|
|
127
|
+
expect(idParam?.['in']).toBe('query');
|
|
128
|
+
expect(idParam?.['required']).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('optional field is marked not required', () => {
|
|
132
|
+
const verboseParam = buildReadSpec().find((p) => p['name'] === 'verbose');
|
|
133
|
+
expect(verboseParam).toBeDefined();
|
|
134
|
+
expect(verboseParam?.['required']).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('request body', () => {
|
|
139
|
+
test('POST input schema becomes requestBody', () => {
|
|
140
|
+
const t = trail('entity.create', {
|
|
141
|
+
input: z.object({ name: z.string() }),
|
|
142
|
+
run: noop,
|
|
143
|
+
});
|
|
144
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
145
|
+
const op = spec.paths['/entity/create']?.['post'] as Record<
|
|
146
|
+
string,
|
|
147
|
+
unknown
|
|
148
|
+
>;
|
|
149
|
+
const body = op['requestBody'] as Record<string, unknown>;
|
|
150
|
+
|
|
151
|
+
expect(body['required']).toBe(true);
|
|
152
|
+
expect(body['content']).toBeDefined();
|
|
153
|
+
const content = body['content'] as Record<string, unknown>;
|
|
154
|
+
expect(content['application/json']).toBeDefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('responses', () => {
|
|
159
|
+
test('trail with output schema → 200 response with schema', () => {
|
|
160
|
+
const t = trail('entity.show', {
|
|
161
|
+
input: z.object({ id: z.string() }),
|
|
162
|
+
intent: 'read',
|
|
163
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
164
|
+
run: noop,
|
|
165
|
+
});
|
|
166
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
167
|
+
const responses = getOperation(spec, '/entity/show', 'get')[
|
|
168
|
+
'responses'
|
|
169
|
+
] as Record<string, unknown>;
|
|
170
|
+
const success = responses['200'] as Record<string, unknown>;
|
|
171
|
+
|
|
172
|
+
expect(success['description']).toBe('Success');
|
|
173
|
+
const schema = getJsonSchema(success);
|
|
174
|
+
expect(schema['type']).toBe('object');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('trail without output → 200 with no schema', () => {
|
|
178
|
+
const t = trail('fire.forget', {
|
|
179
|
+
input: z.object({ msg: z.string() }),
|
|
180
|
+
run: noop,
|
|
181
|
+
});
|
|
182
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
183
|
+
const op = spec.paths['/fire/forget']?.['post'] as Record<
|
|
184
|
+
string,
|
|
185
|
+
unknown
|
|
186
|
+
>;
|
|
187
|
+
const responses = op['responses'] as Record<string, unknown>;
|
|
188
|
+
const success = responses['200'] as Record<string, unknown>;
|
|
189
|
+
|
|
190
|
+
expect(success['description']).toBe('Success');
|
|
191
|
+
expect(success['content']).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('trail with error examples → appropriate error responses', () => {
|
|
195
|
+
const t = trail('entity.show', {
|
|
196
|
+
examples: [
|
|
197
|
+
{ input: { id: '123' }, name: 'found' },
|
|
198
|
+
{
|
|
199
|
+
error: 'NotFoundError',
|
|
200
|
+
input: { id: 'missing' },
|
|
201
|
+
name: 'not found',
|
|
202
|
+
},
|
|
203
|
+
{ error: 'ValidationError', input: {}, name: 'bad input' },
|
|
204
|
+
],
|
|
205
|
+
input: z.object({ id: z.string() }),
|
|
206
|
+
intent: 'read',
|
|
207
|
+
output: z.object({ id: z.string() }),
|
|
208
|
+
run: noop,
|
|
209
|
+
});
|
|
210
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
211
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
212
|
+
const responses = op['responses'] as Record<string, unknown>;
|
|
213
|
+
|
|
214
|
+
expect(responses['404']).toEqual({ description: 'NotFoundError' });
|
|
215
|
+
expect(responses['400']).toEqual({ description: 'ValidationError' });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('operationId', () => {
|
|
220
|
+
test('dots replaced with underscores', () => {
|
|
221
|
+
const t = trail('entity.show', {
|
|
222
|
+
input: z.object({ id: z.string() }),
|
|
223
|
+
intent: 'read',
|
|
224
|
+
run: noop,
|
|
225
|
+
});
|
|
226
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
227
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
228
|
+
|
|
229
|
+
expect(op['operationId']).toBe('entity_show');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('single segment ID preserved', () => {
|
|
233
|
+
const t = trail('search', {
|
|
234
|
+
input: z.object({ q: z.string() }),
|
|
235
|
+
intent: 'read',
|
|
236
|
+
run: noop,
|
|
237
|
+
});
|
|
238
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
239
|
+
const op = spec.paths['/search']?.['get'] as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
expect(op['operationId']).toBe('search');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('tags', () => {
|
|
246
|
+
test('tag is first segment of dotted ID', () => {
|
|
247
|
+
const t = trail('entity.show', {
|
|
248
|
+
input: z.object({ id: z.string() }),
|
|
249
|
+
intent: 'read',
|
|
250
|
+
run: noop,
|
|
251
|
+
});
|
|
252
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
253
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
254
|
+
|
|
255
|
+
expect(op['tags']).toEqual(['entity']);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('single-segment ID uses itself as tag', () => {
|
|
259
|
+
const t = trail('search', {
|
|
260
|
+
input: z.object({ q: z.string() }),
|
|
261
|
+
intent: 'read',
|
|
262
|
+
run: noop,
|
|
263
|
+
});
|
|
264
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
265
|
+
const op = spec.paths['/search']?.['get'] as Record<string, unknown>;
|
|
266
|
+
|
|
267
|
+
expect(op['tags']).toEqual(['search']);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('multiple trails', () => {
|
|
272
|
+
test('all trails populate paths', () => {
|
|
273
|
+
const a = trail('entity.create', {
|
|
274
|
+
input: z.object({ name: z.string() }),
|
|
275
|
+
run: noop,
|
|
276
|
+
});
|
|
277
|
+
const b = trail('entity.show', {
|
|
278
|
+
input: z.object({ id: z.string() }),
|
|
279
|
+
intent: 'read',
|
|
280
|
+
run: noop,
|
|
281
|
+
});
|
|
282
|
+
const c = trail('entity.remove', {
|
|
283
|
+
input: z.object({ id: z.string() }),
|
|
284
|
+
intent: 'destroy',
|
|
285
|
+
run: noop,
|
|
286
|
+
});
|
|
287
|
+
const spec = generateOpenApiSpec(topoFrom({ a, b, c }));
|
|
288
|
+
|
|
289
|
+
expect(Object.keys(spec.paths)).toHaveLength(3);
|
|
290
|
+
expect(spec.paths['/entity/create']?.['post']).toBeDefined();
|
|
291
|
+
expect(spec.paths['/entity/show']?.['get']).toBeDefined();
|
|
292
|
+
expect(spec.paths['/entity/remove']?.['delete']).toBeDefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('internal trails', () => {
|
|
297
|
+
test('trails with metadata.internal are skipped', () => {
|
|
298
|
+
const pub = trail('entity.show', {
|
|
299
|
+
input: z.object({ id: z.string() }),
|
|
300
|
+
intent: 'read',
|
|
301
|
+
run: noop,
|
|
302
|
+
});
|
|
303
|
+
const internal = trail('internal.helper', {
|
|
304
|
+
input: z.object({}),
|
|
305
|
+
metadata: { internal: true },
|
|
306
|
+
run: noop,
|
|
307
|
+
});
|
|
308
|
+
const spec = generateOpenApiSpec(topoFrom({ internal, pub }));
|
|
309
|
+
|
|
310
|
+
expect(Object.keys(spec.paths)).toHaveLength(1);
|
|
311
|
+
expect(spec.paths['/entity/show']).toBeDefined();
|
|
312
|
+
expect(spec.paths['/internal/helper']).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('spec structure', () => {
|
|
317
|
+
test('openapi version is 3.1.0', () => {
|
|
318
|
+
const spec = generateOpenApiSpec(topoFrom({}));
|
|
319
|
+
|
|
320
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('info defaults from topo name', () => {
|
|
324
|
+
const spec = generateOpenApiSpec(topoFrom({}));
|
|
325
|
+
|
|
326
|
+
expect(spec.info.title).toBe('test-app');
|
|
327
|
+
expect(spec.info.version).toBe('1.0.0');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('info uses options when provided', () => {
|
|
331
|
+
const spec = generateOpenApiSpec(topoFrom({}), {
|
|
332
|
+
description: 'Test API',
|
|
333
|
+
title: 'My API',
|
|
334
|
+
version: '2.0.0',
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(spec.info.title).toBe('My API');
|
|
338
|
+
expect(spec.info.version).toBe('2.0.0');
|
|
339
|
+
expect(spec.info.description).toBe('Test API');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('servers included when provided', () => {
|
|
343
|
+
const spec = generateOpenApiSpec(topoFrom({}), {
|
|
344
|
+
servers: [{ description: 'Local', url: 'http://localhost:3000' }],
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(spec.servers).toHaveLength(1);
|
|
348
|
+
expect(spec.servers?.[0]?.url).toBe('http://localhost:3000');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('servers omitted when not provided', () => {
|
|
352
|
+
const spec = generateOpenApiSpec(topoFrom({}));
|
|
353
|
+
|
|
354
|
+
expect(spec.servers).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('components.schemas is present (empty)', () => {
|
|
358
|
+
const spec = generateOpenApiSpec(topoFrom({}));
|
|
359
|
+
|
|
360
|
+
expect(spec.components.schemas).toEqual({});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('summary from description', () => {
|
|
365
|
+
test('trail description becomes operation summary', () => {
|
|
366
|
+
const t = trail('entity.show', {
|
|
367
|
+
description: 'Show an entity by ID',
|
|
368
|
+
input: z.object({ id: z.string() }),
|
|
369
|
+
intent: 'read',
|
|
370
|
+
run: noop,
|
|
371
|
+
});
|
|
372
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
373
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
expect(op['summary']).toBe('Show an entity by ID');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('trail without description has no summary', () => {
|
|
379
|
+
const t = trail('entity.show', {
|
|
380
|
+
input: z.object({ id: z.string() }),
|
|
381
|
+
intent: 'read',
|
|
382
|
+
run: noop,
|
|
383
|
+
});
|
|
384
|
+
const spec = generateOpenApiSpec(topoFrom({ t }));
|
|
385
|
+
const op = spec.paths['/entity/show']?.['get'] as Record<string, unknown>;
|
|
386
|
+
|
|
387
|
+
expect(op['summary']).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
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,287 @@
|
|
|
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
|
+
/** Build the 200 response entry. */
|
|
179
|
+
const buildSuccessResponse = (
|
|
180
|
+
t: Trail<unknown, unknown>
|
|
181
|
+
): Record<string, unknown> => {
|
|
182
|
+
if (!t.output) {
|
|
183
|
+
return { '200': { description: 'Success' } };
|
|
184
|
+
}
|
|
185
|
+
const outputSchema = toJsonSchema(t.output);
|
|
186
|
+
return {
|
|
187
|
+
'200': {
|
|
188
|
+
content: { 'application/json': { schema: outputSchema } },
|
|
189
|
+
description: 'Success',
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Build all responses (success + error) for a trail. */
|
|
195
|
+
const buildResponses = (
|
|
196
|
+
t: Trail<unknown, unknown>
|
|
197
|
+
): Record<string, unknown> => {
|
|
198
|
+
const examples = (t.examples ?? []) as readonly {
|
|
199
|
+
error?: string | undefined;
|
|
200
|
+
}[];
|
|
201
|
+
return {
|
|
202
|
+
...buildSuccessResponse(t),
|
|
203
|
+
...errorResponsesFromExamples(examples),
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Build a complete OpenAPI operation for a trail. */
|
|
208
|
+
const buildOperation = (
|
|
209
|
+
t: Trail<unknown, unknown>,
|
|
210
|
+
method: string
|
|
211
|
+
): Record<string, unknown> => ({
|
|
212
|
+
operationId: t.id.replaceAll('.', '_'),
|
|
213
|
+
responses: buildResponses(t),
|
|
214
|
+
tags: [tagFromId(t.id)],
|
|
215
|
+
...(t.description ? { summary: t.description } : {}),
|
|
216
|
+
...buildInputSpec(t, method),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Path collection
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** Check whether a trail should be included in the spec. */
|
|
224
|
+
const isPublicTrail = (t: Trail<unknown, unknown>): boolean => {
|
|
225
|
+
if (t.kind !== 'trail') {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const { metadata } = t as unknown as {
|
|
229
|
+
metadata?: Record<string, unknown>;
|
|
230
|
+
};
|
|
231
|
+
return metadata?.['internal'] !== true;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Collect all paths from public trails in the topo. */
|
|
235
|
+
const collectPaths = (
|
|
236
|
+
app: Topo,
|
|
237
|
+
basePath: string
|
|
238
|
+
): Record<string, Record<string, unknown>> => {
|
|
239
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
240
|
+
|
|
241
|
+
for (const item of app.list()) {
|
|
242
|
+
const t = item as Trail<unknown, unknown>;
|
|
243
|
+
if (!isPublicTrail(t)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const method = intentToMethod[t.intent] ?? 'post';
|
|
247
|
+
paths[trailIdToPath(t.id, basePath)] = {
|
|
248
|
+
[method]: buildOperation(t, method),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return paths;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Build the info object from options and app name. */
|
|
256
|
+
const buildInfo = (
|
|
257
|
+
appName: string,
|
|
258
|
+
options?: OpenApiOptions
|
|
259
|
+
): OpenApiSpec['info'] => ({
|
|
260
|
+
title: options?.title ?? appName,
|
|
261
|
+
version: options?.version ?? '1.0.0',
|
|
262
|
+
...(options?.description ? { description: options.description } : {}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Public API
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generate an OpenAPI 3.1 specification from a Topo.
|
|
271
|
+
*
|
|
272
|
+
* Iterates all trails, skipping events and internal trails, and produces
|
|
273
|
+
* paths, operations, parameters, and response schemas derived from
|
|
274
|
+
* the trail contract.
|
|
275
|
+
*/
|
|
276
|
+
export const generateOpenApiSpec = (
|
|
277
|
+
app: Topo,
|
|
278
|
+
options?: OpenApiOptions
|
|
279
|
+
): OpenApiSpec => ({
|
|
280
|
+
components: { schemas: {} },
|
|
281
|
+
info: buildInfo(app.name, options),
|
|
282
|
+
openapi: '3.1.0',
|
|
283
|
+
paths: collectPaths(app, options?.basePath ?? ''),
|
|
284
|
+
...(options?.servers && options.servers.length > 0
|
|
285
|
+
? { servers: options.servers }
|
|
286
|
+
: {}),
|
|
287
|
+
});
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -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"}
|