diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 96c4c3b99..3a60a25bb 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -614,6 +614,55 @@ describe('openAPIGenerator operation builders', () => { }) }) + it.each([ + { + // Effect emits `Schema.Literal(201)` as `{ type: 'number', enum: [201] }` + name: 'a single-value enum', + status: { type: 'number', enum: [201], description: 'created' }, + descriptions: { 201: 'created' }, + }, + { + name: 'a multi-value enum', + status: { type: 'number', enum: [200, 201, 200], description: 'success' }, + descriptions: { 200: 'success', 201: 'success' }, + }, + { + // zod and valibot emit a union of literals as `anyOf` of `const` schemas + name: 'a union of literals', + status: { + description: 'accepted', + anyOf: [ + { type: 'number', const: 201, description: 'created' }, + { type: 'number', const: 202 }, + { type: 'number', enum: [203, 204] }, + ], + }, + descriptions: { 201: 'created', 202: 'accepted', 203: 'accepted', 204: 'accepted' }, + }, + ])('maps $name detailed status to per-status responses', ({ status, descriptions }) => { + const { ctx, operation } = createContext() + + buildSuccessResponse(ctx, operation, testDef({ + outputs: [testSchema({ + type: 'object', + properties: { + status: status as any, + headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, + body: { type: 'string' }, + }, + required: ['status', 'headers', 'body'], + })], + }), { outputStructure: 'detailed' }) + + expect(operation.responses).toEqual(Object.fromEntries( + Object.entries(descriptions).map(([code, description]) => [code, { + description, + headers: { 'x-id': { required: true, schema: { type: 'string' } } }, + content: { 'application/json': { schema: { type: 'string' } } }, + }]), + )) + }) + it.each([ { name: 'a detailed output member is not an object', @@ -626,7 +675,7 @@ describe('openAPIGenerator operation builders', () => { message: 'invalid "status" field in the detailed output schema', }, { - name: 'a detailed status is not a const integer', + name: 'a detailed status is not a literal integer', output: { type: 'object', properties: { status: { type: 'number' } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, @@ -635,6 +684,31 @@ describe('openAPIGenerator operation builders', () => { output: { type: 'object', properties: { status: { const: 400 } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, + { + name: 'a detailed enum status contains a non-success status', + output: { type: 'object', properties: { status: { type: 'number', enum: [200, 400] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed enum status is empty', + output: { type: 'object', properties: { status: { type: 'number', enum: [] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed status union is empty', + output: { type: 'object', properties: { status: { anyOf: [] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed status union contains a non-literal member', + output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { type: 'number' }] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed status union contains a non-integer literal', + output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { const: '201' }] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, ])('throws when $name', ({ output, message }) => { const { ctx, operation } = createContext() diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index d88095f35..3e925b7e5 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -410,39 +410,76 @@ function extractDetailedResponseParts( } const statusSchema = entries.find(([name]) => name === 'status')?.[1] + const statuses = statusSchema === undefined + ? new Map([[defaultStatus, undefined]]) + : extractDetailedStatuses(statusSchema) - if (statusSchema !== undefined && (typeof statusSchema !== 'object' || !Number.isInteger(statusSchema.const) || statusSchema.const >= 400)) { + if (!statuses) { throw new OpenAPIGeneratorError( `invalid "status" field in the detailed output schema.\n` - + ` Expected: a literal (const) integer below 400\n` + + ` Expected: literal integers below 400 (const, enum, or a union of them)\n` + ` Received: ${stringifyJSON(statusSchema)}`, ) } - const status = (statusSchema?.const as number || undefined) ?? defaultStatus + const headersSchema = entries.find(([name]) => name === 'headers')?.[1] + const bodySchema = entries.find(([name]) => name === 'body')?.[1] + + for (const [status, description] of statuses) { + let parts = partsByStatus.get(status) + if (!parts) { + parts = { descriptions: [], bodies: [], headers: [] } + partsByStatus.set(status, parts) + } - let parts = partsByStatus.get(status) - if (!parts) { - parts = { descriptions: [], bodies: [], headers: [] } - partsByStatus.set(status, parts) + if (description !== undefined) { + parts.descriptions.push(description) + } + + if (headersSchema !== undefined) { + parts.headers.push(headersSchema) + } + + if (bodySchema !== undefined) { + parts.bodies.push(bodySchema) + } } + } + + return partsByStatus +} - if (statusSchema?.description !== undefined) { - parts.descriptions.push(statusSchema.description) +/** + * Collects every status a detailed output `status` schema allows, mapped to its description. + * Accepts `const`, `enum` (how Effect, arktype, and valibot `picklist` emit literals), or a union of them. + * Returns `undefined` when any member is not a literal integer below 400. + */ +function extractDetailedStatuses(schema: JsonSchema): Map | undefined { + const statuses = new Map() + + for (const member of flattenJsonUnionSchema(schema)) { + if (typeof member !== 'object') { + return undefined } - const headersSchema = entries.find(([name]) => name === 'headers')?.[1] - if (headersSchema !== undefined) { - parts.headers.push(headersSchema) + const values = member.const !== undefined ? [member.const] : member.enum + + if (!values?.length) { + return undefined } - const bodySchema = entries.find(([name]) => name === 'body')?.[1] - if (bodySchema !== undefined) { - parts.bodies.push(bodySchema) + for (const value of values) { + if (!Number.isInteger(value) || value >= 400) { + return undefined + } + + if (!statuses.has(value)) { + statuses.set(value, member.description) + } } } - return partsByStatus + return statuses.size ? statuses : undefined } export function buildErrorResponse(