Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-head-void-collapse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@effect/openapi-generator": patch
---

Route 4xx/5xx void response schemas to the error channel instead of collapsing them to `Effect.void`.

HEAD endpoints (and any operation with bodiless error responses) now generate typed errors for 4xx/5xx status codes, preserving status-code semantics in the generated client. 2xx void schemas remain in the success channel.
76 changes: 69 additions & 7 deletions packages/tools/openapi-generator/src/OpenApiTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ const computeImportRequirements = (operations: ReadonlyArray<ParsedOperation>):
const requiresStreaming = (requirements: ImportRequirements): boolean =>
requirements.eventStream || requirements.octetStream

const hasVoidErrors = (operations: ReadonlyArray<ParsedOperation>): boolean =>
operations.some((op) => Array.from(op.voidSchemas).some(Utils.isErrorStatus))

/**
* Create the transformer used for schema-backed HttpClient output.
*
Expand Down Expand Up @@ -138,6 +141,11 @@ ${clientErrorSource(name)}`
errors
)
}
for (const status of operation.voidSchemas) {
if (Utils.isErrorStatus(status)) {
errors.push(`${name}Error<"${status}", undefined>`)
}
}

const jsdoc = Utils.toComment(operation.description)
const methodKey = `readonly "${operation.id}"`
Expand Down Expand Up @@ -233,6 +241,8 @@ ${clientErrorSource(name)}`
helpers.push(binaryRequestSource)
}

const needsVoidError = hasVoidErrors(operations)

return `export interface OperationConfig {
/**
* Whether or not the response should be included in the value returned from
Expand Down Expand Up @@ -273,7 +283,15 @@ export const make = (
HttpClientResponse.schemaBodyJson(schema)(response),
(cause) => Effect.fail(${name}Error(tag, cause, response)),
)
return {
${
needsVoidError ?
`const decodeVoidError =
<const Tag extends string>(tag: Tag) =>
(response: HttpClientResponse.HttpClientResponse) =>
Effect.fail(${name}Error(tag, undefined, response))
` :
""
}return {
httpClient,
${implMethods.join(",\n ")}
}
Expand Down Expand Up @@ -322,7 +340,11 @@ export const make = (
decodes.push(`"${status}": decodeError("${schema}", ${schema})`)
})
operation.voidSchemas.forEach((status) => {
decodes.push(`"${status}": () => Effect.void`)
if (Utils.isErrorStatus(status)) {
decodes.push(`"${status}": decodeVoidError("${status}")`)
} else {
decodes.push(`"${status}": () => Effect.void`)
}
})
decodes.push(`orElse: unexpectedStatus`)

Expand Down Expand Up @@ -543,6 +565,11 @@ ${clientErrorSource(name)}`
errors.push(`${name}Error<"${schema}", ${schema}>`)
}
}
for (const status of operation.voidSchemas) {
if (Utils.isErrorStatus(status)) {
errors.push(`${name}Error<"${status}", undefined>`)
}
}

const jsdoc = Utils.toComment(operation.description)
const methodKey = `readonly "${operation.id}"`
Expand Down Expand Up @@ -637,6 +664,8 @@ ${clientErrorSource(name)}`
helpers.push(binaryRequestSourceTs)
}

const needsVoidError = hasVoidErrors(operations)

return `export interface OperationConfig {
/**
* Whether or not the response should be included in the value returned from
Expand Down Expand Up @@ -682,9 +711,24 @@ export const make = (
response.json as Effect.Effect<E, HttpClientError.HttpClientError>,
(cause) => Effect.fail(${name}Error(tag, cause, response)),
)
const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
${
needsVoidError ?
`const decodeVoidError =
<Tag extends string>(tag: Tag) =>
(
response: HttpClientResponse.HttpClientResponse,
): Effect.Effect<never, ${name}Error<Tag, undefined>> =>
Effect.fail(${name}Error(tag, undefined, response))
` :
""
}const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
successCodes: ReadonlyArray<string>,
errorCodes?: Record<string, string>,
errorCodes?: Record<string, string>,${
needsVoidError ?
`
voidErrorCodes?: ReadonlyArray<string>,` :
""
}
) => {
const cases: any = { orElse: unexpectedStatus }
for (const code of successCodes) {
Expand All @@ -694,6 +738,15 @@ export const make = (
for (const [code, tag] of Object.entries(errorCodes)) {
cases[code] = decodeError(tag)
}
}${
needsVoidError ?
`
if (voidErrorCodes) {
for (const code of voidErrorCodes) {
cases[code] = decodeVoidError(code)
}
}` :
""
}
if (successCodes.length === 0) {
cases["2xx"] = decodeVoid
Expand Down Expand Up @@ -744,11 +797,20 @@ export const make = (
const singleSuccessCode = successCodesRaw.length === 1 && successCodesRaw[0].startsWith("2")
const errorCodes = operation.errorSchemas.size > 0 &&
Object.fromEntries(operation.errorSchemas.entries())
const voidErrorStatuses = Array.from(operation.voidSchemas).filter(Utils.isErrorStatus)
const configAccessor = resolveConfigAccessor(operation, "options", "config")
const onRequestArgs = [
`[${singleSuccessCode ? `"2xx"` : successCodes}]`,
...(errorCodes ? [JSON.stringify(errorCodes)] : []),
...(voidErrorStatuses.length > 0
? [
...(errorCodes ? [] : ["undefined"]),
JSON.stringify(voidErrorStatuses)
]
: [])
]
pipeline.push(
`onRequest(${configAccessor})([${singleSuccessCode ? `"2xx"` : successCodes}]${
errorCodes ? `, ${JSON.stringify(errorCodes)}` : ""
})`
`onRequest(${configAccessor})(${onRequestArgs.join(", ")})`
)

return (
Expand Down
5 changes: 5 additions & 0 deletions packages/tools/openapi-generator/src/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,8 @@ export const spreadElementsInto = <A>(source: Array<A>, destination: Array<A>):
destination.push(source[i])
}
}

export const isErrorStatus = (status: string): boolean => {
const major = Number(status[0])
return !Number.isNaN(major) && major >= 4
}
101 changes: 101 additions & 0 deletions packages/tools/openapi-generator/test/OpenApiGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2128,4 +2128,105 @@ export const __HttpApiMultipartFiles = Multipart.FilesSchema`,
}
]))
})

describe("HEAD void-collapse fix", () => {
const headSpec: OpenAPISpec = {
openapi: "3.1.0",
info: {
title: "Test API",
version: "1.0.0"
},
paths: {
"/resources/{id}": {
head: {
operationId: "checkResource",
tags: ["Test"],
security: [],
parameters: [
{
name: "id",
in: "path",
schema: { type: "string" },
required: true
}
],
responses: {
200: { description: "Resource exists" },
400: { description: "Bad request" },
404: { description: "Resource not found" },
500: { description: "Internal server error" }
}
}
}
},
components: { schemas: {}, securitySchemes: {} },
security: [],
tags: []
}

it.effect("routes 4xx/5xx void schemas to error channel in schema mode", () =>
assertRuntimeIncludes(headSpec, [
// 200 should remain void (success channel)
`"200": () => Effect.void`,
// 4xx/5xx should route to error channel via decodeVoidError
`"400": decodeVoidError("400")`,
`"404": decodeVoidError("404")`,
`"500": decodeVoidError("500")`,
// The decodeVoidError helper should be generated
`const decodeVoidError`,
// Type signature should include void error types
`TestClientError<"400", undefined>`,
`TestClientError<"404", undefined>`,
`TestClientError<"500", undefined>`
]))

it.effect("routes 4xx/5xx void schemas to error channel in type-only mode", () =>
Effect.gen(function*() {
const generator = yield* OpenApiGenerator.OpenApiGenerator

const result = yield* generator.generate(headSpec, {
name: "TestClient",
format: "httpclient-type-only"
})

// Type signature should include void error types
assert.include(result, `TestClientError<"400", undefined>`)
assert.include(result, `TestClientError<"404", undefined>`)
assert.include(result, `TestClientError<"500", undefined>`)
// decodeVoidError helper should be generated in the implementation
assert.include(result, `const decodeVoidError`)
}).pipe(
Effect.provide(OpenApiGenerator.layerTransformerTs)
))

it.effect("preserves 2xx void schemas as success", () =>
assertRuntimeIncludes(
{
openapi: "3.1.0",
info: { title: "Test API", version: "1.0.0" },
paths: {
"/health": {
head: {
operationId: "healthCheck",
tags: ["Test"],
security: [],
parameters: [],
responses: {
200: { description: "Healthy" },
204: { description: "Healthy, no content" }
}
}
}
},
components: { schemas: {}, securitySchemes: {} },
security: [],
tags: []
},
[
// Both 2xx codes should remain void success
`"200": () => Effect.void`,
`"204": () => Effect.void`
]
))
})
})