Skip to content

Commit 345b267

Browse files
committed
feat(app): add auto-dispatching createClient via dispatcher registry
- Add DispatchersFor types and default dispatcher registry\n- Generate dispatchers-by-path module that registers defaults\n- Update examples and add tests for auto-dispatching client
1 parent 3a55986 commit 345b267

12 files changed

Lines changed: 729 additions & 317 deletions

packages/app/examples/strict-error-handling.ts

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
import * as FetchHttpClient from "@effect/platform/FetchHttpClient"
1010
import type * as HttpClient from "@effect/platform/HttpClient"
11-
import { Console, Effect, Exit, Match } from "effect"
12-
import { createClient, type ClientOptions } from "../src/shell/api-client/create-client.js"
13-
import { dispatchercreatePet, dispatchergetPet, dispatcherlistPets } from "../src/generated/dispatch.js"
11+
import { Cause, Console, Effect, Match } from "effect"
12+
import "../src/generated/dispatchers-by-path.js"
13+
import { type ClientOptions, createClient } from "../src/shell/api-client/create-client.js"
1414
import type { Paths } from "../tests/fixtures/petstore.openapi.js"
1515

1616
/**
@@ -21,6 +21,16 @@ const clientOptions: ClientOptions = {
2121
credentials: "include"
2222
}
2323

24+
// CHANGE: Use default dispatcher registry (registered by generated module)
25+
// WHY: Call createClient(options) without passing dispatcher map
26+
// QUOTE(ТЗ): "const apiClient = createClient<Paths>(clientOptions)"
27+
// REF: user-msg-4
28+
// SOURCE: n/a
29+
// FORMAT THEOREM: ∀ op ∈ Operations: createClient(options) uses registered dispatchers
30+
// PURITY: SHELL
31+
// EFFECT: none
32+
// INVARIANT: default dispatchers registered before client creation
33+
// COMPLEXITY: O(1)
2434
const apiClient = createClient<Paths>(clientOptions)
2535

2636
// =============================================================================
@@ -45,14 +55,9 @@ export const getPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClie
4555
yield* Console.log("=== getPet: Strict Error Handling ===")
4656

4757
// Execute request - yields only on 200
48-
const result = yield* apiClient.GET(
49-
"/pets/{petId}",
50-
dispatchergetPet,
51-
{ params: { petId: "123" } }
58+
yield* apiClient.GET("/pets/{petId}", { params: { petId: "123" } }).pipe(
59+
Effect.tap((result) => Console.log(`Got pet: ${result.body.name}`))
5260
)
53-
54-
// Success! TypeScript knows status is 200
55-
yield* Console.log(`Got pet: ${result.body.name}`)
5661
}).pipe(
5762
// Handle HttpError with EXHAUSTIVE matching (no orElse!)
5863
Effect.catchTag("HttpError", (error) =>
@@ -66,7 +71,7 @@ export const getPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClie
6671
// Handle ALL boundary errors
6772
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
6873
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
69-
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
74+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual ?? "unknown"}`)),
7075
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
7176
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
7277
)
@@ -87,17 +92,15 @@ export const getPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClie
8792
export const createPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
8893
yield* Console.log("=== createPet: Strict Error Handling ===")
8994

90-
const result = yield* apiClient.POST(
95+
yield* apiClient.POST(
9196
"/pets",
92-
dispatchercreatePet,
9397
{
9498
// Body can be typed object - client will auto-stringify and set Content-Type
9599
body: { name: "Fluffy", tag: "cat" }
96100
}
101+
).pipe(
102+
Effect.tap((result) => Console.log(`Created pet: ${result.body.id}`))
97103
)
98-
99-
// Success! TypeScript knows status is 201
100-
yield* Console.log(`Created pet: ${result.body.id}`)
101104
}).pipe(
102105
// Handle HttpError with EXHAUSTIVE matching
103106
Effect.catchTag("HttpError", (error) =>
@@ -110,7 +113,7 @@ export const createPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpC
110113
// Handle ALL boundary errors
111114
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
112115
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
113-
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
116+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual ?? "unknown"}`)),
114117
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
115118
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
116119
)
@@ -131,14 +134,13 @@ export const createPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpC
131134
export const listPetsStrictProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
132135
yield* Console.log("=== listPets: Strict Error Handling ===")
133136

134-
const result = yield* apiClient.GET(
135-
"/pets",
136-
dispatcherlistPets,
137-
{ query: { limit: 10 } }
137+
yield* apiClient.GET("/pets", { query: { limit: 10 } }).pipe(
138+
Effect.tap((result) => Console.log(`Got ${result.body.length} pets`))
138139
)
139140

140-
// Success! TypeScript knows status is 200
141-
yield* Console.log(`Got ${result.body.length} pets`)
141+
const pets = yield* apiClient.GET("/pets", { query: { limit: 10 } })
142+
143+
yield* Console.log(`Got ${pets.body.length} pets`)
142144
}).pipe(
143145
// Handle HttpError with EXHAUSTIVE matching
144146
Effect.catchTag("HttpError", (error) =>
@@ -150,7 +152,7 @@ export const listPetsStrictProgram: Effect.Effect<void, never, HttpClient.HttpCl
150152
// Handle ALL boundary errors
151153
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
152154
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
153-
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
155+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual ?? "unknown"}`)),
154156
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
155157
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
156158
)
@@ -183,22 +185,19 @@ const mainProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.ge
183185
yield* Console.log("========================================")
184186
})
185187

186-
/**
187-
* Execute the program
188-
*
189-
* CRITICAL: Since mainProgram has E=never, Effect.runPromiseExit
190-
* will never fail with a typed error - only defects are possible.
191-
*/
188+
// CHANGE: Remove async/await entrypoint and handle defects in Effect
189+
// WHY: Lint rules forbid async/await and floating promises; defects are handled in Effect channel
190+
// QUOTE(ТЗ): "Запрещён async/await — используй Effect.gen / Effect.tryPromise."
191+
// REF: user-msg-2
192+
// SOURCE: n/a
193+
// FORMAT THEOREM: For all exits, mainProgram E=never implies failure(exit) -> defect(exit)
194+
// PURITY: SHELL
195+
// EFFECT: Effect<void, never, HttpClient> -> Promise<void> via Effect.runPromise
196+
// INVARIANT: Typed error channel remains never
197+
// COMPLEXITY: O(1)
192198
const program = mainProgram.pipe(
193-
Effect.provide(FetchHttpClient.layer)
199+
Effect.provide(FetchHttpClient.layer),
200+
Effect.catchAllCause((cause) => Console.error(`Unexpected defect: ${Cause.pretty(cause)}`))
194201
)
195202

