@scalar/mock-server 0.12.13 → 0.14.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 +60 -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 +0 -2
- package/dist/utils/build-handler-context.d.ts.map +1 -1
- package/dist/utils/build-handler-context.js +3 -4
- package/dist/utils/build-seed-context.d.ts +5 -23
- package/dist/utils/build-seed-context.d.ts.map +1 -1
- package/dist/utils/build-seed-context.js +1 -49
- 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/execute-handler.d.ts +5 -2
- package/dist/utils/execute-handler.d.ts.map +1 -1
- package/dist/utils/execute-handler.js +11 -13
- package/dist/utils/execute-seed.d.ts +5 -2
- package/dist/utils/execute-seed.d.ts.map +1 -1
- package/dist/utils/execute-seed.js +13 -21
- 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/process-openapi-document.d.ts.map +1 -1
- package/dist/utils/process-openapi-document.js +8 -1
- 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/sandbox.d.ts +25 -0
- package/dist/utils/sandbox.d.ts.map +1 -0
- package/dist/utils/sandbox.js +252 -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/store-wrapper.d.ts +1 -1
- package/dist/utils/store-wrapper.d.ts.map +1 -1
- package/dist/utils/validate-request.d.ts.map +1 -1
- package/dist/utils/validate-request.js +13 -2
- package/package.json +7 -6
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
3
|
+
/**
|
|
4
|
+
* Maximum memory the sandboxed guest may allocate. Handler and seed code only
|
|
5
|
+
* shuffles small amounts of mock data around, so this is generous while still
|
|
6
|
+
* bounding a runaway allocation.
|
|
7
|
+
*/
|
|
8
|
+
const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum wall-clock time a single handler or seed run may take. This is the
|
|
11
|
+
* only defence against an infinite loop, since the guest cannot reach anything
|
|
12
|
+
* else on the host.
|
|
13
|
+
*/
|
|
14
|
+
const EXECUTION_TIMEOUT_MS = 1_000;
|
|
15
|
+
/**
|
|
16
|
+
* Property names that let code climb from an object onto its prototype chain and
|
|
17
|
+
* ultimately reach the host `Function` constructor. They are never legitimate
|
|
18
|
+
* faker module or method names, so the bridge refuses to walk through them.
|
|
19
|
+
*/
|
|
20
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
21
|
+
/** Store methods the guest bridge is allowed to call. */
|
|
22
|
+
const STORE_METHODS = new Set(['list', 'get', 'create', 'update', 'delete', 'clear']);
|
|
23
|
+
/**
|
|
24
|
+
* The QuickJS WebAssembly module is expensive to instantiate, so it is loaded
|
|
25
|
+
* once and shared. Each run still gets its own isolated context and runtime.
|
|
26
|
+
*/
|
|
27
|
+
let quickJSModule;
|
|
28
|
+
const loadQuickJS = () => (quickJSModule ??= getQuickJS());
|
|
29
|
+
/**
|
|
30
|
+
* Walk faker with a guest-provided property path and invoke the resolved method.
|
|
31
|
+
* The faker instance lives on the host; only the path and JSON arguments cross
|
|
32
|
+
* the boundary, so guest code can never obtain a reference to faker itself.
|
|
33
|
+
*/
|
|
34
|
+
function callFaker(path, args) {
|
|
35
|
+
const method = path.at(-1);
|
|
36
|
+
if (method === undefined || FORBIDDEN_KEYS.has(method)) {
|
|
37
|
+
throw new Error(`faker: "${method}" is not accessible`);
|
|
38
|
+
}
|
|
39
|
+
// Resolve everything except the last segment to the faker module owning the method.
|
|
40
|
+
let receiver = faker;
|
|
41
|
+
for (const key of path.slice(0, -1)) {
|
|
42
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
43
|
+
throw new Error(`faker: "${key}" is not accessible`);
|
|
44
|
+
}
|
|
45
|
+
receiver = receiver?.[key];
|
|
46
|
+
if (receiver === undefined || receiver === null) {
|
|
47
|
+
throw new Error(`faker: "${key}" does not exist`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fn = receiver?.[method];
|
|
51
|
+
if (typeof fn !== 'function') {
|
|
52
|
+
throw new Error(`faker: "${path.join('.')}" is not a function`);
|
|
53
|
+
}
|
|
54
|
+
// Bind to the resolved module so faker methods keep their expected `this`.
|
|
55
|
+
return fn.apply(receiver, args);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run a store method requested by the guest and return a JSON envelope.
|
|
59
|
+
*/
|
|
60
|
+
function runStoreBridge(store, method, argsJson) {
|
|
61
|
+
try {
|
|
62
|
+
if (!STORE_METHODS.has(method)) {
|
|
63
|
+
throw new Error(`store: "${method}" is not a method`);
|
|
64
|
+
}
|
|
65
|
+
const args = JSON.parse(argsJson);
|
|
66
|
+
const result = store[method](...args);
|
|
67
|
+
return serializeEnvelope({ ok: true, value: result ?? null });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return serializeEnvelope({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run a faker call requested by the guest and return a JSON envelope.
|
|
75
|
+
*/
|
|
76
|
+
function runFakerBridge(pathJson, argsJson) {
|
|
77
|
+
try {
|
|
78
|
+
const path = JSON.parse(pathJson);
|
|
79
|
+
const args = JSON.parse(argsJson);
|
|
80
|
+
return serializeEnvelope({ ok: true, value: callFaker(path, args) ?? null });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return serializeEnvelope({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Serialize a bridge envelope. Faker occasionally returns values such as `Date`
|
|
88
|
+
* that JSON cannot represent as-is; those are converted to their JSON form,
|
|
89
|
+
* which matches what handler code would send over the wire anyway.
|
|
90
|
+
*/
|
|
91
|
+
function serializeEnvelope(envelope) {
|
|
92
|
+
return JSON.stringify(envelope);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Guest-side bootstrap. It rebuilds `store` and `faker` on top of the host
|
|
96
|
+
* bridges so guest code sees the same API as before, but every call is just a
|
|
97
|
+
* JSON message to the host.
|
|
98
|
+
*/
|
|
99
|
+
const GUEST_PRELUDE = `
|
|
100
|
+
const __unwrap = (raw) => {
|
|
101
|
+
const parsed = JSON.parse(raw)
|
|
102
|
+
if (!parsed.ok) {
|
|
103
|
+
throw new Error(parsed.error)
|
|
104
|
+
}
|
|
105
|
+
return parsed.value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const store = {
|
|
109
|
+
list: (...args) => __unwrap(__store('list', JSON.stringify(args))),
|
|
110
|
+
get: (...args) => __unwrap(__store('get', JSON.stringify(args))),
|
|
111
|
+
create: (...args) => __unwrap(__store('create', JSON.stringify(args))),
|
|
112
|
+
update: (...args) => __unwrap(__store('update', JSON.stringify(args))),
|
|
113
|
+
delete: (...args) => __unwrap(__store('delete', JSON.stringify(args))),
|
|
114
|
+
clear: (...args) => __unwrap(__store('clear', JSON.stringify(args))),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const __makeFaker = (path) =>
|
|
118
|
+
new Proxy(function () {}, {
|
|
119
|
+
get: (_target, prop) => (typeof prop === 'string' ? __makeFaker(path.concat(prop)) : undefined),
|
|
120
|
+
apply: (_target, _thisArg, args) => __unwrap(__faker(JSON.stringify(path), JSON.stringify(args))),
|
|
121
|
+
})
|
|
122
|
+
const faker = __makeFaker([])
|
|
123
|
+
`;
|
|
124
|
+
/**
|
|
125
|
+
* Guest-side `seed` helper. It mirrors the Laravel-inspired API and runs the
|
|
126
|
+
* factory callbacks inside the sandbox, persisting through the `store` bridge.
|
|
127
|
+
*/
|
|
128
|
+
const SEED_PRELUDE = `
|
|
129
|
+
const seed = (() => {
|
|
130
|
+
const create = (item) => store.create(schema, item)
|
|
131
|
+
const helper = (arg1, arg2) => {
|
|
132
|
+
if (typeof arg1 === 'number' && typeof arg2 === 'function') {
|
|
133
|
+
const items = []
|
|
134
|
+
for (let index = 0; index < arg1; index++) {
|
|
135
|
+
items.push(create(arg2()))
|
|
136
|
+
}
|
|
137
|
+
return items
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(arg1)) {
|
|
140
|
+
return arg1.map(create)
|
|
141
|
+
}
|
|
142
|
+
if (typeof arg1 === 'function') {
|
|
143
|
+
return create(arg1())
|
|
144
|
+
}
|
|
145
|
+
throw new Error('Invalid seed() usage. Use seed.count(n, factory), seed(array), or seed(factory)')
|
|
146
|
+
}
|
|
147
|
+
helper.count = (n, factory) => {
|
|
148
|
+
const items = []
|
|
149
|
+
for (let index = 0; index < n; index++) {
|
|
150
|
+
items.push(create(factory()))
|
|
151
|
+
}
|
|
152
|
+
return items
|
|
153
|
+
}
|
|
154
|
+
return helper
|
|
155
|
+
})()
|
|
156
|
+
`;
|
|
157
|
+
/**
|
|
158
|
+
* Assemble the full guest program: bridges, injected JSON globals, the optional
|
|
159
|
+
* seed helper, and the user code wrapped in an async IIFE whose result becomes
|
|
160
|
+
* the completion value QuickJS hands back.
|
|
161
|
+
*/
|
|
162
|
+
function buildGuestSource(code, jsonGlobalNames, includeSeed) {
|
|
163
|
+
const jsonGlobals = jsonGlobalNames.map((name) => `const ${name} = JSON.parse(__json_${name})`).join('\n');
|
|
164
|
+
return `
|
|
165
|
+
${GUEST_PRELUDE}
|
|
166
|
+
${jsonGlobals}
|
|
167
|
+
${includeSeed ? SEED_PRELUDE : ''}
|
|
168
|
+
;(async () => {
|
|
169
|
+
${code}
|
|
170
|
+
})()
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Rebuild a host error from a dumped QuickJS error so callers see a normal
|
|
175
|
+
* `Error` instead of an opaque value.
|
|
176
|
+
*/
|
|
177
|
+
function toError(dumped) {
|
|
178
|
+
if (dumped instanceof Error) {
|
|
179
|
+
return dumped;
|
|
180
|
+
}
|
|
181
|
+
if (dumped && typeof dumped === 'object' && 'message' in dumped) {
|
|
182
|
+
const error = new Error(String(dumped.message));
|
|
183
|
+
const name = dumped.name;
|
|
184
|
+
if (typeof name === 'string') {
|
|
185
|
+
error.name = name;
|
|
186
|
+
}
|
|
187
|
+
return error;
|
|
188
|
+
}
|
|
189
|
+
return new Error(typeof dumped === 'string' ? dumped : 'Sandbox execution failed');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Execute untrusted handler or seed code inside a QuickJS WebAssembly sandbox.
|
|
193
|
+
*
|
|
194
|
+
* The guest has no access to the host runtime (`process`, `require`, `fetch`,
|
|
195
|
+
* the `Function` constructor, and so on). It can only talk to the `store` and
|
|
196
|
+
* `faker` bridges and read the injected JSON globals. Memory and time limits
|
|
197
|
+
* bound the remaining denial-of-service risk.
|
|
198
|
+
*/
|
|
199
|
+
export async function runInSandbox(options) {
|
|
200
|
+
const { code, store, jsonGlobals = {}, includeSeed = false } = options;
|
|
201
|
+
const quickJS = await loadQuickJS();
|
|
202
|
+
const context = quickJS.newContext();
|
|
203
|
+
try {
|
|
204
|
+
const { runtime } = context;
|
|
205
|
+
runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
|
|
206
|
+
const deadline = Date.now() + EXECUTION_TIMEOUT_MS;
|
|
207
|
+
runtime.setInterruptHandler(() => Date.now() > deadline);
|
|
208
|
+
// Host bridge: store operations run the real (tracked) store and return JSON.
|
|
209
|
+
const storeBridge = context.newFunction('__store', (methodHandle, argsHandle) => context.newString(runStoreBridge(store, context.getString(methodHandle), context.getString(argsHandle))));
|
|
210
|
+
context.setProp(context.global, '__store', storeBridge);
|
|
211
|
+
storeBridge.dispose();
|
|
212
|
+
// Host bridge: faker. Only a property path and JSON arguments cross the boundary.
|
|
213
|
+
const fakerBridge = context.newFunction('__faker', (pathHandle, argsHandle) => context.newString(runFakerBridge(context.getString(pathHandle), context.getString(argsHandle))));
|
|
214
|
+
context.setProp(context.global, '__faker', fakerBridge);
|
|
215
|
+
fakerBridge.dispose();
|
|
216
|
+
// Inject read-only inputs (req/res/schema) as JSON strings the guest parses.
|
|
217
|
+
for (const [name, value] of Object.entries(jsonGlobals)) {
|
|
218
|
+
const handle = context.newString(JSON.stringify(value ?? null));
|
|
219
|
+
context.setProp(context.global, `__json_${name}`, handle);
|
|
220
|
+
handle.dispose();
|
|
221
|
+
}
|
|
222
|
+
const evalResult = context.evalCode(buildGuestSource(code, Object.keys(jsonGlobals), includeSeed));
|
|
223
|
+
if (evalResult.error) {
|
|
224
|
+
const error = context.dump(evalResult.error);
|
|
225
|
+
evalResult.error.dispose();
|
|
226
|
+
throw toError(error);
|
|
227
|
+
}
|
|
228
|
+
// The completion value is the async IIFE's promise. Resolve it, then drain the
|
|
229
|
+
// job queue; every host bridge is synchronous, so one pass settles the promise.
|
|
230
|
+
const promiseHandle = evalResult.value;
|
|
231
|
+
const resolved = context.resolvePromise(promiseHandle);
|
|
232
|
+
promiseHandle.dispose();
|
|
233
|
+
const jobs = runtime.executePendingJobs();
|
|
234
|
+
if (jobs.error) {
|
|
235
|
+
const error = context.dump(jobs.error);
|
|
236
|
+
jobs.error.dispose();
|
|
237
|
+
throw toError(error);
|
|
238
|
+
}
|
|
239
|
+
const settled = await resolved;
|
|
240
|
+
if (settled.error) {
|
|
241
|
+
const error = context.dump(settled.error);
|
|
242
|
+
settled.error.dispose();
|
|
243
|
+
throw toError(error);
|
|
244
|
+
}
|
|
245
|
+
const value = context.dump(settled.value);
|
|
246
|
+
settled.value.dispose();
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
context.dispose();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -2,7 +2,7 @@ import type { Store } from '../libs/store.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Public interface of the Store class (methods only, no private properties).
|
|
4
4
|
*/
|
|
5
|
-
type StoreInterface = Pick<Store, 'list' | 'get' | 'create' | 'update' | 'delete' | 'clear'>;
|
|
5
|
+
export type StoreInterface = Pick<Store, 'list' | 'get' | 'create' | 'update' | 'delete' | 'clear'>;
|
|
6
6
|
/**
|
|
7
7
|
* Represents a single store operation with its result.
|
|
8
8
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store-wrapper.d.ts","sourceRoot":"","sources":["../../src/utils/store-wrapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAA;AAE1C;;GAEG;AACH,
|
|
1
|
+
{"version":3,"file":"store-wrapper.d.ts","sourceRoot":"","sources":["../../src/utils/store-wrapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAA;AAE1C;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC,CAAA;AAEnG;;GAEG;AACH,KAAK,cAAc,GAAG;IACpB,iDAAiD;IACjD,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAA;IAC1D,mCAAmC;IACnC,MAAM,EAAE,GAAG,CAAA;CACZ,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,0CAA0C;IAC1C,UAAU,EAAE,cAAc,EAAE,CAAA;CAC7B,CAAA;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,KAAK,GAAG;IAChD,YAAY,EAAE,cAAc,CAAA;IAC5B,QAAQ,EAAE,sBAAsB,CAAA;CACjC,CA2CA"}
|
|
@@ -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.14.0",
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22"
|
|
22
22
|
},
|
|
@@ -53,16 +53,17 @@
|
|
|
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
|
+
"quickjs-emscripten": "0.32.0",
|
|
59
60
|
"yaml": "^2.9.0",
|
|
60
|
-
"@scalar/helpers": "0.11.
|
|
61
|
+
"@scalar/helpers": "0.11.3",
|
|
62
|
+
"@scalar/json-magic": "0.13.4",
|
|
61
63
|
"@scalar/openapi-types": "0.9.5",
|
|
62
64
|
"@scalar/openapi-upgrader": "0.2.15",
|
|
63
|
-
"@scalar/types": "0.
|
|
64
|
-
"@scalar/
|
|
65
|
-
"@scalar/workspace-store": "0.58.1"
|
|
65
|
+
"@scalar/types": "0.19.0",
|
|
66
|
+
"@scalar/workspace-store": "0.60.0"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
68
69
|
"@types/node": "^24.1.0",
|