diff --git a/.changeset/good-ghosts-draw.md b/.changeset/good-ghosts-draw.md new file mode 100644 index 00000000..9d41444d --- /dev/null +++ b/.changeset/good-ghosts-draw.md @@ -0,0 +1,5 @@ +--- +"@orpc/openapi": patch +--- + +fix: body schema not remove params fields diff --git a/.changeset/little-sloths-carry.md b/.changeset/little-sloths-carry.md new file mode 100644 index 00000000..1abdca76 --- /dev/null +++ b/.changeset/little-sloths-carry.md @@ -0,0 +1,5 @@ +--- +"@orpc/openapi": patch +--- + +fix: required and schema examples not work on query diff --git a/packages/openapi/src/generator.test.ts b/packages/openapi/src/generator.test.ts index 49be6fe8..a1ae4871 100644 --- a/packages/openapi/src/generator.test.ts +++ b/packages/openapi/src/generator.test.ts @@ -463,3 +463,181 @@ it('work with example', () => { }, }) }) + +it('should remove params on body', () => { + const router = oc.router({ + upload: oc.route({ method: 'POST', path: '/upload/{id}' }).input( + oz.openapi( + z.object({ + id: z.number(), + file: z.string().url(), + }), + { + examples: [ + { + id: 123, + file: 'https://example.com/file.png', + }, + ], + }, + ), + ), + }) + + const spec = generateOpenAPI({ + router, + info: { + title: 'test', + version: '1.0.0', + }, + }) + + expect(spec).toEqual({ + info: { title: 'test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/upload/{id}': { + post: { + summary: undefined, + description: undefined, + deprecated: undefined, + tags: undefined, + operationId: 'upload', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { examples: [123], type: 'number' }, + example: undefined, + }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'uri' } }, + required: ['file'], + examples: [{ file: 'https://example.com/file.png' }], + }, + example: undefined, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { schema: {}, example: undefined }, + }, + }, + }, + }, + }, + }, + }) +}) + +it('should remove params on query', () => { + const router = oc.router({ + upload: oc.route({ method: 'GET', path: '/upload/{id}' }).input( + oz.openapi( + z.object({ + id: z.number(), + file: z.string().url(), + object: z + .object({ + name: z.string(), + }) + .optional(), + }), + { + examples: [ + { + id: 123, + file: 'https://example.com/file.png', + object: { name: 'test' }, + }, + { + id: 456, + file: 'https://example.com/file2.png', + }, + ], + }, + ), + ), + }) + + const spec = generateOpenAPI({ + router, + info: { + title: 'test', + version: '1.0.0', + }, + }) + + expect(spec).toEqual({ + info: { title: 'test', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/upload/{id}': { + get: { + summary: undefined, + description: undefined, + deprecated: undefined, + tags: undefined, + operationId: 'upload', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { examples: [123, 456], type: 'number' }, + example: undefined, + }, + { + name: 'file', + in: 'query', + style: 'deepObject', + required: true, + schema: { + examples: [ + 'https://example.com/file.png', + 'https://example.com/file2.png', + ], + type: 'string', + format: 'uri', + }, + example: undefined, + }, + { + name: 'object', + in: 'query', + style: 'deepObject', + required: false, + schema: { + examples: [{ name: 'test' }], + anyOf: undefined, + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + example: undefined, + }, + ], + requestBody: undefined, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { schema: {}, example: undefined }, + }, + }, + }, + }, + }, + }, + }) +}) diff --git a/packages/openapi/src/generator.ts b/packages/openapi/src/generator.ts index a719e6e0..f5a6a7f6 100644 --- a/packages/openapi/src/generator.ts +++ b/packages/openapi/src/generator.ts @@ -1,6 +1,6 @@ import { type ContractRouter, eachContractRouterLeaf } from '@orpc/contract' import { type Router, toContractRouter } from '@orpc/server' -import { findDeepMatches, omit } from '@orpc/shared' +import { findDeepMatches, isPlainObject, omit } from '@orpc/shared' import { preSerialize } from '@orpc/transformer' import type { JSONSchema } from 'json-schema-typed/draft-2020-12' import { @@ -12,7 +12,11 @@ import { type RequestBodyObject, type ResponseObject, } from 'openapi3-ts/oas31' -import { extractJSONSchema, zodToJsonSchema } from './zod-to-json-schema' +import { + UNSUPPORTED_JSON_SCHEMA, + extractJSONSchema, + zodToJsonSchema, +} from './zod-to-json-schema' // Reference: https://spec.openapis.org/oas/v3.1.0.html#style-values @@ -64,7 +68,7 @@ export function generateOpenAPI( const path = internal.path ?? `/${path_.map(encodeURIComponent).join('/')}` const method = internal.method ?? 'POST' - const inputSchema = internal.InputSchema + let inputSchema = internal.InputSchema ? zodToJsonSchema(internal.InputSchema, { mode: 'input' }) : {} const outputSchema = internal.OutputSchema @@ -87,10 +91,10 @@ export function generateOpenAPI( return names .map((raw) => raw.slice(1, -1)) .map((name) => { - const schema = inputSchema.properties?.[name] + let schema = inputSchema.properties?.[name] const required = inputSchema.required?.includes(name) - if (!schema) { + if (schema === undefined) { throw new Error( `Parameter ${name} is missing in input schema [${path_.join('.')}]`, ) @@ -102,6 +106,54 @@ export function generateOpenAPI( ) } + const examples = inputSchema.examples + ?.filter((example) => { + return isPlainObject(example) && name in example + }) + .map((example) => { + return example[name] + }) + + schema = { + examples: examples?.length ? examples : undefined, + ...(schema === true + ? {} + : schema === false + ? UNSUPPORTED_JSON_SCHEMA + : schema), + } + + inputSchema = { + ...inputSchema, + properties: inputSchema.properties + ? Object.entries(inputSchema.properties).reduce( + (acc, [key, value]) => { + if (key !== name) { + acc[key] = value + } + + return acc + }, + {} as Record, + ) + : undefined, + required: inputSchema.required?.filter((v) => v !== name), + examples: inputSchema.examples?.map((example) => { + if (!isPlainObject(example)) return example + + return Object.entries(example).reduce( + (acc, [key, value]) => { + if (key !== name) { + acc[key] = value + } + + return acc + }, + {} as Record, + ) + }), + } + return { name, in: 'path', @@ -123,18 +175,35 @@ export function generateOpenAPI( ) } - return Object.entries(inputSchema.properties ?? {}) - .filter(([name]) => !params?.find((param) => param.name === name)) - .map(([name, schema]) => { + return Object.entries(inputSchema.properties ?? {}).map( + ([name, schema]) => { + const examples = inputSchema.examples + ?.filter((example) => { + return isPlainObject(example) && name in example + }) + .map((example) => { + return example[name] + }) + + const schema_ = { + examples: examples?.length ? examples : undefined, + ...(schema === true + ? {} + : schema === false + ? UNSUPPORTED_JSON_SCHEMA + : schema), + } + return { name, in: 'query', style: 'deepObject', - required: true, - schema: schema as any, + required: inputSchema?.required?.includes(name) ?? false, + schema: schema_ as any, example: internal.inputExample?.[name], } - }) + }, + ) })() const parameters = [...(params ?? []), ...(query ?? [])]