@prover-coder-ai/openapi-effect 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,471 @@
1
+ // CHANGE: Implement Effect-based HTTP client with Effect-native error handling
2
+ // WHY: Force explicit handling of HTTP errors (4xx, 5xx) via Effect error channel
3
+ // QUOTE(ТЗ): "каждый запрос возвращает Effect<Success, Failure, never>; Failure включает все инварианты протокола и схемы"
4
+ // REF: issue-2, section 2, 4, 5.1
5
+ // SOURCE: n/a
6
+ // FORMAT THEOREM: ∀ req ∈ Requests: execute(req) → Effect<ApiSuccess, ApiFailure, R>
7
+ // PURITY: SHELL
8
+ // EFFECT: Effect<ApiSuccess<Op>, ApiFailure<Op>, HttpClient.HttpClient>
9
+ // INVARIANT: 2xx → success channel, non-2xx → error channel (forced handling)
10
+ // COMPLEXITY: O(1) per request / O(n) for body size
11
+
12
+ import * as HttpBody from "@effect/platform/HttpBody"
13
+ import * as HttpClient from "@effect/platform/HttpClient"
14
+ import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
15
+ import { Effect } from "effect"
16
+ import type { HttpMethod } from "openapi-typescript-helpers"
17
+
18
+ import type {
19
+ ApiFailure,
20
+ ApiSuccess,
21
+ DecodeError,
22
+ OperationFor,
23
+ ParseError,
24
+ ResponsesFor,
25
+ TransportError,
26
+ UnexpectedContentType,
27
+ UnexpectedStatus
28
+ } from "../../core/api-client/strict-types.js"
29
+ import {
30
+ asDispatcher,
31
+ asJson,
32
+ asRawResponse,
33
+ asStrictRequestInit,
34
+ type ClassifyFn,
35
+ type Dispatcher,
36
+ type Json,
37
+ type RawResponse
38
+ } from "../../core/axioms.js"
39
+
40
+ // Re-export Dispatcher type for consumers
41
+
42
+ /**
43
+ * Decoder for response body
44
+ *
45
+ * @pure false - may perform validation
46
+ * @effect Effect<T, DecodeError, never>
47
+ */
48
+ export type Decoder<T> = (
49
+ status: number,
50
+ contentType: string,
51
+ body: string
52
+ ) => Effect.Effect<T, DecodeError>
53
+
54
+ /**
55
+ * Configuration for a strict API client request
56
+ */
57
+ export type StrictRequestInit<Responses> = {
58
+ readonly method: HttpMethod
59
+ readonly url: string
60
+ readonly dispatcher: Dispatcher<Responses>
61
+ readonly headers?: HeadersInit
62
+ readonly body?: BodyInit
63
+ readonly signal?: AbortSignal
64
+ }
65
+
66
+ /**
67
+ * Execute HTTP request with Effect-native error handling
68
+ *
69
+ * @param config - Request configuration with dispatcher
70
+ * @returns Effect with success (2xx) and failures (non-2xx + boundary errors)
71
+ *
72
+ * **Effect Channel Design:**
73
+ * - Success channel: `ApiSuccess<Responses>` - 2xx responses only
74
+ * - Error channel: `ApiFailure<Responses>` - HTTP errors (4xx, 5xx) + boundary errors
75
+ *
76
+ * This forces developers to explicitly handle HTTP errors using:
77
+ * - `Effect.catchTag` for specific error types
78
+ * - `Effect.match` for exhaustive handling
79
+ * - `Effect.catchAll` for generic error handling
80
+ *
81
+ * @pure false - performs HTTP request
82
+ * @effect Effect<ApiSuccess<Responses>, ApiFailure<Responses>, HttpClient.HttpClient>
83
+ * @invariant 2xx → success channel, non-2xx → error channel
84
+ * @precondition config.dispatcher handles all schema statuses
85
+ * @postcondition ∀ response: success(2xx) ∨ httpError(non-2xx) ∨ boundaryError
86
+ * @complexity O(1) + O(|body|) for text extraction
87
+ */
88
+ export const executeRequest = <Responses>(
89
+ config: StrictRequestInit<Responses>
90
+ ): Effect.Effect<ApiSuccess<Responses>, ApiFailure<Responses>, HttpClient.HttpClient> =>
91
+ Effect.gen(function*() {
92
+ // STEP 1: Get HTTP client from context
93
+ const client = yield* HttpClient.HttpClient
94
+
95
+ // STEP 2: Build request based on method
96
+ const request = buildRequest(config)
97
+
98
+ // STEP 3: Execute request with error mapping
99
+ const rawResponse = yield* Effect.mapError(
100
+ Effect.gen(function*() {
101
+ const response = yield* client.execute(request)
102
+ const text = yield* response.text
103
+ return asRawResponse({
104
+ status: response.status,
105
+ headers: toNativeHeaders(response.headers),
106
+ text
107
+ })
108
+ }),
109
+ (error): TransportError => ({
110
+ _tag: "TransportError",
111
+ error: error instanceof Error ? error : new Error(String(error))
112
+ })
113
+ )
114
+
115
+ // STEP 4: Delegate classification to dispatcher (handles status/content-type/decode)
116
+ return yield* config.dispatcher(rawResponse)
117
+ })
118
+
119
+ /**
120
+ * Build HTTP request from config
121
+ *
122
+ * @pure true
123
+ */
124
+ const buildRequest = <Responses>(config: StrictRequestInit<Responses>): HttpClientRequest.HttpClientRequest => {
125
+ const methodMap: Record<string, (url: string) => HttpClientRequest.HttpClientRequest> = {
126
+ get: HttpClientRequest.get,
127
+ post: HttpClientRequest.post,
128
+ put: HttpClientRequest.put,
129
+ patch: HttpClientRequest.patch,
130
+ delete: HttpClientRequest.del,
131
+ head: HttpClientRequest.head,
132
+ options: HttpClientRequest.options
133
+ }
134
+
135
+ const createRequest = methodMap[config.method] ?? HttpClientRequest.get
136
+ let request = createRequest(config.url)
137
+
138
+ // Add headers if provided
139
+ if (config.headers !== undefined) {
140
+ const headers = toRecordHeaders(config.headers)
141
+ request = HttpClientRequest.setHeaders(request, headers)
142
+ }
143
+
144
+ // Add body if provided
145
+ if (config.body !== undefined) {
146
+ const bodyText = typeof config.body === "string" ? config.body : JSON.stringify(config.body)
147
+ request = HttpClientRequest.setBody(request, HttpBody.text(bodyText))
148
+ }
149
+
150
+ return request
151
+ }
152
+
153
+ /**
154
+ * Convert Headers to Record<string, string>
155
+ *
156
+ * @pure true
157
+ */
158
+ const toRecordHeaders = (headers: HeadersInit): Record<string, string> => {
159
+ if (headers instanceof Headers) {
160
+ const result: Record<string, string> = {}
161
+ for (const [key, value] of headers.entries()) {
162
+ result[key] = value
163
+ }
164
+ return result
165
+ }
166
+ if (Array.isArray(headers)) {
167
+ const result: Record<string, string> = {}
168
+ for (const headerPair of headers) {
169
+ const [headerKey, headerValue] = headerPair
170
+ result[headerKey] = headerValue
171
+ }
172
+ return result
173
+ }
174
+ return headers
175
+ }
176
+
177
+ /**
178
+ * Convert @effect/platform Headers to native Headers
179
+ *
180
+ * @pure true
181
+ */
182
+ const toNativeHeaders = (platformHeaders: { readonly [key: string]: string }): Headers => {
183
+ const headers = new Headers()
184
+ for (const [key, value] of Object.entries(platformHeaders)) {
185
+ headers.set(key, value)
186
+ }
187
+ return headers
188
+ }
189
+
190
+ /**
191
+ * Helper to create dispatcher from switch-based classifier
192
+ *
193
+ * This function uses a permissive type signature to allow generated code
194
+ * to work with any response variant without requiring exact type matching.
195
+ * The classify function can return any Effect with union types for success/error.
196
+ *
197
+ * NOTE: Uses axioms module for type casts to allow heterogeneous Effect
198
+ * unions from switch statements. The returned Dispatcher is properly typed.
199
+ *
200
+ * @pure true - returns pure function
201
+ * @complexity O(1)
202
+ */
203
+
204
+ export const createDispatcher = <Responses>(
205
+ classify: ClassifyFn
206
+ ): Dispatcher<Responses> => {
207
+ return asDispatcher<Responses>((response: RawResponse) => {
208
+ const contentType = response.headers.get("content-type") ?? undefined
209
+ return classify(response.status, contentType, response.text)
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Helper to parse JSON with error handling
215
+ *
216
+ * @pure false - performs parsing
217
+ * @effect Effect<Json, ParseError, never>
218
+ */
219
+ export const parseJSON = (
220
+ status: number,
221
+ contentType: string,
222
+ text: string
223
+ ): Effect.Effect<Json, ParseError> =>
224
+ Effect.try({
225
+ try: () => asJson(JSON.parse(text)),
226
+ catch: (error): ParseError => ({
227
+ _tag: "ParseError",
228
+ status,
229
+ contentType,
230
+ error: error instanceof Error ? error : new Error(String(error)),
231
+ body: text
232
+ })
233
+ })
234
+
235
+ /**
236
+ * Helper to create UnexpectedStatus error
237
+ *
238
+ * @pure true
239
+ */
240
+ export const unexpectedStatus = (status: number, body: string): UnexpectedStatus => ({
241
+ _tag: "UnexpectedStatus",
242
+ status,
243
+ body
244
+ })
245
+
246
+ /**
247
+ * Helper to create UnexpectedContentType error
248
+ *
249
+ * @pure true
250
+ */
251
+ export const unexpectedContentType = (
252
+ status: number,
253
+ expected: ReadonlyArray<string>,
254
+ actual: string | undefined,
255
+ body: string
256
+ ): UnexpectedContentType => ({
257
+ _tag: "UnexpectedContentType",
258
+ status,
259
+ expected,
260
+ actual,
261
+ body
262
+ })
263
+
264
+ /**
265
+ * Generic client interface for any OpenAPI schema with Effect-native error handling
266
+ *
267
+ * **Effect Channel Design:**
268
+ * - Success channel: `ApiSuccess<Op>` - 2xx responses
269
+ * - Error channel: `ApiFailure<Op>` - HTTP errors (4xx, 5xx) + boundary errors
270
+ *
271
+ * @pure false - performs HTTP requests
272
+ * @effect Effect<ApiSuccess<Op>, ApiFailure<Op>, HttpClient.HttpClient>
273
+ */
274
+ export type StrictClient<Paths extends object> = {
275
+ readonly GET: <Path extends keyof Paths>(
276
+ path: Path,
277
+ options: RequestOptions<Paths, Path, "get">
278
+ ) => Effect.Effect<
279
+ ApiSuccess<ResponsesFor<OperationFor<Paths, Path, "get">>>,
280
+ ApiFailure<ResponsesFor<OperationFor<Paths, Path, "get">>>,
281
+ HttpClient.HttpClient
282
+ >
283
+
284
+ readonly POST: <Path extends keyof Paths>(
285
+ path: Path,
286
+ options: RequestOptions<Paths, Path, "post">
287
+ ) => Effect.Effect<
288
+ ApiSuccess<ResponsesFor<OperationFor<Paths, Path, "post">>>,
289
+ ApiFailure<ResponsesFor<OperationFor<Paths, Path, "post">>>,
290
+ HttpClient.HttpClient
291
+ >
292
+
293
+ readonly PUT: <Path extends keyof Paths>(
294
+ path: Path,
295
+ options: RequestOptions<Paths, Path, "put">
296
+ ) => Effect.Effect<
297
+ ApiSuccess<ResponsesFor<OperationFor<Paths, Path, "put">>>,
298
+ ApiFailure<ResponsesFor<OperationFor<Paths, Path, "put">>>,
299
+ HttpClient.HttpClient
300
+ >
301
+
302
+ readonly PATCH: <Path extends keyof Paths>(
303
+ path: Path,
304
+ options: RequestOptions<Paths, Path, "patch">
305
+ ) => Effect.Effect<
306
+ ApiSuccess<ResponsesFor<OperationFor<Paths, Path, "patch">>>,
307
+ ApiFailure<ResponsesFor<OperationFor<Paths, Path, "patch">>>,
308
+ HttpClient.HttpClient
309
+ >
310
+
311
+ readonly DELETE: <Path extends keyof Paths>(
312
+ path: Path,
313
+ options: RequestOptions<Paths, Path, "delete">
314
+ ) => Effect.Effect<
315
+ ApiSuccess<ResponsesFor<OperationFor<Paths, Path, "delete">>>,
316
+ ApiFailure<ResponsesFor<OperationFor<Paths, Path, "delete">>>,
317
+ HttpClient.HttpClient
318
+ >
319
+ }
320
+
321
+ /**
322
+ * Request options for a specific operation
323
+ */
324
+ export type RequestOptions<
325
+ Paths extends object,
326
+ Path extends keyof Paths,
327
+ Method extends HttpMethod
328
+ > = {
329
+ readonly dispatcher: Dispatcher<ResponsesFor<OperationFor<Paths, Path, Method>>>
330
+ readonly baseUrl: string
331
+ readonly params?: Record<string, string | number>
332
+ readonly query?: Record<string, string | number>
333
+ readonly headers?: HeadersInit
334
+ readonly body?: BodyInit
335
+ readonly signal?: AbortSignal
336
+ }
337
+
338
+ /**
339
+ * Create a strict client for an OpenAPI schema
340
+ *
341
+ * @pure true - returns pure client object
342
+ * @complexity O(1)
343
+ */
344
+ export const createStrictClient = <Paths extends object>(): StrictClient<
345
+ Paths
346
+ > => {
347
+ const makeRequest = <Path extends keyof Paths, Method extends HttpMethod>(
348
+ method: Method,
349
+ path: Path,
350
+ options: RequestOptions<Paths, Path, Method>
351
+ ) => {
352
+ let url = `${options.baseUrl}${String(path)}`
353
+
354
+ // Replace path parameters
355
+ if (options.params !== undefined) {
356
+ for (const [key, value] of Object.entries(options.params)) {
357
+ url = url.replace(`{${key}}`, encodeURIComponent(String(value)))
358
+ }
359
+ }
360
+
361
+ // Add query parameters
362
+ if (options.query !== undefined) {
363
+ const params = new URLSearchParams()
364
+ for (const [key, value] of Object.entries(options.query)) {
365
+ params.append(key, String(value))
366
+ }
367
+ url = `${url}?${params.toString()}`
368
+ }
369
+
370
+ // Build config object, only including optional properties if they are defined
371
+ // This satisfies exactOptionalPropertyTypes constraint
372
+ const config = asStrictRequestInit<StrictRequestInit<ResponsesFor<OperationFor<Paths, Path, Method>>>>({
373
+ method,
374
+ url,
375
+ dispatcher: options.dispatcher,
376
+ ...(options.headers !== undefined && { headers: options.headers }),
377
+ ...(options.body !== undefined && { body: options.body }),
378
+ ...(options.signal !== undefined && { signal: options.signal })
379
+ })
380
+
381
+ return executeRequest(config)
382
+ }
383
+
384
+ return {
385
+ GET: (path, options) => makeRequest("get", path, options),
386
+ POST: (path, options) => makeRequest("post", path, options),
387
+ PUT: (path, options) => makeRequest("put", path, options),
388
+ PATCH: (path, options) => makeRequest("patch", path, options),
389
+ DELETE: (path, options) => makeRequest("delete", path, options)
390
+ } satisfies StrictClient<Paths>
391
+ }
392
+
393
+ // CHANGE: Add universal dispatcher that handles any OpenAPI responses generically
394
+ // WHY: Enable createClient<Paths>(options) without code generation or manual dispatcher wiring
395
+ // QUOTE(ТЗ): "Я не хочу создавать какие-то дополнительные модули"
396
+ // REF: issue-5
397
+ // SOURCE: n/a
398
+ // FORMAT THEOREM: ∀ status, ct: universalDispatcher(status, ct, text) → success(2xx) ∨ httpError(non-2xx) ∨ boundaryError
399
+ // PURITY: SHELL
400
+ // EFFECT: Effect<ApiSuccess<Responses>, Exclude<ApiFailure<Responses>, TransportError>, never>
401
+ // INVARIANT: 2xx → success channel, non-2xx → error channel, no-content → body: undefined
402
+ // COMPLEXITY: O(1) per dispatch + O(|text|) for JSON parsing
403
+
404
+ /**
405
+ * Create a universal dispatcher that handles any OpenAPI response generically
406
+ *
407
+ * The universal dispatcher classifies responses by status code range:
408
+ * - 2xx → success channel (ApiSuccess)
409
+ * - non-2xx → error channel (HttpError)
410
+ *
411
+ * For JSON content types, it parses the body. For no-content responses (empty body),
412
+ * it returns undefined body with contentType "none".
413
+ *
414
+ * This enables using createClient<Paths>(options) without generating
415
+ * per-operation dispatchers, fulfilling the zero-boilerplate DSL requirement.
416
+ *
417
+ * @pure true - returns pure dispatcher function
418
+ * @complexity O(1) creation + O(|body|) per dispatch
419
+ */
420
+ export const createUniversalDispatcher = <Responses>(): Dispatcher<Responses> => {
421
+ return asDispatcher<Responses>((response: RawResponse) => {
422
+ const contentType = response.headers.get("content-type") ?? undefined
423
+ const is2xx = response.status >= 200 && response.status < 300
424
+
425
+ // No-content response (empty body or 204)
426
+ if (response.text === "" || response.status === 204) {
427
+ const variant = {
428
+ status: response.status,
429
+ contentType: "none" as const,
430
+ body: undefined
431
+ } as const
432
+
433
+ return is2xx
434
+ ? Effect.succeed(variant)
435
+ : Effect.fail({
436
+ _tag: "HttpError" as const,
437
+ ...variant
438
+ })
439
+ }
440
+
441
+ // JSON content type
442
+ if (contentType?.includes("application/json")) {
443
+ return Effect.gen(function*() {
444
+ const parsed = yield* parseJSON(response.status, "application/json", response.text)
445
+ const variant = {
446
+ status: response.status,
447
+ contentType: "application/json" as const,
448
+ body: parsed
449
+ } as const
450
+
451
+ if (is2xx) {
452
+ return variant
453
+ }
454
+ return yield* Effect.fail({
455
+ _tag: "HttpError" as const,
456
+ ...variant
457
+ })
458
+ })
459
+ }
460
+
461
+ // Unknown content type
462
+ return Effect.fail(unexpectedContentType(
463
+ response.status,
464
+ ["application/json"],
465
+ contentType,
466
+ response.text
467
+ ))
468
+ })
469
+ }
470
+
471
+ export { type Dispatcher, type RawResponse } from "../../core/axioms.js"
@@ -0,0 +1,22 @@
1
+ import * as S from "@effect/schema/Schema"
2
+ import { Effect, pipe } from "effect"
3
+
4
+ import type { GreetingVariant } from "../core/greeting.js"
5
+
6
+ const cliSchema = S.Struct({
7
+ name: S.optionalWith(S.NonEmptyString, { default: () => "Effect" })
8
+ })
9
+
10
+ type CliInput = S.Schema.Type<typeof cliSchema>
11
+
12
+ const toVariant = (input: CliInput): GreetingVariant =>
13
+ input.name.toLowerCase() === "effect"
14
+ ? { kind: "effect" }
15
+ : { kind: "named", name: input.name }
16
+
17
+ export const readGreetingVariant = pipe(
18
+ Effect.sync(() => process.argv.slice(2)),
19
+ Effect.map((args) => args.length > 0 && args[0] !== undefined ? { name: args[0] } : {}),
20
+ Effect.flatMap(S.decodeUnknown(cliSchema)),
21
+ Effect.map((input) => toVariant(input))
22
+ )