diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b49f2..27d4f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,21 @@ drift — without reaching into the internal `AttachmentRecord` shape or the IndexedDB layer. Purely additive; no existing behavior changes. (`@apicircle/ui-components`) +- **`parseOpenApiRequestBodies(source, format, deps)` parser export + (`@apicircle/mock-server-core`).** A new additive export that returns each + operation's **request-body schema** — the one part of an operation's contract + the mock pipeline deliberately drops (`MockEndpoint` carries no request body, + since the mock server never validates request bodies). It walks the same + dereferenced operations as `parseOpenApiToEndpoints` and emits + `{ method, path, contentType, schema, required }` per operation: OpenAPI 3.x + `requestBody` content (a JSON media type preferred) and Swagger 2.0 + `in: 'body'` parameters both reduce to the same shape. Exposed browser-safe + from the `/parsing` subpath and swagger-parser-backed from the Node root + (mirroring `parseOpenApiToEndpoints`), so a consumer — e.g. the Lens + code-vs-spec contract-drift check — can read a request body's declared shape + and join it to the endpoint table by `(method, path)`, without touching + `MockEndpoint` or the mock runtime. Purely additive. + (`@apicircle/mock-server-core`) ## 1.3.0 - 2026-07-18 diff --git a/docs/mock-server.md b/docs/mock-server.md index fca0fd6..3124738 100644 --- a/docs/mock-server.md +++ b/docs/mock-server.md @@ -177,6 +177,31 @@ const handle = await startMockServer({ await handle.close(); ``` +### Request-body schemas + +`parseSourceToEndpoints` intentionally drops request bodies — the mock server +responds, it never validates the request payload, so `MockEndpoint` carries no +request-body shape. When a consumer needs the shape a spec _declares_ for a +request (e.g. the Lens edition's code-vs-spec contract drift), the additive +`parseOpenApiRequestBodies(source, format, deps)` export returns it separately, +one entry per operation that declares a body: + +```ts +import { parseOpenApiRequestBodies } from '@apicircle/mock-server-core'; +// browser / renderer: '@apicircle/mock-server-core/parsing' + +const { requestBodies } = await parseOpenApiRequestBodies(rawSpec, 'yaml'); +// → [{ method: 'POST', path: '/pets', contentType: 'application/json', +// schema: { … }, required: true }, … ] +``` + +OpenAPI 3.x `requestBody` (a JSON media type preferred) and Swagger 2.0 +`in: 'body'` parameters both reduce to the same `{ method, path, contentType, +schema, required }` shape. It follows the same two-entry-point `$ref` contract +as the endpoint parser (root = swagger-parser, `/parsing` = in-document only) +and leaves `MockEndpoint` and the mock runtime untouched; join it back to +`parseSourceToEndpoints` by `(method, path)` for an operation's full contract. + ## Port binding errors `startMockServer` (and the Desktop / VS Code / CLI wrappers around it) throws diff --git a/packages/mock-server-core/README.md b/packages/mock-server-core/README.md index e5cf022..f66aa99 100644 --- a/packages/mock-server-core/README.md +++ b/packages/mock-server-core/README.md @@ -140,6 +140,29 @@ slow regions, and "what does the app do when this call returns 500?". await handle.close(); ``` +## Extracting request-body schemas + +The mock pipeline doesn't model request bodies — a mock server responds, it +doesn't validate what you send it — so `parseSourceToEndpoints` drops them. When +you need the **shape a spec declares for a request** (say, to diff it against +what your code actually reads), `parseOpenApiRequestBodies` returns it directly: + +```ts +import { parseOpenApiRequestBodies } from '@apicircle/mock-server-core'; +// browser / renderer: import from '@apicircle/mock-server-core/parsing' + +const { requestBodies, warnings } = await parseOpenApiRequestBodies(rawYamlOrJson, 'yaml'); +// → [{ method: 'POST', path: '/pets', contentType: 'application/json', +// schema: { type: 'object', properties: { … } }, required: true }, … ] +``` + +One entry per operation that declares a body: OpenAPI 3.x `requestBody` (a JSON +media type preferred) and Swagger 2.0 `in: 'body'` parameters both reduce to the +same `{ method, path, contentType, schema, required }` shape. Join it back to +`parseSourceToEndpoints` by `(method, path)` for an operation's full contract. +Same `$ref` contract as the endpoint parser — the Node root resolves external +refs via swagger-parser; the `/parsing` subpath resolves in-document refs only. + ## Format support matrix | Format | Versions | What we pull out | diff --git a/packages/mock-server-core/src/index.ts b/packages/mock-server-core/src/index.ts index 6232dae..1823d4a 100644 --- a/packages/mock-server-core/src/index.ts +++ b/packages/mock-server-core/src/index.ts @@ -23,10 +23,12 @@ export type { MockServerHandle, ServeOptions } from './runtime/nodeAdapter'; export { MockServerStartError } from './runtime/nodeAdapter'; export type { BuildRouterOptions } from './handlers/buildRouter'; export { openApiPathToHono } from './handlers/buildRouter'; -// The Node entry exposes the swagger-parser-backed OpenAPI parser as the -// canonical `parseOpenApiToEndpoints` so CLI / MCP consumers get full -// external-reference resolution. +// The Node entry exposes the swagger-parser-backed OpenAPI parsers as the +// canonical `parseOpenApiToEndpoints` / `parseOpenApiRequestBodies` so CLI / +// MCP consumers get full external-reference resolution. export { parseOpenApiToEndpointsNode as parseOpenApiToEndpoints } from './parsers/openapiNode'; +export { parseOpenApiRequestBodiesNode as parseOpenApiRequestBodies } from './parsers/openapiNode'; +export type { ParseOpenApiRequestBodiesResult, OpenApiRequestBodySpec } from './parsers/openapi'; export { parsePostmanToEndpoints } from './parsers/postman'; export { parseInsomniaToEndpoints } from './parsers/insomnia'; export { schemaToExample } from './faker/schemaToExample'; diff --git a/packages/mock-server-core/src/parsers/openapi.test.ts b/packages/mock-server-core/src/parsers/openapi.test.ts index b020a55..a3b5c91 100644 --- a/packages/mock-server-core/src/parsers/openapi.test.ts +++ b/packages/mock-server-core/src/parsers/openapi.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { MockEndpoint } from '@apicircle/shared'; -import { parseOpenApiToEndpoints } from './openapi'; +import { parseOpenApiToEndpoints, parseOpenApiRequestBodies } from './openapi'; // Helper accessors — every endpoint stores its parsed response on // `defaultResponse`; these wrap that detail so the tests below stay @@ -509,3 +509,354 @@ describe('parseOpenApiToEndpoints', () => { expect(JSON.parse(bodyContent(endpoints[0]))).toEqual({ ok: 1 }); }); }); + +describe('parseOpenApiRequestBodies', () => { + // Compact spec builders — the request-body walk only cares about `paths`. + const doc = (paths: Record) => + JSON.stringify({ openapi: '3.0.0', info: { title: 'T', version: '1.0.0' }, paths }); + const swaggerDoc = (paths: Record) => + JSON.stringify({ swagger: '2.0', info: { title: 'T', version: '1.0.0' }, paths }); + + const petSchema = { + type: 'object', + required: ['id', 'name'], + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + }; + + it('extracts an OpenAPI 3.x JSON request body and skips body-less operations', async () => { + const { requestBodies, warnings } = await parseOpenApiRequestBodies( + doc({ + '/pets': { + get: { responses: { '200': { description: 'ok' } } }, + post: { + requestBody: { + required: true, + content: { 'application/json': { schema: petSchema } }, + }, + responses: { '201': { description: 'created' } }, + }, + }, + }), + 'json', + ); + expect(warnings).toEqual([]); + expect(requestBodies).toEqual([ + { + method: 'POST', + path: '/pets', + contentType: 'application/json', + schema: petSchema, + required: true, + }, + ]); + }); + + it('defaults `required` to false when the OpenAPI 3.x requestBody omits it', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/pets': { + post: { + requestBody: { content: { 'application/json': { schema: petSchema } } }, + responses: { '201': {} }, + }, + }, + }), + ); + expect(requestBodies[0].required).toBe(false); + }); + + it('prefers a JSON media type when several are present', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/pets': { + post: { + requestBody: { + content: { + 'application/xml': { schema: { type: 'string' } }, + 'application/json': { schema: petSchema }, + }, + }, + responses: { '201': {} }, + }, + }, + }), + ); + expect(requestBodies[0].contentType).toBe('application/json'); + expect(requestBodies[0].schema).toEqual(petSchema); + }); + + it('falls back to the first media type when none is JSON', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/upload': { + post: { + requestBody: { + content: { + 'application/octet-stream': { schema: { type: 'string', format: 'binary' } }, + }, + }, + responses: { '200': {} }, + }, + }, + }), + ); + expect(requestBodies[0].contentType).toBe('application/octet-stream'); + }); + + it('omits an operation whose requestBody has no content', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ '/x': { post: { requestBody: { required: true }, responses: { '200': {} } } } }), + ); + expect(requestBodies).toEqual([]); + }); + + it('omits an operation whose requestBody content map is empty', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ '/x': { post: { requestBody: { content: {} }, responses: { '200': {} } } } }), + ); + expect(requestBodies).toEqual([]); + }); + + it('omits an operation whose chosen media type has no schema', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/x': { + post: { + requestBody: { content: { 'application/json': { example: { id: 1 } } } }, + responses: { '200': {} }, + }, + }, + }), + ); + expect(requestBodies).toEqual([]); + }); + + it('parses a YAML spec', async () => { + const yamlSpec = `openapi: 3.0.0 +info: + title: T + version: 1.0.0 +paths: + /pets: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '201': + description: created +`; + const { requestBodies } = await parseOpenApiRequestBodies(yamlSpec, 'yaml'); + expect(requestBodies).toHaveLength(1); + expect(requestBodies[0]).toMatchObject({ + method: 'POST', + path: '/pets', + contentType: 'application/json', + required: true, + }); + expect(requestBodies[0].schema).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + }); + }); + + it('extracts a Swagger 2.0 body parameter, honoring `consumes`, ignoring non-body params', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + swaggerDoc({ + '/pets': { + post: { + consumes: ['application/xml'], + // A null entry + a query param exercise the defensive guards; only + // the `in: 'body'` parameter is picked up. + parameters: [ + null, + { name: 'q', in: 'query', type: 'string' }, + { name: 'body', in: 'body', required: true, schema: petSchema }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }), + ); + expect(requestBodies).toEqual([ + { + method: 'POST', + path: '/pets', + contentType: 'application/xml', + schema: petSchema, + required: true, + }, + ]); + }); + + it('defaults the Swagger 2.0 body content type to JSON and `required` to false', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + swaggerDoc({ + '/pets': { + post: { + parameters: [{ name: 'body', in: 'body', schema: { type: 'object' } }], + responses: { '200': {} }, + }, + }, + }), + ); + expect(requestBodies[0].contentType).toBe('application/json'); + expect(requestBodies[0].required).toBe(false); + }); + + it('reads a Swagger 2.0 body parameter declared at the path-item level', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + swaggerDoc({ + '/pets': { + parameters: [{ name: 'body', in: 'body', required: true, schema: petSchema }], + post: { responses: { '200': {} } }, + }, + }), + ); + expect(requestBodies).toHaveLength(1); + expect(requestBodies[0]).toMatchObject({ method: 'POST', schema: petSchema, required: true }); + }); + + it('prefers an operation-level Swagger 2.0 body over a path-item-level one', async () => { + const opSchema = { type: 'object', properties: { op: { type: 'boolean' } } }; + const pathSchema = { type: 'object', properties: { path: { type: 'boolean' } } }; + const { requestBodies } = await parseOpenApiRequestBodies( + swaggerDoc({ + '/pets': { + parameters: [{ name: 'body', in: 'body', schema: pathSchema }], + post: { + parameters: [{ name: 'body', in: 'body', schema: opSchema }], + responses: { '200': {} }, + }, + }, + }), + ); + expect(requestBodies[0].schema).toEqual(opSchema); + }); + + it('skips a malformed Swagger 2.0 body parameter that has no schema', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + swaggerDoc({ + '/pets': { + post: { + parameters: [{ name: 'body', in: 'body', required: true }], + responses: { '200': {} }, + }, + }, + }), + ); + expect(requestBodies).toEqual([]); + }); + + it('resolves an in-document $ref in a request body schema (default browser parser)', async () => { + const { requestBodies, warnings } = await parseOpenApiRequestBodies( + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Ref', version: '1.0.0' }, + components: { schemas: { Pet: petSchema } }, + paths: { + '/pets': { + post: { + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }, + responses: { '201': {} }, + }, + }, + }, + }), + ); + expect(warnings).toEqual([]); + expect(requestBodies[0].schema).toEqual(petSchema); + }); + + it('returns a warning and no bodies for invalid JSON', async () => { + const { requestBodies, warnings } = await parseOpenApiRequestBodies('{not json', 'json'); + expect(requestBodies).toEqual([]); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('returns "Could not parse" for non-object input', async () => { + const { requestBodies, warnings } = await parseOpenApiRequestBodies('"just a string"', 'json'); + expect(requestBodies).toEqual([]); + expect(warnings).toContain('Could not parse OpenAPI source'); + }); + + it('uses an injected dereferencer and surfaces its warnings', async () => { + let called = false; + const { warnings } = await parseOpenApiRequestBodies( + doc({ + '/x': { + post: { + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + responses: { '200': {} }, + }, + }, + }), + 'json', + { + dereference: (root) => { + called = true; + return { doc: root, warnings: ['custom-deref-warning'] }; + }, + }, + ); + expect(called).toBe(true); + expect(warnings).toContain('custom-deref-warning'); + }); + + it('falls back to the raw spec when the dereferencer returns a non-object doc', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/x': { + post: { + requestBody: { content: { 'application/json': { schema: { type: 'string' } } } }, + responses: { '200': {} }, + }, + }, + }), + 'json', + { dereference: () => ({ doc: null, warnings: [] }) }, + ); + expect(requestBodies).toHaveLength(1); + expect(requestBodies[0].schema).toEqual({ type: 'string' }); + }); + + it('returns no bodies when the spec declares no paths', async () => { + const { requestBodies, warnings } = await parseOpenApiRequestBodies( + JSON.stringify({ openapi: '3.0.0', info: { title: 'Empty', version: '1.0.0' } }), + ); + expect(requestBodies).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('skips path items and operations that are not objects', async () => { + const { requestBodies } = await parseOpenApiRequestBodies( + doc({ + '/bad': null, + '/pets': { + get: 'not an operation', + post: { + requestBody: { content: { 'application/json': { schema: petSchema } } }, + responses: { '201': {} }, + }, + }, + }), + ); + expect(requestBodies).toEqual([ + { + method: 'POST', + path: '/pets', + contentType: 'application/json', + schema: petSchema, + required: false, + }, + ]); + }); +}); diff --git a/packages/mock-server-core/src/parsers/openapi.ts b/packages/mock-server-core/src/parsers/openapi.ts index 997a7c1..6093aa0 100644 --- a/packages/mock-server-core/src/parsers/openapi.ts +++ b/packages/mock-server-core/src/parsers/openapi.ts @@ -64,9 +64,27 @@ interface OpenApiOperation { summary?: string; responses?: Record; parameters?: OpenApiParameter[]; - // Swagger 2.0 carries `produces` here; we honor it as a fallback for - // content-type when `responses[status].content` is absent. + // OpenAPI 3.x request body. Swagger 2.0 has no `requestBody`; it models the + // body as a single `in: 'body'` parameter instead (see + // {@link parseOpenApiRequestBodies}). + requestBody?: OpenApiRequestBody; + // Swagger 2.0 carries `produces` / `consumes` here. We honor `produces` as a + // response content-type fallback, and `consumes` as the request-body content + // type for an `in: 'body'` parameter. produces?: string[]; + consumes?: string[]; +} + +/** + * OpenAPI 3.x request body object. Only the media-type `schema`s matter for + * contract extraction — examples live on the response side. Swagger 2.0 has no + * equivalent object; its body is a single `in: 'body'` parameter carrying a + * `schema` (handled in {@link pickRequestBody}). + */ +interface OpenApiRequestBody { + content?: Record; + required?: boolean; + description?: string; } interface OpenApiResponse { @@ -94,6 +112,25 @@ export interface ParseOpenApiResult { warnings: string[]; } +/** + * One operation's request body, extracted by {@link parseOpenApiRequestBodies}. + * Consumers (e.g. the Lens code-vs-spec contract-drift check) join it back to + * the parsed endpoint table by `(method, path)`. + */ +export interface OpenApiRequestBodySpec { + method: HttpMethod; + path: string; + /** The chosen media type — a JSON one is preferred when several are present. */ + contentType: string; + schema: JsonSchemaLike; + required: boolean; +} + +export interface ParseOpenApiRequestBodiesResult { + requestBodies: OpenApiRequestBodySpec[]; + warnings: string[]; +} + /** * Parse an OpenAPI / Swagger 2.0 spec string and return the mock endpoint * table. `format` is a hint — the parser will fall back to JSON.parse if @@ -156,6 +193,122 @@ export async function parseOpenApiToEndpoints( return { endpoints, warnings }; } +/** + * Extract each operation's **request body** schema from an OpenAPI / Swagger + * 2.0 spec — the one part of an operation's contract that the mock pipeline + * deliberately drops. `MockEndpoint` carries no request-body shape (the mock + * server never validates request bodies), so {@link parseOpenApiToEndpoints} + * skips it; this returns it separately, leaving the mock endpoint shape + * untouched. + * + * Callers that need an operation's full contract (e.g. the Lens code-vs-spec + * contract-drift check) run this alongside {@link parseOpenApiToEndpoints} and + * join the two by `(method, path)`. The `$ref` dereferencer is injected exactly + * as there (browser: in-document; Node: swagger-parser via the + * `parseOpenApiRequestBodiesNode` entry point). + * + * Each operation with a resolvable body yields `{ method, path, contentType, + * schema, required }`: + * - OpenAPI 3.x — from `requestBody.content[mediaType].schema`, preferring a + * JSON media type (mirrors {@link pickResponsePayload}). + * - Swagger 2.0 — from the `in: 'body'` parameter's `schema`, with the + * content type taken from the operation's `consumes` (default JSON). + * + * Operations without a body (GET/DELETE, or a body with no resolvable schema) + * are omitted from the result. + */ +export async function parseOpenApiRequestBodies( + source: string, + format: 'json' | 'yaml' = 'json', + deps: ParseOpenApiDeps = {}, +): Promise { + const warnings: string[] = []; + + const raw = format === 'yaml' ? safeYamlLoad(source) : safeJsonParse(source); + if (!raw || typeof raw !== 'object') { + return { requestBodies: [], warnings: ['Could not parse OpenAPI source'] }; + } + + // Same dereference contract as parseOpenApiToEndpoints: resolve `$ref`s + // up-front (browser: in-document only; Node: swagger-parser) so the walk + // below only ever sees inline schemas. + const dereference = deps.dereference ?? dereferenceInternal; + const { doc, warnings: derefWarnings } = await dereference(raw); + warnings.push(...derefWarnings); + const api = (doc && typeof doc === 'object' ? doc : raw) as Record; + + const paths = (api.paths ?? {}) as Record>; + const requestBodies: OpenApiRequestBodySpec[] = []; + + for (const [path, ops] of Object.entries(paths)) { + if (!ops || typeof ops !== 'object') continue; + // Swagger 2.0 body params may live at the path-item level; merge them in + // with operation-level precedence (as buildRequestSchema does for the + // other param kinds). + const pathItemParams = (ops as { parameters?: OpenApiParameter[] }).parameters ?? []; + for (const method of Object.keys(ops)) { + const upper = method.toUpperCase() as HttpMethod; + if (!SUPPORTED_METHODS.includes(upper)) continue; + const op = ops[method]; + if (!op || typeof op !== 'object') continue; + + const body = pickRequestBody(upper, path, op, pathItemParams); + if (body) requestBodies.push(body); + } + } + + return { requestBodies, warnings }; +} + +/** Extract one operation's request body — OpenAPI 3.x `requestBody` first, then + * the Swagger 2.0 `in: 'body'` parameter (operation-level winning over + * path-item-level). Returns `null` when there's no resolvable body schema. + * Pure. */ +function pickRequestBody( + method: HttpMethod, + path: string, + op: OpenApiOperation, + pathItemParams: OpenApiParameter[], +): OpenApiRequestBodySpec | null { + // OpenAPI 3.x: `requestBody.content` keyed by media type — prefer JSON, the + // same preference pickResponsePayload applies to responses. + const requestBody = op.requestBody; + if (requestBody?.content) { + const content = requestBody.content; + const mediaTypes = Object.keys(content); + const preferred = mediaTypes.find((m) => m.toLowerCase().includes('json')) ?? mediaTypes[0]; + if (preferred) { + const entry = content[preferred]; + if (entry.schema) { + return { + method, + path, + contentType: preferred, + schema: entry.schema, + required: requestBody.required ?? false, + }; + } + } + } + + // Swagger 2.0: the body is a single `in: 'body'` parameter; its content type + // comes from `consumes` (default JSON). Operation params are checked before + // path-item params so an operation-level body wins. + for (const p of [...(op.parameters ?? []), ...pathItemParams]) { + if (p && p.in === 'body' && p.schema) { + return { + method, + path, + contentType: op.consumes?.[0] ?? 'application/json', + schema: p.schema, + required: typeof p.required === 'boolean' ? p.required : false, + }; + } + } + + return null; +} + /** Merge path-item + operation parameters (operation wins by name+in) and map * them into a `MockRequestSchema`. Pure. */ function buildRequestSchema( diff --git a/packages/mock-server-core/src/parsers/openapiNode.test.ts b/packages/mock-server-core/src/parsers/openapiNode.test.ts index 7b3fc1f..3485cb0 100644 --- a/packages/mock-server-core/src/parsers/openapiNode.test.ts +++ b/packages/mock-server-core/src/parsers/openapiNode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { MockEndpoint } from '@apicircle/shared'; -import { parseOpenApiToEndpointsNode } from './openapiNode'; +import { parseOpenApiToEndpointsNode, parseOpenApiRequestBodiesNode } from './openapiNode'; const bodyContent = (e: MockEndpoint) => e.defaultResponse.body.type === 'json' ? e.defaultResponse.body.content : ''; @@ -55,3 +55,45 @@ describe('parseOpenApiToEndpointsNode (swagger-parser)', () => { expect(warnings.some((w) => w.includes('falling back to in-document'))).toBe(true); }); }); + +describe('parseOpenApiRequestBodiesNode (swagger-parser)', () => { + it('resolves a request body $ref via swagger-parser', async () => { + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Ref', version: '1.0.0' }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['id', 'name'], + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + }, + }, + }, + paths: { + '/pets': { + post: { + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } }, + }, + responses: { '201': { description: 'created' } }, + }, + }, + }, + }); + const { requestBodies, warnings } = await parseOpenApiRequestBodiesNode(spec, 'json'); + expect(warnings).toEqual([]); + expect(requestBodies).toHaveLength(1); + expect(requestBodies[0]).toMatchObject({ + method: 'POST', + path: '/pets', + contentType: 'application/json', + required: true, + }); + expect(requestBodies[0].schema).toMatchObject({ + type: 'object', + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + }); + }); +}); diff --git a/packages/mock-server-core/src/parsers/openapiNode.ts b/packages/mock-server-core/src/parsers/openapiNode.ts index 9c0a184..ef0cc24 100644 --- a/packages/mock-server-core/src/parsers/openapiNode.ts +++ b/packages/mock-server-core/src/parsers/openapiNode.ts @@ -11,8 +11,10 @@ import SwaggerParser from '@apidevtools/swagger-parser'; import { dereferenceInternal } from './refDeref'; import { parseOpenApiToEndpoints, + parseOpenApiRequestBodies, type ParseOpenApiOptions, type ParseOpenApiResult, + type ParseOpenApiRequestBodiesResult, } from './openapi'; async function swaggerDereference(root: unknown) { @@ -47,3 +49,13 @@ export function parseOpenApiToEndpointsNode( ): Promise { return parseOpenApiToEndpoints(source, format, opts, { dereference: swaggerDereference }); } + +/** Node variant of {@link parseOpenApiRequestBodies} using swagger-parser, so + * request-body schemas behind external / remote `$ref`s resolve fully on the + * Desktop main, CLI, MCP, and VS Code host surfaces. */ +export function parseOpenApiRequestBodiesNode( + source: string, + format: 'json' | 'yaml' = 'json', +): Promise { + return parseOpenApiRequestBodies(source, format, { dereference: swaggerDereference }); +} diff --git a/packages/mock-server-core/src/parsing.ts b/packages/mock-server-core/src/parsing.ts index 0e05efe..b7a4580 100644 --- a/packages/mock-server-core/src/parsing.ts +++ b/packages/mock-server-core/src/parsing.ts @@ -14,10 +14,12 @@ import { parseOpenApiToEndpoints, type ParseOpenApiResult } from './parsers/open import { parsePostmanToEndpoints } from './parsers/postman'; import { parseInsomniaToEndpoints } from './parsers/insomnia'; -export { parseOpenApiToEndpoints } from './parsers/openapi'; +export { parseOpenApiToEndpoints, parseOpenApiRequestBodies } from './parsers/openapi'; export type { ParseOpenApiOptions, ParseOpenApiResult, + ParseOpenApiRequestBodiesResult, + OpenApiRequestBodySpec, DereferenceFn, ParseOpenApiDeps, } from './parsers/openapi';