Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions packages/app/src/shell/api-client/create-client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { HttpMethod } from "openapi-typescript-helpers"
import type {
ApiFailure,
ApiSuccess,
HttpError,
OperationFor,
PathsForMethod,
RequestOptionsFor,
Expand Down Expand Up @@ -78,6 +79,20 @@ type RequestEffect<
HttpClient.HttpClient
>

type RequestEffectWithHttpErrorsInSuccess<
Paths extends object,
Path extends keyof Paths,
Method extends HttpMethod
> = Effect.Effect<
| ApiSuccess<ResponsesForOperation<Paths, Path, Method>>
| HttpError<ResponsesForOperation<Paths, Path, Method>>,
Exclude<
ApiFailure<ResponsesForOperation<Paths, Path, Method>>,
HttpError<ResponsesForOperation<Paths, Path, Method>>
>,
HttpClient.HttpClient
>

type DispatcherFor<
Paths extends object,
Path extends keyof Paths,
Expand Down Expand Up @@ -253,3 +268,47 @@ export type StrictApiClientWithDispatchers<Paths extends object> = {
options?: RequestOptionsForOperation<Paths, Path, "options">
) => RequestEffect<Paths, Path, "options">
}

/**
* 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<Paths extends object> = {
readonly GET: <Path extends PathsForMethod<Paths, "get">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "get">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "get">

readonly POST: <Path extends PathsForMethod<Paths, "post">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "post">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "post">

readonly PUT: <Path extends PathsForMethod<Paths, "put">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "put">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "put">

readonly DELETE: <Path extends PathsForMethod<Paths, "delete">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "delete">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "delete">

readonly PATCH: <Path extends PathsForMethod<Paths, "patch">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "patch">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "patch">

readonly HEAD: <Path extends PathsForMethod<Paths, "head">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "head">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "head">

readonly OPTIONS: <Path extends PathsForMethod<Paths, "options">>(
path: Path,
options?: RequestOptionsForOperation<Paths, Path, "options">
) => RequestEffectWithHttpErrorsInSuccess<Paths, Path, "options">
}
67 changes: 53 additions & 14 deletions packages/app/src/shell/api-client/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +24,7 @@ import type { StrictRequestInit } from "./strict-client.js"
import { createUniversalDispatcher, executeRequest } from "./strict-client.js"

export type {
ClientEffect,
ClientOptions,
DispatchersFor,
StrictApiClient,
Expand Down Expand Up @@ -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 = <A, E>(
request: Effect.Effect<A, E, HttpClient.HttpClient>
): Effect.Effect<
A | Extract<E, HttpErrorTag>,
Exclude<E, Extract<E, HttpErrorTag>>,
HttpClient.HttpClient
> =>
request.pipe(
Effect.catchIf(
(error): error is Extract<E, HttpErrorTag> => 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<Paths>(clientOptions); apiClientEffect.POST('/api/auth/login', { body: credentials })"
// REF: issue-5
// SOURCE: n/a
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options) → StrictApiClientWithDispatchers<Paths>
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options) → ClientEffect<Paths>
// PURITY: SHELL
// EFFECT: Client methods return Effect<ApiSuccess, ApiFailure, HttpClient>
// EFFECT: Client methods return Effect<ApiSuccess | HttpError, BoundaryError, HttpClient>
// INVARIANT: ∀ path, method: path ∈ PathsForMethod<Paths, method> (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.
Expand Down Expand Up @@ -398,14 +437,14 @@ const createMethodHandlerWithUniversalDispatcher = (
*/
export const createClientEffect = <Paths extends object>(
options: ClientOptions
): StrictApiClientWithDispatchers<Paths> => {
return asStrictApiClient<StrictApiClientWithDispatchers<Paths>>({
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<Paths> => {
return asStrictApiClient<ClientEffect<Paths>>({
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)
})
}
8 changes: 7 additions & 1 deletion packages/app/src/shell/api-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// 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<Paths>(options).POST(path, { body }) → Effect<ApiSuccess, ApiFailure, HttpClient>
// FORMAT THEOREM: ∀ Paths, options: createClientEffect<Paths>(options).POST(path, { body }) → Effect<ApiSuccess | HttpError, BoundaryError, HttpClient>
// PURITY: SHELL
// EFFECT: Effect<void, never, never>
// INVARIANT: The exact user snippet compiles and produces correct runtime behavior
// 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"

// CHANGE: Import via the package's main entry point (src/index.ts)
Expand Down Expand Up @@ -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)", () =>
Expand All @@ -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
})
Expand All @@ -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")
Expand All @@ -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()
Expand Down
Loading