Skip to content

Commit df5a3e0

Browse files
authored
feat(api-client): infer createClientEffect output channels (#21)
1 parent 6b3e168 commit df5a3e0

16 files changed

Lines changed: 686 additions & 132 deletions

README.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,42 @@ Drop-in replacement for `openapi-fetch` with an opt-in Effect API.
88
pnpm add @prover-coder-ai/openapi-effect
99
```
1010

11-
## Usage (Promise API)
11+
## Usage (Envelope API)
1212

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

1515
```ts
16+
import { Effect } from "effect"
1617
import createClient from "@prover-coder-ai/openapi-effect"
1718
import type { paths } from "./openapi"
1819

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

21-
const { data, error } = await client.GET("/pets", {
22-
params: { query: { limit: 10 } }
23-
})
22+
const program = Effect.gen(function* () {
23+
const { data, error } = yield* client.GET("/pets", {
24+
params: { query: { limit: 10 } }
25+
})
2426

25-
if (error) {
26-
// handle error
27-
}
27+
return error ?? data
28+
})
2829
```
2930

3031
## Usage (Effect API)
3132

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

3435
```ts
35-
import { createClientEffect, FetchHttpClient } from "@prover-coder-ai/openapi-effect"
36+
import { Effect } from "effect"
37+
import { createClientEffect } from "@prover-coder-ai/openapi-effect"
38+
import type { paths } from "./openapi"
39+
40+
const client = createClientEffect<paths>({ baseUrl: "https://api.example.com" })
41+
42+
const program = Effect.gen(function* () {
43+
const result = yield* client.GET("/pets", {
44+
params: { query: { limit: 10 } }
45+
})
46+
47+
return result.body
48+
})
3649
```

packages/app/src/core/axioms.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,12 @@ export const asStrictApiClient = <T>(client: object): T => client as T
141141
* @pure true
142142
*/
143143
export const asDispatchersFor = <T>(value: unknown): T => value as T
144+
145+
/**
146+
* Cast middleware callback output after async boundary normalization.
147+
* AXIOM: Middleware runtime validation checks the concrete Request/Response/Error
148+
* shape before the value is used to modify execution.
149+
*
150+
* @pure true
151+
*/
152+
export const asMiddlewareResult = <T>(value: unknown): T => value as T

packages/app/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,19 @@ export type * from "./core/api-client/index.js"
1010
export { assertNever } from "./core/api-client/index.js"
1111

1212
export type {
13+
Client,
14+
ClientEffect,
1315
ClientOptions,
1416
DispatchersFor,
17+
EffectClient,
18+
EffectClientMethod,
19+
EffectClientRequestMethod,
20+
FetchOptions,
21+
FetchResponse,
22+
Middleware,
23+
PathBasedClient,
24+
QuerySerializer,
25+
QuerySerializerOptions,
1526
StrictApiClient,
1627
StrictApiClientWithDispatchers
1728
} from "./shell/api-client/create-client.js"
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// CHANGE: Define Effect-channel client types over openapi-fetch-compatible inputs
2+
// WHY: Method inputs stay derived from openapi-fetch helpers while output is inferred from operation responses
3+
// QUOTE(ТЗ): "output должен отличаться тем что он стаёт Effect ... input должен быть 1 в 1"
4+
// REF: user-msg-openapi-effect-input-compat
5+
// SOURCE: n/a
6+
// PURITY: CORE - compile-time types only
7+
// EFFECT: Effect<ApiSuccess<Responses>, ApiFailure<Responses>, never>
8+
// INVARIANT: ∀ call: Path ∧ Method select exactly one OpenAPI operation response set
9+
// COMPLEXITY: O(1) runtime / compile-time only
10+
11+
import type { Effect } from "effect"
12+
import type { HttpMethod, PathsWithMethod } from "openapi-typescript-helpers"
13+
14+
import type { ApiFailure, ApiSuccess, ResponsesFor } from "../../core/api-client/strict-types.js"
15+
import type {
16+
MaybeOptionalInit,
17+
MethodArgs,
18+
Middleware,
19+
OperationFor,
20+
RequestMethodArgs
21+
} from "./create-client-types.js"
22+
23+
type EffectMethodResult<
24+
Paths extends object,
25+
Path extends PathsWithMethod<Paths, Method>,
26+
Method extends HttpMethod
27+
> = Effect.Effect<
28+
ApiSuccess<ResponsesFor<OperationFor<Paths, Path & keyof Paths, Method>>>,
29+
ApiFailure<ResponsesFor<OperationFor<Paths, Path & keyof Paths, Method>>>
30+
>
31+
32+
type EffectPath<Paths extends object, Method extends HttpMethod> = PathsWithMethod<Paths, Method>
33+
34+
type EffectInit<
35+
Paths extends object,
36+
Method extends HttpMethod,
37+
Path extends EffectPath<Paths, Method>
38+
> = MaybeOptionalInit<Paths[Path], Extract<Method, keyof Paths[Path]>>
39+
40+
export interface EffectClientMethod<
41+
Paths extends object,
42+
Method extends HttpMethod
43+
> {
44+
<
45+
Path extends EffectPath<Paths, Method>,
46+
Init extends EffectInit<Paths, Method, Path>
47+
>(
48+
...args: MethodArgs<Paths, Method, Path, Init>
49+
): EffectMethodResult<Paths, Path, Method>
50+
}
51+
52+
export interface EffectClientRequestMethod<Paths extends object> {
53+
<
54+
Method extends HttpMethod,
55+
Path extends EffectPath<Paths, Method>,
56+
Init extends EffectInit<Paths, Method, Path>
57+
>(
58+
...args: RequestMethodArgs<Method, MethodArgs<Paths, Method, Path, Init>>
59+
): EffectMethodResult<Paths, Path, Method>
60+
}
61+
62+
export interface EffectClient<Paths extends object> {
63+
request: EffectClientRequestMethod<Paths>
64+
GET: EffectClientMethod<Paths, "get">
65+
PUT: EffectClientMethod<Paths, "put">
66+
POST: EffectClientMethod<Paths, "post">
67+
DELETE: EffectClientMethod<Paths, "delete">
68+
OPTIONS: EffectClientMethod<Paths, "options">
69+
HEAD: EffectClientMethod<Paths, "head">
70+
PATCH: EffectClientMethod<Paths, "patch">
71+
TRACE: EffectClientMethod<Paths, "trace">
72+
use(...middleware: Array<Middleware>): void
73+
eject(...middleware: Array<Middleware>): void
74+
}
75+
76+
export type ClientEffect<Paths extends object> = EffectClient<Paths>

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

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Effect } from "effect"
22

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

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

13-
export const toPromiseEffect = <T>(value: AsyncValue<T>): Effect.Effect<T, Error> => (
14+
const succeedMiddlewareResult = <T>(value: unknown): Effect.Effect<T | undefined> => {
15+
if (value === undefined) {
16+
return Effect.sync((): undefined => undefined)
17+
}
18+
19+
return Effect.succeed(asMiddlewareResult<T>(value))
20+
}
21+
22+
export const toPromiseEffect = <T>(value: unknown): Effect.Effect<T | undefined, Error> => (
1423
isThenable(value)
15-
? Effect.async<T, Error>((resume) => {
24+
? Effect.async<T | undefined, Error>((resume) => {
1625
value.then(
1726
(result) => {
18-
resume(Effect.succeed(result))
27+
resume(succeedMiddlewareResult<T>(result))
1928
},
2029
(error) => {
2130
resume(Effect.fail(toError(error)))
2231
}
2332
)
2433
})
25-
: Effect.succeed(value)
34+
: succeedMiddlewareResult<T>(value)
2635
)
2736

2837
export type MiddlewareContext = {
@@ -80,7 +89,9 @@ export const applyRequestMiddleware = (
8089
continue
8190
}
8291

83-
const result = yield* toPromiseEffect(item.onRequest(createMiddlewareParams(nextRequest, context)))
92+
const result = yield* toPromiseEffect<Request | Response>(
93+
item.onRequest(createMiddlewareParams(nextRequest, context))
94+
)
8495

8596
if (result === undefined) {
8697
continue
@@ -116,10 +127,12 @@ export const applyResponseMiddleware = (
116127
continue
117128
}
118129

119-
const result = yield* toPromiseEffect(item.onResponse({
120-
...createMiddlewareParams(request, context),
121-
response: nextResponse
122-
}))
130+
const result = yield* toPromiseEffect<Response>(
131+
item.onResponse({
132+
...createMiddlewareParams(request, context),
133+
response: nextResponse
134+
})
135+
)
123136

124137
if (result === undefined) {
125138
continue
@@ -160,10 +173,12 @@ export const applyErrorMiddleware = (
160173
continue
161174
}
162175

163-
const rawResult = yield* toPromiseEffect(item.onError({
164-
...createMiddlewareParams(request, context),
165-
error: nextError
166-
}))
176+
const rawResult = yield* toPromiseEffect<Response | Error>(
177+
item.onError({
178+
...createMiddlewareParams(request, context),
179+
error: nextError
180+
})
181+
)
167182

168183
const result = yield* normalizeErrorResult(rawResult)
169184
if (result instanceof Response) {

0 commit comments

Comments
 (0)