diff --git a/.changeset/fix-head-void-collapse.md b/.changeset/fix-head-void-collapse.md new file mode 100644 index 00000000000..8f23c26fb81 --- /dev/null +++ b/.changeset/fix-head-void-collapse.md @@ -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. diff --git a/packages/tools/openapi-generator/src/OpenApiTransformer.ts b/packages/tools/openapi-generator/src/OpenApiTransformer.ts index e511a64072a..f45028c64c7 100644 --- a/packages/tools/openapi-generator/src/OpenApiTransformer.ts +++ b/packages/tools/openapi-generator/src/OpenApiTransformer.ts @@ -60,6 +60,9 @@ const computeImportRequirements = (operations: ReadonlyArray): const requiresStreaming = (requirements: ImportRequirements): boolean => requirements.eventStream || requirements.octetStream +const hasVoidErrors = (operations: ReadonlyArray): boolean => + operations.some((op) => Array.from(op.voidSchemas).some(Utils.isErrorStatus)) + /** * Create the transformer used for schema-backed HttpClient output. * @@ -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}"` @@ -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 @@ -273,7 +283,15 @@ export const make = ( HttpClientResponse.schemaBodyJson(schema)(response), (cause) => Effect.fail(${name}Error(tag, cause, response)), ) - return { + ${ + needsVoidError ? + `const decodeVoidError = + (tag: Tag) => + (response: HttpClientResponse.HttpClientResponse) => + Effect.fail(${name}Error(tag, undefined, response)) + ` : + "" + }return { httpClient, ${implMethods.join(",\n ")} } @@ -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`) @@ -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}"` @@ -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 @@ -682,9 +711,24 @@ export const make = ( response.json as Effect.Effect, (cause) => Effect.fail(${name}Error(tag, cause, response)), ) - const onRequest = (config: Config | undefined) => ( + ${ + needsVoidError ? + `const decodeVoidError = + (tag: Tag) => + ( + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect> => + Effect.fail(${name}Error(tag, undefined, response)) + ` : + "" + }const onRequest = (config: Config | undefined) => ( successCodes: ReadonlyArray, - errorCodes?: Record, + errorCodes?: Record,${ + needsVoidError ? + ` + voidErrorCodes?: ReadonlyArray,` : + "" + } ) => { const cases: any = { orElse: unexpectedStatus } for (const code of successCodes) { @@ -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 @@ -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 ( diff --git a/packages/tools/openapi-generator/src/Utils.ts b/packages/tools/openapi-generator/src/Utils.ts index 958f0faf814..9d4f0d07e54 100644 --- a/packages/tools/openapi-generator/src/Utils.ts +++ b/packages/tools/openapi-generator/src/Utils.ts @@ -109,3 +109,8 @@ export const spreadElementsInto = (source: Array, destination: Array): destination.push(source[i]) } } + +export const isErrorStatus = (status: string): boolean => { + const major = Number(status[0]) + return !Number.isNaN(major) && major >= 4 +} diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index bf93c46b416..cbc28f73f9f 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -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` + ] + )) + }) })