Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion packages/openapi/src/openapi-generator-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
},
Expand All @@ -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()

Expand Down
69 changes: 53 additions & 16 deletions packages/openapi/src/openapi-generator-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string | undefined> | undefined {
const statuses = new Map<number, string | undefined>()

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(
Expand Down
Loading