@scalar/mock-server 0.10.18 → 0.11.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 +13 -0
- package/dist/create-mock-server.d.ts.map +1 -1
- package/dist/create-mock-server.js +21 -3
- package/dist/routes/mock-any-response.d.ts +2 -3
- package/dist/routes/mock-any-response.d.ts.map +1 -1
- package/dist/routes/mock-any-response.js +34 -22
- 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 +16 -13
- package/dist/types.d.ts +13 -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 +7 -2
- package/dist/utils/handle-authentication.d.ts +5 -0
- package/dist/utils/handle-authentication.d.ts.map +1 -1
- package/dist/utils/handle-authentication.js +150 -98
- package/dist/utils/parse-prefer-header.d.ts +26 -0
- package/dist/utils/parse-prefer-header.d.ts.map +1 -0
- package/dist/utils/parse-prefer-header.js +26 -0
- package/dist/utils/select-response-example.d.ts +22 -0
- package/dist/utils/select-response-example.d.ts.map +1 -0
- package/dist/utils/select-response-example.js +47 -0
- package/dist/utils/validate-request.d.ts +14 -0
- package/dist/utils/validate-request.d.ts.map +1 -0
- package/dist/utils/validate-request.js +187 -0
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @scalar/mock-server
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#9466](https://github.com/scalar/scalar/pull/9466): Validate incoming requests against the matched operation by default. Path/query parameters and the `application/json` request body are checked against their schema, and contract violations return a `422` with an `application/problem+json` body listing every violation. Set `validateRequest: false` to opt out and always return a mock response.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [#9467](https://github.com/scalar/scalar/pull/9467): Fix security checking: evaluate `security` as OR-of-ANDs, inherit document-level security when an operation defines none, and validate credential shape (well-formed Basic and Bearer)
|
|
12
|
+
- [#9464](https://github.com/scalar/scalar/pull/9464): Add `Prefer` header support to control mock responses: use `code=<status>` to request a specific response status and `example=<name>` to pick a named example from the `examples` map. Also adds support for the OpenAPI `examples` map (previously only the singular `example` was used).
|
|
13
|
+
|
|
14
|
+
## 0.10.19
|
|
15
|
+
|
|
3
16
|
## 0.10.18
|
|
4
17
|
|
|
5
18
|
## 0.10.17
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-mock-server.d.ts","sourceRoot":"","sources":["../src/create-mock-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAG3B,OAAO,KAAK,EAAc,iBAAiB,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"create-mock-server.d.ts","sourceRoot":"","sources":["../src/create-mock-server.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAG3B,OAAO,KAAK,EAAc,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAiB5D;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,aAAa,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2GtF"}
|
|
@@ -10,6 +10,7 @@ import { isAuthenticationRequired } from './utils/is-authentication-required.js'
|
|
|
10
10
|
import { logAuthenticationInstructions } from './utils/log-authentication-instructions.js';
|
|
11
11
|
import { processOpenApiDocument } from './utils/process-openapi-document.js';
|
|
12
12
|
import { setUpAuthenticationRoutes } from './utils/set-up-authentication-routes.js';
|
|
13
|
+
import { validateRequest } from './utils/validate-request.js';
|
|
13
14
|
import { store } from './libs/store.js';
|
|
14
15
|
import { mockAnyResponse } from './routes/mock-any-response.js';
|
|
15
16
|
import { mockHandlerResponse } from './routes/mock-handler-response.js';
|
|
@@ -62,20 +63,37 @@ export async function createMockServer(configuration) {
|
|
|
62
63
|
methods.forEach((method) => {
|
|
63
64
|
const route = honoRouteFromPath(path);
|
|
64
65
|
const operation = pathItem?.[method];
|
|
66
|
+
// Operation-level security overrides the global requirement, so fall back to the
|
|
67
|
+
// document-wide `security` when the operation does not define its own.
|
|
68
|
+
const effectiveSecurity = operation.security ?? schema?.security;
|
|
65
69
|
// Check if authentication is required for this operation
|
|
66
|
-
if (isAuthenticationRequired(
|
|
70
|
+
if (isAuthenticationRequired(effectiveSecurity)) {
|
|
67
71
|
app[method](route, handleAuthentication(schema, operation));
|
|
68
72
|
}
|
|
73
|
+
// Notify the `onRequest` callback before validation runs, so it fires for every request —
|
|
74
|
+
// including ones the validation middleware rejects with a `422`.
|
|
75
|
+
if (configuration.onRequest) {
|
|
76
|
+
app[method](route, async (c, next) => {
|
|
77
|
+
configuration.onRequest?.({ context: c, operation });
|
|
78
|
+
await next();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Validate the incoming request against the operation contract (on by default;
|
|
82
|
+
// opt out with `validateRequest: false`). Runs after authentication but before the
|
|
83
|
+
// mock handler. Validators are compiled once here, so there is no per-request recompilation.
|
|
84
|
+
if (configuration.validateRequest !== false) {
|
|
85
|
+
app[method](route, validateRequest(operation, pathItem?.parameters));
|
|
86
|
+
}
|
|
69
87
|
// Check if operation has x-handler extension
|
|
70
88
|
// Validate that it's a non-empty string (consistent with x-seed validation)
|
|
71
89
|
const handlerCode = operation?.['x-handler'];
|
|
72
90
|
const hasHandler = handlerCode && typeof handlerCode === 'string' && handlerCode.trim().length > 0;
|
|
73
91
|
// Route to appropriate handler
|
|
74
92
|
if (hasHandler) {
|
|
75
|
-
app[method](route, (c) => mockHandlerResponse(c, operation
|
|
93
|
+
app[method](route, (c) => mockHandlerResponse(c, operation));
|
|
76
94
|
}
|
|
77
95
|
else {
|
|
78
|
-
app[method](route, (c) => mockAnyResponse(c, operation
|
|
96
|
+
app[method](route, (c) => mockAnyResponse(c, operation));
|
|
79
97
|
}
|
|
80
98
|
});
|
|
81
99
|
});
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
2
2
|
import type { Context } from 'hono';
|
|
3
3
|
import type { StatusCode } from 'hono/utils/http-status';
|
|
4
|
-
import type { MockServerOptions } from '../types.js';
|
|
5
4
|
/**
|
|
6
5
|
* Mock any response
|
|
7
6
|
*/
|
|
8
|
-
export declare function mockAnyResponse(c: Context, operation: OpenAPIV3_1.OperationObject
|
|
7
|
+
export declare function mockAnyResponse(c: Context, operation: OpenAPIV3_1.OperationObject): (Response & import("hono").TypedResponse<{
|
|
9
8
|
error: string;
|
|
10
|
-
}, import("hono/utils/http-status").ContentfulStatusCode, "json">) | (Response & import("hono").TypedResponse<null, StatusCode, "body">) | (Response & import("hono").TypedResponse<
|
|
9
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">) | (Response & import("hono").TypedResponse<null, StatusCode, "body">) | (Response & import("hono").TypedResponse<string, import("hono/utils/http-status").ContentfulStatusCode, "body">);
|
|
11
10
|
//# sourceMappingURL=mock-any-response.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock-any-response.d.ts","sourceRoot":"","sources":["../../src/routes/mock-any-response.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"mock-any-response.d.ts","sourceRoot":"","sources":["../../src/routes/mock-any-response.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AAMxD;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,eAAe;;4PAiGjF"}
|
|
@@ -4,29 +4,32 @@ import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved
|
|
|
4
4
|
import { getExampleFromSchema } from '@scalar/workspace-store/request-example';
|
|
5
5
|
import { accepts } from 'hono/accepts';
|
|
6
6
|
import { findPreferredResponseKey } from '../utils/find-preferred-response-key.js';
|
|
7
|
+
import { parsePreferHeader } from '../utils/parse-prefer-header.js';
|
|
8
|
+
import { selectResponseExample } from '../utils/select-response-example.js';
|
|
7
9
|
/**
|
|
8
10
|
* Mock any response
|
|
9
11
|
*/
|
|
10
|
-
export function mockAnyResponse(c, operation
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
12
|
+
export function mockAnyResponse(c, operation) {
|
|
13
|
+
// Note: the `onRequest` callback runs as middleware (see `create-mock-server`) so it also fires
|
|
14
|
+
// for requests rejected before reaching this handler.
|
|
15
|
+
// Parse the Prefer header (RFC 7240) so clients can request a specific
|
|
16
|
+
// response status (`code=`) and named example (`example=`).
|
|
17
|
+
const prefer = parsePreferHeader(c.req.header('Prefer'));
|
|
18
|
+
// Response selection:
|
|
19
|
+
// 1. An explicit `Prefer: code=<status>` that matches a defined response
|
|
20
|
+
// 2. Otherwise the preferred key (default, 200, 201 …)
|
|
21
|
+
// An unknown `code=` is ignored and falls back to the preferred key.
|
|
20
22
|
const preferredResponseKey = findPreferredResponseKey(Object.keys(operation.responses ?? {}));
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
+
const responseKey = prefer.code && operation.responses?.[prefer.code] ? prefer.code : preferredResponseKey;
|
|
24
|
+
const selectedResponse = responseKey ? getResolvedRef(operation.responses?.[responseKey]) : null;
|
|
25
|
+
if (!selectedResponse) {
|
|
23
26
|
c.status(500);
|
|
24
27
|
return c.json({ error: 'No response defined for this operation.' });
|
|
25
28
|
}
|
|
26
29
|
// Status code
|
|
27
|
-
const statusCode = Number.parseInt(
|
|
30
|
+
const statusCode = Number.parseInt(responseKey === 'default' ? '200' : (responseKey ?? '200'), 10);
|
|
28
31
|
// Headers
|
|
29
|
-
const headers =
|
|
32
|
+
const headers = selectedResponse?.headers ?? {};
|
|
30
33
|
Object.keys(headers).forEach((header) => {
|
|
31
34
|
const headerObject = getResolvedRef(headers[header]);
|
|
32
35
|
const value = headerObject?.schema
|
|
@@ -41,7 +44,7 @@ export function mockAnyResponse(c, operation, options) {
|
|
|
41
44
|
c.status(statusCode);
|
|
42
45
|
return c.body(null);
|
|
43
46
|
}
|
|
44
|
-
const supportedContentTypes = Object.keys(
|
|
47
|
+
const supportedContentTypes = Object.keys(selectedResponse?.content ?? {});
|
|
45
48
|
// If no content types are defined, return the status with no body
|
|
46
49
|
if (supportedContentTypes.length === 0) {
|
|
47
50
|
c.status(statusCode);
|
|
@@ -56,10 +59,12 @@ export function mockAnyResponse(c, operation, options) {
|
|
|
56
59
|
: (supportedContentTypes[0] ?? 'text/plain;charset=UTF-8'),
|
|
57
60
|
});
|
|
58
61
|
c.header('Content-Type', acceptedContentType);
|
|
59
|
-
const acceptedResponse =
|
|
60
|
-
// Body
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
const acceptedResponse = selectedResponse?.content?.[acceptedContentType];
|
|
63
|
+
// Body: a named/singular/first example if one is defined, otherwise generate
|
|
64
|
+
// a value from the schema. `Prefer: example=<name>` picks a named example.
|
|
65
|
+
const selectedExample = selectResponseExample(acceptedResponse, prefer.example);
|
|
66
|
+
const body = selectedExample
|
|
67
|
+
? selectedExample.value
|
|
63
68
|
: acceptedResponse?.schema
|
|
64
69
|
? getExampleFromSchema(getResolvedRefDeep(acceptedResponse.schema), {
|
|
65
70
|
emptyString: 'string',
|
|
@@ -68,12 +73,19 @@ export function mockAnyResponse(c, operation, options) {
|
|
|
68
73
|
})
|
|
69
74
|
: null;
|
|
70
75
|
c.status(statusCode);
|
|
71
|
-
return c.body(
|
|
76
|
+
return c.body(
|
|
77
|
+
// `null` is `typeof 'object'` too, but it is not a valid XML/JSON object
|
|
78
|
+
// root — serialize it (and any non-string primitive) with `JSON.stringify`
|
|
79
|
+
// so a `null` example does not get fed into `json2xml`.
|
|
80
|
+
body !== null && typeof body === 'object'
|
|
72
81
|
? // XML
|
|
73
82
|
acceptedContentType?.includes('xml')
|
|
74
83
|
? json2xml(body)
|
|
75
84
|
: // JSON
|
|
76
85
|
JSON.stringify(body, null, 2)
|
|
77
|
-
:
|
|
78
|
-
|
|
86
|
+
: typeof body === 'string'
|
|
87
|
+
? // String
|
|
88
|
+
body
|
|
89
|
+
: // null / number / boolean
|
|
90
|
+
JSON.stringify(body));
|
|
79
91
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
2
2
|
import type { Context } from 'hono';
|
|
3
3
|
import type { StatusCode } from 'hono/utils/http-status';
|
|
4
|
-
import type { MockServerOptions } from '../types.js';
|
|
5
4
|
/**
|
|
6
5
|
* Mock response using x-handler code.
|
|
7
6
|
* Executes the handler and returns its result as the response.
|
|
8
7
|
*/
|
|
9
|
-
export declare function mockHandlerResponse(c: Context, operation: OpenAPIV3_1.OperationObject
|
|
8
|
+
export declare function mockHandlerResponse(c: Context, operation: OpenAPIV3_1.OperationObject): Promise<(Response & import("hono").TypedResponse<null, StatusCode, "body">) | (Response & import("hono").TypedResponse<any, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
10
9
|
//# sourceMappingURL=mock-handler-response.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock-handler-response.d.ts","sourceRoot":"","sources":["../../src/routes/mock-handler-response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"mock-handler-response.d.ts","sourceRoot":"","sources":["../../src/routes/mock-handler-response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AA8HxD;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,eAAe,gMA8D3F"}
|
|
@@ -4,11 +4,17 @@ import { getExampleFromSchema } from '@scalar/workspace-store/request-example';
|
|
|
4
4
|
import { accepts } from 'hono/accepts';
|
|
5
5
|
import { buildHandlerContext } from '../utils/build-handler-context.js';
|
|
6
6
|
import { executeHandler } from '../utils/execute-handler.js';
|
|
7
|
+
import { parsePreferHeader } from '../utils/parse-prefer-header.js';
|
|
8
|
+
import { selectResponseExample } from '../utils/select-response-example.js';
|
|
7
9
|
/**
|
|
8
10
|
* Get example response from OpenAPI spec for a given status code.
|
|
9
11
|
* Returns the example value if found, or null if not available.
|
|
12
|
+
*
|
|
13
|
+
* Honors `Prefer: example=<name>` to pick a named example from the
|
|
14
|
+
* `examples` map; otherwise it falls back to the singular `example`, the
|
|
15
|
+
* first entry of the map, or a value generated from the schema.
|
|
10
16
|
*/
|
|
11
|
-
function getExampleFromResponse(c, statusCode, responses) {
|
|
17
|
+
function getExampleFromResponse(c, statusCode, responses, exampleName) {
|
|
12
18
|
if (!responses) {
|
|
13
19
|
return null;
|
|
14
20
|
}
|
|
@@ -34,9 +40,10 @@ function getExampleFromResponse(c, statusCode, responses) {
|
|
|
34
40
|
if (!acceptedResponse) {
|
|
35
41
|
return null;
|
|
36
42
|
}
|
|
37
|
-
// Extract example
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
// Extract example (named, singular, or first) or generate from schema
|
|
44
|
+
const selectedExample = selectResponseExample(acceptedResponse, exampleName);
|
|
45
|
+
return selectedExample
|
|
46
|
+
? selectedExample.value
|
|
40
47
|
: acceptedResponse.schema
|
|
41
48
|
? getExampleFromSchema(getResolvedRefDeep(acceptedResponse.schema), {
|
|
42
49
|
emptyString: 'string',
|
|
@@ -98,14 +105,9 @@ function determineStatusCode(tracking) {
|
|
|
98
105
|
* Mock response using x-handler code.
|
|
99
106
|
* Executes the handler and returns its result as the response.
|
|
100
107
|
*/
|
|
101
|
-
export async function mockHandlerResponse(c, operation
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
options.onRequest({
|
|
105
|
-
context: c,
|
|
106
|
-
operation,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
108
|
+
export async function mockHandlerResponse(c, operation) {
|
|
109
|
+
// Note: the `onRequest` callback runs as middleware (see `create-mock-server`) so it also fires
|
|
110
|
+
// for requests rejected before reaching this handler.
|
|
109
111
|
// Get x-handler code from operation
|
|
110
112
|
const handlerCode = operation?.['x-handler'];
|
|
111
113
|
if (!handlerCode) {
|
|
@@ -131,7 +133,8 @@ export async function mockHandlerResponse(c, operation, options) {
|
|
|
131
133
|
// Handle undefined/null results gracefully
|
|
132
134
|
if (result === undefined || result === null) {
|
|
133
135
|
// Try to pick up example response from OpenAPI spec if available
|
|
134
|
-
const
|
|
136
|
+
const prefer = parsePreferHeader(c.req.header('Prefer'));
|
|
137
|
+
const exampleResponse = getExampleFromResponse(c, statusCode, operation.responses, prefer.example);
|
|
135
138
|
if (exampleResponse !== null) {
|
|
136
139
|
return c.json(exampleResponse);
|
|
137
140
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -30,6 +30,19 @@ type BaseMockServerOptions = {
|
|
|
30
30
|
context: Context;
|
|
31
31
|
operation: OpenAPIV3_1.OperationObject;
|
|
32
32
|
}) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Validate the incoming request against the matched operation's schema. When the request
|
|
35
|
+
* violates the contract, the server responds with `422` and a `application/problem+json`
|
|
36
|
+
* body describing the violations, instead of returning a mock response.
|
|
37
|
+
*
|
|
38
|
+
* Validates path/query parameters and the `application/json` request body.
|
|
39
|
+
*
|
|
40
|
+
* @default true
|
|
41
|
+
*
|
|
42
|
+
* Enabled by default for Prism-style contract enforcement. Set to `false` to opt out and
|
|
43
|
+
* always return a mock response regardless of whether the request matches the contract.
|
|
44
|
+
*/
|
|
45
|
+
validateRequest?: boolean;
|
|
33
46
|
};
|
|
34
47
|
export type MockServerOptions = RequireAtLeastOne<BaseMockServerOptions, 'specification' | 'document'>;
|
|
35
48
|
export {};
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,6CAA6C;AAC7C,eAAO,MAAM,WAAW,+DAAgE,CAAA;AAExF,wBAAwB;AACxB,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAA;AAErD;;GAEG;AACH,KAAK,iBAAiB,CAAC,CAAC,EAAE,IAAI,SAAS,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,GACzF;KACG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;CACzE,CAAC,IAAI,CAAC,CAAA;AAET,KAAK,qBAAqB,GAAG;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE5C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAEvC;;OAEG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,WAAW,CAAC,eAAe,CAAA;KAAE,KAAK,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAEnC,6CAA6C;AAC7C,eAAO,MAAM,WAAW,+DAAgE,CAAA;AAExF,wBAAwB;AACxB,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAA;AAErD;;GAEG;AACH,KAAK,iBAAiB,CAAC,CAAC,EAAE,IAAI,SAAS,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,GACzF;KACG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;CACzE,CAAC,IAAI,CAAC,CAAA;AAET,KAAK,qBAAqB,GAAG;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE5C;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAEvC;;OAEG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,WAAW,CAAC,eAAe,CAAA;KAAE,KAAK,IAAI,CAAA;IAExF;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,CAAC,qBAAqB,EAAE,eAAe,GAAG,UAAU,CAAC,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-handler-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-handler-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAInC,OAAO,EAAE,KAAK,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEjF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,KAAK,EAAE,OAAO,KAAK,CAAA;IACnB,GAAG,EAAE;QACH,IAAI,EAAE,GAAG,CAAA;QACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAChC,CAAA;IACD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACzB,CAAA;AAED;;GAEG;AACH,KAAK,oBAAoB,GAAG;IAC1B,OAAO,EAAE,cAAc,CAAA;IACvB,QAAQ,EAAE,sBAAsB,CAAA;CACjC,CAAA;AAuDD;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,CAAC,EAAE,WAAW,CAAC,eAAe,GACtC,OAAO,CAAC,oBAAoB,CAAC,
|
|
1
|
+
{"version":3,"file":"build-handler-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-handler-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAA;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAInC,OAAO,EAAE,KAAK,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAEjF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,KAAK,EAAE,OAAO,KAAK,CAAA;IACnB,GAAG,EAAE;QACH,IAAI,EAAE,GAAG,CAAA;QACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAChC,CAAA;IACD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACzB,CAAA;AAED;;GAEG;AACH,KAAK,oBAAoB,GAAG;IAC1B,OAAO,EAAE,cAAc,CAAA;IACvB,QAAQ,EAAE,sBAAsB,CAAA;CACjC,CAAA;AAuDD;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,CAAC,EAAE,WAAW,CAAC,eAAe,GACtC,OAAO,CAAC,oBAAoB,CAAC,CAiD/B"}
|
|
@@ -51,8 +51,13 @@ function getExampleFromResponse(c, statusCode, responses) {
|
|
|
51
51
|
export async function buildHandlerContext(c, operation) {
|
|
52
52
|
let body = undefined;
|
|
53
53
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// Compare case-insensitively, since media types are case-insensitive and request validation
|
|
55
|
+
// matches them the same way. Otherwise a body like `Application/JSON` could pass validation yet
|
|
56
|
+
// never reach the handler as `req.body`.
|
|
57
|
+
const contentType = (c.req.header('content-type') ?? '').toLowerCase();
|
|
58
|
+
// An empty/absent Content-Type is treated as JSON to match request validation, so a headerless
|
|
59
|
+
// JSON body that passes validation is still delivered to the handler as `req.body`.
|
|
60
|
+
if (contentType === '' || contentType.includes('application/json')) {
|
|
56
61
|
body = await c.req.json().catch(() => undefined);
|
|
57
62
|
}
|
|
58
63
|
else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
@@ -2,6 +2,11 @@ import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
|
2
2
|
import type { Context } from 'hono';
|
|
3
3
|
/**
|
|
4
4
|
* Handles authentication for incoming requests based on the OpenAPI document.
|
|
5
|
+
*
|
|
6
|
+
* The `security` array is evaluated as OR-of-ANDs: the request is authenticated if
|
|
7
|
+
* *any* requirement object is fully satisfied, and a requirement object is satisfied
|
|
8
|
+
* only when *every* scheme it lists is satisfied. An empty requirement object (`{}`)
|
|
9
|
+
* means authentication is optional and always passes.
|
|
5
10
|
*/
|
|
6
11
|
export declare function handleAuthentication(schema?: OpenAPIV3_1.Document, operation?: OpenAPIV3_1.OperationObject): (c: Context, next: () => Promise<void>) => Promise<Response | void>;
|
|
7
12
|
//# sourceMappingURL=handle-authentication.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handle-authentication.d.ts","sourceRoot":"","sources":["../../src/utils/handle-authentication.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"handle-authentication.d.ts","sourceRoot":"","sources":["../../src/utils/handle-authentication.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAa,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAEnE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAiInC;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,WAAW,CAAC,eAAe,IAC3F,GAAG,OAAO,EAAE,MAAM,MAAM,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CA4D/E"}
|
|
@@ -1,112 +1,164 @@
|
|
|
1
1
|
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
2
2
|
import { getCookie } from 'hono/cookie';
|
|
3
|
+
/** Realm advertised in `WWW-Authenticate` challenges. */
|
|
4
|
+
const REALM = 'Scalar Mock Server';
|
|
5
|
+
/**
|
|
6
|
+
* Validate the shape of a `Basic` credential.
|
|
7
|
+
*
|
|
8
|
+
* Like Prism, we do not verify the credential against a user store (a mock server
|
|
9
|
+
* has no way to know valid credentials), but we do check that the header is a
|
|
10
|
+
* well-formed `Basic <base64(user:password)>` value.
|
|
11
|
+
*/
|
|
12
|
+
function isValidBasicAuth(authHeader) {
|
|
13
|
+
if (!authHeader?.startsWith('Basic ')) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const encoded = authHeader.slice('Basic '.length).trim();
|
|
17
|
+
if (!encoded) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
// A valid Basic credential decodes to `user:password`, so it must contain a colon.
|
|
22
|
+
return atob(encoded).includes(':');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// `atob` throws on invalid base64.
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate the shape of a `Bearer` credential.
|
|
31
|
+
*
|
|
32
|
+
* We only check that a non-empty token is present after the scheme. Verifying the
|
|
33
|
+
* token itself (signature, expiry, scopes) is out of scope for a mock server.
|
|
34
|
+
*/
|
|
35
|
+
function isValidBearerAuth(authHeader) {
|
|
36
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return authHeader.slice('Bearer '.length).trim().length > 0;
|
|
40
|
+
}
|
|
41
|
+
/** Check whether a single security scheme is satisfied by the request. */
|
|
42
|
+
function isSchemeSatisfied(scheme, c) {
|
|
43
|
+
switch (scheme.type) {
|
|
44
|
+
case 'http': {
|
|
45
|
+
const authHeader = c.req.header('Authorization');
|
|
46
|
+
if ('scheme' in scheme && scheme.scheme?.toLowerCase() === 'basic') {
|
|
47
|
+
return isValidBasicAuth(authHeader);
|
|
48
|
+
}
|
|
49
|
+
if ('scheme' in scheme && scheme.scheme?.toLowerCase() === 'bearer') {
|
|
50
|
+
return isValidBearerAuth(authHeader);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
case 'apiKey': {
|
|
55
|
+
if (!('name' in scheme) || !scheme.name || !('in' in scheme)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const value = scheme.in === 'header'
|
|
59
|
+
? c.req.header(scheme.name)
|
|
60
|
+
: scheme.in === 'query'
|
|
61
|
+
? c.req.query(scheme.name)
|
|
62
|
+
: scheme.in === 'cookie'
|
|
63
|
+
? getCookie(c, scheme.name)
|
|
64
|
+
: undefined;
|
|
65
|
+
return Boolean(value);
|
|
66
|
+
}
|
|
67
|
+
// OAuth 2.0 and OpenID Connect both carry a bearer token in the `Authorization` header.
|
|
68
|
+
case 'oauth2':
|
|
69
|
+
case 'openIdConnect':
|
|
70
|
+
return isValidBearerAuth(c.req.header('Authorization'));
|
|
71
|
+
default:
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the `WWW-Authenticate` challenge for a single security scheme.
|
|
77
|
+
*
|
|
78
|
+
* Returns `null` for schemes that do not map to an HTTP authentication challenge.
|
|
79
|
+
*/
|
|
80
|
+
function getChallenge(scheme) {
|
|
81
|
+
switch (scheme.type) {
|
|
82
|
+
case 'http':
|
|
83
|
+
if ('scheme' in scheme && scheme.scheme?.toLowerCase() === 'basic') {
|
|
84
|
+
return `Basic realm="${REALM}", charset="UTF-8"`;
|
|
85
|
+
}
|
|
86
|
+
if ('scheme' in scheme && scheme.scheme?.toLowerCase() === 'bearer') {
|
|
87
|
+
return `Bearer realm="${REALM}", error="invalid_token", error_description="The access token is invalid or has expired"`;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
case 'apiKey':
|
|
91
|
+
if ('name' in scheme && scheme.name) {
|
|
92
|
+
return `ApiKey realm="${REALM}", error="invalid_token", error_description="Invalid or missing API key", name="${scheme.name}"`;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
case 'oauth2':
|
|
96
|
+
case 'openIdConnect':
|
|
97
|
+
return `Bearer realm="${REALM}", error="invalid_token", error_description="The access token is invalid or has expired"`;
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Resolve all schemes referenced by a single security requirement object. */
|
|
103
|
+
function resolveSchemes(requirement, schema) {
|
|
104
|
+
return Object.keys(requirement)
|
|
105
|
+
.map((name) => getResolvedRef(schema?.components?.securitySchemes?.[name]))
|
|
106
|
+
.filter((scheme) => Boolean(scheme) && 'type' in (scheme ?? {}));
|
|
107
|
+
}
|
|
3
108
|
/**
|
|
4
109
|
* Handles authentication for incoming requests based on the OpenAPI document.
|
|
110
|
+
*
|
|
111
|
+
* The `security` array is evaluated as OR-of-ANDs: the request is authenticated if
|
|
112
|
+
* *any* requirement object is fully satisfied, and a requirement object is satisfied
|
|
113
|
+
* only when *every* scheme it lists is satisfied. An empty requirement object (`{}`)
|
|
114
|
+
* means authentication is optional and always passes.
|
|
5
115
|
*/
|
|
6
116
|
export function handleAuthentication(schema, operation) {
|
|
7
117
|
return async (c, next) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if ('scheme' in securityScheme && securityScheme.scheme === 'basic') {
|
|
21
|
-
authScheme = 'Basic';
|
|
22
|
-
const authHeader = c.req.header('Authorization');
|
|
23
|
-
if (authHeader?.startsWith('Basic ')) {
|
|
24
|
-
isAuthenticated = true;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
else if ('scheme' in securityScheme && securityScheme.scheme === 'bearer') {
|
|
28
|
-
authScheme = 'Bearer';
|
|
29
|
-
const authHeader = c.req.header('Authorization');
|
|
30
|
-
if (authHeader?.startsWith('Bearer ')) {
|
|
31
|
-
isAuthenticated = true;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
break;
|
|
35
|
-
case 'apiKey':
|
|
36
|
-
if ('name' in securityScheme && 'in' in securityScheme && securityScheme.name) {
|
|
37
|
-
authScheme = `ApiKey ${securityScheme.name}`;
|
|
38
|
-
if (securityScheme.in === 'header') {
|
|
39
|
-
const apiKey = c.req.header(securityScheme.name);
|
|
40
|
-
if (apiKey) {
|
|
41
|
-
isAuthenticated = true;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
else if (securityScheme.in === 'query') {
|
|
45
|
-
const apiKey = c.req.query(securityScheme.name);
|
|
46
|
-
if (apiKey) {
|
|
47
|
-
isAuthenticated = true;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
else if (securityScheme.in === 'cookie') {
|
|
51
|
-
const apiKey = getCookie(c, securityScheme.name);
|
|
52
|
-
if (apiKey) {
|
|
53
|
-
isAuthenticated = true;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
break;
|
|
58
|
-
case 'oauth2':
|
|
59
|
-
authScheme = 'Bearer';
|
|
60
|
-
// Handle OAuth 2.0 flows, including password grant
|
|
61
|
-
if (c.req.header('Authorization')?.startsWith('Bearer ')) {
|
|
62
|
-
isAuthenticated = true;
|
|
63
|
-
}
|
|
64
|
-
break;
|
|
65
|
-
case 'openIdConnect':
|
|
66
|
-
authScheme = 'Bearer';
|
|
67
|
-
// Handle OpenID Connect similar to OAuth2
|
|
68
|
-
if (c.req.header('Authorization')?.startsWith('Bearer ')) {
|
|
69
|
-
isAuthenticated = true;
|
|
70
|
-
}
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (!isAuthenticated) {
|
|
75
|
-
securitySchemeAuthenticated = false;
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
if (securitySchemeAuthenticated) {
|
|
80
|
-
isAuthenticated = true;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
118
|
+
// Operation-level security overrides the global security requirement.
|
|
119
|
+
const security = operation?.security ?? schema?.security;
|
|
120
|
+
// Nothing to enforce.
|
|
121
|
+
if (!security || security.length === 0) {
|
|
122
|
+
await next();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const isAuthenticated = security.some((requirement) => {
|
|
126
|
+
const schemeNames = Object.keys(requirement);
|
|
127
|
+
// An empty requirement object makes authentication optional.
|
|
128
|
+
if (schemeNames.length === 0) {
|
|
129
|
+
return true;
|
|
83
130
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
131
|
+
// Every scheme listed in the requirement must resolve and be satisfied (AND).
|
|
132
|
+
// A name that does not resolve to a valid scheme makes the whole requirement
|
|
133
|
+
// unsatisfiable, so a missing scheme cannot be silently skipped.
|
|
134
|
+
return schemeNames.every((name) => {
|
|
135
|
+
const scheme = getResolvedRef(schema?.components?.securitySchemes?.[name]);
|
|
136
|
+
if (!scheme || !('type' in scheme)) {
|
|
137
|
+
return false;
|
|
88
138
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
139
|
+
return isSchemeSatisfied(scheme, c);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
if (isAuthenticated) {
|
|
143
|
+
await next();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Advertise a challenge for every scheme that could satisfy the request.
|
|
147
|
+
const challenges = new Set();
|
|
148
|
+
for (const requirement of security) {
|
|
149
|
+
for (const scheme of resolveSchemes(requirement, schema)) {
|
|
150
|
+
const challenge = getChallenge(scheme);
|
|
151
|
+
if (challenge) {
|
|
152
|
+
challenges.add(challenge);
|
|
101
153
|
}
|
|
102
|
-
c.header('WWW-Authenticate', wwwAuthenticateValue);
|
|
103
|
-
return c.json({
|
|
104
|
-
error: 'Unauthorized',
|
|
105
|
-
message: 'Authentication is required to access this resource.',
|
|
106
|
-
}, 401);
|
|
107
154
|
}
|
|
108
155
|
}
|
|
109
|
-
|
|
110
|
-
|
|
156
|
+
for (const challenge of challenges) {
|
|
157
|
+
c.header('WWW-Authenticate', challenge, { append: true });
|
|
158
|
+
}
|
|
159
|
+
return c.json({
|
|
160
|
+
error: 'Unauthorized',
|
|
161
|
+
message: 'Authentication is required to access this resource.',
|
|
162
|
+
}, 401);
|
|
111
163
|
};
|
|
112
164
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directives parsed from an HTTP `Prefer` header (RFC 7240).
|
|
3
|
+
*
|
|
4
|
+
* The mock server understands `code` (pick a response status) and `example`
|
|
5
|
+
* (pick a named example). The index signature keeps the shape open so future
|
|
6
|
+
* directives can be read without changing the parser.
|
|
7
|
+
*/
|
|
8
|
+
type PreferDirectives = {
|
|
9
|
+
/** Requested response status code, e.g. `code=404`. */
|
|
10
|
+
code?: string;
|
|
11
|
+
/** Requested named example from the `examples` map, e.g. `example=notFound`. */
|
|
12
|
+
example?: string;
|
|
13
|
+
[directive: string]: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Parse an HTTP `Prefer` header into its directives.
|
|
17
|
+
*
|
|
18
|
+
* The header is a comma- and semicolon-separated list of `key=value` tokens
|
|
19
|
+
* (RFC 7240). We treat every token uniformly, lower-case the keys, and strip
|
|
20
|
+
* optional surrounding quotes from the values. A missing header or a token
|
|
21
|
+
* without a value is simply ignored so callers always fall through to their
|
|
22
|
+
* default behavior instead of erroring.
|
|
23
|
+
*/
|
|
24
|
+
export declare const parsePreferHeader: (header: string | null | undefined) => PreferDirectives;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=parse-prefer-header.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-prefer-header.d.ts","sourceRoot":"","sources":["../../src/utils/parse-prefer-header.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,KAAK,gBAAgB,GAAG;IACtB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CACxC,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAAI,QAAQ,MAAM,GAAG,IAAI,GAAG,SAAS,KAAG,gBAqBrE,CAAA"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an HTTP `Prefer` header into its directives.
|
|
3
|
+
*
|
|
4
|
+
* The header is a comma- and semicolon-separated list of `key=value` tokens
|
|
5
|
+
* (RFC 7240). We treat every token uniformly, lower-case the keys, and strip
|
|
6
|
+
* optional surrounding quotes from the values. A missing header or a token
|
|
7
|
+
* without a value is simply ignored so callers always fall through to their
|
|
8
|
+
* default behavior instead of erroring.
|
|
9
|
+
*/
|
|
10
|
+
export const parsePreferHeader = (header) => {
|
|
11
|
+
if (!header) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
const directives = {};
|
|
15
|
+
for (const token of header.split(/[,;]/)) {
|
|
16
|
+
const [rawKey, ...rest] = token.split('=');
|
|
17
|
+
const key = rawKey?.trim().toLowerCase();
|
|
18
|
+
// Skip tokens that have no key or no value (e.g. `respond-async`).
|
|
19
|
+
if (!key || rest.length === 0) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Re-join in case the value itself contained an `=` and drop quotes.
|
|
23
|
+
directives[key] = rest.join('=').trim().replace(/^"|"$/g, '');
|
|
24
|
+
}
|
|
25
|
+
return directives;
|
|
26
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
2
|
+
/**
|
|
3
|
+
* Pick the example body for a response media type.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (per the OpenAPI Media Type Object plus the `Prefer: example=`
|
|
6
|
+
* directive):
|
|
7
|
+
* 1. A named example explicitly requested via `exampleName`.
|
|
8
|
+
* 2. The singular `example` keyword.
|
|
9
|
+
* 3. The first entry of the `examples` map.
|
|
10
|
+
*
|
|
11
|
+
* Returns a `{ value }` wrapper when an example is available — so a `null` or
|
|
12
|
+
* otherwise falsy value is still treated as a real example — or `undefined`
|
|
13
|
+
* to signal that the caller should fall back to generating a body from the
|
|
14
|
+
* schema. An example whose resolved `value` is `undefined` (for example, an
|
|
15
|
+
* Example Object that only carries an `externalValue`) is skipped, so the
|
|
16
|
+
* caller still gets a schema-generated body. An unknown `exampleName` simply
|
|
17
|
+
* falls through to the later steps.
|
|
18
|
+
*/
|
|
19
|
+
export declare const selectResponseExample: (mediaType: OpenAPIV3_1.MediaTypeObject | undefined, exampleName?: string) => {
|
|
20
|
+
value: unknown;
|
|
21
|
+
} | undefined;
|
|
22
|
+
//# sourceMappingURL=select-response-example.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"select-response-example.d.ts","sourceRoot":"","sources":["../../src/utils/select-response-example.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,qBAAqB,GAChC,WAAW,WAAW,CAAC,eAAe,GAAG,SAAS,EAClD,cAAc,MAAM,KACnB;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,SAoCvB,CAAA"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
2
|
+
/**
|
|
3
|
+
* Pick the example body for a response media type.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (per the OpenAPI Media Type Object plus the `Prefer: example=`
|
|
6
|
+
* directive):
|
|
7
|
+
* 1. A named example explicitly requested via `exampleName`.
|
|
8
|
+
* 2. The singular `example` keyword.
|
|
9
|
+
* 3. The first entry of the `examples` map.
|
|
10
|
+
*
|
|
11
|
+
* Returns a `{ value }` wrapper when an example is available — so a `null` or
|
|
12
|
+
* otherwise falsy value is still treated as a real example — or `undefined`
|
|
13
|
+
* to signal that the caller should fall back to generating a body from the
|
|
14
|
+
* schema. An example whose resolved `value` is `undefined` (for example, an
|
|
15
|
+
* Example Object that only carries an `externalValue`) is skipped, so the
|
|
16
|
+
* caller still gets a schema-generated body. An unknown `exampleName` simply
|
|
17
|
+
* falls through to the later steps.
|
|
18
|
+
*/
|
|
19
|
+
export const selectResponseExample = (mediaType, exampleName) => {
|
|
20
|
+
if (!mediaType) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const { example, examples } = mediaType;
|
|
24
|
+
// 1. A named example requested via `Prefer: example=<name>`
|
|
25
|
+
if (exampleName && examples && exampleName in examples) {
|
|
26
|
+
const value = getResolvedRef(examples[exampleName])?.value;
|
|
27
|
+
if (value !== undefined) {
|
|
28
|
+
return { value };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// 2. The singular `example` keyword
|
|
32
|
+
if (example !== undefined) {
|
|
33
|
+
return { value: example };
|
|
34
|
+
}
|
|
35
|
+
// 3. The first entry of the `examples` map
|
|
36
|
+
if (examples) {
|
|
37
|
+
const firstKey = Object.keys(examples)[0];
|
|
38
|
+
if (firstKey !== undefined) {
|
|
39
|
+
const value = getResolvedRef(examples[firstKey])?.value;
|
|
40
|
+
if (value !== undefined) {
|
|
41
|
+
return { value };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 4. Nothing defined: let the caller generate a body from the schema
|
|
46
|
+
return undefined;
|
|
47
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OpenAPIV3_1 } from '@scalar/openapi-types';
|
|
2
|
+
import type { MiddlewareHandler } from 'hono';
|
|
3
|
+
/**
|
|
4
|
+
* Create a Hono middleware bound to a single resolved operation that enforces its request contract.
|
|
5
|
+
*
|
|
6
|
+
* Validators are compiled once here (at route-setup time) and reused on every request. On any
|
|
7
|
+
* violation the middleware short-circuits with a `422` and a `application/problem+json` body;
|
|
8
|
+
* otherwise it calls `next()` and the normal mock handler runs.
|
|
9
|
+
*
|
|
10
|
+
* TODO: Parity follow-ups, intentionally deferred in this slice — response validation,
|
|
11
|
+
* header/cookie parameter validation, non-JSON body validation, and validation proxy mode.
|
|
12
|
+
*/
|
|
13
|
+
export declare const validateRequest: (operation: OpenAPIV3_1.OperationObject, pathItemParameters?: OpenAPIV3_1.PathItemObject["parameters"]) => MiddlewareHandler;
|
|
14
|
+
//# sourceMappingURL=validate-request.d.ts.map
|
|
@@ -0,0 +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;AAmLtD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,WAAW,CAAC,eAAe,EACtC,qBAAqB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KAC5D,iBAyDF,CAAA"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
|
|
2
|
+
import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
|
|
3
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
4
|
+
import addFormats from 'ajv-formats';
|
|
5
|
+
/**
|
|
6
|
+
* Build a JSON Schema object for the parameters declared `in` the given location.
|
|
7
|
+
*
|
|
8
|
+
* Parameters arrive as strings, so the caller compiles these with `coerceTypes: true` to let
|
|
9
|
+
* `type: integer`/`boolean` validate correctly. Returns `null` when there is nothing to validate.
|
|
10
|
+
*/
|
|
11
|
+
const buildParameterSchema = (parameters, location) => {
|
|
12
|
+
const properties = {};
|
|
13
|
+
const required = [];
|
|
14
|
+
for (const parameterOrRef of parameters ?? []) {
|
|
15
|
+
const parameter = getResolvedRef(parameterOrRef);
|
|
16
|
+
if (parameter?.in !== location) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (parameter.schema) {
|
|
20
|
+
properties[parameter.name] = getResolvedRefDeep(parameter.schema);
|
|
21
|
+
}
|
|
22
|
+
if (parameter.required) {
|
|
23
|
+
required.push(parameter.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (Object.keys(properties).length === 0 && required.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// Allow undeclared parameters to pass through; we only enforce what the operation declares.
|
|
30
|
+
return { type: 'object', properties, required, additionalProperties: true };
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Merge path-item-level parameters with operation-level parameters.
|
|
34
|
+
*
|
|
35
|
+
* OpenAPI allows declaring `parameters` on the path item, where they apply to every operation under
|
|
36
|
+
* it. Operation-level parameters override path-item ones that share the same name and location.
|
|
37
|
+
*/
|
|
38
|
+
const mergeParameters = (pathItemParameters, operationParameters) => {
|
|
39
|
+
const byKey = new Map();
|
|
40
|
+
// Guard against malformed documents where `parameters` is not an array.
|
|
41
|
+
const pathItemList = Array.isArray(pathItemParameters) ? pathItemParameters : [];
|
|
42
|
+
const operationList = Array.isArray(operationParameters) ? operationParameters : [];
|
|
43
|
+
// Path-item parameters come first so that operation-level entries overwrite them by identity.
|
|
44
|
+
for (const parameterOrRef of [...pathItemList, ...operationList]) {
|
|
45
|
+
const parameter = getResolvedRef(parameterOrRef);
|
|
46
|
+
if (!parameter?.name || !parameter.in) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
byKey.set(`${parameter.in}:${parameter.name}`, parameterOrRef);
|
|
50
|
+
}
|
|
51
|
+
return [...byKey.values()];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Compile a single schema, failing open if it cannot compile.
|
|
55
|
+
*
|
|
56
|
+
* A malformed or uncompilable schema must not crash the server: we log and skip validation for that
|
|
57
|
+
* part only, consistent with how `x-seed` errors are swallowed during setup. Each schema is compiled
|
|
58
|
+
* in isolation so one broken schema never disables the others.
|
|
59
|
+
*/
|
|
60
|
+
const compileSchema = (ajv, schema, label) => {
|
|
61
|
+
if (!schema) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return ajv.compile(schema);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(`Error compiling ${label} validator, skipping validation for it:`, error);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Compile the validators for a single operation once, so they can be reused on every request.
|
|
74
|
+
*/
|
|
75
|
+
const compileValidators = (operation, pathItemParameters) => {
|
|
76
|
+
// Coerce string parameters to their declared types (integer, boolean, …).
|
|
77
|
+
const parameterAjv = new Ajv2020({ strict: false, allErrors: true, coerceTypes: true });
|
|
78
|
+
addFormats(parameterAjv);
|
|
79
|
+
// Do not coerce the JSON body; a JSON value already carries its type and coercion would mask errors.
|
|
80
|
+
const bodyAjv = new Ajv2020({ strict: false, allErrors: true });
|
|
81
|
+
addFormats(bodyAjv);
|
|
82
|
+
// Path-item parameters apply to every operation, so fold them in before building the schemas.
|
|
83
|
+
const parameters = mergeParameters(pathItemParameters, operation.parameters);
|
|
84
|
+
const requestBody = getResolvedRef(operation.requestBody);
|
|
85
|
+
// Build the body schema defensively; resolving a malformed `$ref` should not crash setup.
|
|
86
|
+
let bodySchema = null;
|
|
87
|
+
try {
|
|
88
|
+
const jsonSchema = getResolvedRef(requestBody?.content?.['application/json'])?.schema;
|
|
89
|
+
bodySchema = jsonSchema ? getResolvedRefDeep(jsonSchema) : null;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.error('Error resolving request body schema, skipping body validation:', error);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
path: compileSchema(parameterAjv, buildParameterSchema(parameters, 'path'), 'path parameter'),
|
|
96
|
+
query: compileSchema(parameterAjv, buildParameterSchema(parameters, 'query'), 'query parameter'),
|
|
97
|
+
body: compileSchema(bodyAjv, bodySchema, 'request body'),
|
|
98
|
+
// Required-body enforcement is independent of whether the body schema compiles.
|
|
99
|
+
bodyRequired: requestBody?.required === true,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Map Ajv errors into our violation shape. For path/query parameters the offending parameter name
|
|
104
|
+
* is prepended to the message so the response is readable without cross-referencing the pointer.
|
|
105
|
+
*/
|
|
106
|
+
const mapErrors = (errors, location) => (errors ?? []).map((error) => {
|
|
107
|
+
const missingProperty = typeof error.params === 'object' && error.params && 'missingProperty' in error.params
|
|
108
|
+
? String(error.params.missingProperty)
|
|
109
|
+
: '';
|
|
110
|
+
const path = error.instancePath || (missingProperty ? `/${missingProperty}` : '');
|
|
111
|
+
const name = error.instancePath.replace(/^\//, '') || missingProperty;
|
|
112
|
+
const message = location !== 'body' && name ? `${name} ${error.message ?? ''}`.trim() : (error.message ?? '');
|
|
113
|
+
return { location, path, message };
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Detect whether the request body should be treated as JSON. We only validate JSON bodies in this
|
|
117
|
+
* slice; an empty/absent Content-Type is treated as JSON to stay forgiving. This intentionally
|
|
118
|
+
* mirrors how `build-handler-context` parses the body, so we never validate a body the handler then
|
|
119
|
+
* fails to deliver as `req.body`.
|
|
120
|
+
*/
|
|
121
|
+
const isJsonRequest = (c) => {
|
|
122
|
+
const contentType = c.req.header('Content-Type')?.toLowerCase() ?? '';
|
|
123
|
+
return contentType === '' || contentType.includes('application/json');
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Create a Hono middleware bound to a single resolved operation that enforces its request contract.
|
|
127
|
+
*
|
|
128
|
+
* Validators are compiled once here (at route-setup time) and reused on every request. On any
|
|
129
|
+
* violation the middleware short-circuits with a `422` and a `application/problem+json` body;
|
|
130
|
+
* otherwise it calls `next()` and the normal mock handler runs.
|
|
131
|
+
*
|
|
132
|
+
* TODO: Parity follow-ups, intentionally deferred in this slice — response validation,
|
|
133
|
+
* header/cookie parameter validation, non-JSON body validation, and validation proxy mode.
|
|
134
|
+
*/
|
|
135
|
+
export const validateRequest = (operation, pathItemParameters) => {
|
|
136
|
+
const validators = compileValidators(operation, pathItemParameters);
|
|
137
|
+
return async (c, next) => {
|
|
138
|
+
const violations = [];
|
|
139
|
+
// Path parameters (coerced from strings)
|
|
140
|
+
if (validators.path) {
|
|
141
|
+
const data = { ...c.req.param() };
|
|
142
|
+
if (!validators.path(data)) {
|
|
143
|
+
violations.push(...mapErrors(validators.path.errors, 'path'));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Query parameters (coerced from strings)
|
|
147
|
+
if (validators.query) {
|
|
148
|
+
const data = { ...c.req.query() };
|
|
149
|
+
if (!validators.query(data)) {
|
|
150
|
+
violations.push(...mapErrors(validators.query.errors, 'query'));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Request body — only `application/json` in this slice
|
|
154
|
+
if (validators.body || validators.bodyRequired) {
|
|
155
|
+
// Read from a clone so the original request stream stays intact for the mock or `x-handler`
|
|
156
|
+
// downstream. Consuming `c.req.text()` directly would leave `c.req.parseBody()` (used for
|
|
157
|
+
// form bodies) unable to reconstruct the body, so handlers would see it as undefined.
|
|
158
|
+
const raw = await c.req.raw.clone().text();
|
|
159
|
+
if (!raw) {
|
|
160
|
+
if (validators.bodyRequired) {
|
|
161
|
+
violations.push({ location: 'body', path: '', message: 'Request body is required' });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (validators.body && isJsonRequest(c)) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(raw);
|
|
167
|
+
if (!validators.body(parsed)) {
|
|
168
|
+
violations.push(...mapErrors(validators.body.errors, 'body'));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// The client sent a non-empty JSON body that cannot be parsed, so it cannot satisfy the
|
|
173
|
+
// schema regardless of whether the body is required.
|
|
174
|
+
violations.push({ location: 'body', path: '', message: 'Request body must be valid JSON' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (violations.length > 0) {
|
|
179
|
+
// Use `c.body` (not `c.json`) so the `application/problem+json` content type is preserved;
|
|
180
|
+
// `c.json` would force `application/json`.
|
|
181
|
+
return c.body(JSON.stringify({ error: 'Request validation failed', violations }), 422, {
|
|
182
|
+
'Content-Type': 'application/problem+json',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
await next();
|
|
186
|
+
};
|
|
187
|
+
};
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"swagger",
|
|
17
17
|
"cli"
|
|
18
18
|
],
|
|
19
|
-
"version": "0.
|
|
19
|
+
"version": "0.11.0",
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=22"
|
|
22
22
|
},
|
|
@@ -52,13 +52,15 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@faker-js/faker": "10.4.0",
|
|
55
|
+
"ajv": "^8.17.1",
|
|
56
|
+
"ajv-formats": "^3.0.1",
|
|
55
57
|
"hono": "^4.12.7",
|
|
56
58
|
"yaml": "^2.8.3",
|
|
57
|
-
"@scalar/
|
|
59
|
+
"@scalar/helpers": "0.8.2",
|
|
58
60
|
"@scalar/json-magic": "0.12.16",
|
|
59
61
|
"@scalar/openapi-types": "0.9.1",
|
|
60
|
-
"@scalar/
|
|
61
|
-
"@scalar/workspace-store": "0.54.
|
|
62
|
+
"@scalar/openapi-upgrader": "0.2.9",
|
|
63
|
+
"@scalar/workspace-store": "0.54.3"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"@types/node": "^24.1.0",
|