From db4d1c3adf0d37200338fe242933458c2ca84daf Mon Sep 17 00:00:00 2001 From: Ujjwal Ojha Date: Wed, 15 Jul 2026 08:41:44 +1000 Subject: [PATCH] Parameterize OpenAPI client with error/requirements generics Replace hardcoded HttpClientError and never with E/R type parameters on generated client interfaces, make factories, and internal helpers, matching the existing pattern used by HttpApiClient and RpcClient. --- .../openapi-generator-client-generics.md | 5 ++ .../src/OpenApiTransformer.ts | 56 ++++++++++--------- .../test/OpenApiGenerator.test.ts | 50 ++++++++++------- .../test/OpenApiGeneratorCli.test.ts | 4 +- 4 files changed, 66 insertions(+), 49 deletions(-) create mode 100644 .changeset/openapi-generator-client-generics.md diff --git a/.changeset/openapi-generator-client-generics.md b/.changeset/openapi-generator-client-generics.md new file mode 100644 index 00000000000..a95ec749b79 --- /dev/null +++ b/.changeset/openapi-generator-client-generics.md @@ -0,0 +1,5 @@ +--- +"@effect/openapi-generator": minor +--- + +Thread HttpClient error (`E`) and requirements (`R`) channels through generated client interfaces and the `make()` constructor, preserving middleware-introduced error types at the call site. diff --git a/packages/tools/openapi-generator/src/OpenApiTransformer.ts b/packages/tools/openapi-generator/src/OpenApiTransformer.ts index e511a64072a..346bbccd0d3 100644 --- a/packages/tools/openapi-generator/src/OpenApiTransformer.ts +++ b/packages/tools/openapi-generator/src/OpenApiTransformer.ts @@ -88,8 +88,8 @@ export const makeTransformerSchema = () => { methods.push(operationToBinaryMethod(name, op)) } } - return `export interface ${name} { - readonly httpClient: HttpClient.HttpClient + return `export interface ${name} { + readonly httpClient: HttpClient.HttpClient.With ${methods.join("\n ")} } @@ -129,7 +129,7 @@ ${clientErrorSource(name)}` .map((schema) => `typeof ${schema}.Type`) .join(" | ") } - const errors = ["HttpClientError.HttpClientError", "SchemaError"] + const errors = ["E", "SchemaError"] if (operation.errorSchemas.size > 0) { Utils.spreadElementsInto( Array.from(operation.errorSchemas.values()).map( @@ -143,7 +143,7 @@ ${clientErrorSource(name)}` const methodKey = `readonly "${operation.id}"` const generic = `` const parameters = args.join(", ") - const returnType = `Effect.Effect, ${errors.join(" | ")}>` + const returnType = `Effect.Effect, ${errors.join(" | ")}, R>` return `${jsdoc}${methodKey}: ${generic}(${parameters}) => ${returnType}` } @@ -174,7 +174,7 @@ ${clientErrorSource(name)}` const methodKey = `readonly "${operation.id}Sse"` const parameters = args.join(", ") const returnType = - `Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof ${operation.sseSchema}.Type }, HttpClientError.HttpClientError | SchemaError | Sse.Retry, typeof ${operation.sseSchema}.DecodingServices>` + `Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof ${operation.sseSchema}.Type }, E | HttpClientError.HttpClientError | SchemaError | Sse.Retry, R | typeof ${operation.sseSchema}.DecodingServices>` return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}` } @@ -204,7 +204,7 @@ ${clientErrorSource(name)}` const jsdoc = Utils.toComment(operation.description) const methodKey = `readonly "${operation.id}Stream"` const parameters = args.join(", ") - const returnType = `Stream.Stream` + const returnType = `Stream.Stream` return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}` } @@ -255,12 +255,12 @@ export type WithOptionalResponse = Config ext readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A -export const make = ( - httpClient: HttpClient.HttpClient, +export const make = ( + httpClient: HttpClient.HttpClient.With, options: { - readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + readonly transformClient?: ((client: HttpClient.HttpClient.With) => Effect.Effect>) | undefined } = {} -): ${name} => { +): ${name} => { ${helpers.join("\n ")} const decodeSuccess = (schema: Schema) => @@ -499,8 +499,8 @@ export const makeTransformerTs = () => { methods.push(operationToBinaryMethod(op)) } } - return `export interface ${name} { - readonly httpClient: HttpClient.HttpClient + return `export interface ${name} { + readonly httpClient: HttpClient.HttpClient.With ${methods.join("\n ")} } @@ -537,7 +537,7 @@ ${clientErrorSource(name)}` success = Array.from(operation.successSchemas.values()).join(" | ") } - const errors = ["HttpClientError.HttpClientError"] + const errors = ["E"] if (operation.errorSchemas.size > 0) { for (const schema of operation.errorSchemas.values()) { errors.push(`${name}Error<"${schema}", ${schema}>`) @@ -548,7 +548,7 @@ ${clientErrorSource(name)}` const methodKey = `readonly "${operation.id}"` const generic = `` const parameters = args.join(", ") - const returnType = `Effect.Effect, ${errors.join(" | ")}>` + const returnType = `Effect.Effect, ${errors.join(" | ")}, R>` return `${jsdoc}${methodKey}: ${generic}(${parameters}) => ${returnType}` } @@ -608,7 +608,7 @@ ${clientErrorSource(name)}` const jsdoc = Utils.toComment(operation.description) const methodKey = `readonly "${operation.id}Stream"` const parameters = args.join(", ") - const returnType = `Stream.Stream` + const returnType = `Stream.Stream` return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}` } @@ -659,12 +659,12 @@ export type WithOptionalResponse = Config ext readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A -export const make = ( - httpClient: HttpClient.HttpClient, +export const make = ( + httpClient: HttpClient.HttpClient.With, options: { - readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + readonly transformClient?: ((client: HttpClient.HttpClient.With) => Effect.Effect>) | undefined } = {} -): ${name} => { +): ${name} => { ${helpers.join("\n ")} const decodeSuccess = (response: HttpClientResponse.HttpClientResponse) => response.json as Effect.Effect @@ -895,9 +895,13 @@ const commonSource = `const unexpectedStatus = (response: HttpClientResponse.Htt }), ), ) - const withResponse = (config: Config | undefined) => ( - f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, - ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect => { + const withResponse = (config: Config | undefined) => ( + f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect< + Config extends { readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A, + E | E2, + R | R2 + > => { const withOptionalResponse = ( config?.includeResponse ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response]) @@ -923,8 +927,8 @@ const sseRequestSource = (_importName: string) => request: HttpClientRequest.HttpClientRequest ): Stream.Stream< { readonly event: string; readonly id: string | undefined; readonly data: Type }, - HttpClientError.HttpClientError | SchemaError | Sse.Retry, - DecodingServices + E | HttpClientError.HttpClientError | SchemaError | Sse.Retry, + R | DecodingServices > => HttpClient.filterStatusOk(httpClient).execute(request).pipe( Effect.map((response) => response.stream), @@ -934,7 +938,7 @@ const sseRequestSource = (_importName: string) => )` const binaryRequestSource = - `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream => + `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream => HttpClient.filterStatusOk(httpClient).execute(request).pipe( Effect.map((response) => response.stream), Stream.unwrap @@ -953,7 +957,7 @@ const sseRequestSourceTs = )` const binaryRequestSourceTs = - `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream => + `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream => HttpClient.filterStatusOk(httpClient).execute(request).pipe( Effect.map((response) => response.stream), Stream.unwrap diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index bf93c46b416..6f30608e368 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -379,12 +379,12 @@ export type WithOptionalResponse = Config ext readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A -export const make = ( - httpClient: HttpClient.HttpClient, +export const make = ( + httpClient: HttpClient.HttpClient.With, options: { - readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + readonly transformClient?: ((client: HttpClient.HttpClient.With) => Effect.Effect>) | undefined } = {} -): TestClient => { +): TestClient => { const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) => Effect.flatMap( Effect.orElseSucceed(response.json, () => "Unexpected status code"), @@ -399,9 +399,13 @@ export const make = ( }), ), ) - const withResponse = (config: Config | undefined) => ( - f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, - ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect => { + const withResponse = (config: Config | undefined) => ( + f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect< + Config extends { readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A, + E | E2, + R | R2 + > => { const withOptionalResponse = ( config?.includeResponse ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response]) @@ -437,9 +441,9 @@ export const make = ( } } -export interface TestClient { - readonly httpClient: HttpClient.HttpClient - readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError> +export interface TestClient { + readonly httpClient: HttpClient.HttpClient.With + readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, E | SchemaError, R> } export interface TestClientError { @@ -518,7 +522,7 @@ export const TestClientError = ( }, [ `import * as Sse from "effect/unstable/encoding/Sse"`, - `readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof StreamEvents200Sse.Type }, HttpClientError.HttpClientError | SchemaError | Sse.Retry, typeof StreamEvents200Sse.DecodingServices>`, + `readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof StreamEvents200Sse.Type }, E | HttpClientError.HttpClientError | SchemaError | Sse.Retry, R | typeof StreamEvents200Sse.DecodingServices>`, `"streamEventsSse": () => HttpClientRequest.get(\`/events\`).pipe(`, `sseRequest(StreamEvents200Sse)`, `schema: Schema.ConstraintDecoder` @@ -678,12 +682,12 @@ export type WithOptionalResponse = Config ext readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A -export const make = ( - httpClient: HttpClient.HttpClient, +export const make = ( + httpClient: HttpClient.HttpClient.With, options: { - readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect) | undefined + readonly transformClient?: ((client: HttpClient.HttpClient.With) => Effect.Effect>) | undefined } = {} -): TestClient => { +): TestClient => { const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) => Effect.flatMap( Effect.orElseSucceed(response.json, () => "Unexpected status code"), @@ -698,9 +702,13 @@ export const make = ( }), ), ) - const withResponse = (config: Config | undefined) => ( - f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, - ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect => { + const withResponse = (config: Config | undefined) => ( + f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, + ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect< + Config extends { readonly includeResponse: true } ? [A, HttpClientResponse.HttpClientResponse] : A, + E | E2, + R | R2 + > => { const withOptionalResponse = ( config?.includeResponse ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response]) @@ -756,9 +764,9 @@ export const make = ( } } -export interface TestClient { - readonly httpClient: HttpClient.HttpClient - readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError> +export interface TestClient { + readonly httpClient: HttpClient.HttpClient.With + readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, E, R> } export interface TestClientError { diff --git a/packages/tools/openapi-generator/test/OpenApiGeneratorCli.test.ts b/packages/tools/openapi-generator/test/OpenApiGeneratorCli.test.ts index c121043a82d..1694708f33b 100644 --- a/packages/tools/openapi-generator/test/OpenApiGeneratorCli.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGeneratorCli.test.ts @@ -122,7 +122,7 @@ describe("openapigen CLI", () => { const result = yield* runCli(["--spec", spec, "--name", "CliClient"]) assert.isTrue(Exit.isSuccess(result.exit)) - assert.include(result.stdout, "export const make = (") + assert.include(result.stdout, "export const make = (") assert.include(result.stderr, "WARNING [cookie-parameter-dropped]") assert.include(result.stderr, "cookie-parameter-dropped") assert.notInclude(result.stdout, "cookie-parameter-dropped") @@ -135,7 +135,7 @@ describe("openapigen CLI", () => { const result = yield* runCliProcess(["--spec", spec, "--name", "CliClient"]) assert.strictEqual(result.exitCode, ChildProcessSpawner.ExitCode(0)) - assert.include(result.stdout, "export const make = (") + assert.include(result.stdout, "export const make = (") assert.notInclude(result.stdout, "WARNING [") assert.notInclude(result.stdout, "cookie-parameter-dropped") assert.include(