196-
const main = async () => {
197-
const exit = await Effect.runPromiseExit(program)
198-
if (Exit.isFailure(exit)) {
199-
// This can only be a defect (unexpected exception), not a typed error
200-
console.error("Unexpected defect:", exit.cause)
201-
}
202-
}
203-
204-
main()
203+
void Effect.runPromise(program)

packages/app/examples/test-create-client.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88

99
import * as FetchHttpClient from "@effect/platform/FetchHttpClient"
1010
import { Console, Effect, Exit, Match } from "effect"
11-
import { createClient, type ClientOptions } from "../src/shell/api-client/create-client.js"
12-
import { dispatchercreatePet, dispatchergetPet, dispatcherlistPets } from "../src/generated/dispatch.js"
11+
import {
12+
createClient,
13+
type ClientOptions
14+
} from "../src/shell/api-client/create-client.js"
15+
import "../src/generated/dispatchers-by-path.js"
1316
import type { Paths } from "../tests/fixtures/petstore.openapi.js"
1417
// Types are automatically inferred - no need to import them explicitly
1518

@@ -24,6 +27,16 @@ const clientOptions: ClientOptions = {
2427
credentials: "include"
2528
}
2629

30+
// CHANGE: Use default dispatcher registry (registered by generated module)
31+
// WHY: Call createClient(options) without passing dispatcher map
32+
// QUOTE(ТЗ): "const apiClient = createClient<Paths>(clientOptions)"
33+
// REF: user-msg-4
34+
// SOURCE: n/a
35+
// FORMAT THEOREM: ∀ op ∈ Operations: createClient(options) uses registered dispatchers
36+
// PURITY: SHELL
37+
// EFFECT: none
38+
// INVARIANT: default dispatchers registered before client creation
39+
// COMPLEXITY: O(1)
2740
const apiClient = createClient<Paths>(clientOptions)
2841

