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
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,42 @@ Drop-in replacement for `openapi-fetch` with an opt-in Effect API.
pnpm add @prover-coder-ai/openapi-effect
```

## Usage (Promise API)
## Usage (Envelope API)

This package implements an `openapi-fetch` compatible API, so most code can be migrated by changing only the import.
This package implements `openapi-fetch` compatible method inputs and response envelopes.

```ts
import { Effect } from "effect"
import createClient from "@prover-coder-ai/openapi-effect"
import type { paths } from "./openapi"

const client = createClient<paths>({ baseUrl: "https://api.example.com" })

const { data, error } = await client.GET("/pets", {
params: { query: { limit: 10 } }
})
const program = Effect.gen(function* () {
const { data, error } = yield* client.GET("/pets", {
params: { query: { limit: 10 } }
})

if (error) {
// handle error
}
return error ?? data
})
```

## Usage (Effect API)

Effect-based client is available as an opt-in API.
`createClientEffect` keeps the same method inputs but moves non-2xx responses into the Effect error channel.

```ts
import { createClientEffect, FetchHttpClient } from "@prover-coder-ai/openapi-effect"
import { Effect } from "effect"
import { createClientEffect } from "@prover-coder-ai/openapi-effect"
import type { paths } from "./openapi"

const client = createClientEffect<paths>({ baseUrl: "https://api.example.com" })

