@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 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;AAgB5D;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,aAAa,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuFtF"}
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(operation.security)) {
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, configuration));
93
+ app[method](route, (c) => mockHandlerResponse(c, operation));
76
94
  }
77
95
  else {
78
- app[method](route, (c) => mockAnyResponse(c, operation, configuration));
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, options: MockServerOptions): (Response & import("hono").TypedResponse<{
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<any, import("hono/utils/http-status").ContentfulStatusCode, "body">);
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;AAExD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAGhD;;GAEG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,eAAe,EAAE,OAAO,EAAE,iBAAiB;;yPAwF7G"}
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, options) {
11
- // Call onRequest callback
12
- if (options?.onRequest) {
13
- options.onRequest({
14
- context: c,
15
- operation,
16
- });
17
- }
18
- // Response
19
- // default, 200, 201
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 preferredResponse = preferredResponseKey ? getResolvedRef(operation.responses?.[preferredResponseKey]) : null;
22
- if (!preferredResponse) {
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(preferredResponseKey === 'default' ? '200' : (preferredResponseKey ?? '200'), 10);
30
+ const statusCode = Number.parseInt(responseKey === 'default' ? '200' : (responseKey ?? '200'), 10);
28
31
  // Headers
29
- const headers = preferredResponse?.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(preferredResponse?.content ?? {});
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 = preferredResponse?.content?.[acceptedContentType];
60
- // Body
61
- const body = acceptedResponse?.example
62
- ? acceptedResponse.example
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(typeof body === 'object'
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
- : // String
78
- body);
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, options: MockServerOptions): Promise<(Response & import("hono").TypedResponse<null, StatusCode, "body">) | (Response & import("hono").TypedResponse<any, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
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;AAExD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAoHhD;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,EAAE,WAAW,CAAC,eAAe,EACtC,OAAO,EAAE,iBAAiB,gMAkE3B"}
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 from example property or generate from schema
38
- return acceptedResponse.example !== undefined
39
- ? acceptedResponse.example
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, options) {
102
- // Call onRequest callback
103
- if (options?.onRequest) {
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 exampleResponse = getExampleFromResponse(c, statusCode, operation.responses);
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 {};
@@ -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;CACzF,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,CAAC,qBAAqB,EAAE,eAAe,GAAG,UAAU,CAAC,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,CA4C/B"}
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
- const contentType = c.req.header('content-type') ?? '';
55
- if (contentType.includes('application/json')) {
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,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAGnC;;GAEG;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,CAwH/E"}
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
- const operationSecuritySchemes = operation?.security || schema?.security;
9
- if (operationSecuritySchemes && operationSecuritySchemes.length > 0) {
10
- let isAuthenticated = false;
11
- let authScheme = '';
12
- for (const securityRequirement of operationSecuritySchemes) {
13
- let securitySchemeAuthenticated = true;
14
- for (const [schemeName] of Object.entries(securityRequirement)) {
15
- const scheme = getResolvedRef(schema?.components?.securitySchemes?.[schemeName]);
16
- if (scheme && 'type' in scheme) {
17
- const securityScheme = scheme;
18
- switch (securityScheme.type) {
19
- case 'http':
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
- if (!isAuthenticated) {
85
- let wwwAuthenticateValue = authScheme;
86
- if (authScheme.startsWith('ApiKey')) {
87
- wwwAuthenticateValue += ` realm="Scalar Mock Server", error="invalid_token", error_description="Invalid or missing API key"`;
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
- else {
90
- switch (authScheme) {
91
- case 'Basic':
92
- wwwAuthenticateValue += ' realm="Scalar Mock Server", charset="UTF-8"';
93
- break;
94
- case 'Bearer':
95
- wwwAuthenticateValue +=
96
- ' realm="Scalar Mock Server", error="invalid_token", error_description="The access token is invalid or has expired"';
97
- break;
98
- default:
99
- wwwAuthenticateValue = 'Bearer realm="Scalar Mock Server"';
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
- // If all checks pass, continue to the next middleware
110
- await next();
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.10.18",
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/openapi-upgrader": "0.2.9",
59
+ "@scalar/helpers": "0.8.2",
58
60
  "@scalar/json-magic": "0.12.16",
59
61
  "@scalar/openapi-types": "0.9.1",
60
- "@scalar/helpers": "0.8.2",
61
- "@scalar/workspace-store": "0.54.1"
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",