@scalar/mock-server 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @scalar/mock-server
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#10189](https://github.com/scalar/scalar/pull/10189): Support OpenAPI 3.2 `in: querystring` parameters in request validation and custom handlers. Decode JSON, text, and form content from the entire query string, including inherited parameters and form property encoding.
8
+
9
+ Treat only null or undefined as absent validator schemas, preserving boolean `false` whole-query schemas that reject every value. Empty object schemas were already compiled and continue to accept unconstrained values.
10
+
11
+ - [#10186](https://github.com/scalar/scalar/pull/10186): Support additionalOperations with case-sensitive custom HTTP methods, existing operation middleware, and CORS preflight responses.
12
+
13
+ Preserve QUERY in the default CORS method list when adding methods declared by the API description.
14
+
3
15
  ## 0.15.0
4
16
 
5
17
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"create-mock-server.d.ts","sourceRoot":"","sources":["../src/create-mock-server.ts"],"names":[],"mappings":"AAIA,OAAO,EAAgB,IAAI,EAA0B,MAAM,MAAM,CAAA;AAIjE,OAAO,KAAK,EAAc,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAwD5D;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,aAAa,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8NtF"}
1
+ {"version":3,"file":"create-mock-server.d.ts","sourceRoot":"","sources":["../src/create-mock-server.ts"],"names":[],"mappings":"AAGA,OAAO,EAAgB,IAAI,EAA0B,MAAM,MAAM,CAAA;AAIjE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAwDhD;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,aAAa,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwOtF"}
@@ -103,7 +103,13 @@ export async function createMockServer(configuration) {
103
103
  }
104
104
  }
105
105
  // CORS headers
106
- app.use(cors());
106
+ const allowedMethods = new Set(['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY']);
107
+ for (const pathItem of Object.values(schema?.paths ?? {})) {
108
+ for (const method of Object.keys(getOperations(getResolvedRef(pathItem)))) {
109
+ allowedMethods.add(method);
110
+ }
111
+ }
112
+ app.use(cors({ origin: '*', allowMethods: [...allowedMethods] }));
107
113
  /** Authentication methods defined in the OpenAPI document */
108
114
  setUpAuthenticationRoutes(app, schema);
109
115
  // Only the instructions honor `logger` (on by default); the util still prints warnings and errors
