@scalar/mock-server 0.12.13 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/create-asyncapi-mock-server.d.ts +8 -2
- package/dist/create-asyncapi-mock-server.d.ts.map +1 -1
- package/dist/create-asyncapi-mock-server.js +5 -2
- package/dist/create-mock-server.d.ts.map +1 -1
- package/dist/create-mock-server.js +112 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/routes/mock-any-response.d.ts +1 -4
- package/dist/routes/mock-any-response.d.ts.map +1 -1
- package/dist/routes/mock-any-response.js +43 -22
- package/dist/routes/mock-handler-response.d.ts.map +1 -1
- package/dist/routes/mock-handler-response.js +2 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/build-handler-context.d.ts.map +1 -1
- package/dist/utils/build-handler-context.js +3 -2
- package/dist/utils/collect-sse-events.d.ts +30 -0
- package/dist/utils/collect-sse-events.d.ts.map +1 -0
- package/dist/utils/collect-sse-events.js +137 -0
- package/dist/utils/hono-route-from-path.d.ts +16 -3
- package/dist/utils/hono-route-from-path.d.ts.map +1 -1
- package/dist/utils/hono-route-from-path.js +98 -5
- package/dist/utils/log-authentication-instructions.d.ts +7 -2
- package/dist/utils/log-authentication-instructions.d.ts.map +1 -1
- package/dist/utils/log-authentication-instructions.js +64 -60
- package/dist/utils/path-parameters.d.ts +13 -0
- package/dist/utils/path-parameters.d.ts.map +1 -0
- package/dist/utils/path-parameters.js +17 -0
- package/dist/utils/replace-circular-markers.d.ts +22 -0
- package/dist/utils/replace-circular-markers.d.ts.map +1 -0
- package/dist/utils/replace-circular-markers.js +226 -0
- package/dist/utils/request-matches-pinned-query.d.ts +10 -0
- package/dist/utils/request-matches-pinned-query.d.ts.map +1 -0
- package/dist/utils/request-matches-pinned-query.js +13 -0
- package/dist/utils/resolve-logger.d.ts +12 -0
- package/dist/utils/resolve-logger.d.ts.map +1 -0
- package/dist/utils/resolve-logger.js +16 -0
- package/dist/utils/serialize-response-body.d.ts +11 -0
- package/dist/utils/serialize-response-body.d.ts.map +1 -0
- package/dist/utils/serialize-response-body.js +88 -0
- package/dist/utils/split-path-key.d.ts +30 -0
- package/dist/utils/split-path-key.d.ts.map +1 -0
- package/dist/utils/split-path-key.js +77 -0
- package/dist/utils/validate-request.d.ts.map +1 -1
- package/dist/utils/validate-request.js +13 -2
- package/package.json +6 -6
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { isObject } from '@scalar/helpers/object/is-object';
|
|
2
|
+
/**
|
|
3
|
+
* The placeholder `getResolvedRefDeep` leaves behind wherever it had to cut a `$ref` cycle.
|
|
4
|
+
*
|
|
5
|
+
* The marker is owned by `@scalar/workspace-store`; the Ajv test in this file's suite resolves a real
|
|
6
|
+
* recursive schema, so it fails here if that sentinel ever changes.
|
|
7
|
+
*/
|
|
8
|
+
const CIRCULAR_MARKER = '[circular]';
|
|
9
|
+
/**
|
|
10
|
+
* Keywords whose value is a schema, or an array of schemas.
|
|
11
|
+
*
|
|
12
|
+
* `items` covers the JSON Schema 2020-12 form as well as the older tuple form, which the walker reads
|
|
13
|
+
* structurally — a tuple `items` does not compile under 2020-12 either way, with or without a cut.
|
|
14
|
+
* `additionalItems` is listed for the same reason: Ajv 2020 ignores the keyword, but an older document
|
|
15
|
+
* carrying one still walks cleanly.
|
|
16
|
+
*/
|
|
17
|
+
const SCHEMA_KEYWORDS = new Set([
|
|
18
|
+
'additionalItems',
|
|
19
|
+
'additionalProperties',
|
|
20
|
+
'allOf',
|
|
21
|
+
'anyOf',
|
|
22
|
+
'contains',
|
|
23
|
+
'contentSchema',
|
|
24
|
+
'else',
|
|
25
|
+
'if',
|
|
26
|
+
'items',
|
|
27
|
+
'not',
|
|
28
|
+
'oneOf',
|
|
29
|
+
'prefixItems',
|
|
30
|
+
'propertyNames',
|
|
31
|
+
'then',
|
|
32
|
+
'unevaluatedItems',
|
|
33
|
+
'unevaluatedProperties',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Keywords whose value maps names to schemas. Their keys are author-chosen names rather than
|
|
37
|
+
* keywords, so the walker must not read them as keywords of their own.
|
|
38
|
+
*/
|
|
39
|
+
const SCHEMA_MAP_KEYWORDS = new Set([
|
|
40
|
+
'$defs',
|
|
41
|
+
'definitions',
|
|
42
|
+
// Ajv 2020 still implements the draft-07 `dependencies`, so a marker under it has to be rewritten
|
|
43
|
+
// too. Its array form (`{ name: ['other'] }`) walks through untouched.
|
|
44
|
+
'dependencies',
|
|
45
|
+
'dependentSchemas',
|
|
46
|
+
'patternProperties',
|
|
47
|
+
'properties',
|
|
48
|
+
]);
|
|
49
|
+
/** Map keywords `additionalProperties` reads to decide which properties it still applies to */
|
|
50
|
+
const PROPERTY_MAP_KEYWORDS = new Set(['patternProperties', 'properties']);
|
|
51
|
+
/**
|
|
52
|
+
* Keywords that invert what a relaxed subschema means: matching more values there makes the schema as
|
|
53
|
+
* a whole reject more requests. Verified against Ajv — `not: {}` rejects every request, `if: {}`
|
|
54
|
+
* forces `then` onto every request, `oneOf: [{}, …]` rejects anything that also matches a sibling
|
|
55
|
+
* branch (a recursive union or a nullable recursive reference being the realistic cases), and a
|
|
56
|
+
* `contains` that matches more items can overshoot `maxContains`. Whenever the cycle was cut anywhere
|
|
57
|
+
* below one of these, the keyword is dropped instead, so a valid request is never turned away.
|
|
58
|
+
*/
|
|
59
|
+
const INVERTING_SCHEMA_KEYWORDS = new Set(['contains', 'if', 'not', 'oneOf']);
|
|
60
|
+
/**
|
|
61
|
+
* In-place applicators, whose subschemas decide which properties and items count as evaluated. A
|
|
62
|
+
* relaxed branch stops contributing those annotations, so a sibling `unevaluatedProperties` or
|
|
63
|
+
* `unevaluatedItems` would start rejecting values it used to accept. `if` and `oneOf` are listed for
|
|
64
|
+
* completeness; a relaxed one of those is dropped by the inverting rule before it gets here, which
|
|
65
|
+
* clears the sibling anyway.
|
|
66
|
+
*/
|
|
67
|
+
const IN_PLACE_SCHEMA_KEYWORDS = new Set([
|
|
68
|
+
'allOf',
|
|
69
|
+
'anyOf',
|
|
70
|
+
'dependencies',
|
|
71
|
+
'dependentSchemas',
|
|
72
|
+
'else',
|
|
73
|
+
'if',
|
|
74
|
+
'oneOf',
|
|
75
|
+
'then',
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Keywords whose value has to be an array of schemas. A cut at the array itself cannot be answered
|
|
79
|
+
* with a schema, so the keyword is dropped rather than left as something Ajv refuses to compile.
|
|
80
|
+
* `oneOf` belongs here too but never reaches it, because the inverting rule already drops it.
|
|
81
|
+
*/
|
|
82
|
+
const SCHEMA_ARRAY_KEYWORDS = new Set(['allOf', 'anyOf', 'prefixItems']);
|
|
83
|
+
/**
|
|
84
|
+
* Replace the `'[circular]'` markers `getResolvedRefDeep` leaves in schema positions with an empty
|
|
85
|
+
* (always-valid) schema.
|
|
86
|
+
*
|
|
87
|
+
* A recursive schema — a `Node` whose `child` is another `Node` — resolves to a document where the
|
|
88
|
+
* recursion point is the *string* `'[circular]'`. Ajv rejects the whole schema for it
|
|
89
|
+
* (`data/properties/child must be object,boolean`), so a single recursive type silently disabled
|
|
90
|
+
* validation of the request body, or of every parameter in the same location. Accepting anything at
|
|
91
|
+
* the point where the cycle was cut keeps the rest of the schema enforceable.
|
|
92
|
+
*
|
|
93
|
+
* The rewrite only ever loosens what is enforced, so it can lose a violation but never invent one.
|
|
94
|
+
* That is why relaxing is tracked as it goes: under `not`, `if`, `oneOf` or `contains` a looser
|
|
95
|
+
* subschema would make the schema *stricter*, so those keywords are dropped rather than rewritten,
|
|
96
|
+
* and a sibling `unevaluatedProperties`/`unevaluatedItems` goes with a relaxed in-place applicator
|
|
97
|
+
* for the same reason.
|
|
98
|
+
*
|
|
99
|
+
* Only known schema positions are rewritten. A marker anywhere else — `enum`, `const`, `default`,
|
|
100
|
+
* `example`, or a vendor extension — is data rather than a schema Ajv compiles, so it is copied
|
|
101
|
+
* through untouched.
|
|
102
|
+
*/
|
|
103
|
+
export const replaceCircularMarkers = (schema) => {
|
|
104
|
+
// `getResolvedRefDeep` returns a graph, not a tree: one resolved schema object is shared by every
|
|
105
|
+
// place that referenced it. Reusing the rewritten copy keeps a widely shared schema from being
|
|
106
|
+
// walked once per occurrence, and is what makes the walk terminate rather than recur forever should
|
|
107
|
+
// it ever be handed a genuinely cyclic object — which the resolver, having cut every cycle, is not
|
|
108
|
+
// able to produce.
|
|
109
|
+
//
|
|
110
|
+
// Schemas and schema maps are cached apart, because the same object read as one or the other
|
|
111
|
+
// rewrites differently: `{ not: … }` is a keyword in a schema and a schema named `not` in a map.
|
|
112
|
+
const rewritten = new WeakMap();
|
|
113
|
+
const rewrittenMaps = new WeakMap();
|
|
114
|
+
const asSchema = (value) => {
|
|
115
|
+
// The cycle was cut here, so nothing is known about the value any more: accept anything.
|
|
116
|
+
if (value === CIRCULAR_MARKER) {
|
|
117
|
+
return { value: {}, relaxed: true };
|
|
118
|
+
}
|
|
119
|
+
if (!isObject(value) && !Array.isArray(value)) {
|
|
120
|
+
return { value, relaxed: false };
|
|
121
|
+
}
|
|
122
|
+
const cached = rewritten.get(value);
|
|
123
|
+
if (cached) {
|
|
124
|
+
return cached;
|
|
125
|
+
}
|
|
126
|
+
// Register each copy before filling it, so a self-referencing value resolves to the copy itself.
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
const items = [];
|
|
129
|
+
const rewrite = { value: items, relaxed: false };
|
|
130
|
+
rewritten.set(value, rewrite);
|
|
131
|
+
for (const item of value) {
|
|
132
|
+
const child = asSchema(item);
|
|
133
|
+
items.push(child.value);
|
|
134
|
+
rewrite.relaxed ||= child.relaxed;
|
|
135
|
+
}
|
|
136
|
+
return rewrite;
|
|
137
|
+
}
|
|
138
|
+
const result = {};
|
|
139
|
+
const rewrite = { value: result, relaxed: false };
|
|
140
|
+
rewritten.set(value, rewrite);
|
|
141
|
+
let droppedIf = false;
|
|
142
|
+
let droppedContains = false;
|
|
143
|
+
let droppedPrefixItems = false;
|
|
144
|
+
let droppedPropertyMap = false;
|
|
145
|
+
// Whether this schema stopped saying which properties and items it accounted for — either a
|
|
146
|
+
// keyword was dropped outright, or a relaxed in-place branch no longer contributes what it did.
|
|
147
|
+
let annotationsLost = false;
|
|
148
|
+
for (const [keyword, child] of Object.entries(value)) {
|
|
149
|
+
if (!SCHEMA_KEYWORDS.has(keyword) && !SCHEMA_MAP_KEYWORDS.has(keyword)) {
|
|
150
|
+
// Data rather than a schema, so it is carried over as it stands. Nothing mutates the result,
|
|
151
|
+
// so sharing the value with the resolved document it came from is safe.
|
|
152
|
+
result[keyword] = child;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// A cut at an array-valued keyword itself cannot become a schema, so the keyword goes. A dropped
|
|
156
|
+
// keyword also stops saying which properties and items it accounted for, so any `unevaluated*`
|
|
157
|
+
// sibling has to go with it.
|
|
158
|
+
if (SCHEMA_ARRAY_KEYWORDS.has(keyword) && child === CIRCULAR_MARKER) {
|
|
159
|
+
droppedPrefixItems ||= keyword === 'prefixItems';
|
|
160
|
+
rewrite.relaxed = true;
|
|
161
|
+
annotationsLost = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// A cut at a schema map itself leaves an empty map, which accounts for nothing any more, so an
|
|
165
|
+
// `unevaluated*` sibling has to go the same way a dropped keyword takes it.
|
|
166
|
+
if (SCHEMA_MAP_KEYWORDS.has(keyword) && child === CIRCULAR_MARKER) {
|
|
167
|
+
result[keyword] = {};
|
|
168
|
+
droppedPropertyMap ||= PROPERTY_MAP_KEYWORDS.has(keyword);
|
|
169
|
+
rewrite.relaxed = true;
|
|
170
|
+
annotationsLost = true;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const rewrittenChild = SCHEMA_MAP_KEYWORDS.has(keyword) && isObject(child) ? asSchemaMap(child) : asSchema(child);
|
|
174
|
+
if (INVERTING_SCHEMA_KEYWORDS.has(keyword) && rewrittenChild.relaxed) {
|
|
175
|
+
droppedIf ||= keyword === 'if';
|
|
176
|
+
droppedContains ||= keyword === 'contains';
|
|
177
|
+
rewrite.relaxed = true;
|
|
178
|
+
annotationsLost = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
result[keyword] = rewrittenChild.value;
|
|
182
|
+
rewrite.relaxed ||= rewrittenChild.relaxed;
|
|
183
|
+
annotationsLost ||= rewrittenChild.relaxed && IN_PLACE_SCHEMA_KEYWORDS.has(keyword);
|
|
184
|
+
}
|
|
185
|
+
// `then` and `else` only apply alongside an `if`, so they leave with the dropped condition, and
|
|
186
|
+
// `minContains`/`maxContains` only qualify a `contains`.
|
|
187
|
+
if (droppedIf) {
|
|
188
|
+
delete result.then;
|
|
189
|
+
delete result.else;
|
|
190
|
+
}
|
|
191
|
+
if (droppedContains) {
|
|
192
|
+
delete result.minContains;
|
|
193
|
+
delete result.maxContains;
|
|
194
|
+
}
|
|
195
|
+
// `additionalProperties` and `items` only apply to what their siblings did not cover, so once that
|
|
196
|
+
// sibling is gone they would start policing values it used to account for.
|
|
197
|
+
if (droppedPropertyMap) {
|
|
198
|
+
delete result.additionalProperties;
|
|
199
|
+
}
|
|
200
|
+
if (droppedPrefixItems) {
|
|
201
|
+
delete result.items;
|
|
202
|
+
}
|
|
203
|
+
if (annotationsLost) {
|
|
204
|
+
delete result.unevaluatedProperties;
|
|
205
|
+
delete result.unevaluatedItems;
|
|
206
|
+
}
|
|
207
|
+
return rewrite;
|
|
208
|
+
};
|
|
209
|
+
/** Rewrite every schema under a map keyword, keeping its author-chosen names as they are */
|
|
210
|
+
const asSchemaMap = (map) => {
|
|
211
|
+
const cached = rewrittenMaps.get(map);
|
|
212
|
+
if (cached) {
|
|
213
|
+
return cached;
|
|
214
|
+
}
|
|
215
|
+
const result = {};
|
|
216
|
+
const rewrite = { value: result, relaxed: false };
|
|
217
|
+
rewrittenMaps.set(map, rewrite);
|
|
218
|
+
for (const [name, sub] of Object.entries(map)) {
|
|
219
|
+
const child = asSchema(sub);
|
|
220
|
+
result[name] = child.value;
|
|
221
|
+
rewrite.relaxed ||= child.relaxed;
|
|
222
|
+
}
|
|
223
|
+
return rewrite;
|
|
224
|
+
};
|
|
225
|
+
return asSchema(schema).value;
|
|
226
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { PinnedQueryParameter } from '../utils/split-path-key.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a request carries every query parameter a path key pins.
|
|
5
|
+
*
|
|
6
|
+
* `/v1/messages?beta=true` describes a variant of `/v1/messages`, so it may only answer requests
|
|
7
|
+
* that actually send `beta=true`. A parameter pinned without a value (`?beta`) matches any value.
|
|
8
|
+
*/
|
|
9
|
+
export declare const requestMatchesPinnedQuery: (context: Context, query: PinnedQueryParameter[]) => boolean;
|
|
10
|
+
//# sourceMappingURL=request-matches-pinned-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-matches-pinned-query.d.ts","sourceRoot":"","sources":["../../src/utils/request-matches-pinned-query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAElE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,OAAO,EAAE,OAAO,oBAAoB,EAAE,KAAG,OASxF,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether a request carries every query parameter a path key pins.
|
|
3
|
+
*
|
|
4
|
+
* `/v1/messages?beta=true` describes a variant of `/v1/messages`, so it may only answer requests
|
|
5
|
+
* that actually send `beta=true`. A parameter pinned without a value (`?beta`) matches any value.
|
|
6
|
+
*/
|
|
7
|
+
export const requestMatchesPinnedQuery = (context, query) => query.every(({ name, value }) => {
|
|
8
|
+
const values = context.req.queries(name);
|
|
9
|
+
if (!values?.length) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return value === undefined || values.includes(value);
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MockServerLogger } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the `logger` option accepted by the mock server factories into a concrete sink.
|
|
4
|
+
*
|
|
5
|
+
* The option is a superset of a plain sink:
|
|
6
|
+
* - a function is used as-is,
|
|
7
|
+
* - `true` logs each line to the console,
|
|
8
|
+
* - `false` drops every line,
|
|
9
|
+
* - `undefined` falls back to the factory's default (`enabledByDefault`).
|
|
10
|
+
*/
|
|
11
|
+
export declare const resolveLogger: (logger: boolean | MockServerLogger | undefined, enabledByDefault: boolean) => MockServerLogger;
|
|
12
|
+
//# sourceMappingURL=resolve-logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-logger.d.ts","sourceRoot":"","sources":["../../src/utils/resolve-logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE/C;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GACxB,QAAQ,OAAO,GAAG,gBAAgB,GAAG,SAAS,EAC9C,kBAAkB,OAAO,KACxB,gBAQF,CAAA"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the `logger` option accepted by the mock server factories into a concrete sink.
|
|
3
|
+
*
|
|
4
|
+
* The option is a superset of a plain sink:
|
|
5
|
+
* - a function is used as-is,
|
|
6
|
+
* - `true` logs each line to the console,
|
|
7
|
+
* - `false` drops every line,
|
|
8
|
+
* - `undefined` falls back to the factory's default (`enabledByDefault`).
|
|
9
|
+
*/
|
|
10
|
+
export const resolveLogger = (logger, enabledByDefault) => {
|
|
11
|
+
const value = logger ?? enabledByDefault;
|
|
12
|
+
if (typeof value === 'function') {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
return value ? (line) => console.log(line) : () => undefined;
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
2
|
+
type Schema = NonNullable<OpenAPIV3_1.ComponentsObject['schemas']>[string];
|
|
3
|
+
/**
|
|
4
|
+
* Serializes a mocked response body for the negotiated media type.
|
|
5
|
+
*
|
|
6
|
+
* Returns `undefined` for an `undefined` body, mirroring `JSON.stringify`, so the caller can send an
|
|
7
|
+
* empty body rather than the characters `undefined`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const serializeResponseBody: (body: unknown, contentType: string | undefined, schema?: Schema) => string | undefined;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=serialize-response-body.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serialize-response-body.d.ts","sourceRoot":"","sources":["../../src/utils/serialize-response-body.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,KAAK,MAAM,GAAG,WAAW,CAAC,WAAW,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;AAkE1E;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,OAAO,EACb,aAAa,MAAM,GAAG,SAAS,EAC/B,SAAS,MAAM,KACd,MAAM,GAAG,SAgCX,CAAA"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { json2xml } from '@scalar/helpers/file/json2xml';
|
|
2
|
+
import { parseMimeType } from '@scalar/helpers/http/mime-type';
|
|
3
|
+
/**
|
|
4
|
+
* Whether a media type carries a single JSON document.
|
|
5
|
+
*
|
|
6
|
+
* Matched on the parsed subtype, so suffixed types (`application/problem+json`) and parameterized ones
|
|
7
|
+
* (`application/json; charset=utf-8`) count, while a type that merely mentions JSON in a parameter does
|
|
8
|
+
* not. Line-delimited relatives (`application/jsonl`, `application/x-ndjson`) are deliberately excluded:
|
|
9
|
+
* their payload is a sequence of documents, so a string body already carries the framing. A missing
|
|
10
|
+
* media type parses as `text/plain`, which is the safe answer here: the body is written as it is.
|
|
11
|
+
*/
|
|
12
|
+
const isJsonDocumentContentType = (contentType) => {
|
|
13
|
+
const { subtype } = parseMimeType(contentType);
|
|
14
|
+
return subtype === 'json' || subtype.endsWith('+json');
|
|
15
|
+
};
|
|
16
|
+
/** Whether a media type carries XML, including suffixed types such as `application/xhtml+xml`. */
|
|
17
|
+
const isXmlContentType = (contentType) => {
|
|
18
|
+
const { subtype } = parseMimeType(contentType);
|
|
19
|
+
return subtype === 'xml' || subtype.endsWith('+xml');
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* How the resolved response schema describes the body: as a string, as something else, or not at all.
|
|
23
|
+
*
|
|
24
|
+
* Composite schemas (`allOf`, an `enum` without a type) land on `unknown`, which is the honest answer:
|
|
25
|
+
* they say nothing this decision can act on.
|
|
26
|
+
*/
|
|
27
|
+
const declaredBodyKind = (schema) => {
|
|
28
|
+
if (!schema || typeof schema !== 'object' || !('type' in schema) || schema.type === undefined) {
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
const { type } = schema;
|
|
32
|
+
if (Array.isArray(type)) {
|
|
33
|
+
if (type.length === 0) {
|
|
34
|
+
return 'unknown';
|
|
35
|
+
}
|
|
36
|
+
return type.includes('string') ? 'string' : 'other';
|
|
37
|
+
}
|
|
38
|
+
return type === 'string' ? 'string' : 'other';
|
|
39
|
+
};
|
|
40
|
+
/** Whether a string holds serialized JSON of any shape, a bare scalar included. */
|
|
41
|
+
const isSerializedJson = (value) => {
|
|
42
|
+
try {
|
|
43
|
+
JSON.parse(value);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/** Whether a string holds a serialized JSON object or array. */
|
|
51
|
+
const isSerializedJsonDocument = (value) => {
|
|
52
|
+
const trimmed = value.trim();
|
|
53
|
+
return (trimmed.startsWith('{') || trimmed.startsWith('[')) && isSerializedJson(trimmed);
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Serializes a mocked response body for the negotiated media type.
|
|
57
|
+
*
|
|
58
|
+
* Returns `undefined` for an `undefined` body, mirroring `JSON.stringify`, so the caller can send an
|
|
59
|
+
* empty body rather than the characters `undefined`.
|
|
60
|
+
*/
|
|
61
|
+
export const serializeResponseBody = (body, contentType, schema) => {
|
|
62
|
+
// XML: only an object tree can be turned into a document. `null` is `typeof 'object'` too, but it is
|
|
63
|
+
// not a valid XML root, so it falls through to `JSON.stringify` below rather than into `json2xml`.
|
|
64
|
+
if (body !== null && typeof body === 'object' && isXmlContentType(contentType)) {
|
|
65
|
+
return json2xml(body);
|
|
66
|
+
}
|
|
67
|
+
if (typeof body === 'string') {
|
|
68
|
+
// Anywhere but a single JSON document, the characters are the payload: `text/plain`, `text/html`,
|
|
69
|
+
// XML, `text/event-stream`, line-delimited JSON, and anything else the mock does not recognize.
|
|
70
|
+
if (!isJsonDocumentContentType(contentType)) {
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
73
|
+
// Under a JSON media type a string has to be encoded, or a `type: string` response arrives as the
|
|
74
|
+
// bare characters `string`, which no JSON client can parse. What survives unencoded is text that is
|
|
75
|
+
// already the body the document describes: whatever parses when the schema declares a non-string
|
|
76
|
+
// type, and an object or array when the schema says nothing, both of which are documents the author
|
|
77
|
+
// serialized by hand. A quoted scalar without a schema behind it stays a string, since the author
|
|
78
|
+
// quoting `'123'` is the only signal available about what they meant.
|
|
79
|
+
const kind = declaredBodyKind(schema);
|
|
80
|
+
if (kind === 'other' && isSerializedJson(body)) {
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
if (kind === 'unknown' && isSerializedJsonDocument(body)) {
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return JSON.stringify(body);
|
|
88
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches a `{parameterName}` template inside a path key.
|
|
3
|
+
*
|
|
4
|
+
* A template has to be balanced and non-empty, so a stray brace counts as literal path text.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PATH_KEY_TEMPLATE: RegExp;
|
|
7
|
+
/** A query parameter that an OpenAPI path key pins, for example `beta=true` in `/v1/messages?beta=true`. */
|
|
8
|
+
export type PinnedQueryParameter = {
|
|
9
|
+
/** Decoded name of the query parameter. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Decoded value the request has to send, or `undefined` when the path key only pins the name. */
|
|
12
|
+
value: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
/** An OpenAPI path key taken apart into the portion Hono can route and the query it pins. */
|
|
15
|
+
type SplitPathKey = {
|
|
16
|
+
/** The path portion of the key, without the query string. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** Query parameters the key pins. Empty for a regular path key. */
|
|
19
|
+
query: PinnedQueryParameter[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Split an OpenAPI path key into its path and its pinned query parameters.
|
|
23
|
+
*
|
|
24
|
+
* Some documents carry a literal query string in the path key to describe a variant of an operation,
|
|
25
|
+
* for example `/v1/messages?beta=true` next to `/v1/messages`. That is not a routable path, so the
|
|
26
|
+
* query has to be peeled off and matched against the incoming request separately.
|
|
27
|
+
*/
|
|
28
|
+
export declare const splitPathKey: (pathKey: string) => SplitPathKey;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=split-path-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"split-path-key.d.ts","sourceRoot":"","sources":["../../src/utils/split-path-key.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,QAAkB,CAAA;AAKhD,4GAA4G;AAC5G,MAAM,MAAM,oBAAoB,GAAG;IACjC,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAA;IACZ,kGAAkG;IAClG,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAC1B,CAAA;AAED,6FAA6F;AAC7F,KAAK,YAAY,GAAG;IAClB,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAA;IACZ,mEAAmE;IACnE,KAAK,EAAE,oBAAoB,EAAE,CAAA;CAC9B,CAAA;AAyCD;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,YA+B9C,CAAA"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches a `{parameterName}` template inside a path key.
|
|
3
|
+
*
|
|
4
|
+
* A template has to be balanced and non-empty, so a stray brace counts as literal path text.
|
|
5
|
+
*/
|
|
6
|
+
export const PATH_KEY_TEMPLATE = /\{([^{}]+)\}/g;
|
|
7
|
+
/** Matches a query value that is nothing but a template, as in `?status={status}`. */
|
|
8
|
+
const TEMPLATE_VALUE = /^\{[^{}]+\}$/;
|
|
9
|
+
/**
|
|
10
|
+
* Decode one part of a query string the way `URLSearchParams` does.
|
|
11
|
+
*
|
|
12
|
+
* A malformed escape sequence is kept verbatim instead of throwing, so a single odd path key cannot
|
|
13
|
+
* take the whole server down.
|
|
14
|
+
*/
|
|
15
|
+
const decodeQueryPart = (value) => {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(value.replace(/\+/g, ' '));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Find the `?` that starts the query string of a path key.
|
|
25
|
+
*
|
|
26
|
+
* Only a `?` outside a `{…}` template counts, so a path parameter whose name contains a `?` does not
|
|
27
|
+
* accidentally cut the key in half. An unbalanced brace is literal path text rather than an open
|
|
28
|
+
* template, so it cannot hide the query string of the rest of the key. Returns `-1` when the key
|
|
29
|
+
* carries no query string.
|
|
30
|
+
*/
|
|
31
|
+
const findQueryStart = (pathKey) => {
|
|
32
|
+
// A fresh instance, because `matchAll` reads the `lastIndex` of the regular expression it is
|
|
33
|
+
// given and `PATH_KEY_TEMPLATE` is shared with other modules.
|
|
34
|
+
const templates = [...pathKey.matchAll(new RegExp(PATH_KEY_TEMPLATE))].map((match) => ({
|
|
35
|
+
start: match.index,
|
|
36
|
+
end: match.index + match[0].length,
|
|
37
|
+
}));
|
|
38
|
+
for (let index = pathKey.indexOf('?'); index !== -1; index = pathKey.indexOf('?', index + 1)) {
|
|
39
|
+
if (!templates.some(({ start, end }) => index > start && index < end)) {
|
|
40
|
+
return index;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Split an OpenAPI path key into its path and its pinned query parameters.
|
|
47
|
+
*
|
|
48
|
+
* Some documents carry a literal query string in the path key to describe a variant of an operation,
|
|
49
|
+
* for example `/v1/messages?beta=true` next to `/v1/messages`. That is not a routable path, so the
|
|
50
|
+
* query has to be peeled off and matched against the incoming request separately.
|
|
51
|
+
*/
|
|
52
|
+
export const splitPathKey = (pathKey) => {
|
|
53
|
+
const queryStart = findQueryStart(pathKey);
|
|
54
|
+
if (queryStart === -1) {
|
|
55
|
+
return { path: pathKey, query: [] };
|
|
56
|
+
}
|
|
57
|
+
const query = pathKey
|
|
58
|
+
.slice(queryStart + 1)
|
|
59
|
+
.split('&')
|
|
60
|
+
.filter((pair) => pair !== '')
|
|
61
|
+
.map((pair) => {
|
|
62
|
+
const separator = pair.indexOf('=');
|
|
63
|
+
// `?beta` pins the name only, `?beta=true` pins the name and the value.
|
|
64
|
+
if (separator === -1) {
|
|
65
|
+
return { name: decodeQueryPart(pair), value: undefined };
|
|
66
|
+
}
|
|
67
|
+
const value = pair.slice(separator + 1);
|
|
68
|
+
return {
|
|
69
|
+
name: decodeQueryPart(pair.slice(0, separator)),
|
|
70
|
+
// A value that is nothing but a template (`?status={status}`) names the parameter rather
|
|
71
|
+
// than fixing it, so any value the request sends satisfies it.
|
|
72
|
+
value: TEMPLATE_VALUE.test(value) ? undefined : decodeQueryPart(value),
|
|
73
|
+
};
|
|
74
|
+
})
|
|
75
|
+
.filter(({ name }) => name !== '');
|
|
76
|
+
return { path: pathKey.slice(0, queryStart), query };
|
|
77
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAMxD,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAMxD,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAoStD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,WAAW,CAAC,eAAe,EACtC,qBAAqB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KAC5D,iBAkKF,CAAA"}
|
|
@@ -4,6 +4,17 @@ import Ajv2020 from 'ajv/dist/2020.js';
|
|
|
4
4
|
import addFormats from 'ajv-formats';
|
|
5
5
|
import { getCookie } from 'hono/cookie';
|
|
6
6
|
import { deserializeArrayParameter, deserializeObjectParameter, getObjectPropertyNames, isArraySchema, isObjectSchema, resolveSerialization, } from './deserialize-parameter.js';
|
|
7
|
+
import { replaceCircularMarkers } from './replace-circular-markers.js';
|
|
8
|
+
/**
|
|
9
|
+
* Prepare a resolved schema for Ajv by replacing the `'[circular]'` markers a recursive schema leaves
|
|
10
|
+
* behind. Ajv refuses to compile a schema that still carries one, which makes the body — or the whole
|
|
11
|
+
* parameter location built around it — fail open.
|
|
12
|
+
*
|
|
13
|
+
* Only Ajv sees this copy. Everything that reads the *shape* of a schema keeps reading the resolved
|
|
14
|
+
* one, because the rewrite may drop a keyword (a `oneOf` the cycle ran through, say) that the shape
|
|
15
|
+
* still depends on.
|
|
16
|
+
*/
|
|
17
|
+
const asCompilableSchema = (resolved) => replaceCircularMarkers(resolved);
|
|
7
18
|
/**
|
|
8
19
|
* Header parameters named `Accept`, `Content-Type`, or `Authorization` are defined elsewhere in
|
|
9
20
|
* OpenAPI (through `content` and `security`), so the spec says such parameter definitions SHALL be
|
|
@@ -61,7 +72,7 @@ const buildParameterSchema = (parameters, location) => {
|
|
|
61
72
|
propertyNames,
|
|
62
73
|
});
|
|
63
74
|
if (resolvedSchema) {
|
|
64
|
-
properties[parameter.name] = resolvedSchema;
|
|
75
|
+
properties[parameter.name] = asCompilableSchema(resolvedSchema);
|
|
65
76
|
}
|
|
66
77
|
if (parameter.required) {
|
|
67
78
|
required.push(parameter.name);
|
|
@@ -134,7 +145,7 @@ const compileValidators = (operation, pathItemParameters) => {
|
|
|
134
145
|
let bodySchema = null;
|
|
135
146
|
try {
|
|
136
147
|
const jsonSchema = getResolvedRef(requestBody?.content?.['application/json'])?.schema;
|
|
137
|
-
bodySchema = jsonSchema ? getResolvedRefDeep(jsonSchema) : null;
|
|
148
|
+
bodySchema = jsonSchema ? asCompilableSchema(getResolvedRefDeep(jsonSchema)) : null;
|
|
138
149
|
}
|
|
139
150
|
catch (error) {
|
|
140
151
|
console.error('Error resolving request body schema, skipping body validation:', error);
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"swagger",
|
|
17
17
|
"cli"
|
|
18
18
|
],
|
|
19
|
-
"version": "0.
|
|
19
|
+
"version": "0.13.0",
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22"
|
|
22
22
|
},
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@faker-js/faker": "10.4.0",
|
|
55
55
|
"@hono/node-ws": "^1.2.0",
|
|
56
|
-
"ajv": "^8.
|
|
56
|
+
"ajv": "^8.20.0",
|
|
57
57
|
"ajv-formats": "^3.0.1",
|
|
58
58
|
"hono": "^4.12.7",
|
|
59
59
|
"yaml": "^2.9.0",
|
|
60
|
-
"@scalar/helpers": "0.11.
|
|
60
|
+
"@scalar/helpers": "0.11.2",
|
|
61
|
+
"@scalar/json-magic": "0.13.3",
|
|
61
62
|
"@scalar/openapi-types": "0.9.5",
|
|
62
63
|
"@scalar/openapi-upgrader": "0.2.15",
|
|
63
|
-
"@scalar/
|
|
64
|
-
"@scalar/
|
|
65
|
-
"@scalar/workspace-store": "0.58.1"
|
|
64
|
+
"@scalar/workspace-store": "0.59.0",
|
|
65
|
+
"@scalar/types": "0.18.3"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "^24.1.0",
|