From ce6395f5e7cc370cb0f5a772bb9c05d73e80291f Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sun, 19 Jul 2026 21:22:11 +0530 Subject: [PATCH] feat(mock-server-core): add parseOpenApiRequestBodies export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive parser export returning 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 payloads). 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 — 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). Zero change to MockEndpoint or the mock runtime. - openapi.ts: OpenApiRequestBody interface + requestBody?/consumes? on OpenApiOperation; parseOpenApiRequestBodies + pickRequestBody helper - openapiNode.ts: parseOpenApiRequestBodiesNode (swagger-parser deref) - parsing.ts / index.ts: re-exports (+ OpenApiRequestBodySpec / ParseOpenApiRequestBodiesResult result types) - tests: 100% patch coverage (openapi.test.ts, openapiNode.test.ts) - docs: CHANGELOG (Unreleased), mock-server-core README, docs/mock-server.md --- CHANGELOG.md | 15 + docs/mock-server.md | 25 ++ packages/mock-server-core/README.md | 23 ++ packages/mock-server-core/src/index.ts | 8 +- .../src/parsers/openapi.test.ts | 353 +++++++++++++++++- .../mock-server-core/src/parsers/openapi.ts | 157 +++++++- .../src/parsers/openapiNode.test.ts | 44 ++- .../src/parsers/openapiNode.ts | 12 + packages/mock-server-core/src/parsing.ts | 4 +- 9 files changed, 633 insertions(+), 8 deletions(-) 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';