@@ -136,19 +142,17 @@ export async function createMockServer(configuration) {
136
142
  orderedPathKeys.forEach(({ path, query }) => {
137
143
  // A path item may itself be a `$ref`, so resolve it before reading its operations.
138
144
  const pathItem = getResolvedRef(paths[path]);
139
- const methods = Object.keys(getOperations(pathItem));
145
+ const operations = getOperations(pathItem);
140
146
  /** Keys for all operations of a specified path */
141
- methods.forEach((method) => {
147
+ Object.entries(operations).forEach(([method, operation]) => {
142
148
  const route = honoRouteFromPath(path);
143
- const operation = pathItem?.[method];
144
149
  // Remember which operation this route mocks, so the error handler can name it when something
145
150
  // fails downstream. Recorded on the context rather than mapped back from the request path,
146
151
  // which would not survive the app being mounted under a base path. Registered before the rest
147
152
  // of the route so a failure in request validation is named too. The OpenAPI path key is kept
148
153
  // (rather than the Hono route) because that is what the document author reads.
149
154
  const mockedOperation = {
150
- // `toUpperCase` widens to `string`, so restate the narrower type the method union guarantees.
151
- method: method.toUpperCase(),
155
+ method,
152
156
  path,
153
157
  ...(operation?.operationId ? { operationId: operation.operationId } : {}),
154
158
  };
@@ -188,21 +192,28 @@ export async function createMockServer(configuration) {
188
192
  const hasHandler = handlerCode && typeof handlerCode === 'string' && handlerCode.trim().length > 0;
189
193
  // Route to appropriate handler
190
194
  if (hasHandler) {
191
- handlers.push(async (c) => await mockHandlerResponse(c, operation));
195
+ handlers.push(async (c) => await mockHandlerResponse(c, operation, pathItem?.parameters));
192
196
  }
193
197
  else {
194
198
  handlers.push(async (c) => await mockAnyResponse(c, operation));
195
199
  }
196
- if (query.length === 0) {
197
- handlers.forEach((handler) => app[method](route, handler));
198
- return;
199
- }
200
200
  // The pinned query parameters are not part of the route, so they are checked here. A request
201
201
  // that does not carry them is handed on to the next matching route — usually the sibling path
202
202
  // key without the query string.
203
203
  const operationChain = every(...handlers);
204
- app[method](route, async (c, next) => {
205
- if (!requestMatchesPinnedQuery(c, query)) {
204
+ // Hono uppercases methods during registration. Match the original method ourselves so
205
+ // additional operations such as COPY and copy remain distinct.
206
+ const register = (handler) => {
207
+ if (method === method.toUpperCase()) {
208
+ app.on(method, route, handler);
209
+ }
210
+ else {
211
+ app.all(route, handler);
212
+ }
213
+ };
214
+ register(async (c, next) => {
215
+ if ((c.req.method !== method && !(method === 'GET' && c.req.method === 'HEAD')) ||
216
+ !requestMatchesPinnedQuery(c, query)) {
206
217
  await next();
207
218
  return;
208
219
  }
@@ -4,5 +4,5 @@ import type { Context } from 'hono';
4
4
  * Mock response using x-handler code.
5
5
  * Executes the handler and returns its result as the response.
6
6
  */
7
- export declare function mockHandlerResponse(c: Context, operation: OpenAPIV3_1.OperationObject): Promise<Response>;
7
+ export declare function mockHandlerResponse(c: Context, operation: OpenAPIV3_1.OperationObject, pathItemParameters?: OpenAPIV3_1.PathItemObject['parameters']): Promise<Response>;
8
8
  //# 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;AAExD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AA4HnC;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,eAAe,qBAoE3F"}
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;AAExD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AA4HnC;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,EAAE,WAAW,CAAC,eAAe,EACtC,kBAAkB,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,qBAqE9D"}
@@ -98,7 +98,7 @@ function determineStatusCode(tracking) {
98
98
  * Mock response using x-handler code.
99
99
  * Executes the handler and returns its result as the response.
100
100
  */
101
- export async function mockHandlerResponse(c, operation) {
101
+ export async function mockHandlerResponse(c, operation, pathItemParameters) {
102
102
  // Note: the `onRequest` callback runs as middleware (see `create-mock-server`) so it also fires
103
103
  // for requests rejected before reaching this handler.
104
104
  // Get x-handler code from operation
@@ -109,7 +109,7 @@ export async function mockHandlerResponse(c, operation) {
109
109
  }
110
110
  try {
111
111
  // Build handler context with tracking
112
- const { context, tracking } = await buildHandlerContext(c, operation);
112
+ const { context, tracking } = await buildHandlerContext(c, operation, pathItemParameters);
113
113
  // Execute handler
114
114
  const { result } = await executeHandler(handlerCode, context);
115
115
  // Determine status code based on all store operations, prioritizing semantically meaningful ones
package/dist/types.d.ts CHANGED
@@ -2,8 +2,6 @@ import type { OpenAPIV3_1 } from '@scalar/openapi-types';
2
2
  import type { Context } from 'hono';
3
3
  /** Available HTTP methods for Hono routes */
4
4
  export declare const httpMethods: readonly ["get", "put", "post", "delete", "options", "patch"];
5
- /** Valid HTTP method */
6
- export type HttpMethod = (typeof httpMethods)[number];
7
5
  /**
8
6
  * Represents a partial object where at least one of the given properties is required.
9
7
  */
@@ -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,uFAAuF;AACvF,MAAM,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;AAErD,KAAK,qBAAqB,GAAG;IAC3B,iGAAiG;IACjG,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;;;;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;IAEzB;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAA;CACpC,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;;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,uFAAuF;AACvF,MAAM,MAAM,gBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;AAErD,KAAK,qBAAqB,GAAG;IAC3B,iGAAiG;IACjG,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;;;;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;IAEzB;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAA;CACpC,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,CAAC,qBAAqB,EAAE,eAAe,GAAG,UAAU,CAAC,CAAA"}
@@ -9,7 +9,8 @@ export type HandlerContext = {
9
9
  req: {
10
10
  body: any;
11
11
  params: Record<string, string>;
12
- query: Record<string, string>;
12
+ /** Named query values, or the decoded content of an `in: querystring` parameter. */
13
+ query: unknown;
13
14
  headers: Record<string, string>;
14
15
  };
15
16
  res: Record<string, any>;
@@ -24,6 +25,6 @@ type HandlerContextResult = {
24
25
  /**
25
26
  * Build the handler context from a Hono context.
26
27
  */
27
- export declare function buildHandlerContext(c: Context, operation?: OpenAPIV3_1.OperationObject): Promise<HandlerContextResult>;
28
+ export declare function buildHandlerContext(c: Context, operation?: OpenAPIV3_1.OperationObject, pathItemParameters?: OpenAPIV3_1.PathItemObject['parameters']): Promise<HandlerContextResult>;
28
29
  export {};
29
30
  //# sourceMappingURL=build-handler-context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-handler-context.d.ts","sourceRoot":"","sources":["../../src/utils/build-handler-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AAQnC,OAAO,EAAE,KAAK,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAGjF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,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;AA6DD;;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,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AASnC,OAAO,EAAE,KAAK,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAGjF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,cAAc,CAAC,CAAA;IAC5D,GAAG,EAAE;QACH,IAAI,EAAE,GAAG,CAAA;QACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC9B,oFAAoF;QACpF,KAAK,EAAE,OAAO,CAAA;QACd,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;AA6DD;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,EACV,SAAS,CAAC,EAAE,WAAW,CAAC,eAAe,EACvC,kBAAkB,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,GAC5D,OAAO,CAAC,oBAAoB,CAAC,CAuD/B"}
@@ -6,6 +6,7 @@ import { generateResponseExample } from './generate-response-example.js';
6
6
  import { normalizeResponseBody } from './normalize-response-body.js';
7
7
  import { parsePreferHeader } from './parse-prefer-header.js';
8
8
  import { pathParameters } from './path-parameters.js';
9
+ import { findQuerystringParameter, parseQuerystringParameter } from './querystring-parameter.js';
9
10
  import { createStoreWrapper } from './store-wrapper.js';
10
11
  import { getStreamingResponse } from './streaming-response.js';
11
12
  /**
@@ -55,7 +56,7 @@ function getExampleFromResponse(c, statusCode, responses) {
55
56
  /**
56
57
  * Build the handler context from a Hono context.
57
58
  */
58
- export async function buildHandlerContext(c, operation) {
59
+ export async function buildHandlerContext(c, operation, pathItemParameters) {
59
60
  let body = undefined;
60
61
  try {
61
62
  // Compare case-insensitively, since media types are case-insensitive and request validation
@@ -77,6 +78,17 @@ export async function buildHandlerContext(c, operation) {
77
78
  catch {
78
79
  // Ignore parsing errors, body remains undefined
79
80
  }
81
+ const parameter = findQuerystringParameter(operation, pathItemParameters);
82
+ let query = Object.fromEntries(new URL(c.req.url).searchParams.entries());
83
+ if (parameter) {
84
+ try {
85
+ query = parseQuerystringParameter(c.req.url, parameter);
86
+ }
87
+ catch {
88
+ // With validation disabled, malformed content remains unavailable just like an invalid body.
89
+ query = undefined;
90
+ }
91
+ }
80
92
  const { wrappedStore, tracking } = createStoreWrapper(store);
81
93
  // Build res object with examples for all response status codes
82
94
  const res = {};
@@ -91,7 +103,7 @@ export async function buildHandlerContext(c, operation) {
91
103
  req: {
92
104
  body,
93
105
  params: pathParameters(c),
94
- query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
106
+ query,
95
107
  headers: Object.fromEntries(Object.entries(c.req.header()).map(([key, value]) => [key, value ?? ''])),
96
108
  },
97
109
  res,
@@ -1,8 +1,10 @@
1
1
  import type { OpenAPIV3_1 } from '@scalar/openapi-types';
2
- import { type HttpMethod } from '../types.js';
3
2
  /**
4
3
  * Takes a dereferenced OpenAPI document and returns all operations.
4
+ * Keys use their wire capitalization: fixed methods are uppercase, additional methods retain their case.
5
5
  * Ignores other attributes, like summary, parameters, etc.
6
6
  */
7
- export declare function getOperations(path?: OpenAPIV3_1.PathItemObject): Record<HttpMethod, OpenAPIV3_1.OperationObject>;
7
+ export declare const getOperations: (path?: OpenAPIV3_1.PathItemObject & {
8
+ additionalOperations?: Record<string, OpenAPIV3_1.OperationObject>;
9
+ }) => Record<string, OpenAPIV3_1.OperationObject>;
8
10
  //# sourceMappingURL=get-operation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"get-operation.d.ts","sourceRoot":"","sources":["../../src/utils/get-operation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EAAE,KAAK,UAAU,EAAe,MAAM,SAAS,CAAA;AAEtD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,CAAC,EAAE,WAAW,CAAC,cAAc,GAAG,MAAM,CAAC,UAAU,EAAE,WAAW,CAAC,eAAe,CAAC,CAUhH"}
1
+ {"version":3,"file":"get-operation.d.ts","sourceRoot":"","sources":["../../src/utils/get-operation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAIxD;;;;GAIG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,WAAW,CAAC,cAAc,GAAG;IAClC,oBAAoB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,eAAe,CAAC,CAAA;CACnE,KACA,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,eAAe,CAc5C,CAAA"}
@@ -1,14 +1,18 @@
1
1
  import { httpMethods } from '../types.js';
2
2
  /**
3
3
  * Takes a dereferenced OpenAPI document and returns all operations.
4
+ * Keys use their wire capitalization: fixed methods are uppercase, additional methods retain their case.
4
5
  * Ignores other attributes, like summary, parameters, etc.
5
6
  */
6
- export function getOperations(path) {
7
- const operations = {};
7
+ export const getOperations = (path) => {
8
+ const operations = new Map();
8
9
  for (const method of httpMethods) {
9
10
  if (path?.[method]) {
10
- operations[method] = path?.[method];
11
+ operations.set(method.toUpperCase(), path[method]);
11
12
  }
12
13
  }
13
- return operations;
14
- }
14
+ for (const [method, operation] of Object.entries(path?.additionalOperations ?? {})) {
15
+ operations.set(method, operation);
16
+ }
17
+ return Object.fromEntries(operations);
18
+ };
@@ -0,0 +1,11 @@
1
+ import type { OpenAPIV3_2 } from '@scalar/openapi-types';
2
+ /**
3
+ * Find the whole-query parameter, giving operation declarations precedence over path declarations.
4
+ * This module decodes incoming queries; workspace-store/src/helpers/querystring-parameter.ts serializes outgoing ones.
5
+ */
6
+ export declare const findQuerystringParameter: (operation?: OpenAPIV3_2.OperationObject, pathParameters?: OpenAPIV3_2.PathItemObject["parameters"]) => OpenAPIV3_2.ParameterObject | undefined;
7
+ /** Validate JSON-encoded form properties before the coercing form validator can alter their native types. */
8
+ export declare const getQuerystringJsonSchema: (parameter: OpenAPIV3_2.ParameterObject | undefined) => Record<string, unknown> | null;
9
+ /** Decode the complete query without introducing the parameter's documentary name. Throws on malformed JSON or URI escaping. */
10
+ export declare const parseQuerystringParameter: (url: string, parameter: OpenAPIV3_2.ParameterObject) => unknown;
11
+ //# sourceMappingURL=querystring-parameter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"querystring-parameter.d.ts","sourceRoot":"","sources":["../../src/utils/querystring-parameter.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAaxD;;;GAGG;AACH,eAAO,MAAM,wBAAwB,GACnC,YAAY,WAAW,CAAC,eAAe,EACvC,iBAAiB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KACxD,WAAW,CAAC,eAAe,GAAG,SAMwB,CAAA;AAkBzD,6GAA6G;AAC7G,eAAO,MAAM,wBAAwB,GACnC,WAAW,WAAW,CAAC,eAAe,GAAG,SAAS,KACjD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAkC5B,CAAA;AAED,gIAAgI;AAChI,eAAO,MAAM,yBAAyB,GAAI,KAAK,MAAM,EAAE,WAAW,WAAW,CAAC,eAAe,KAAG,OA0E/F,CAAA"}
@@ -0,0 +1,135 @@
1
+ import { getFirstMediaType } from '@scalar/helpers/http/get-first-media-type';
2
+ import { isJsonMediaType } from '@scalar/helpers/http/is-json-media-type';
3
+ import { parseMimeType } from '@scalar/helpers/http/mime-type';
4
+ import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
5
+ import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
6
+ import { deserializeArrayParameter, deserializeObjectParameter, getObjectPropertyNames, isArraySchema, isObjectSchema, resolveSerialization, } from './deserialize-parameter.js';
7
+ /**
8
+ * Find the whole-query parameter, giving operation declarations precedence over path declarations.
9
+ * This module decodes incoming queries; workspace-store/src/helpers/querystring-parameter.ts serializes outgoing ones.
10
+ */
11
+ export const findQuerystringParameter = (operation, pathParameters) => [
12
+ ...(Array.isArray(operation?.parameters) ? operation.parameters : []),
13
+ ...(Array.isArray(pathParameters) ? pathParameters : []),
14
+ ]
15
+ .map((parameter) => getResolvedRef(parameter))
16
+ .find((parameter) => parameter?.in === 'querystring');
17
+ /** Collect form properties through compositions so nullable and composed objects retain their encoding. */
18
+ const getPropertySchemas = (schema) => {
19
+ const properties = {};
20
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
21
+ const branches = schema?.[keyword];
22
+ if (Array.isArray(branches)) {
23
+ for (const branch of branches) {
24
+ if (branch && typeof branch === 'object') {
25
+ Object.assign(properties, getPropertySchemas(branch));
26
+ }
27
+ }
28
+ }
29
+ }
30
+ return { ...properties, ...schema?.properties };
31
+ };
32
+ /** Validate JSON-encoded form properties before the coercing form validator can alter their native types. */
33
+ export const getQuerystringJsonSchema = (parameter) => {
34
+ const [contentType, media] = getFirstMediaType(parameter?.content) ?? [];
35
+ if (parseMimeType(contentType).essence !== 'application/x-www-form-urlencoded') {
36
+ return null;
37
+ }
38
+ const selectJsonProperties = (schema) => {
39
+ const properties = (schema?.properties ?? {});
40
+ const jsonProperties = Object.fromEntries(Object.entries(properties).filter(([name, property]) => {
41
+ const encoding = media?.encoding?.[name];
42
+ if (encoding?.style !== undefined || encoding?.explode !== undefined || encoding?.allowReserved !== undefined) {
43
+ return false;
44
+ }
45
+ const item = isArraySchema(property) ? property.items : property;
46
+ return isJsonMediaType(encoding?.contentType) || isObjectSchema(item) || isArraySchema(item);
47
+ }));
48
+ const result = { properties: jsonProperties };
49
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
50
+ const branches = schema?.[keyword];
51
+ if (Array.isArray(branches)) {
52
+ // Non-JSON fields may distinguish oneOf branches. The complete validator enforces exclusivity.
53
+ const target = keyword === 'oneOf' ? 'anyOf' : keyword;
54
+ const selected = branches.map((branch) => selectJsonProperties(branch));
55
+ if (target in result) {
56
+ result.allOf = [...(Array.isArray(result.allOf) ? result.allOf : []), { [target]: selected }];
57
+ }
58
+ else {
59
+ result[target] = selected;
60
+ }
61
+ }
62
+ }
63
+ return result;
64
+ };
65
+ return selectJsonProperties(getResolvedRefDeep(media?.schema));
66
+ };
67
+ /** Decode the complete query without introducing the parameter's documentary name. Throws on malformed JSON or URI escaping. */
68
+ export const parseQuerystringParameter = (url, parameter) => {
69
+ const query = new URL(url).search.slice(1);
70
+ if (!query) {
71
+ return undefined;
72
+ }
73
+ const [contentType, media] = getFirstMediaType(parameter.content) ?? [];
74
+ const mediaType = parseMimeType(contentType).essence;
75
+ if (mediaType !== 'application/x-www-form-urlencoded') {
76
+ const decoded = decodeURIComponent(query);
77
+ return isJsonMediaType(mediaType) ? JSON.parse(decoded) : decoded;
78
+ }
79
+ const params = new URLSearchParams(query);
80
+ const map = Object.fromEntries([...new Set(params.keys())].map((key) => {
81
+ const values = params.getAll(key);
82
+ return [key, values.length === 1 ? (values[0] ?? '') : values];
83
+ }));
84
+ const result = { ...map };
85
+ const schema = getResolvedRefDeep(media?.schema);
86
+ const properties = getPropertySchemas(schema);
87
+ for (const name of getObjectPropertyNames(schema)) {
88
+ const property = properties?.[name];
89
+ const encoding = media?.encoding?.[name];
90
+ const single = params.get(name) ?? undefined;
91
+ const styleBased = encoding?.style !== undefined || encoding?.explode !== undefined || encoding?.allowReserved !== undefined;
92
+ if (styleBased) {
93
+ const { style, explode } = resolveSerialization('query', encoding?.style, encoding?.explode);
94
+ const value = isObjectSchema(property)
95
+ ? deserializeObjectParameter({
96
+ style,
97
+ explode,
98
+ single,
99
+ map,
100
+ name,
101
+ propertyNames: getObjectPropertyNames(property),
102
+ reservedKeys: new Set(Object.keys(properties)),
103
+ })
104
+ : isArraySchema(property)
105
+ ? deserializeArrayParameter({
106
+ style,
107
+ explode,
108
+ single,
109
+ multi: params.has(name) ? params.getAll(name) : undefined,
110
+ })
111
+ : single;
112
+ if (value !== undefined) {
113
+ if (isObjectSchema(property) &&
114
+ (style === 'deepObject' || (style === 'form' && explode)) &&
115
+ typeof value === 'object' &&
116
+ value !== null) {
117
+ for (const key of Object.keys(value)) {
118
+ delete result[style === 'deepObject' ? `${name}[${key}]` : key];
119
+ }
120
+ }
121
+ result[name] = value;
122
+ }
123
+ continue;
124
+ }
125
+ if (single === undefined) {
126
+ continue;
127
+ }
128
+ const json = isJsonMediaType(encoding?.contentType);
129
+ const decode = (value, itemSchema) => json || isObjectSchema(itemSchema) || isArraySchema(itemSchema) ? JSON.parse(value) : value;
130
+ result[name] = isArraySchema(property)
131
+ ? params.getAll(name).map((value) => decode(value, property?.items))
132
+ : decode(single, property);
133
+ }
134
+ return result;
135
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAMxD,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAoStD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,WAAW,CAAC,eAAe,EACtC,qBAAqB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KAC5D,iBAkKF,CAAA"}
1
+ {"version":3,"file":"validate-request.d.ts","sourceRoot":"","sources":["../../src/utils/validate-request.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,uBAAuB,CAAA;AAMrE,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAuTtD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAC1B,WAAW,WAAW,CAAC,eAAe,EACtC,qBAAqB,WAAW,CAAC,cAAc,CAAC,YAAY,CAAC,KAC5D,iBAmLF,CAAA"}
@@ -1,9 +1,12 @@
1
+ import { getFirstMediaType } from '@scalar/helpers/http/get-first-media-type';
2
+ import { parseMimeType } from '@scalar/helpers/http/mime-type';
1
3
  import { getResolvedRef } from '@scalar/workspace-store/helpers/get-resolved-ref';
2
4
  import { getResolvedRefDeep } from '@scalar/workspace-store/helpers/get-resolved-ref-deep';
3
5
  import Ajv2020 from 'ajv/dist/2020.js';
4
6
  import addFormats from 'ajv-formats';
5
7
  import { getCookie } from 'hono/cookie';
6
8
  import { deserializeArrayParameter, deserializeObjectParameter, getObjectPropertyNames, isArraySchema, isObjectSchema, resolveSerialization, } from './deserialize-parameter.js';
9
+ import { findQuerystringParameter, getQuerystringJsonSchema, parseQuerystringParameter } from './querystring-parameter.js';
7
10
  import { replaceCircularMarkers } from './replace-circular-markers.js';
8
11
  /**
9
12
  * Prepare a resolved schema for Ajv by replacing the `'[circular]'` markers a recursive schema leaves
@@ -113,7 +116,7 @@ const mergeParameters = (pathItemParameters, operationParameters) => {
113
116
  * in isolation so one broken schema never disables the others.
114
117
  */
115
118
  const compileSchema = (ajv, schema, label) => {
116
- if (!schema) {
119
+ if (schema === null || schema === undefined) {
117
120
  return null;
118
121
  }
119
122
  try {
@@ -140,6 +143,9 @@ const compileValidators = (operation, pathItemParameters) => {
140
143
  const queryParameters = buildParameterSchema(parameters, 'query');
141
144
  const headerParameters = buildParameterSchema(parameters, 'header');
142
145
  const cookieParameters = buildParameterSchema(parameters, 'cookie');
146
+ const querystringParameter = findQuerystringParameter(operation, pathItemParameters);
147
+ const [querystringContentType, querystringMedia] = getFirstMediaType(querystringParameter?.content) ?? [];
148
+ const querystringSchema = querystringMedia?.schema;
143
149
  const requestBody = getResolvedRef(operation.requestBody);
144
150
  // Build the body schema defensively; resolving a malformed `$ref` should not crash setup.
145
151
  let bodySchema = null;
@@ -151,6 +157,9 @@ const compileValidators = (operation, pathItemParameters) => {
151
157
  console.error('Error resolving request body schema, skipping body validation:', error);
152
158
  }
153
159
  return {
160
+ querystringParameter,
161
+ querystringJson: compileSchema(bodyAjv, getQuerystringJsonSchema(querystringParameter), 'querystring JSON properties'),
162
+ querystring: compileSchema(parseMimeType(querystringContentType).essence === 'application/x-www-form-urlencoded' ? parameterAjv : bodyAjv, querystringSchema === undefined ? null : asCompilableSchema(getResolvedRefDeep(querystringSchema)), 'querystring parameter'),
154
163
  path: compileSchema(parameterAjv, pathParameters?.schema ?? null, 'path parameter'),
155
164
  query: compileSchema(parameterAjv, queryParameters?.schema ?? null, 'query parameter'),
156
165
  header: compileSchema(parameterAjv, headerParameters?.schema ?? null, 'header parameter'),
@@ -312,6 +321,25 @@ export const validateRequest = (operation, pathItemParameters) => {
312
321
  violations.push(...mapErrors(validator.errors, location));
313
322
  }
314
323
  }
324
+ if (validators.querystringParameter) {
325
+ try {
326
+ const value = parseQuerystringParameter(c.req.url, validators.querystringParameter);
327
+ if (value === undefined) {
328
+ if (validators.querystringParameter.required) {
329
+ violations.push({ location: 'query', path: '', message: 'Query string is required' });
330
+ }
331
+ }
332
+ else if (validators.querystringJson && !validators.querystringJson(value)) {
333
+ violations.push(...mapErrors(validators.querystringJson.errors, 'query'));
334
+ }
335
+ else if (validators.querystring && !validators.querystring(value)) {
336
+ violations.push(...mapErrors(validators.querystring.errors, 'query'));
337
+ }
338
+ }
339
+ catch {
340
+ violations.push({ location: 'query', path: '', message: 'Query string could not be decoded' });
341
+ }
342
+ }
315
343
  // Request body — only `application/json` in this slice
316
344
  if (validators.body || validators.bodyRequired) {
317
345
  // Read from a clone so the original request stream stays intact for the mock or `x-handler`
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "swagger",
17
17
  "cli"
18
18
  ],
19
- "version": "0.15.0",
19
+ "version": "0.16.0",
20
20
  "engines": {
21
21
  "node": ">=22"
22
22
  },
@@ -53,12 +53,12 @@
53
53
  "dependencies": {
54
54
  "@faker-js/faker": "10.6.0",
55
55
  "@hono/node-server": "^2.1.1",
56
- "@scalar/helpers": "0.13.0",
57
- "@scalar/json-magic": "0.15.0",
56
+ "@scalar/helpers": "0.14.0",
57
+ "@scalar/json-magic": "0.15.1",
58
58
  "@scalar/openapi-types": "0.9.7",
59
- "@scalar/openapi-upgrader": "0.3.0",
60
- "@scalar/types": "0.21.0",
61
- "@scalar/workspace-store": "0.65.0",
59
+ "@scalar/openapi-upgrader": "0.3.1",
60
+ "@scalar/types": "0.22.0",
61
+ "@scalar/workspace-store": "0.66.0",
62
62
  "ajv": "^8.20.0",
63
63
  "ajv-formats": "^3.0.1",
64
64
  "hono": "^4.13.8",