diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 2cda8cc..b07cca5 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -9,6 +9,7 @@ // High-level API (recommended for most users) export { createClient as default } from "./shell/api-client/create-client.js" export type { + ClientEffect, ClientOptions, DispatchersFor, StrictApiClient, diff --git a/packages/app/src/shell/api-client/create-client-types.ts b/packages/app/src/shell/api-client/create-client-types.ts index e880de9..762d90d 100644 --- a/packages/app/src/shell/api-client/create-client-types.ts +++ b/packages/app/src/shell/api-client/create-client-types.ts @@ -15,6 +15,7 @@ import type { HttpMethod } from "openapi-typescript-helpers" import type { ApiFailure, ApiSuccess, + HttpError, OperationFor, PathsForMethod, RequestOptionsFor, @@ -78,6 +79,20 @@ type RequestEffect< HttpClient.HttpClient > +type RequestEffectWithHttpErrorsInSuccess< + Paths extends object, + Path extends keyof Paths, + Method extends HttpMethod +> = Effect.Effect< + | ApiSuccess> + | HttpError>, + Exclude< + ApiFailure>, + HttpError> + >, + HttpClient.HttpClient +> + type DispatcherFor< Paths extends object, Path extends keyof Paths, @@ -253,3 +268,47 @@ export type StrictApiClientWithDispatchers = { options?: RequestOptionsForOperation ) => RequestEffect } + +/** + * Ergonomic API client where HTTP statuses (2xx + 4xx/5xx from schema) + * are returned in the success value channel. + * + * Boundary/protocol errors remain in the error channel. + * This removes the need for `Effect.either` when handling normal HTTP statuses. + */ +export type ClientEffect = { + readonly GET: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly POST: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly PUT: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly DELETE: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly PATCH: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly HEAD: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess + + readonly OPTIONS: >( + path: Path, + options?: RequestOptionsForOperation + ) => RequestEffectWithHttpErrorsInSuccess +} diff --git a/packages/app/src/shell/api-client/create-client.ts b/packages/app/src/shell/api-client/create-client.ts index beee71d..8344dc5 100644 --- a/packages/app/src/shell/api-client/create-client.ts +++ b/packages/app/src/shell/api-client/create-client.ts @@ -8,10 +8,13 @@ // INVARIANT: All operations are type-safe from path → operation → request → response // COMPLEXITY: O(1) client creation +import type * as HttpClient from "@effect/platform/HttpClient" +import { Effect } from "effect" import type { HttpMethod } from "openapi-typescript-helpers" import { asDispatchersFor, asStrictApiClient, asStrictRequestInit, type Dispatcher } from "../../core/axioms.js" import type { + ClientEffect, ClientOptions, DispatchersFor, DispatchersForMethod, @@ -21,6 +24,7 @@ import type { StrictRequestInit } from "./strict-client.js" import { createUniversalDispatcher, executeRequest } from "./strict-client.js" export type { + ClientEffect, ClientOptions, DispatchersFor, StrictApiClient, @@ -349,22 +353,57 @@ const createMethodHandlerWithUniversalDispatcher = ( options ) +type HttpErrorTag = { readonly _tag: "HttpError" } + +const isHttpErrorValue = (error: unknown): error is HttpErrorTag => + typeof error === "object" + && error !== null + && "_tag" in error + && Reflect.get(error, "_tag") === "HttpError" + +const exposeHttpErrorsAsValues = ( + request: Effect.Effect +): Effect.Effect< + A | Extract, + Exclude>, + HttpClient.HttpClient +> => + request.pipe( + Effect.catchIf( + (error): error is Extract => isHttpErrorValue(error), + (error) => Effect.succeed(error) + ) + ) + +const createMethodHandlerWithUniversalDispatcherValue = ( + method: HttpMethod, + clientOptions: ClientOptions +) => +( + path: string, + options?: MethodHandlerOptions +) => + exposeHttpErrorsAsValues( + createMethodHandlerWithUniversalDispatcher(method, clientOptions)(path, options) + ) + // CHANGE: Add createClientEffect — zero-boilerplate Effect-based API client // WHY: Enable the user's desired DSL without any generated code or dispatcher setup // QUOTE(ТЗ): "const apiClientEffect = createClientEffect(clientOptions); apiClientEffect.POST('/api/auth/login', { body: credentials })" // REF: issue-5 // SOURCE: n/a -// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options) → StrictApiClientWithDispatchers +// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options) → ClientEffect // PURITY: SHELL -// EFFECT: Client methods return Effect +// EFFECT: Client methods return Effect // INVARIANT: ∀ path, method: path ∈ PathsForMethod (compile-time) ∧ response classified by status range (runtime) // COMPLEXITY: O(1) client creation /** * Create type-safe Effect-based API client with zero boilerplate * - * Uses a universal dispatcher that classifies responses by HTTP status range: - * - 2xx → success channel (ApiSuccess) - * - non-2xx → error channel (HttpError) + * Uses a universal dispatcher and exposes HTTP statuses as values: + * - 2xx → success value (ApiSuccess) + * - non-2xx schema statuses → success value (HttpError with _tag) + * - boundary/protocol failures stay in error channel * - JSON parsed automatically for application/json content types * * **No code generation needed.** No dispatcher registry needed. @@ -398,14 +437,14 @@ const createMethodHandlerWithUniversalDispatcher = ( */ export const createClientEffect = ( options: ClientOptions -): StrictApiClientWithDispatchers => { - return asStrictApiClient>({ - GET: createMethodHandlerWithUniversalDispatcher("get", options), - POST: createMethodHandlerWithUniversalDispatcher("post", options), - PUT: createMethodHandlerWithUniversalDispatcher("put", options), - DELETE: createMethodHandlerWithUniversalDispatcher("delete", options), - PATCH: createMethodHandlerWithUniversalDispatcher("patch", options), - HEAD: createMethodHandlerWithUniversalDispatcher("head", options), - OPTIONS: createMethodHandlerWithUniversalDispatcher("options", options) +): ClientEffect => { + return asStrictApiClient>({ + GET: createMethodHandlerWithUniversalDispatcherValue("get", options), + POST: createMethodHandlerWithUniversalDispatcherValue("post", options), + PUT: createMethodHandlerWithUniversalDispatcherValue("put", options), + DELETE: createMethodHandlerWithUniversalDispatcherValue("delete", options), + PATCH: createMethodHandlerWithUniversalDispatcherValue("patch", options), + HEAD: createMethodHandlerWithUniversalDispatcherValue("head", options), + OPTIONS: createMethodHandlerWithUniversalDispatcherValue("options", options) }) } diff --git a/packages/app/src/shell/api-client/index.ts b/packages/app/src/shell/api-client/index.ts index 47e0b49..6501150 100644 --- a/packages/app/src/shell/api-client/index.ts +++ b/packages/app/src/shell/api-client/index.ts @@ -27,5 +27,11 @@ export { } from "./strict-client.js" // High-level client creation API -export type { ClientOptions, DispatchersFor, StrictApiClient, StrictApiClientWithDispatchers } from "./create-client.js" +export type { + ClientEffect, + ClientOptions, + DispatchersFor, + StrictApiClient, + StrictApiClientWithDispatchers +} from "./create-client.js" export { createClient, createClientEffect, registerDefaultDispatchers } from "./create-client.js" diff --git a/packages/app/tests/api-client/create-client-effect-integration.test.ts b/packages/app/tests/api-client/create-client-effect-integration.test.ts index e39853d..a6aa598 100644 --- a/packages/app/tests/api-client/create-client-effect-integration.test.ts +++ b/packages/app/tests/api-client/create-client-effect-integration.test.ts @@ -1,9 +1,9 @@ // CHANGE: Integration test verifying the exact user DSL snippet from issue #5 // WHY: Ensure the import/usage pattern requested by the user compiles and works end-to-end // QUOTE(ТЗ): "import { createClientEffect, type ClientOptions } from 'openapi-effect' ... apiClientEffect.POST('/api/auth/login', { body: credentials })" -// REF: issue-5, PR#6 comment from skulidropek +// REF: issue-5 // SOURCE: n/a -// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options).POST(path, { body }) → Effect +// FORMAT THEOREM: ∀ Paths, options: createClientEffect(options).POST(path, { body }) → Effect // PURITY: SHELL // EFFECT: Effect // INVARIANT: The exact user snippet compiles and produces correct runtime behavior @@ -11,7 +11,7 @@ import * as HttpClient from "@effect/platform/HttpClient" import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import { Effect, Either, Layer } from "effect" +import { Effect, Layer } from "effect" import { describe, expect, it } from "vitest" // 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", () => { }) // Type-safe — path, method, and body all enforced at compile time - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { - body: credentials - }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: credentials + }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) ) ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(200) - expect(result.right.contentType).toBe("application/json") - const body = result.right.body as { id: string; email: string } - expect(body.email).toBe("user@example.com") - } + expect("_tag" in result).toBe(false) + expect(result.status).toBe(200) + expect(result.contentType).toBe("application/json") + const body = result.body as { id: string; email: string } + expect(body.email).toBe("user@example.com") }).pipe(Effect.runPromise)) it("should compile and execute: yield* apiClientEffect.POST (inside Effect.gen)", () => @@ -122,30 +118,29 @@ describe("CI/CD: exact user snippet from issue #5", () => { ) ) + expect("_tag" in result).toBe(false) expect(result.status).toBe(200) expect(result.contentType).toBe("application/json") const body = result.body as { email: string } expect(body.email).toBe("user@example.com") }).pipe(Effect.runPromise)) - it("should handle error responses via Effect error channel", () => + it("should expose 401 as HttpError value without Effect.either", () => Effect.gen(function*() { const credentials = fixtures.wrongLoginBody() const errorBody = JSON.stringify({ error: "invalid_credentials" }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { - body: credentials - }).pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { + body: credentials + }).pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 401 }) @@ -165,6 +160,7 @@ describe("CI/CD: exact user snippet from issue #5", () => { ) ) + expect("_tag" in result).toBe(false) expect(result.status).toBe(200) const body = result.body as { email: string } expect(body.email).toBe("user@example.com") @@ -178,6 +174,7 @@ describe("CI/CD: exact user snippet from issue #5", () => { ) ) + expect("_tag" in result).toBe(false) expect(result.status).toBe(204) expect(result.contentType).toBe("none") expect(result.body).toBeUndefined() diff --git a/packages/app/tests/api-client/create-client-effect.test.ts b/packages/app/tests/api-client/create-client-effect.test.ts index e5b2dbc..3e69987 100644 --- a/packages/app/tests/api-client/create-client-effect.test.ts +++ b/packages/app/tests/api-client/create-client-effect.test.ts @@ -3,15 +3,15 @@ // QUOTE(ТЗ): "apiClientEffect.POST('/api/auth/login', { body: credentials }) — Что бы это работало" // REF: issue-5 // SOURCE: n/a -// FORMAT THEOREM: ∀ op ∈ AuthOperations: createClientEffect(options).METHOD(path, opts) → Effect +// FORMAT THEOREM: ∀ op ∈ AuthOperations: createClientEffect(options).METHOD(path, opts) → Effect // PURITY: SHELL // EFFECT: Effect -// INVARIANT: 2xx → isRight (success), non-2xx → isLeft (HttpError), transport failure → isLeft (TransportError) +// INVARIANT: HTTP statuses are returned as values, boundary failures stay in error channel // COMPLEXITY: O(1) per test import * as HttpClient from "@effect/platform/HttpClient" import * as HttpClientResponse from "@effect/platform/HttpClientResponse" -import { Effect, Either, Layer } from "effect" +import { Effect, Layer } from "effect" import { describe, expect, it } from "vitest" import type { paths } from "../../src/core/api/openapi.js" @@ -76,86 +76,76 @@ describe("createClientEffect (zero-boilerplate, auth schema)", () => { phoneVerified: false }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, successBody) ) ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(200) - expect(result.right.contentType).toBe("application/json") - const body = result.right.body as { id: string; email: string } - expect(body.id).toBe("550e8400-e29b-41d4-a716-446655440000") - expect(body.email).toBe("user@example.com") - } + expect("_tag" in result).toBe(false) + expect(result.status).toBe(200) + expect(result.contentType).toBe("application/json") + const body = result.body as { id: string; email: string } + expect(body.id).toBe("550e8400-e29b-41d4-a716-446655440000") + expect(body.email).toBe("user@example.com") }).pipe(Effect.runPromise)) - it("should return HttpError for 401 invalid_credentials", () => + it("should return HttpError value for 401 invalid_credentials", () => Effect.gen(function*() { const credentials = fixtures.wrongLoginBody() const errorBody = JSON.stringify({ error: "invalid_credentials" }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 401 }) - const left = result.left as { body: { error: string } } - expect(left.body.error).toBe("invalid_credentials") + const body = result.body as { error: string } + expect(body.error).toBe("invalid_credentials") } }).pipe(Effect.runPromise)) - it("should return HttpError for 400 bad request", () => + it("should return HttpError value for 400 bad request", () => Effect.gen(function*() { const credentials = fixtures.loginBody() const errorBody = JSON.stringify({ error: "invalid_payload" }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(400, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(400, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 400 }) } }).pipe(Effect.runPromise)) - it("should return HttpError for 500 internal_error", () => + it("should return HttpError value for 500 internal_error", () => Effect.gen(function*() { const credentials = fixtures.loginBody() const errorBody = JSON.stringify({ error: "internal_error" }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(500, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(500, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 500 }) @@ -164,20 +154,16 @@ describe("createClientEffect (zero-boilerplate, auth schema)", () => { it("should POST /api/auth/logout and return 204 no-content success", () => Effect.gen(function*() { - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/logout").pipe( - Effect.provide( - createMockHttpClientLayer(204, {}, "") - ) + const result = yield* apiClientEffect.POST("/api/auth/logout").pipe( + Effect.provide( + createMockHttpClientLayer(204, {}, "") ) ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(204) - expect(result.right.contentType).toBe("none") - expect(result.right.body).toBeUndefined() - } + expect("_tag" in result).toBe(false) + expect(result.status).toBe(204) + expect(result.contentType).toBe("none") + expect(result.body).toBeUndefined() }).pipe(Effect.runPromise)) it("should GET /api/auth/me and return 200 with user profile", () => @@ -199,38 +185,32 @@ describe("createClientEffect (zero-boilerplate, auth schema)", () => { workPhone: null }) - const result = yield* Effect.either( - apiClientEffect.GET("/api/auth/me").pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "application/json" }, profileBody) - ) + const result = yield* apiClientEffect.GET("/api/auth/me").pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "application/json" }, profileBody) ) ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(200) - const body = result.right.body as { email: string; firstName: string } - expect(body.email).toBe("user@example.com") - expect(body.firstName).toBe("John") - } + expect("_tag" in result).toBe(false) + expect(result.status).toBe(200) + const body = result.body as { email: string; firstName: string } + expect(body.email).toBe("user@example.com") + expect(body.firstName).toBe("John") }).pipe(Effect.runPromise)) - it("should GET /api/auth/me and return HttpError for 401 unauthorized", () => + it("should GET /api/auth/me and return HttpError value for 401 unauthorized", () => Effect.gen(function*() { const errorBody = JSON.stringify({ error: "unauthorized" }) - const result = yield* Effect.either( - apiClientEffect.GET("/api/auth/me").pipe( - Effect.provide( - createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.GET("/api/auth/me").pipe( + Effect.provide( + createMockHttpClientLayer(401, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 401 }) @@ -248,43 +228,37 @@ describe("createClientEffect (zero-boilerplate, auth schema)", () => { profileImageUrl: null }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/register", { body: registerBody }).pipe( - Effect.provide( - createMockHttpClientLayer(201, { "content-type": "application/json" }, successBody) - ) + const result = yield* apiClientEffect.POST("/api/register", { body: registerBody }).pipe( + Effect.provide( + createMockHttpClientLayer(201, { "content-type": "application/json" }, successBody) ) ) - expect(Either.isRight(result)).toBe(true) - if (Either.isRight(result)) { - expect(result.right.status).toBe(201) - const body = result.right.body as { id: string; email: string } - expect(body.email).toBe("new@example.com") - } + expect("_tag" in result).toBe(false) + expect(result.status).toBe(201) + const body = result.body as { id: string; email: string } + expect(body.email).toBe("new@example.com") }).pipe(Effect.runPromise)) - it("should POST /api/register and return HttpError for 409 user_exists", () => + it("should POST /api/register and return HttpError value for 409 user_exists", () => Effect.gen(function*() { const registerBody = fixtures.registerBody() const errorBody = JSON.stringify({ error: "user_exists" }) - const result = yield* Effect.either( - apiClientEffect.POST("/api/register", { body: registerBody }).pipe( - Effect.provide( - createMockHttpClientLayer(409, { "content-type": "application/json" }, errorBody) - ) + const result = yield* apiClientEffect.POST("/api/register", { body: registerBody }).pipe( + Effect.provide( + createMockHttpClientLayer(409, { "content-type": "application/json" }, errorBody) ) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "HttpError", status: 409 }) - const left = result.left as { body: { error: string } } - expect(left.body.error).toBe("user_exists") + const body = result.body as { error: string } + expect(body.error).toBe("user_exists") } }).pipe(Effect.runPromise)) @@ -292,17 +266,16 @@ describe("createClientEffect (zero-boilerplate, auth schema)", () => { Effect.gen(function*() { const credentials = fixtures.loginBody() - const result = yield* Effect.either( - apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( - Effect.provide( - createMockHttpClientLayer(200, { "content-type": "text/html" }, "error") - ) - ) + const result = yield* apiClientEffect.POST("/api/auth/login", { body: credentials }).pipe( + Effect.provide( + createMockHttpClientLayer(200, { "content-type": "text/html" }, "error") + ), + Effect.catchTag("UnexpectedContentType", (error) => Effect.succeed(error)) ) - expect(Either.isLeft(result)).toBe(true) - if (Either.isLeft(result)) { - expect(result.left).toMatchObject({ + expect("_tag" in result).toBe(true) + if ("_tag" in result) { + expect(result).toMatchObject({ _tag: "UnexpectedContentType", status: 200 })