2942
/**
@@ -44,7 +57,6 @@ const listAllPetsExample = Effect.gen(function*() {
4457
// Now: success = 200 only, error = 500 | BoundaryError
4558
const result = yield* apiClient.GET(
4659
"/pets",
47-
dispatcherlistPets,
4860
{
4961
query: { limit: 10 }
5062
}
@@ -83,7 +95,6 @@ const getPetExample = Effect.gen(function*() {
8395
// Success = 200, Error = 404 | 500 | BoundaryError
8496
const result = yield* apiClient.GET(
8597
"/pets/{petId}",
86-
dispatchergetPet,
8798
{
8899
params: { petId: "123" }
89100
}
@@ -125,7 +136,6 @@ const createPetExample = Effect.gen(function*() {
125136
// Success = 201, Error = 400 | 500 | BoundaryError
126137
const result = yield* apiClient.POST(
127138
"/pets",
128-
dispatchercreatePet,
129139
{
130140
// Typed body - client will auto-stringify and set Content-Type
131141
body: newPet
@@ -159,7 +169,7 @@ const eitherExample = Effect.gen(function*() {
159169
yield* Console.log("\n=== Example 4: Using Effect.either ===")
160170

161171
const result = yield* Effect.either(
162-
apiClient.GET("/pets/{petId}", dispatchergetPet, {
172+
apiClient.GET("/pets/{petId}", {
163173
params: { petId: "999" } // Non-existent pet
164174
})
165175
)
@@ -199,7 +209,7 @@ const mainProgram = Effect.gen(function*() {
199209
yield* Console.log(" - Developers MUST handle HTTP errors explicitly!\n")
200210

201211
yield* Console.log("Example code:")
202-
yield* Console.log(' const result = yield* client.GET("/path", dispatcher)')
212+
yield* Console.log(' const result = yield* client.GET("/path", { params })')
203213
yield* Console.log(" // result is 200 - no need to check status!")
204214
yield* Console.log("").pipe(Effect.flatMap(() =>
205215
Console.log(" // HTTP errors handled via Effect.catchTag or Effect.match\n")

packages/app/src/core/axioms.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,11 @@ export type ClassifyFn = (
133133
* @pure true
134134
*/
135135
export const asStrictApiClient = <T>(client: object): T => client as T
136+
137+
/**
138+
* Cast default dispatchers registry to specific schema type
139+
* AXIOM: Default dispatcher registry was registered for the current Paths type
140+
*
141+
* @pure true
142+
*/
143+
export const asDispatchersFor = <T>(value: unknown): T => value as T
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// CHANGE: Auto-generated dispatcher map by path+method
2+
// WHY: Provide a single dispatcher registry without manual wiring in examples
3+
// QUOTE(ТЗ): "Этого в плане вообще не должно быть"
4+
// REF: user-msg-3
5+
// SOURCE: Generated from tests/fixtures/petstore.openapi.json
6+
// FORMAT THEOREM: ∀ path, method: dispatchersByPath[path][method] = dispatcher(op)
7+
// PURITY: SHELL
8+
// EFFECT: none
9+
// INVARIANT: dispatcher map is total for all operations in Paths
10+
// COMPLEXITY: O(1)
11+
12+
import type { Paths } from "../../tests/fixtures/petstore.openapi.js"
13+
import { type DispatchersFor, registerDefaultDispatchers } from "../shell/api-client/create-client.js"
14+
import { dispatchercreatePet, dispatcherdeletePet, dispatchergetPet, dispatcherlistPets } from "./dispatch.js"
15+
16+
/**
17+
* Dispatcher map keyed by OpenAPI path and HTTP method
18+
*/
19+
export const dispatchersByPath: DispatchersFor<Paths> = {
20+
"/pets": {
21+
get: dispatcherlistPets,
22+
post: dispatchercreatePet
23+
},
24+
"/pets/{petId}": {
25+
get: dispatchergetPet,
26+
delete: dispatcherdeletePet
27+
}
28+
}
29+
30+
// CHANGE: Register default dispatchers at module load
31+
// WHY: Enable createClient(options) without passing dispatcher map
32+
// QUOTE(ТЗ): "const apiClient = createClient<Paths>(clientOptions)"
33+
// REF: user-msg-4
34+
// SOURCE: n/a
35+
// FORMAT THEOREM: ∀ call: createClient(options) uses dispatchersByPath
36+
// PURITY: SHELL
37+
// EFFECT: none
38+
// INVARIANT: registerDefaultDispatchers is called exactly once per module load
39+
// COMPLEXITY: O(1)
40+
registerDefaultDispatchers(dispatchersByPath)

packages/app/src/generated/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66

77
export * from "./decoders.js"
88
export * from "./dispatch.js"
9+
export * from "./dispatchers-by-path.js"

packages/app/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88

99
// High-level API (recommended for most users)
1010
export { createClient as default } from "./shell/api-client/create-client.js"
11-
export type { ClientOptions, StrictApiClient } from "./shell/api-client/create-client.js"
11+
export type {
12+
ClientOptions,
13+
DispatchersFor,
14+
StrictApiClient,
15+
StrictApiClientWithDispatchers
16+
} from "./shell/api-client/create-client.js"
17+
export { registerDefaultDispatchers } from "./shell/api-client/create-client.js"
1218

1319
// Core types (for advanced type manipulation)
1420
// Effect Channel Design:

0 commit comments

Comments
 (0)