@scalar/mock-server 0.14.4 → 0.16.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 +66 -0
- package/dist/create-asyncapi-mock-server.d.ts +8 -10
- package/dist/create-asyncapi-mock-server.d.ts.map +1 -1
- package/dist/create-asyncapi-mock-server.js +6 -10
- package/dist/create-mock-server.d.ts.map +1 -1
- package/dist/create-mock-server.js +36 -17
- package/dist/routes/mock-any-response.d.ts.map +1 -1
- package/dist/routes/mock-any-response.js +28 -13
- package/dist/routes/mock-handler-response.d.ts +1 -2
- package/dist/routes/mock-handler-response.d.ts.map +1 -1
- package/dist/routes/mock-handler-response.js +15 -10
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/build-handler-context.d.ts +3 -2
- package/dist/utils/build-handler-context.d.ts.map +1 -1
- package/dist/utils/build-handler-context.js +23 -2
- package/dist/utils/get-oauth2-metadata.d.ts +1 -0
- package/dist/utils/get-oauth2-metadata.d.ts.map +1 -1
- package/dist/utils/get-oauth2-metadata.js +5 -1
- package/dist/utils/get-open-auth-token-urls.d.ts +2 -2
- package/dist/utils/get-open-auth-token-urls.d.ts.map +1 -1
- package/dist/utils/get-open-auth-token-urls.js +5 -3
- package/dist/utils/get-operation.d.ts +4 -2
- package/dist/utils/get-operation.d.ts.map +1 -1
- package/dist/utils/get-operation.js +9 -5
- package/dist/utils/handle-authentication.d.ts +2 -2
- package/dist/utils/handle-authentication.d.ts.map +1 -1
- package/dist/utils/log-authentication-instructions.d.ts +2 -2
- package/dist/utils/log-authentication-instructions.d.ts.map +1 -1
- package/dist/utils/log-authentication-instructions.js +7 -0
- package/dist/utils/negotiate-content-type.d.ts +4 -0
- package/dist/utils/negotiate-content-type.d.ts.map +1 -0
- package/dist/utils/negotiate-content-type.js +12 -0
- package/dist/utils/process-openapi-document.d.ts +7 -4
- package/dist/utils/process-openapi-document.d.ts.map +1 -1
- package/dist/utils/process-openapi-document.js +26 -11
- package/dist/utils/querystring-parameter.d.ts +11 -0
- package/dist/utils/querystring-parameter.d.ts.map +1 -0
- package/dist/utils/querystring-parameter.js +135 -0
- package/dist/utils/select-response-example.d.ts +3 -1
- package/dist/utils/select-response-example.d.ts.map +1 -1
- package/dist/utils/set-up-authentication-routes.d.ts.map +1 -1
- package/dist/utils/set-up-authentication-routes.js +2 -0
- package/dist/utils/set-up-device-authorization.d.ts +5 -0
- package/dist/utils/set-up-device-authorization.d.ts.map +1 -0
- package/dist/utils/set-up-device-authorization.js +192 -0
- package/dist/utils/streaming-response.d.ts +24 -0
- package/dist/utils/streaming-response.d.ts.map +1 -0
- package/dist/utils/streaming-response.js +65 -0
- package/dist/utils/validate-request.d.ts.map +1 -1
- package/dist/utils/validate-request.js +29 -1
- package/package.json +12 -9
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
2
|
+
import { html } from 'hono/html';
|
|
3
|
+
import { getPathFromUrl } from './get-open-auth-token-urls.js';
|
|
4
|
+
/** Both endpoints compare the decoded client ID, regardless of credential location. */
|
|
5
|
+
const getClientId = (clientId, authorization) => {
|
|
6
|
+
if (typeof clientId === 'string') {
|
|
7
|
+
return clientId;
|
|
8
|
+
}
|
|
9
|
+
if (!authorization?.startsWith('Basic ')) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
const credentials = Buffer.from(authorization.slice(6), 'base64').toString();
|
|
13
|
+
const separator = credentials.indexOf(':');
|
|
14
|
+
if (separator === -1) {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return decodeURIComponent(credentials.slice(0, separator).replace(/\+/g, ' '));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Keeps the device form and its outcomes consistent with the mock OAuth authorization page. */
|
|
25
|
+
const renderDevicePage = (title, heading, content) => html `
|
|
26
|
+
<!doctype html>
|
|
27
|
+
<html lang="en">
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="utf-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
31
|
+
<title>${heading}</title>
|
|
32
|
+
<style>
|
|
33
|
+
* { box-sizing: border-box; }
|
|
34
|
+
body { margin: 0; min-height: 100svh; display: grid; place-items: center; padding: 32px 16px; background: #f3f4f6; color: #4b5563; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
35
|
+
main { width: 100%; max-width: 448px; }
|
|
36
|
+
.brand { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 20px; color: #111827; font-size: 18px; font-weight: 500; }
|
|
37
|
+
.brand img { width: 24px; }
|
|
38
|
+
.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
39
|
+
.card { padding: 4px; border-radius: 8px; background: #f9fafb; box-shadow: 0 1px 3px #0000001a, 0 1px 2px #0000001a; }
|
|
40
|
+
h1 { margin: 0; padding: 8px 24px 12px; color: #1f2937; font-size: 16px; font-weight: 500; }
|
|
41
|
+
.content { padding: 24px; border-radius: 4px; background: white; }
|
|
42
|
+
p { margin: 0 0 20px; }
|
|
43
|
+
.content > p:last-child { margin-bottom: 0; }
|
|
44
|
+
label { display: block; margin-bottom: 8px; color: #374151; font-weight: 500; }
|
|
45
|
+
input { width: 100%; min-width: 0; padding: 12px; border: 1px solid #d1d5db; border-radius: 4px; background: #f9fafb; color: #111827; font: 18px ui-monospace, monospace; letter-spacing: .15em; }
|
|
46
|
+
.actions { display: flex; flex-direction: row-reverse; justify-content: space-between; gap: 12px; margin-top: 24px; }
|
|
47
|
+
button { padding: 8px 24px; border: 1px solid #e5e7eb; border-radius: 4px; background: white; color: #4b5563; font: inherit; cursor: pointer; }
|
|
48
|
+
button:hover { background: #f3f4f6; }
|
|
49
|
+
button[value="approve"] { border-color: black; background: black; color: white; }
|
|
50
|
+
button[value="approve"]:hover { background: #1f2937; }
|
|
51
|
+
:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
|
|
52
|
+
footer { margin-top: 20px; color: #6b7280; text-align: center; font-size: 12px; }
|
|
53
|
+
a { color: #4b5563; text-underline-offset: 2px; }
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<main>
|
|
58
|
+
<div class="brand">
|
|
59
|
+
<img src="https://cdn.scalar.com/images/logo-dark.svg" alt="Scalar">
|
|
60
|
+
<span>${title}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<section class="card" aria-labelledby="page-title">
|
|
63
|
+
<h1 id="page-title">${heading}</h1>
|
|
64
|
+
<div class="content">${content}</div>
|
|
65
|
+
</section>
|
|
66
|
+
<footer>
|
|
67
|
+
This authorization page is provided by the
|
|
68
|
+
<a href="https://scalar.com/tools/mock-server/getting-started" target="_blank" rel="noopener noreferrer">Scalar Mock Server</a>.
|
|
69
|
+
</footer>
|
|
70
|
+
</main>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
73
|
+
`;
|
|
74
|
+
/** Registers a local RFC8628 approval flow before the permissive mock token handlers. */
|
|
75
|
+
export const setUpDeviceAuthorization = (app, document) => {
|
|
76
|
+
const title = document?.info?.title ?? '';
|
|
77
|
+
const grants = new Map();
|
|
78
|
+
const devicePaths = new Map();
|
|
79
|
+
const securitySchemes = document?.components?.securitySchemes ?? {};
|
|
80
|
+
for (const rawScheme of Object.values(securitySchemes)) {
|
|
81
|
+
const scheme = getResolvedRef(rawScheme);
|
|
82
|
+
if (scheme?.type === 'oauth2' && scheme.flows?.deviceAuthorization) {
|
|
83
|
+
const flow = scheme.flows.deviceAuthorization;
|
|
84
|
+
devicePaths.set(getPathFromUrl(flow.deviceAuthorizationUrl || '/oauth/device'), getPathFromUrl(flow.tokenUrl || '/oauth/token'));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const tokenPaths = new Set(devicePaths.values());
|
|
88
|
+
const removeExpired = () => {
|
|
89
|
+
for (const [code, grant] of grants) {
|
|
90
|
+
if (grant.expiresAt <= Date.now()) {
|
|
91
|
+
grants.delete(code);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
for (const [devicePath, tokenPath] of devicePaths) {
|
|
96
|
+
const verificationPath = `${devicePath.replace(/\/$/, '')}/verify`;
|
|
97
|
+
app.post(devicePath, async (c) => {
|
|
98
|
+
const body = await c.req.parseBody();
|
|
99
|
+
const clientId = getClientId(body.client_id, c.req.header('Authorization'));
|
|
100
|
+
c.header('Cache-Control', 'no-store');
|
|
101
|
+
c.header('Pragma', 'no-cache');
|
|
102
|
+
if (!clientId) {
|
|
103
|
+
return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400);
|
|
104
|
+
}
|
|
105
|
+
removeExpired();
|
|
106
|
+
const deviceCode = crypto.randomUUID();
|
|
107
|
+
const userCode = crypto.randomUUID().slice(0, 8).toUpperCase();
|
|
108
|
+
grants.set(deviceCode, {
|
|
109
|
+
clientId,
|
|
110
|
+
userCode,
|
|
111
|
+
tokenPath,
|
|
112
|
+
expiresAt: Date.now() + 600000,
|
|
113
|
+
nextPoll: 0,
|
|
114
|
+
interval: 5000,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
});
|
|
117
|
+
const verificationUri = new URL(verificationPath, c.req.url).href;
|
|
118
|
+
return c.json({
|
|
119
|
+
device_code: deviceCode,
|
|
120
|
+
user_code: userCode,
|
|
121
|
+
verification_uri: verificationUri,
|
|
122
|
+
verification_uri_complete: `${verificationUri}?user_code=${userCode}`,
|
|
123
|
+
expires_in: 600,
|
|
124
|
+
interval: 5,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
app.get(verificationPath, (c) => c.html(renderDevicePage(title, 'Authorize device', html `
|
|
128
|
+
<p>Enter the code shown in your API client to approve this mock authorization request.</p>
|
|
129
|
+
<form method="post">
|
|
130
|
+
<label for="user-code">Device code</label>
|
|
131
|
+
<input id="user-code" name="user_code" required value="${c.req.query('user_code') ?? ''}" autocomplete="one-time-code" spellcheck="false" autocapitalize="characters">
|
|
132
|
+
<div class="actions">
|
|
133
|
+
<button name="decision" value="approve">Approve</button>
|
|
134
|
+
<button name="decision" value="deny">Deny</button>
|
|
135
|
+
</div>
|
|
136
|
+
</form>
|
|
137
|
+
`)));
|
|
138
|
+
app.post(verificationPath, async (c) => {
|
|
139
|
+
const body = await c.req.parseBody();
|
|
140
|
+
const grant = [...grants.values()].find((entry) => entry.userCode === String(body.user_code).trim().toUpperCase() && entry.tokenPath === tokenPath);
|
|
141
|
+
if (!grant || grant.expiresAt <= Date.now() || grant.status !== 'pending') {
|
|
142
|
+
return c.html(renderDevicePage(title, 'Invalid or expired device code', html `<p>Start a new authorization request in your API client and enter the new device code.</p>`), 400);
|
|
143
|
+
}
|
|
144
|
+
if (body.decision !== 'approve' && body.decision !== 'deny') {
|
|
145
|
+
return c.html(renderDevicePage(title, 'Choose Approve or Deny', html `<p>Return to the device authorization form and choose whether to approve or deny the request.</p>`), 400);
|
|
146
|
+
}
|
|
147
|
+
grant.status = body.decision === 'approve' ? 'approved' : 'denied';
|
|
148
|
+
return c.html(renderDevicePage(title, grant.status === 'approved' ? 'Device authorized' : 'Device authorization denied', grant.status === 'approved'
|
|
149
|
+
? html `<p>Device authorized. You can return to your API client.</p>`
|
|
150
|
+
: html `<p>Device authorization denied. You can return to your API client to start a new request.</p>`));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
for (const tokenPath of tokenPaths) {
|
|
154
|
+
app.post(tokenPath, async (c, next) => {
|
|
155
|
+
const body = await c.req.parseBody();
|
|
156
|
+
if (body.grant_type !== 'urn:ietf:params:oauth:grant-type:device_code') {
|
|
157
|
+
return next();
|
|
158
|
+
}
|
|
159
|
+
c.header('Cache-Control', 'no-store');
|
|
160
|
+
c.header('Pragma', 'no-cache');
|
|
161
|
+
const clientId = getClientId(body.client_id, c.req.header('Authorization'));
|
|
162
|
+
const code = String(body.device_code ?? '');
|
|
163
|
+
const grant = grants.get(code);
|
|
164
|
+
if (!grant || grant.tokenPath !== tokenPath || grant.clientId !== clientId) {
|
|
165
|
+
return c.json({ error: 'invalid_grant' }, 400);
|
|
166
|
+
}
|
|
167
|
+
if (grant.expiresAt <= Date.now()) {
|
|
168
|
+
grants.delete(code);
|
|
169
|
+
return c.json({ error: 'expired_token' }, 400);
|
|
170
|
+
}
|
|
171
|
+
if (Date.now() < grant.nextPoll) {
|
|
172
|
+
grant.interval += 5000;
|
|
173
|
+
grant.nextPoll = Date.now() + grant.interval;
|
|
174
|
+
return c.json({ error: 'slow_down' }, 400);
|
|
175
|
+
}
|
|
176
|
+
grant.nextPoll = Date.now() + grant.interval;
|
|
177
|
+
if (grant.status === 'pending') {
|
|
178
|
+
return c.json({ error: 'authorization_pending' }, 400);
|
|
179
|
+
}
|
|
180
|
+
grants.delete(code);
|
|
181
|
+
if (grant.status === 'denied') {
|
|
182
|
+
return c.json({ error: 'access_denied' }, 400);
|
|
183
|
+
}
|
|
184
|
+
return c.json({
|
|
185
|
+
access_token: 'super-secret-access-token',
|
|
186
|
+
token_type: 'Bearer',
|
|
187
|
+
expires_in: 3600,
|
|
188
|
+
refresh_token: 'example-refresh-token',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OpenAPIV3_2 } from '@scalar/openapi-types';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
/** A finite mock stream, with its data-model value available to custom handlers. */
|
|
4
|
+
type StreamingResponse = {
|
|
5
|
+
body: unknown;
|
|
6
|
+
chunks: string[];
|
|
7
|
+
contentType: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Build a finite response for media types with an OpenAPI 3.2 itemSchema.
|
|
11
|
+
* Explicit examples describe the whole response. Generated item-only streams contain three items,
|
|
12
|
+
* keeping array-valued items intact. A complete schema controls the sequence when also present.
|
|
13
|
+
*/
|
|
14
|
+
export declare const getStreamingResponse: (mediaType: (Omit<OpenAPIV3_2.MediaTypeObject, "itemSchema"> & {
|
|
15
|
+
itemSchema?: OpenAPIV3_2.MediaTypeObject["itemSchema"] | boolean;
|
|
16
|
+
}) | undefined, contentType: string, options?: {
|
|
17
|
+
exampleName?: string;
|
|
18
|
+
variables?: Record<string, unknown>;
|
|
19
|
+
body?: unknown;
|
|
20
|
+
}) => StreamingResponse | undefined;
|
|
21
|
+
/** Write each mocked item separately and close the stream when the finite sequence is exhausted. */
|
|
22
|
+
export declare const sendStreamingResponse: (c: Context, response: StreamingResponse) => Response;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=streaming-response.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streaming-response.d.ts","sourceRoot":"","sources":["../../src/utils/streaming-response.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAKxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAMnC,oFAAoF;AACpF,KAAK,iBAAiB,GAAG;IACvB,IAAI,EAAE,OAAO,CAAA;IACb,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAC/B,WACI,CAAC,IAAI,CAAC,WAAW,CAAC,eAAe,EAAE,YAAY,CAAC,GAAG;IACjD,UAAU,CAAC,EAAE,WAAW,CAAC,eAAe,CAAC,YAAY,CAAC,GAAG,OAAO,CAAA;CACjE,CAAC,GACF,SAAS,EACb,aAAa,MAAM,EACnB,UAAS;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,KAC1F,iBAAiB,GAAG,SA8CtB,CAAA;AAED,oGAAoG;AACpG,eAAO,MAAM,qBAAqB,GAAI,GAAG,OAAO,EAAE,UAAU,iBAAiB,KAAG,QAY/E,CAAA"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getStreamFormat } from '@scalar/helpers/http/is-streaming-content-type';
|
|
2
|
+
import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
|
|
3
|
+
import { serializeStreamExample } from '@scalar/workspace-store/helpers/serialize-stream-example';
|
|
4
|
+
import { coerceValue } from '@scalar/workspace-store/schemas/typebox-coerce';
|
|
5
|
+
import { SchemaObjectSchema } from '@scalar/workspace-store/schemas/v3.2/strict/openapi-document';
|
|
6
|
+
import { stream } from 'hono/streaming';
|
|
7
|
+
import { generateResponseExample } from './generate-response-example.js';
|
|
8
|
+
import { selectResponseExample } from './select-response-example.js';
|
|
9
|
+
/**
|
|
10
|
+
* Build a finite response for media types with an OpenAPI 3.2 itemSchema.
|
|
11
|
+
* Explicit examples describe the whole response. Generated item-only streams contain three items,
|
|
12
|
+
* keeping array-valued items intact. A complete schema controls the sequence when also present.
|
|
13
|
+
*/
|
|
14
|
+
export const getStreamingResponse = (mediaType, contentType, options = {}) => {
|
|
15
|
+
if (mediaType?.itemSchema === undefined) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const format = getStreamFormat(contentType);
|
|
19
|
+
if (format === undefined || format === 'multipart') {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const example = options.body !== undefined ? { value: options.body } : selectResponseExample(mediaType, options.exampleName);
|
|
23
|
+
if (example && typeof example.value === 'string') {
|
|
24
|
+
return { body: example.value, chunks: [example.value], contentType };
|
|
25
|
+
}
|
|
26
|
+
const itemSchema = typeof mediaType.itemSchema === 'boolean' ? mediaType.itemSchema : getResolvedRefDeep(mediaType.itemSchema);
|
|
27
|
+
const completeSchema = typeof mediaType.schema === 'boolean' ? mediaType.schema : getResolvedRefDeep(mediaType.schema);
|
|
28
|
+
const generateBody = () => {
|
|
29
|
+
if (itemSchema === false || completeSchema === false) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
if (completeSchema === undefined) {
|
|
33
|
+
return Array.from({ length: 3 }, () => itemSchema === true
|
|
34
|
+
? null
|
|
35
|
+
: generateResponseExample(coerceValue(SchemaObjectSchema, itemSchema), options.variables));
|
|
36
|
+
}
|
|
37
|
+
if (typeof completeSchema === 'object' && 'type' in completeSchema && completeSchema.type === 'array') {
|
|
38
|
+
return generateResponseExample(coerceValue(SchemaObjectSchema, {
|
|
39
|
+
...completeSchema,
|
|
40
|
+
items: ('items' in completeSchema ? completeSchema.items : undefined) ?? itemSchema,
|
|
41
|
+
}), options.variables);
|
|
42
|
+
}
|
|
43
|
+
return generateResponseExample(coerceValue(SchemaObjectSchema, completeSchema), options.variables);
|
|
44
|
+
};
|
|
45
|
+
const body = example ? example.value : generateBody();
|
|
46
|
+
const items = body === undefined ? [] : Array.isArray(body) ? body : [body];
|
|
47
|
+
const chunks = items
|
|
48
|
+
.filter((item) => item !== undefined)
|
|
49
|
+
.map((item) => serializeStreamExample(item, contentType, true) ?? '');
|
|
50
|
+
return { body, chunks, contentType };
|
|
51
|
+
};
|
|
52
|
+
/** Write each mocked item separately and close the stream when the finite sequence is exhausted. */
|
|
53
|
+
export const sendStreamingResponse = (c, response) => {
|
|
54
|
+
c.header('Content-Type', response.contentType);
|
|
55
|
+
c.header('Cache-Control', 'no-cache');
|
|
56
|
+
c.header('X-Accel-Buffering', 'no');
|
|
57
|
+
return stream(c, async (writer) => {
|
|
58
|
+
for (const chunk of response.chunks) {
|
|
59
|
+
if (writer.aborted) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
await writer.write(chunk);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,uBAAuB,CAAA;AAMrE,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAuTtD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,WAAW,CAAC,eAAe,EACtC,qBAAqB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KAC5D,iBAmLF,CAAA"}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { getFirstMediaType } from '@scalar/helpers/http/get-first-media-type';
|
|
2
|
+
import { parseMimeType } from '@scalar/helpers/http/mime-type';
|
|
1
3
|
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
2
4
|
import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
|
|
3
5
|
import Ajv2020 from 'ajv/dist/2020.js';
|
|
4
6
|
import addFormats from 'ajv-formats';
|
|
5
7
|
import { getCookie } from 'hono/cookie';
|
|
6
8
|
import { deserializeArrayParameter, deserializeObjectParameter, getObjectPropertyNames, isArraySchema, isObjectSchema, resolveSerialization, } from './deserialize-parameter.js';
|
|
9
|
+
import { findQuerystringParameter, getQuerystringJsonSchema, parseQuerystringParameter } from './querystring-parameter.js';
|
|
7
10
|
import { replaceCircularMarkers } from './replace-circular-markers.js';
|
|
8
11
|
/**
|
|
9
12
|
* Prepare a resolved schema for Ajv by replacing the `'[circular]'` markers a recursive schema leaves
|
|
@@ -113,7 +116,7 @@ const mergeParameters = (pathItemParameters, operationParameters) => {
|
|
|
113
116
|
* in isolation so one broken schema never disables the others.
|
|
114
117
|
*/
|
|
115
118
|
const compileSchema = (ajv, schema, label) => {
|
|
116
|
-
if (
|
|
119
|
+
if (schema === null || schema === undefined) {
|
|
117
120
|
return null;
|
|
118
121
|
}
|
|
119
122
|
try {
|
|
@@ -140,6 +143,9 @@ const compileValidators = (operation, pathItemParameters) => {
|
|
|
140
143
|
const queryParameters = buildParameterSchema(parameters, 'query');
|
|
141
144
|
const headerParameters = buildParameterSchema(parameters, 'header');
|
|
142
145
|
const cookieParameters = buildParameterSchema(parameters, 'cookie');
|
|
146
|
+
const querystringParameter = findQuerystringParameter(operation, pathItemParameters);
|
|
147
|
+
const [querystringContentType, querystringMedia] = getFirstMediaType(querystringParameter?.content) ?? [];
|
|
148
|
+
const querystringSchema = querystringMedia?.schema;
|
|
143
149
|
const requestBody = getResolvedRef(operation.requestBody);
|
|
144
150
|
// Build the body schema defensively; resolving a malformed `$ref` should not crash setup.
|
|
145
151
|
let bodySchema = null;
|
|
@@ -151,6 +157,9 @@ const compileValidators = (operation, pathItemParameters) => {
|
|
|
151
157
|
console.error('Error resolving request body schema, skipping body validation:', error);
|
|
152
158
|
}
|
|
153
159
|
return {
|
|
160
|
+
querystringParameter,
|
|
161
|
+
querystringJson: compileSchema(bodyAjv, getQuerystringJsonSchema(querystringParameter), 'querystring JSON properties'),
|
|
162
|
+
querystring: compileSchema(parseMimeType(querystringContentType).essence === 'application/x-www-form-urlencoded' ? parameterAjv : bodyAjv, querystringSchema === undefined ? null : asCompilableSchema(getResolvedRefDeep(querystringSchema)), 'querystring parameter'),
|
|
154
163
|
path: compileSchema(parameterAjv, pathParameters?.schema ?? null, 'path parameter'),
|
|
155
164
|
query: compileSchema(parameterAjv, queryParameters?.schema ?? null, 'query parameter'),
|
|
156
165
|
header: compileSchema(parameterAjv, headerParameters?.schema ?? null, 'header parameter'),
|
|
@@ -312,6 +321,25 @@ export const validateRequest = (operation, pathItemParameters) => {
|
|
|
312
321
|
violations.push(...mapErrors(validator.errors, location));
|
|
313
322
|
}
|
|
314
323
|
}
|
|
324
|
+
if (validators.querystringParameter) {
|
|
325
|
+
try {
|
|
326
|
+
const value = parseQuerystringParameter(c.req.url, validators.querystringParameter);
|
|
327
|
+
if (value === undefined) {
|
|
328
|
+
if (validators.querystringParameter.required) {
|
|
329
|
+
violations.push({ location: 'query', path: '', message: 'Query string is required' });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else if (validators.querystringJson && !validators.querystringJson(value)) {
|
|
333
|
+
violations.push(...mapErrors(validators.querystringJson.errors, 'query'));
|
|
334
|
+
}
|
|
335
|
+
else if (validators.querystring && !validators.querystring(value)) {
|
|
336
|
+
violations.push(...mapErrors(validators.querystring.errors, 'query'));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
violations.push({ location: 'query', path: '', message: 'Query string could not be decoded' });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
315
343
|
// Request body — only `application/json` in this slice
|
|
316
344
|
if (validators.body || validators.bodyRequired) {
|
|
317
345
|
// Read from a clone so the original request stream stays intact for the mock or `x-handler`
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"swagger",
|
|
17
17
|
"cli"
|
|
18
18
|
],
|
|
19
|
-
"version": "0.
|
|
19
|
+
"version": "0.16.0",
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22"
|
|
22
22
|
},
|
|
@@ -52,21 +52,24 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@faker-js/faker": "10.6.0",
|
|
55
|
-
"@hono/node-
|
|
56
|
-
"@scalar/helpers": "0.
|
|
57
|
-
"@scalar/json-magic": "0.
|
|
58
|
-
"@scalar/openapi-types": "0.9.
|
|
59
|
-
"@scalar/openapi-upgrader": "0.
|
|
60
|
-
"@scalar/types": "0.
|
|
61
|
-
"@scalar/workspace-store": "0.
|
|
55
|
+
"@hono/node-server": "^2.1.1",
|
|
56
|
+
"@scalar/helpers": "0.14.0",
|
|
57
|
+
"@scalar/json-magic": "0.15.1",
|
|
58
|
+
"@scalar/openapi-types": "0.9.7",
|
|
59
|
+
"@scalar/openapi-upgrader": "0.3.1",
|
|
60
|
+
"@scalar/types": "0.22.0",
|
|
61
|
+
"@scalar/workspace-store": "0.66.0",
|
|
62
62
|
"ajv": "^8.20.0",
|
|
63
63
|
"ajv-formats": "^3.0.1",
|
|
64
|
-
"hono": "^4.13.
|
|
64
|
+
"hono": "^4.13.8",
|
|
65
65
|
"quickjs-emscripten": "0.32.0",
|
|
66
|
+
"ws": "8.21.3",
|
|
66
67
|
"yaml": "^2.9.0"
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
|
69
70
|
"@types/node": "^24.1.0",
|
|
71
|
+
"@types/ws": "8.18.1",
|
|
72
|
+
"undici": "7.24.4",
|
|
70
73
|
"vite": "8.1.5"
|
|
71
74
|
},
|
|
72
75
|
"scripts": {
|