Skip to content

Commit 221f231

Browse files
fix(app): createClientEffect without Effect.either for HTTP errors (#8)
* fix(app): return HttpError as value in createClientEffect * fix(types): tighten catchIf typing for HttpError values --------- Co-authored-by: codex-ci <codex-ci@users.noreply.github.com>
1 parent 266d607 commit 221f231

6 files changed

Lines changed: 226 additions & 151 deletions

File tree

packages/app/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// High-level API (recommended for most users)
1010
export { createClient as default } from "./shell/api-client/create-client.js"
1111
export type {
12+
ClientEffect,
1213
ClientOptions,
1314
DispatchersFor,
1415
StrictApiClient,

packages/app/src/shell/api-client/create-client-types.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { HttpMethod } from "openapi-typescript-helpers"
1515
import type {
1616
ApiFailure,
1717
ApiSuccess,
18+
HttpError,
1819
OperationFor,
1920
PathsForMethod,
2021
RequestOptionsFor,
@@ -78,6 +79,20 @@ type RequestEffect<
7879
HttpClient.HttpClient
7980
>
8081

82+
type RequestEffectWithHttpErrorsInSuccess<
83+
Paths extends object,
84+
Path extends keyof Paths,
85+
Method extends HttpMethod
86+
> = Effect.Effect<
87+
| ApiSuccess<ResponsesForOperation<Paths, Path, Method>>
88+
| HttpError<ResponsesForOperation<Paths, Path, Method>>,
89+
Exclude<
90+
ApiFailure<ResponsesForOperation<Paths, Path, Method>>,
91+
HttpError<ResponsesForOperation<Paths, Path, Method>>
92+
>,
93+
HttpClient.HttpClient
94+
>
95+
8196
type DispatcherFor<
8297
Paths extends object,
8398
Path extends keyof Paths,
@@ -253,3 +268,47 @@ export type StrictApiClientWithDispatchers<Paths extends object> = {
253268
options?: RequestOptionsForOperation<Paths, Path, "options">
254269
) => RequestEffect<Paths, Path, "options">
255270
}
271+
272+
/**
273+
* Ergonomic API client where HTTP statuses (2xx + 4xx/5xx from schema)
274+
* are returned in the success value channel.
275+
*
276+
* Boundary/protocol errors remain in the error channel.
277+
* This removes the need for `Effect.either` when handling normal HTTP statuses.
278+
*/
279+
export type ClientEffect<Paths extends object> = {
280+
readonly GET: <Path extends PathsForMethod<Paths, "get">>(
281+
path: Path,
282+
options?: RequestOptionsForOperation<Paths, Path, "get">
283+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "get">
284+
285+
readonly POST: <Path extends PathsForMethod<Paths, "post">>(
286+
path: Path,
287+
options?: RequestOptionsForOperation<Paths, Path, "post">
288+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "post">
289+
290+
readonly PUT: <Path extends PathsForMethod<Paths, "put">>(
291+
path: Path,
292+
options?: RequestOptionsForOperation<Paths, Path, "put">
293+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "put">
294+
295+
readonly DELETE: <Path extends PathsForMethod<Paths, "delete">>(
296+
path: Path,
297+
options?: RequestOptionsForOperation<Paths, Path, "delete">
298+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "delete">
299+
300+
readonly PATCH: <Path extends PathsForMethod<Paths, "patch">>(
301+
path: Path,
302+
options?: RequestOptionsForOperation<Paths, Path, "patch">
303+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "patch">
304+
305+
readonly HEAD: <Path extends PathsForMethod<Paths, "head">>(
306+
path: Path,
307+
options?: RequestOptionsForOperation<Paths, Path, "head">
308+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "head">
309+
310+
readonly OPTIONS: <Path extends PathsForMethod<Paths, "options">>(
311+
path: Path,
312+
options?: RequestOptionsForOperation<Paths, Path, "options">
313+
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "options">
314+
}

packages/app/src/shell/api-client/create-client.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
// INVARIANT: All operations are type-safe from path → operation → request → response
99
// COMPLEXITY: O(1) client creation
1010

11+
import type * as HttpClient from "@effect/platform/HttpClient"
12+
import { Effect } from "effect"
1113
import type { HttpMethod } from "openapi-typescript-helpers"
1214

1315
import { asDispatchersFor, asStrictApiClient, asStrictRequestInit, type Dispatcher } from "../../core/axioms.js"
1416
import type {
17+
ClientEffect,
1518
ClientOptions,
1619
DispatchersFor,
1720
DispatchersForMethod,
@@ -21,6 +24,7 @@ import type { StrictRequestInit } from "./strict-client.js"
2124
import { createUniversalDispatcher, executeRequest } from "./strict-client.js"
2225

2326
export type {
27+
ClientEffect,
2428
ClientOptions,
2529
DispatchersFor,
2630
StrictApiClient,
@@ -349,22 +353,57 @@ const createMethodHandlerWithUniversalDispatcher = (
349353
options
350354
)
351355

356+
type HttpErrorTag = { readonly _tag: "HttpError" }
357+
358+
const isHttpErrorValue = (error: unknown): error is HttpErrorTag =>
359+
typeof error === "object"
360+
&& error !== null
361+
&& "_tag" in error
362+
&& Reflect.get(error, "_tag") === "HttpError"
363+
364+
const exposeHttpErrorsAsValues = <A, E>(
365+
request: Effect.Effect<A, E, HttpClient.HttpClient>
366+
): Effect.Effect<
367+
A | Extract<E, HttpErrorTag>,
368+
Exclude<E, Extract<E, HttpErrorTag>>,
369+
HttpClient.HttpClient
370+
> =>
371+
request.pipe(
372+
Effect.catchIf(
373+
(error): error is Extract<E, HttpErrorTag> => isHttpErrorValue(error),
374+
(error) => Effect.succeed(error)
375+
)
376+
)
377+
378+
const createMethodHandlerWithUniversalDispatcherValue = (
379+
method: HttpMethod,
380+
clientOptions: ClientOptions
381+
) =>
382+
(
383+
path: string,
384+
options?: MethodHandlerOptions
385+
) =>
386+
exposeHttpErrorsAsValues(
387+
createMethodHandlerWithUniversalDispatcher(method, clientOptions)(path, options)
388+
)
389+
352390
// CHANGE: Add createClientEffect — zero-boilerplate Effect-based API client
353391
// WHY: Enable the user's desired DSL without any generated code or dispatcher setup
354392
// QUOTE(ТЗ): "const apiClientEffect = createClientEffect<Paths>(clientOptions); apiClientEffect.POST('/api/auth/login', { body: credentials })"
355393
// REF: issue-5
356394
// SOURCE: n/a
357-
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options) → StrictApiClientWithDispatchers<Paths>
395+
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options) → ClientEffect<Paths>
358396
// PURITY: SHELL
359-
// EFFECT: Client methods return Effect<ApiSuccess, ApiFailure, HttpClient>
397+
// EFFECT: Client methods return Effect<ApiSuccess | HttpError, BoundaryError, HttpClient>
360398
// INVARIANT: ∀ path, method: path ∈ PathsForMethod<Paths, method> (compile-time) ∧ response classified by status range (runtime)
361399
// COMPLEXITY: O(1) client creation
362400
/**
363401
* Create type-safe Effect-based API client with zero boilerplate
364402
*
365-
* Uses a universal dispatcher that classifies responses by HTTP status range:
366-
* - 2xx → success channel (ApiSuccess)
367-
* - non-2xx → error channel (HttpError)
403+
* Uses a universal dispatcher and exposes HTTP statuses as values:
404+
* - 2xx → success value (ApiSuccess)
405+
* - non-2xx schema statuses → success value (HttpError with _tag)
406+
* - boundary/protocol failures stay in error channel
368407
* - JSON parsed automatically for application/json content types
369408
*
370409
* **No code generation needed.** No dispatcher registry needed.
@@ -398,14 +437,14 @@ const createMethodHandlerWithUniversalDispatcher = (
398437
*/
399438
export const createClientEffect = <Paths extends object>(
400439
options: ClientOptions
401-
): StrictApiClientWithDispatchers<Paths> => {
402-
return asStrictApiClient<StrictApiClientWithDispatchers<Paths>>({
403-
GET: createMethodHandlerWithUniversalDispatcher("get", options),
404-
POST: createMethodHandlerWithUniversalDispatcher("post", options),
405-
PUT: createMethodHandlerWithUniversalDispatcher("put", options),
406-
DELETE: createMethodHandlerWithUniversalDispatcher("delete", options),
407-
PATCH: createMethodHandlerWithUniversalDispatcher("patch", options),
408-
HEAD: createMethodHandlerWithUniversalDispatcher("head", options),
409-
OPTIONS: createMethodHandlerWithUniversalDispatcher("options", options)
440+
): ClientEffect<Paths> => {
441+
return asStrictApiClient<ClientEffect<Paths>>({
442+
GET: createMethodHandlerWithUniversalDispatcherValue("get", options),
443+
POST: createMethodHandlerWithUniversalDispatcherValue("post", options),
444+
PUT: createMethodHandlerWithUniversalDispatcherValue("put", options),
445+
DELETE: createMethodHandlerWithUniversalDispatcherValue("delete", options),
446+
PATCH: createMethodHandlerWithUniversalDispatcherValue("patch", options),
447+
HEAD: createMethodHandlerWithUniversalDispatcherValue("head", options),
448+
OPTIONS: createMethodHandlerWithUniversalDispatcherValue("options", options)
410449
})
411450
}

packages/app/src/shell/api-client/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,11 @@ export {
2727
} from "./strict-client.js"
2828

2929
// High-level client creation API
30-
export type { ClientOptions, DispatchersFor, StrictApiClient, StrictApiClientWithDispatchers } from "./create-client.js"
30+
export type {
31+
ClientEffect,
32+
ClientOptions,
33+
DispatchersFor,
34+
StrictApiClient,
35+
StrictApiClientWithDispatchers
36+
} from "./create-client.js"
3137
export { createClient, createClientEffect, registerDefaultDispatchers } from "./create-client.js"

packages/app/tests/api-client/create-client-effect-integration.test.ts

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
// CHANGE: Integration test verifying the exact user DSL snippet from issue #5
22
// WHY: Ensure the import/usage pattern requested by the user compiles and works end-to-end
33
// QUOTE(ТЗ): "import { createClientEffect, type ClientOptions } from 'openapi-effect' ... apiClientEffect.POST('/api/auth/login', { body: credentials })"
4-
// REF: issue-5, PR#6 comment from skulidropek
4+
// REF: issue-5
55
// SOURCE: n/a
6-
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options).POST(path, { body }) → Effect<ApiSuccess, ApiFailure, HttpClient>
6+
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options).POST(path, { body }) → Effect<ApiSuccess | HttpError, BoundaryError, HttpClient>
77
// PURITY: SHELL
88
// EFFECT: Effect<void, never, never>
99
// INVARIANT: The exact user snippet compiles and produces correct runtime behavior
1010
// COMPLEXITY: O(1) per test
1111

1212
import * as HttpClient from "@effect/platform/HttpClient"
1313
import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
14-
import { Effect, Either, Layer } from "effect"
14+
import { Effect, Layer } from "effect"
1515
import { describe, expect, it } from "vitest"
1616

1717
// CHANGE: Import via the package's main entry point (src/index.ts)
@@ -86,23 +86,19 @@ describe("CI/CD: exact user snippet from issue #5", () => {
8686
})
8787

8888
// Type-safe — path, method, and body all enforced at compile time
89-
const result = yield* Effect.either(
90-
apiClientEffect.POST("/api/auth/login", {
91-
body: credentials
92-
}).pipe(
93-
Effect.provide(
94-
createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody)
95-
)
89+
const result = yield* apiClientEffect.POST("/api/auth/login", {
90+
body: credentials
91+
}).pipe(
92+
Effect.provide(
93+
createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody)
9694
)
9795
)
9896

99-
expect(Either.isRight(result)).toBe(true)
100-
if (Either.isRight(result)) {
101-
expect(result.right.status).toBe(200)
102-
expect(result.right.contentType).toBe("application/json")
103-
const body = result.right.body as { id: string; email: string }
104-
expect(body.email).toBe("user@example.com")
105-
}
97+
expect("_tag" in result).toBe(false)
98+
expect(result.status).toBe(200)
99+
expect(result.contentType).toBe("application/json")
100+
const body = result.body as { id: string; email: string }
101+
expect(body.email).toBe("user@example.com")
106102
}).pipe(Effect.runPromise))
107103

108104
it("should compile and execute: yield* apiClientEffect.POST (inside Effect.gen)", () =>
@@ -122,30 +118,29 @@ describe("CI/CD: exact user snippet from issue #5", () => {
122118
)
123119
)
124120

121+
expect("_tag" in result).toBe(false)
125122
expect(result.status).toBe(200)
126123
expect(result.contentType).toBe("application/json")
127124
const body = result.body as { email: string }
128125
expect(body.email).toBe("user@example.com")
129126
}).pipe(Effect.runPromise))
130127

131-
it("should handle error responses via Effect error channel", () =>
128+
it("should expose 401 as HttpError value without Effect.either", () =>
132129
Effect.gen(function*() {
133130
const credentials = fixtures.wrongLoginBody()
134131
const errorBody = JSON.stringify({ error: "invalid_credentials" })
135132

136-
const result = yield* Effect.either(
137-
apiClientEffect.POST("/api/auth/login", {
138-
body: credentials
139-
}).pipe(
140-
Effect.provide(
141-
createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody)
142-
)
133+
const result = yield* apiClientEffect.POST("/api/auth/login", {
134+
body: credentials
135+
}).pipe(
136+
Effect.provide(
137+
createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody)
143138
)
144139
)
145140

146-
expect(Either.isLeft(result)).toBe(true)
147-
if (Either.isLeft(result)) {
148-
expect(result.left).toMatchObject({
141+
expect("_tag" in result).toBe(true)
142+
if ("_tag" in result) {
143+
expect(result).toMatchObject({
149144
_tag: "HttpError",
150145
status: 401
151146
})
@@ -165,6 +160,7 @@ describe("CI/CD: exact user snippet from issue #5", () => {
165160
)
166161
)
167162

163+
expect("_tag" in result).toBe(false)
168164
expect(result.status).toBe(200)
169165
const body = result.body as { email: string }
170166
expect(body.email).toBe("user@example.com")
@@ -178,6 +174,7 @@ describe("CI/CD: exact user snippet from issue #5", () => {
178174
)
179175
)
180176

177+
expect("_tag" in result).toBe(false)
181178
expect(result.status).toBe(204)
182179
expect(result.contentType).toBe("none")
183180
expect(result.body).toBeUndefined()

0 commit comments

Comments
 (0)