const program = Effect.gen(function* () {
const result = yield* client.GET("/pets", {
params: { query: { limit: 10 } }
})

return result.body
})
```
9 changes: 9 additions & 0 deletions packages/app/src/core/axioms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,12 @@ export const asStrictApiClient = <T>(client: object): T => client as T
* @pure true
*/
export const asDispatchersFor = <T>(value: unknown): T => value as T

/**
* Cast middleware callback output after async boundary normalization.
* AXIOM: Middleware runtime validation checks the concrete Request/Response/Error
* shape before the value is used to modify execution.
*
* @pure true
*/
export const asMiddlewareResult = <T>(value: unknown): T => value as T
11 changes: 11 additions & 0 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,19 @@ export type * from "./core/api-client/index.js"
export { assertNever } from "./core/api-client/index.js"

export type {
Client,
ClientEffect,
ClientOptions,
DispatchersFor,
EffectClient,
EffectClientMethod,
EffectClientRequestMethod,
FetchOptions,
FetchResponse,
Middleware,
PathBasedClient,
QuerySerializer,
QuerySerializerOptions,
StrictApiClient,
StrictApiClientWithDispatchers
} from "./shell/api-client/create-client.js"
Expand Down
76 changes: 76 additions & 0 deletions packages/app/src/shell/api-client/create-client-effect-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// CHANGE: Define Effect-channel client types over openapi-fetch-compatible inputs
// WHY: Method inputs stay derived from openapi-fetch helpers while output is inferred from operation responses
// QUOTE(ТЗ): "output должен отличаться тем что он стаёт Effect ... input должен быть 1 в 1"
// REF: user-msg-openapi-effect-input-compat
// SOURCE: n/a
// PURITY: CORE - compile-time types only
// EFFECT: Effect<ApiSuccess<Responses>, ApiFailure<Responses>, never>
// INVARIANT: ∀ call: Path ∧ Method select exactly one OpenAPI operation response set
// COMPLEXITY: O(1) runtime / compile-time only

import type { Effect } from "effect"
import type { HttpMethod, PathsWithMethod } from "openapi-typescript-helpers"

import type { ApiFailure, ApiSuccess, ResponsesFor } from "../../core/api-client/strict-types.js"
import type {
MaybeOptionalInit,
MethodArgs,
Middleware,
OperationFor,
RequestMethodArgs
} from "./create-client-types.js"

type EffectMethodResult<
Paths extends object,
Path extends PathsWithMethod<Paths, Method>,
Method extends HttpMethod
> = Effect.Effect<
ApiSuccess<ResponsesFor<OperationFor<Paths, Path & keyof Paths, Method>>>,
ApiFailure<ResponsesFor<OperationFor<Paths, Path & keyof Paths, Method>>>
>

type EffectPath<Paths extends object, Method extends HttpMethod> = PathsWithMethod<Paths, Method>

type EffectInit<
Paths extends object,
Method extends HttpMethod,
Path extends EffectPath<Paths, Method>
> = MaybeOptionalInit<Paths[Path], Extract<Method, keyof Paths[Path]>>

export interface EffectClientMethod<
Paths extends object,
Method extends HttpMethod
> {
<
Path extends EffectPath<Paths, Method>,
Init extends EffectInit<Paths, Method, Path>
>(
...args: MethodArgs<Paths, Method, Path, Init>
): EffectMethodResult<Paths, Path, Method>
}

export interface EffectClientRequestMethod<Paths extends object> {
<
Method extends HttpMethod,
Path extends EffectPath<Paths, Method>,
Init extends EffectInit<Paths, Method, Path>
>(
...args: RequestMethodArgs<Method, MethodArgs<Paths, Method, Path, Init>>
): EffectMethodResult<Paths, Path, Method>
}

export interface EffectClient<Paths extends object> {
request: EffectClientRequestMethod<Paths>
GET: EffectClientMethod<Paths, "get">
PUT: EffectClientMethod<Paths, "put">
POST: EffectClientMethod<Paths, "post">
DELETE: EffectClientMethod<Paths, "delete">
OPTIONS: EffectClientMethod<Paths, "options">
HEAD: EffectClientMethod<Paths, "head">
PATCH: EffectClientMethod<Paths, "patch">
TRACE: EffectClientMethod<Paths, "trace">
use(...middleware: Array<Middleware>): void
eject(...middleware: Array<Middleware>): void
}

export type ClientEffect<Paths extends object> = EffectClient<Paths>
43 changes: 29 additions & 14 deletions packages/app/src/shell/api-client/create-client-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Effect } from "effect"

import { asMiddlewareResult } from "../../core/axioms.js"
import { toError } from "./create-client-response.js"
import type { AsyncValue, MergedOptions, Middleware, MiddlewareRequestParams, Thenable } from "./create-client-types.js"
import type { MergedOptions, Middleware, MiddlewareRequestParams, Thenable } from "./create-client-types.js"

const isThenable = <T>(value: unknown): value is Thenable<T> => (
typeof value === "object"
Expand All @@ -10,19 +11,27 @@ const isThenable = <T>(value: unknown): value is Thenable<T> => (
&& typeof Reflect.get(value, "then") === "function"
)

export const toPromiseEffect = <T>(value: AsyncValue<T>): Effect.Effect<T, Error> => (
const succeedMiddlewareResult = <T>(value: unknown): Effect.Effect<T | undefined> => {
if (value === undefined) {
return Effect.sync((): undefined => undefined)
}

return Effect.succeed(asMiddlewareResult<T>(value))
}

export const toPromiseEffect = <T>(value: unknown): Effect.Effect<T | undefined, Error> => (
isThenable(value)
? Effect.async<T, Error>((resume) => {
? Effect.async<T | undefined, Error>((resume) => {
value.then(
(result) => {
resume(Effect.succeed(result))
resume(succeedMiddlewareResult<T>(result))
},
(error) => {
resume(Effect.fail(toError(error)))
}
)
})
: Effect.succeed(value)
: succeedMiddlewareResult<T>(value)
)

export type MiddlewareContext = {
Expand Down Expand Up @@ -80,7 +89,9 @@ export const applyRequestMiddleware = (
continue
}

const result = yield* toPromiseEffect(item.onRequest(createMiddlewareParams(nextRequest, context)))
const result = yield* toPromiseEffect<Request | Response>(
item.onRequest(createMiddlewareParams(nextRequest, context))
)

if (result === undefined) {
continue
Expand Down Expand Up @@ -116,10 +127,12 @@ export const applyResponseMiddleware = (
continue
}

const result = yield* toPromiseEffect(item.onResponse({
...createMiddlewareParams(request, context),
response: nextResponse
}))
const result = yield* toPromiseEffect<Response>(
item.onResponse({
...createMiddlewareParams(request, context),
response: nextResponse
})
)

if (result === undefined) {
continue
Expand Down Expand Up @@ -160,10 +173,12 @@ export const applyErrorMiddleware = (
continue
}

const rawResult = yield* toPromiseEffect(item.onError({
...createMiddlewareParams(request, context),
error: nextError
}))
const rawResult = yield* toPromiseEffect<Response | Error>(
item.onError({
...createMiddlewareParams(request, context),
error: nextError
})
)

const result = yield* normalizeErrorResult(rawResult)
if (result instanceof Response) {
Expand Down
Loading
Loading