@nanobpm/urban 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/openapi/spec.d.ts +127 -0
- package/dist/openapi/spec.js +444 -0
- package/dist/runtime/core/modules/api.d.ts +61 -0
- package/dist/runtime/core/modules/api.js +359 -0
- package/dist/runtime/core/modules/cancel.d.ts +31 -0
- package/dist/runtime/core/modules/cancel.js +90 -0
- package/dist/runtime/core/modules/datasource.d.ts +5 -2
- package/dist/runtime/core/modules/datasource.js +5 -4
- package/dist/runtime/core/modules/instance-tracking.d.ts +8 -0
- package/dist/runtime/core/modules/instance-tracking.js +28 -11
- package/dist/runtime/core/modules/pages.d.ts +8 -0
- package/dist/runtime/core/modules/pages.js +43 -7
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/engine/nanosdk.js +41 -7
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/dist/toolkit/artifact.d.ts +5 -0
- package/dist/toolkit/artifact.js +7 -0
- package/dist/toolkit/derivers/api.d.ts +19 -0
- package/dist/toolkit/derivers/api.js +224 -0
- package/dist/toolkit/gen.js +23 -4
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +12 -1
- package/package.json +1 -1
- package/src/openapi/spec.test.ts +292 -0
- package/src/openapi/spec.ts +540 -0
- package/src/runtime/core/modules/api.test.ts +445 -0
- package/src/runtime/core/modules/api.ts +453 -0
- package/src/runtime/core/modules/cancel.test.ts +310 -0
- package/src/runtime/core/modules/cancel.ts +119 -0
- package/src/runtime/core/modules/datasource.ts +5 -4
- package/src/runtime/core/modules/instance-tracking.ts +33 -11
- package/src/runtime/core/modules/pages.test.ts +63 -2
- package/src/runtime/core/modules/pages.ts +49 -7
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/engine/nanosdk.test.ts +45 -5
- package/src/runtime/engine/nanosdk.ts +39 -7
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/artifact.ts +8 -0
- package/src/toolkit/derivers/api.test.ts +219 -0
- package/src/toolkit/derivers/api.ts +255 -0
- package/src/toolkit/gen.test.ts +39 -0
- package/src/toolkit/gen.ts +24 -4
- package/src/toolkit/index.ts +41 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** A JSON Schema (the OpenAPI subset we read). Kept structural and permissive — unknown keywords
|
|
2
|
+
* are ignored by the validator and fall back to `unknown` in the type emitter. */
|
|
3
|
+
export interface OpenApiSchema {
|
|
4
|
+
$ref?: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
nullable?: boolean;
|
|
7
|
+
enum?: unknown[];
|
|
8
|
+
const?: unknown;
|
|
9
|
+
format?: string;
|
|
10
|
+
properties?: Record<string, OpenApiSchema>;
|
|
11
|
+
required?: string[];
|
|
12
|
+
additionalProperties?: boolean | OpenApiSchema;
|
|
13
|
+
items?: OpenApiSchema;
|
|
14
|
+
minItems?: number;
|
|
15
|
+
maxItems?: number;
|
|
16
|
+
minLength?: number;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
pattern?: string;
|
|
19
|
+
minimum?: number;
|
|
20
|
+
maximum?: number;
|
|
21
|
+
exclusiveMinimum?: number | boolean;
|
|
22
|
+
exclusiveMaximum?: number | boolean;
|
|
23
|
+
allOf?: OpenApiSchema[];
|
|
24
|
+
oneOf?: OpenApiSchema[];
|
|
25
|
+
anyOf?: OpenApiSchema[];
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
|
|
30
|
+
* keyword be omitted when `properties`/`required`/`additionalProperties` already imply
|
|
31
|
+
* an object. The type emitter (`schemaToTs`) and the runtime validator (`validateValue`)
|
|
32
|
+
* both route object detection through this single predicate so they never drift — a body
|
|
33
|
+
* the emitted type calls an object is the same body the validator shape-checks.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isObjectSchema(schema: OpenApiSchema): boolean;
|
|
36
|
+
export interface OpenApiParameter {
|
|
37
|
+
name: string;
|
|
38
|
+
in: "path" | "query" | "header" | "cookie";
|
|
39
|
+
required?: boolean;
|
|
40
|
+
schema?: OpenApiSchema;
|
|
41
|
+
}
|
|
42
|
+
export interface OpenApiMediaType {
|
|
43
|
+
schema?: OpenApiSchema;
|
|
44
|
+
}
|
|
45
|
+
export interface OpenApiRequestBody {
|
|
46
|
+
required?: boolean;
|
|
47
|
+
content?: Record<string, OpenApiMediaType>;
|
|
48
|
+
}
|
|
49
|
+
export interface OpenApiResponse {
|
|
50
|
+
description?: string;
|
|
51
|
+
content?: Record<string, OpenApiMediaType>;
|
|
52
|
+
}
|
|
53
|
+
export interface OpenApiDoc {
|
|
54
|
+
openapi?: string;
|
|
55
|
+
info?: {
|
|
56
|
+
title?: string;
|
|
57
|
+
version?: string;
|
|
58
|
+
};
|
|
59
|
+
paths?: Record<string, Record<string, unknown>>;
|
|
60
|
+
components?: {
|
|
61
|
+
schemas?: Record<string, OpenApiSchema>;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
65
|
+
export declare const HTTP_METHODS: readonly ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
66
|
+
export type HttpMethodLower = (typeof HTTP_METHODS)[number];
|
|
67
|
+
/** One resolved operation: its id, method, path, parameters, request body schema, and responses.
|
|
68
|
+
* This is the shared unit both the deriver (→ types) and the runtime (→ routes) consume. */
|
|
69
|
+
export interface OperationInfo {
|
|
70
|
+
operationId: string;
|
|
71
|
+
method: HttpMethodLower;
|
|
72
|
+
/** The OpenAPI path template, e.g. "/invoices/{id}". */
|
|
73
|
+
path: string;
|
|
74
|
+
parameters: OpenApiParameter[];
|
|
75
|
+
requestBodySchema?: OpenApiSchema;
|
|
76
|
+
requestBodyRequired: boolean;
|
|
77
|
+
/** Schema of the first 2xx (else default) JSON response, when declared. */
|
|
78
|
+
responseSchema?: OpenApiSchema;
|
|
79
|
+
eject: boolean;
|
|
80
|
+
summary?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Parse an OpenAPI document from text. JSON only in this slice (ADR 0058 open question: YAML).
|
|
83
|
+
* Throws with a clear message on malformed input. */
|
|
84
|
+
export declare function parseSpec(text: string): OpenApiDoc;
|
|
85
|
+
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
86
|
+
export declare function refName(ref: string): string | undefined;
|
|
87
|
+
/** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
|
|
88
|
+
export declare function resolveSchema(doc: OpenApiDoc, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
|
|
89
|
+
/** Walk the document's paths and return every operation that carries an operationId, in a stable
|
|
90
|
+
* (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
|
|
91
|
+
* them at `warn` and skips them too — this keeps the pure collector total. */
|
|
92
|
+
export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
|
|
93
|
+
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
94
|
+
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
95
|
+
export declare function operationsWithoutId(doc: OpenApiDoc): string[];
|
|
96
|
+
/** "METHOD path {param}" pointers for every path-template parameter that no declared `in: "path"`
|
|
97
|
+
* parameter covers. Such params are still captured and passed to the delegate at runtime, but are
|
|
98
|
+
* neither typed (the deriver only emits declared params) nor validated — a type/runtime drift the
|
|
99
|
+
* runtime mount surfaces at `warn`. */
|
|
100
|
+
export declare function undeclaredPathParams(doc: OpenApiDoc): string[];
|
|
101
|
+
/** An operationId names the delegate module file (`<dir>/<operationId>`), so it must be a single
|
|
102
|
+
* safe path segment. Rejects separators and parent-dir traversal so a crafted spec can't import a
|
|
103
|
+
* file outside the operations directory. Single source of truth for the rule (used by the pure
|
|
104
|
+
* collector, the diagnostic collector, and the runtime import sink). */
|
|
105
|
+
export declare function isSafeOperationId(id: string): boolean;
|
|
106
|
+
/** "METHOD path (operationId)" pointers for operations whose operationId is present but not a safe
|
|
107
|
+
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
108
|
+
* surfaces them at `warn`. */
|
|
109
|
+
export declare function operationsWithUnsafeId(doc: OpenApiDoc): string[];
|
|
110
|
+
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
111
|
+
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
112
|
+
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
|
113
|
+
/** Escape RegExp metacharacters so a string is matched literally inside a `new RegExp(...)`. */
|
|
114
|
+
export declare function escapeRegex(s: string): string;
|
|
115
|
+
export declare function toRouteMatcher(base: string, path: string): {
|
|
116
|
+
pattern: RegExp;
|
|
117
|
+
paramNames: string[];
|
|
118
|
+
sample: string;
|
|
119
|
+
};
|
|
120
|
+
/** One validation failure: a JSON-pointer-ish path and a human message. */
|
|
121
|
+
export interface ValidationIssue {
|
|
122
|
+
path: string;
|
|
123
|
+
message: string;
|
|
124
|
+
}
|
|
125
|
+
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
126
|
+
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
127
|
+
export declare function validateValue(doc: OpenApiDoc, schemaIn: OpenApiSchema | undefined, value: unknown, path?: string): ValidationIssue[];
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Shared, pure OpenAPI machinery for the Urban endpoint surface (ADR 0058). Both the toolkit
|
|
2
|
+
// deriver (`toolkit/derivers/api.ts`, authoring-time type + wrapper emission) and the runtime
|
|
3
|
+
// (`runtime/core/modules/api.ts`, request validation + routing) build on this one module, so the
|
|
4
|
+
// derived types and the runtime routes/validation always agree. No IO, no `node:*`, no `Deno` —
|
|
5
|
+
// it operates on an already-parsed document object.
|
|
6
|
+
//
|
|
7
|
+
// Scope: the ADR 0058 "supported profile" — JSON bodies, path/query parameters, a JSON
|
|
8
|
+
// requestBody, response codes, and a JSON-Schema subset validator (type/required/enum/numeric &
|
|
9
|
+
// string bounds/pattern/array & object shape/nullable/$ref). Exotic OpenAPI (callbacks, links,
|
|
10
|
+
// XML, discriminated oneOf composition) is intentionally out of scope for this slice.
|
|
11
|
+
/**
|
|
12
|
+
* Whether a schema describes an object shape. OpenAPI/JSON-Schema let the `type`
|
|
13
|
+
* keyword be omitted when `properties`/`required`/`additionalProperties` already imply
|
|
14
|
+
* an object. The type emitter (`schemaToTs`) and the runtime validator (`validateValue`)
|
|
15
|
+
* both route object detection through this single predicate so they never drift — a body
|
|
16
|
+
* the emitted type calls an object is the same body the validator shape-checks.
|
|
17
|
+
*/
|
|
18
|
+
export function isObjectSchema(schema) {
|
|
19
|
+
if (schema.type === "object")
|
|
20
|
+
return true;
|
|
21
|
+
if (schema.type !== undefined)
|
|
22
|
+
return false;
|
|
23
|
+
return (schema.properties !== undefined ||
|
|
24
|
+
schema.required !== undefined ||
|
|
25
|
+
schema.additionalProperties !== undefined);
|
|
26
|
+
}
|
|
27
|
+
/** The HTTP methods we mount, lowercase as they appear as path-item keys. */
|
|
28
|
+
export const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"];
|
|
29
|
+
function isRecord(v) {
|
|
30
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
31
|
+
}
|
|
32
|
+
/** A structural gate for a schema object: any record is treated as a (loose) OpenAPI schema. */
|
|
33
|
+
function isSchema(v) {
|
|
34
|
+
return isRecord(v);
|
|
35
|
+
}
|
|
36
|
+
/** Parse an OpenAPI document from text. JSON only in this slice (ADR 0058 open question: YAML).
|
|
37
|
+
* Throws with a clear message on malformed input. */
|
|
38
|
+
export function parseSpec(text) {
|
|
39
|
+
let doc;
|
|
40
|
+
try {
|
|
41
|
+
doc = JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new Error(`OpenAPI spec is not valid JSON (YAML is not yet supported — ADR 0058): ${e instanceof Error ? e.message : String(e)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(doc))
|
|
47
|
+
throw new Error("OpenAPI spec must be a JSON object");
|
|
48
|
+
return doc;
|
|
49
|
+
}
|
|
50
|
+
/** The basename of a local `#/components/schemas/Name` ref, else undefined. */
|
|
51
|
+
export function refName(ref) {
|
|
52
|
+
const m = ref.match(/^#\/components\/schemas\/([^/]+)$/);
|
|
53
|
+
return m ? m[1] : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
|
|
56
|
+
export function resolveSchema(doc, schema) {
|
|
57
|
+
if (!schema)
|
|
58
|
+
return undefined;
|
|
59
|
+
let current = schema;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
while (current.$ref) {
|
|
62
|
+
if (seen.has(current.$ref))
|
|
63
|
+
return undefined; // cyclic ref guard
|
|
64
|
+
seen.add(current.$ref);
|
|
65
|
+
const name = refName(current.$ref);
|
|
66
|
+
const next = name ? doc.components?.schemas?.[name] : undefined;
|
|
67
|
+
if (!next)
|
|
68
|
+
return undefined;
|
|
69
|
+
current = next;
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
function firstJsonSchema(content) {
|
|
74
|
+
if (!content)
|
|
75
|
+
return undefined;
|
|
76
|
+
const pick = (media) => isRecord(media) && isSchema(media.schema) ? media.schema : undefined;
|
|
77
|
+
// JSON-only: the runtime always JSON.parses the body and responds application/json, so derive
|
|
78
|
+
// types/validation only from JSON media types (application/json, its charset variants, and any
|
|
79
|
+
// `+json` structured suffix). A non-JSON media type is ignored rather than typed as if JSON.
|
|
80
|
+
for (const [type, media] of Object.entries(content)) {
|
|
81
|
+
const base = type.split(";", 1)[0].trim().toLowerCase();
|
|
82
|
+
if (base === "application/json" || base.endsWith("+json")) {
|
|
83
|
+
const s = pick(media);
|
|
84
|
+
if (s)
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Walk the document's paths and return every operation that carries an operationId, in a stable
|
|
91
|
+
* (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
|
|
92
|
+
* them at `warn` and skips them too — this keeps the pure collector total. */
|
|
93
|
+
export function collectOperations(doc) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const paths = doc.paths ?? {};
|
|
96
|
+
for (const path of Object.keys(paths).sort()) {
|
|
97
|
+
const item = paths[path];
|
|
98
|
+
if (!isRecord(item))
|
|
99
|
+
continue;
|
|
100
|
+
// Path-level parameters apply to every operation on the path (OpenAPI Path Item Object).
|
|
101
|
+
const pathParams = Array.isArray(item.parameters) ? item.parameters : [];
|
|
102
|
+
for (const method of HTTP_METHODS) {
|
|
103
|
+
const opRaw = item[method];
|
|
104
|
+
if (!isRecord(opRaw))
|
|
105
|
+
continue;
|
|
106
|
+
const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
|
|
107
|
+
if (!operationId || !isSafeOperationId(operationId))
|
|
108
|
+
continue;
|
|
109
|
+
const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
|
|
110
|
+
const parameters = normalizeParameters([...pathParams, ...opParams]);
|
|
111
|
+
const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
|
|
112
|
+
const requestBodySchema = firstJsonSchema(isRecord(requestBody?.content) ? requestBody.content : undefined);
|
|
113
|
+
const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
|
|
114
|
+
out.push({
|
|
115
|
+
operationId,
|
|
116
|
+
method,
|
|
117
|
+
path,
|
|
118
|
+
parameters,
|
|
119
|
+
requestBodySchema,
|
|
120
|
+
requestBodyRequired: requestBody?.required === true,
|
|
121
|
+
responseSchema: firstSuccessSchema(responses),
|
|
122
|
+
eject: opRaw["x-urban-eject"] === true,
|
|
123
|
+
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** "method path" pointers for every operation that declares no `operationId`. Such operations are
|
|
130
|
+
* unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
|
|
131
|
+
export function operationsWithoutId(doc) {
|
|
132
|
+
const missing = [];
|
|
133
|
+
const paths = doc.paths ?? {};
|
|
134
|
+
for (const path of Object.keys(paths).sort()) {
|
|
135
|
+
const item = paths[path];
|
|
136
|
+
if (!isRecord(item))
|
|
137
|
+
continue;
|
|
138
|
+
for (const method of HTTP_METHODS) {
|
|
139
|
+
const opRaw = item[method];
|
|
140
|
+
if (!isRecord(opRaw))
|
|
141
|
+
continue;
|
|
142
|
+
if (typeof opRaw.operationId !== "string" || opRaw.operationId.length === 0) {
|
|
143
|
+
missing.push(`${method.toUpperCase()} ${path}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return missing;
|
|
148
|
+
}
|
|
149
|
+
/** "METHOD path {param}" pointers for every path-template parameter that no declared `in: "path"`
|
|
150
|
+
* parameter covers. Such params are still captured and passed to the delegate at runtime, but are
|
|
151
|
+
* neither typed (the deriver only emits declared params) nor validated — a type/runtime drift the
|
|
152
|
+
* runtime mount surfaces at `warn`. */
|
|
153
|
+
export function undeclaredPathParams(doc) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const op of collectOperations(doc)) {
|
|
156
|
+
const declared = new Set(op.parameters.filter((p) => p.in === "path").map((p) => p.name));
|
|
157
|
+
for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
|
|
158
|
+
if (!declared.has(m[1]))
|
|
159
|
+
out.push(`${op.method.toUpperCase()} ${op.path} {${m[1]}}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** An operationId names the delegate module file (`<dir>/<operationId>`), so it must be a single
|
|
165
|
+
* safe path segment. Rejects separators and parent-dir traversal so a crafted spec can't import a
|
|
166
|
+
* file outside the operations directory. Single source of truth for the rule (used by the pure
|
|
167
|
+
* collector, the diagnostic collector, and the runtime import sink). */
|
|
168
|
+
export function isSafeOperationId(id) {
|
|
169
|
+
return id.length > 0 && !id.includes("/") && !id.includes("\\") && !id.includes("..") && id !== ".";
|
|
170
|
+
}
|
|
171
|
+
/** "METHOD path (operationId)" pointers for operations whose operationId is present but not a safe
|
|
172
|
+
* path segment. collectOperations skips these (they never mount or drive an import); the runtime
|
|
173
|
+
* surfaces them at `warn`. */
|
|
174
|
+
export function operationsWithUnsafeId(doc) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const paths = doc.paths ?? {};
|
|
177
|
+
for (const path of Object.keys(paths).sort()) {
|
|
178
|
+
const item = paths[path];
|
|
179
|
+
if (!isRecord(item))
|
|
180
|
+
continue;
|
|
181
|
+
for (const method of HTTP_METHODS) {
|
|
182
|
+
const opRaw = item[method];
|
|
183
|
+
if (!isRecord(opRaw))
|
|
184
|
+
continue;
|
|
185
|
+
const id = opRaw.operationId;
|
|
186
|
+
if (typeof id === "string" && id.length > 0 && !isSafeOperationId(id)) {
|
|
187
|
+
out.push(`${method.toUpperCase()} ${path} (${id})`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function normalizeParameters(raw) {
|
|
194
|
+
// OpenAPI: parameters are uniquely identified by (name, in). A path-level parameter can be
|
|
195
|
+
// overridden by an operation-level one with the same (name, in) — never duplicated. Callers pass
|
|
196
|
+
// [...pathParams, ...opParams], so dedup later-wins (op overrides path) while preserving the
|
|
197
|
+
// first-seen position, so the emitted binding never carries two `path`/`query` fields with the
|
|
198
|
+
// same name (which would collide in the generated params/query types and in runtime extraction).
|
|
199
|
+
const byKey = new Map();
|
|
200
|
+
const order = [];
|
|
201
|
+
for (const p of raw) {
|
|
202
|
+
if (!isRecord(p))
|
|
203
|
+
continue;
|
|
204
|
+
const name = typeof p.name === "string" ? p.name : undefined;
|
|
205
|
+
const location = typeof p.in === "string" ? p.in : undefined;
|
|
206
|
+
if (!name || !location)
|
|
207
|
+
continue;
|
|
208
|
+
if (location !== "path" && location !== "query" && location !== "header" && location !== "cookie") {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const schema = isRecord(p.schema) ? p.schema : undefined;
|
|
212
|
+
const key = `${location}\u0000${name}`;
|
|
213
|
+
if (!byKey.has(key))
|
|
214
|
+
order.push(key);
|
|
215
|
+
byKey.set(key, { name, in: location, required: p.required === true, schema });
|
|
216
|
+
}
|
|
217
|
+
return order.map((k) => byKey.get(k));
|
|
218
|
+
}
|
|
219
|
+
function firstSuccessSchema(responses) {
|
|
220
|
+
const codes = Object.keys(responses)
|
|
221
|
+
.filter((c) => /^2\d\d$/.test(c))
|
|
222
|
+
.sort();
|
|
223
|
+
const code = codes[0] ?? (isRecord(responses.default) ? "default" : undefined);
|
|
224
|
+
if (!code)
|
|
225
|
+
return undefined;
|
|
226
|
+
const resp = responses[code];
|
|
227
|
+
if (!isRecord(resp))
|
|
228
|
+
return undefined;
|
|
229
|
+
return firstJsonSchema(isRecord(resp.content) ? resp.content : undefined);
|
|
230
|
+
}
|
|
231
|
+
/** Turn an OpenAPI path template ("/invoices/{id}") into a matcher under `base` ("/app/api"):
|
|
232
|
+
* a RegExp that captures each `{param}` positionally, plus the ordered path-parameter names. The
|
|
233
|
+
* runtime matches concrete request paths, so a templated segment becomes a single-segment capture. */
|
|
234
|
+
/** Escape RegExp metacharacters so a string is matched literally inside a `new RegExp(...)`. */
|
|
235
|
+
export function escapeRegex(s) {
|
|
236
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
237
|
+
}
|
|
238
|
+
export function toRouteMatcher(base, path) {
|
|
239
|
+
const cleanBase = base.replace(/\/+$/, "");
|
|
240
|
+
const paramNames = [];
|
|
241
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
242
|
+
const regexParts = segments.map((seg) => {
|
|
243
|
+
const m = seg.match(/^\{([^}]+)\}$/);
|
|
244
|
+
if (m) {
|
|
245
|
+
paramNames.push(m[1]);
|
|
246
|
+
return "([^/]+)";
|
|
247
|
+
}
|
|
248
|
+
return escapeRegex(seg);
|
|
249
|
+
});
|
|
250
|
+
const body = regexParts.length > 0 ? `/${regexParts.join("/")}` : "";
|
|
251
|
+
// `base` is a literal path prefix (e.g. "/app/api(v2)"), not a pattern — escape its metacharacters
|
|
252
|
+
// just like the static template segments so it can't alter or break the matcher.
|
|
253
|
+
const pattern = new RegExp(`^${escapeRegex(cleanBase)}${body}/?$`);
|
|
254
|
+
const sample = `${cleanBase}${segments.length > 0 ? `/${segments.join("/")}` : ""}`;
|
|
255
|
+
return { pattern, paramNames, sample };
|
|
256
|
+
}
|
|
257
|
+
/** Structural (deep) equality for JSON values — enum/const members can be objects or arrays, which
|
|
258
|
+
* a parsed request will never be `===` to. Compares by shape so those comparisons are correct. */
|
|
259
|
+
function deepEqual(a, b) {
|
|
260
|
+
if (a === b)
|
|
261
|
+
return true;
|
|
262
|
+
if (typeof a !== typeof b || a === null || b === null)
|
|
263
|
+
return false;
|
|
264
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
265
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
266
|
+
return false;
|
|
267
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
268
|
+
}
|
|
269
|
+
if (isRecord(a) && isRecord(b)) {
|
|
270
|
+
const ak = Object.keys(a);
|
|
271
|
+
const bk = Object.keys(b);
|
|
272
|
+
if (ak.length !== bk.length)
|
|
273
|
+
return false;
|
|
274
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
function typeOfValue(v) {
|
|
279
|
+
if (Array.isArray(v))
|
|
280
|
+
return "array";
|
|
281
|
+
if (typeof v === "number" && Number.isInteger(v))
|
|
282
|
+
return "integer";
|
|
283
|
+
return typeof v;
|
|
284
|
+
}
|
|
285
|
+
function matchesType(declared, value) {
|
|
286
|
+
const actual = typeOfValue(value);
|
|
287
|
+
if (declared === "number")
|
|
288
|
+
return actual === "number" || actual === "integer";
|
|
289
|
+
if (declared === "integer")
|
|
290
|
+
return actual === "integer";
|
|
291
|
+
return actual === declared;
|
|
292
|
+
}
|
|
293
|
+
/** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
|
|
294
|
+
* found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
|
|
295
|
+
export function validateValue(doc, schemaIn, value, path = "") {
|
|
296
|
+
const schema = resolveSchema(doc, schemaIn);
|
|
297
|
+
if (!schema)
|
|
298
|
+
return [];
|
|
299
|
+
const issues = [];
|
|
300
|
+
const at = path || "(root)";
|
|
301
|
+
if (value === null) {
|
|
302
|
+
if (schema.nullable === true || schema.type === "null")
|
|
303
|
+
return issues;
|
|
304
|
+
// enum/const may explicitly permit null even without `nullable`.
|
|
305
|
+
if (schema.enum && schema.enum.some((e) => e === null))
|
|
306
|
+
return issues;
|
|
307
|
+
if (schema.const === null)
|
|
308
|
+
return issues;
|
|
309
|
+
// Otherwise null is invalid for a typed schema, an object schema (properties/required imply
|
|
310
|
+
// object), or an enum/const that doesn't list null. A truly empty schema ({}) allows anything.
|
|
311
|
+
if (schema.enum) {
|
|
312
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
313
|
+
}
|
|
314
|
+
else if (schema.const !== undefined) {
|
|
315
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
316
|
+
}
|
|
317
|
+
else if (schema.type) {
|
|
318
|
+
issues.push({ path: at, message: `expected ${schema.type}, got null` });
|
|
319
|
+
}
|
|
320
|
+
else if (isObjectSchema(schema)) {
|
|
321
|
+
issues.push({ path: at, message: "expected object, got null" });
|
|
322
|
+
}
|
|
323
|
+
return issues;
|
|
324
|
+
}
|
|
325
|
+
if (schema.enum && !schema.enum.some((e) => deepEqual(e, value))) {
|
|
326
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
327
|
+
}
|
|
328
|
+
if (schema.const !== undefined && !deepEqual(schema.const, value)) {
|
|
329
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
330
|
+
}
|
|
331
|
+
// Composition keywords — kept in lockstep with schemaToTs, which emits `allOf` as an intersection
|
|
332
|
+
// and `anyOf`/`oneOf` as unions. Runs before the `type` early-return since a composed schema may
|
|
333
|
+
// omit a top-level `type` and carry it on the sub-schemas instead.
|
|
334
|
+
if (schema.allOf) {
|
|
335
|
+
for (const sub of schema.allOf)
|
|
336
|
+
issues.push(...validateValue(doc, sub, value, path));
|
|
337
|
+
}
|
|
338
|
+
if (schema.anyOf && !schema.anyOf.some((sub) => validateValue(doc, sub, value, path).length === 0)) {
|
|
339
|
+
issues.push({ path: at, message: "does not match any schema in anyOf" });
|
|
340
|
+
}
|
|
341
|
+
if (schema.oneOf) {
|
|
342
|
+
const matched = schema.oneOf.filter((sub) => validateValue(doc, sub, value, path).length === 0).length;
|
|
343
|
+
if (matched !== 1) {
|
|
344
|
+
issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (schema.type && !matchesType(schema.type, value)) {
|
|
348
|
+
issues.push({ path: at, message: `expected ${schema.type}, got ${typeOfValue(value)}` });
|
|
349
|
+
return issues; // the shape checks below assume the declared type held
|
|
350
|
+
}
|
|
351
|
+
if (schema.type === "string" && typeof value === "string") {
|
|
352
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
353
|
+
issues.push({ path: at, message: `shorter than minLength ${schema.minLength}` });
|
|
354
|
+
}
|
|
355
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
356
|
+
issues.push({ path: at, message: `longer than maxLength ${schema.maxLength}` });
|
|
357
|
+
}
|
|
358
|
+
if (schema.pattern) {
|
|
359
|
+
let re;
|
|
360
|
+
try {
|
|
361
|
+
re = new RegExp(schema.pattern);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// A malformed pattern is a spec-author error, not client input. Surface it as a
|
|
365
|
+
// controlled failure (the dispatcher's outer catch maps it to a 500 with this
|
|
366
|
+
// message) instead of letting `new RegExp` throw an opaque error deep in
|
|
367
|
+
// validation and crash the host request handler.
|
|
368
|
+
throw new Error(`schema at ${at}: invalid pattern ${JSON.stringify(schema.pattern)}`);
|
|
369
|
+
}
|
|
370
|
+
if (!re.test(value)) {
|
|
371
|
+
issues.push({ path: at, message: `does not match pattern ${schema.pattern}` });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if ((schema.type === "number" || schema.type === "integer") && typeof value === "number") {
|
|
376
|
+
// OpenAPI 3.0 uses the boolean form (`exclusiveMinimum: true` makes `minimum` exclusive);
|
|
377
|
+
// JSON Schema 2020-12 (OpenAPI 3.1) uses the numeric form (`exclusiveMinimum: <number>`).
|
|
378
|
+
// Support both so specs in either dialect are validated, not silently under-checked.
|
|
379
|
+
const minExclusive = schema.exclusiveMinimum === true;
|
|
380
|
+
const maxExclusive = schema.exclusiveMaximum === true;
|
|
381
|
+
if (schema.minimum !== undefined && (minExclusive ? value <= schema.minimum : value < schema.minimum)) {
|
|
382
|
+
issues.push({
|
|
383
|
+
path: at,
|
|
384
|
+
message: minExclusive ? `must be > ${schema.minimum}` : `less than minimum ${schema.minimum}`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (schema.maximum !== undefined && (maxExclusive ? value >= schema.maximum : value > schema.maximum)) {
|
|
388
|
+
issues.push({
|
|
389
|
+
path: at,
|
|
390
|
+
message: maxExclusive ? `must be < ${schema.maximum}` : `greater than maximum ${schema.maximum}`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
|
|
394
|
+
issues.push({ path: at, message: `must be > ${schema.exclusiveMinimum}` });
|
|
395
|
+
}
|
|
396
|
+
if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
|
|
397
|
+
issues.push({ path: at, message: `must be < ${schema.exclusiveMaximum}` });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (schema.type === "array" && Array.isArray(value)) {
|
|
401
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
402
|
+
issues.push({ path: at, message: `fewer than minItems ${schema.minItems}` });
|
|
403
|
+
}
|
|
404
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
405
|
+
issues.push({ path: at, message: `more than maxItems ${schema.maxItems}` });
|
|
406
|
+
}
|
|
407
|
+
if (schema.items) {
|
|
408
|
+
value.forEach((item, i) => {
|
|
409
|
+
issues.push(...validateValue(doc, schema.items, item, `${path}[${i}]`));
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (isObjectSchema(schema)) {
|
|
414
|
+
if (!isRecord(value)) {
|
|
415
|
+
// A typeless object schema (properties/required imply object, but `type` is omitted) skips
|
|
416
|
+
// the type check above, so guard here — otherwise a non-object like `42` or `[]` would pass.
|
|
417
|
+
issues.push({ path: at, message: `expected object, got ${typeOfValue(value)}` });
|
|
418
|
+
return issues;
|
|
419
|
+
}
|
|
420
|
+
for (const req of schema.required ?? []) {
|
|
421
|
+
if (!Object.hasOwn(value, req))
|
|
422
|
+
issues.push({ path: `${path}/${req}`, message: "is required" });
|
|
423
|
+
}
|
|
424
|
+
const props = schema.properties ?? {};
|
|
425
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
426
|
+
if (Object.hasOwn(value, key))
|
|
427
|
+
issues.push(...validateValue(doc, propSchema, value[key], `${path}/${key}`));
|
|
428
|
+
}
|
|
429
|
+
const extra = schema.additionalProperties;
|
|
430
|
+
if (extra === false) {
|
|
431
|
+
for (const key of Object.keys(value)) {
|
|
432
|
+
if (!Object.hasOwn(props, key))
|
|
433
|
+
issues.push({ path: `${path}/${key}`, message: "is not an allowed property" });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (isRecord(extra)) {
|
|
437
|
+
for (const key of Object.keys(value)) {
|
|
438
|
+
if (!Object.hasOwn(props, key))
|
|
439
|
+
issues.push(...validateValue(doc, extra, value[key], `${path}/${key}`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return issues;
|
|
444
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
2
|
+
import type { HttpRequest } from "../host.ts";
|
|
3
|
+
import { type Route } from "../router.ts";
|
|
4
|
+
/** The manifest `api` binding (ADR 0058). Mirrored locally until `@nanobpm/nano-app-schema`
|
|
5
|
+
* republishes with the `api` field folded into `AppManifest` (the schema PR lands first). */
|
|
6
|
+
export interface ApiBinding {
|
|
7
|
+
spec: string;
|
|
8
|
+
dir?: string;
|
|
9
|
+
base?: string;
|
|
10
|
+
validateResponses?: "dev" | "always" | "never";
|
|
11
|
+
eject?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** The `{ params, query, body }` contract a typed operation is generic over. */
|
|
14
|
+
export interface OperationContract {
|
|
15
|
+
params: unknown;
|
|
16
|
+
query: unknown;
|
|
17
|
+
body: unknown;
|
|
18
|
+
}
|
|
19
|
+
/** The loose contract an untyped delegate sees (strings on the wire; unknown JSON body). */
|
|
20
|
+
export interface DefaultContract {
|
|
21
|
+
params: Record<string, string>;
|
|
22
|
+
query: Record<string, string | string[] | undefined>;
|
|
23
|
+
body: unknown;
|
|
24
|
+
}
|
|
25
|
+
/** The input handed to an operation delegate: the raw request plus the parsed (and, unless ejected,
|
|
26
|
+
* validated) path params, query, and JSON body. */
|
|
27
|
+
export interface OperationInput<Req extends OperationContract = DefaultContract> {
|
|
28
|
+
req: HttpRequest;
|
|
29
|
+
params: Req["params"];
|
|
30
|
+
query: Req["query"];
|
|
31
|
+
body: Req["body"];
|
|
32
|
+
}
|
|
33
|
+
/** What a delegate returns; the runtime serializes `body` as JSON. Empty return → `204`. */
|
|
34
|
+
export interface OperationResult<Res = unknown> {
|
|
35
|
+
status?: number;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
body?: Res;
|
|
38
|
+
}
|
|
39
|
+
/** An app-authored operation delegate: validated/typed input + the injected AppApi. */
|
|
40
|
+
export type OperationHandler<Req extends OperationContract = DefaultContract, Res = unknown> = (input: OperationInput<Req>, app: AppApi) => Promise<OperationResult<Res> | void> | OperationResult<Res> | void;
|
|
41
|
+
/**
|
|
42
|
+
* Typed-identity helper the generated `operations.ts` wrapper re-types per operationId. Authoring:
|
|
43
|
+
* `export default defineOperation("createInvoice", async ({ params, query, body }, app) => { ... })`
|
|
44
|
+
* — the handler's first argument is the validated `OperationInput` (params/query/body/req), not the
|
|
45
|
+
* raw request. The runtime resolves a delegate by its module path (`<dir>/<operationId>`), so the id
|
|
46
|
+
* here is a documentation + typing aid; it is returned unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
|
|
49
|
+
/** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
|
|
50
|
+
export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
|
|
51
|
+
export interface ApiHandle {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
routes: Route[];
|
|
54
|
+
describe(): Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Mount the OpenAPI endpoint surface. Returns no routes when the app declares no `api` binding, so
|
|
58
|
+
* it is a no-op for apps that use only `actions[]`. A malformed spec (bad JSON / not an object)
|
|
59
|
+
* mounts a single route under the base that returns a clear 500, rather than failing app boot.
|
|
60
|
+
*/
|
|
61
|
+
export declare function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle;
|