@distilled.cloud/core 0.0.0-john

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.
package/src/client.ts ADDED
@@ -0,0 +1,631 @@
1
+ /**
2
+ * REST API Client
3
+ *
4
+ * Provides the core API.make() factory for building typed Effect-based API operations.
5
+ * This is the shared client for REST/OpenAPI-style SDKs (PlanetScale, Neon, GCP).
6
+ *
7
+ * AWS and Cloudflare have their own more specialized client implementations,
8
+ * but they share the same OperationMethod pattern.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { API } from "@distilled.cloud/core/client";
13
+ *
14
+ * const listDatabases = API.make(() => ({
15
+ * inputSchema: ListDatabasesInput,
16
+ * outputSchema: ListDatabasesOutput,
17
+ * errors: [NotFound, Forbidden] as const,
18
+ * }));
19
+ *
20
+ * // Direct call
21
+ * const result = yield* listDatabases({ organization: "my-org" });
22
+ *
23
+ * // Yield first for requirement-free function
24
+ * const fn = yield* listDatabases;
25
+ * const result = yield* fn({ organization: "my-org" });
26
+ * ```
27
+ */
28
+ import * as Effect from "effect/Effect";
29
+ import { pipeArguments } from "effect/Pipeable";
30
+ import * as Schema from "effect/Schema";
31
+ import * as AST from "effect/SchemaAST";
32
+ import * as Stream from "effect/Stream";
33
+ import * as HttpBody from "effect/unstable/http/HttpBody";
34
+ import * as HttpClient from "effect/unstable/http/HttpClient";
35
+ import * as HttpClientError from "effect/unstable/http/HttpClientError";
36
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
37
+ import { SingleShotGen } from "effect/Utils";
38
+ import {
39
+ extractItems,
40
+ paginateWithDefaults,
41
+ type PaginatedTrait,
42
+ type PaginationStrategy,
43
+ } from "./pagination.ts";
44
+ import * as Traits from "./traits.ts";
45
+ import { getPath } from "./traits.ts";
46
+
47
+ // ============================================================================
48
+ // Client Types
49
+ // ============================================================================
50
+
51
+ /**
52
+ * An operation that can be used in two ways:
53
+ * 1. Direct call: `yield* operation(input)` - returns Effect with requirements
54
+ * 2. Yield first: `const fn = yield* operation` - captures services, returns requirement-free function
55
+ */
56
+ export type OperationMethod<I, A, E, R> = Effect.Effect<
57
+ (input: I) => Effect.Effect<A, E, never>,
58
+ never,
59
+ R
60
+ > &
61
+ ((input: I) => Effect.Effect<A, E, R>);
62
+
63
+ /**
64
+ * A paginated operation that additionally has `.pages()` and `.items()` methods.
65
+ */
66
+ type PaginatedItem<A> =
67
+ A extends ReadonlyArray<infer Item>
68
+ ? Item
69
+ : A extends { result: ReadonlyArray<infer Item> }
70
+ ? Item
71
+ : A extends { result?: ReadonlyArray<infer Item> | null | undefined }
72
+ ? Item
73
+ : A extends { result: { items: ReadonlyArray<infer Item> } }
74
+ ? Item
75
+ : A extends {
76
+ result?:
77
+ | {
78
+ items?: ReadonlyArray<infer Item> | null | undefined;
79
+ }
80
+ | null
81
+ | undefined;
82
+ }
83
+ ? Item
84
+ : unknown;
85
+
86
+ export type PaginatedOperationMethod<I, A, E, R> = OperationMethod<
87
+ I,
88
+ A,
89
+ E,
90
+ R
91
+ > & {
92
+ pages: (input: I) => Stream.Stream<A, E, R>;
93
+ items: (input: I) => Stream.Stream<PaginatedItem<A>, E, R>;
94
+ };
95
+
96
+ type ResolvedClientCredentials<Creds> =
97
+ Creds extends Effect.Effect<infer Resolved, any, any> ? Resolved : Creds;
98
+
99
+ const isEffectLike = (value: unknown): value is Effect.Effect<unknown> =>
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ typeof (value as { pipe?: unknown }).pipe === "function" &&
103
+ typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] ===
104
+ "function";
105
+
106
+ /**
107
+ * Configuration for the API client factory.
108
+ * SDKs provide this to customize how errors are matched and credentials are applied.
109
+ */
110
+ export interface ClientConfig<Creds> {
111
+ /** The credentials service tag */
112
+ credentials: {
113
+ new (): Creds;
114
+ };
115
+
116
+ /** Get the base URL from credentials */
117
+ getBaseUrl: (creds: ResolvedClientCredentials<Creds>) => string;
118
+
119
+ /** Get authorization header(s) from credentials */
120
+ getAuthHeaders: (
121
+ creds: ResolvedClientCredentials<Creds>,
122
+ ) => Record<string, string>;
123
+
124
+ /** Match an error response body to a typed error.
125
+ * Should return Effect.fail(error) for known errors,
126
+ * or Effect.fail(fallbackError) for unknown errors.
127
+ * The optional `errors` parameter provides per-operation typed error classes.
128
+ */
129
+ matchError: (
130
+ status: number,
131
+ body: unknown,
132
+ errors?: readonly ApiErrorClass[],
133
+ ) => Effect.Effect<never, unknown>;
134
+
135
+ /** Parse error class for schema decode failures */
136
+ ParseError: new (props: { body: unknown; cause: unknown }) => unknown;
137
+
138
+ /**
139
+ * Optional transform applied to the response body before schema decoding.
140
+ * For example, Cloudflare wraps responses in `{ result: <data>, ... }`.
141
+ */
142
+ transformResponse?: (body: unknown) => unknown;
143
+
144
+ /**
145
+ * Optional transform applied to encoded request parts before building the
146
+ * outbound HTTP request.
147
+ */
148
+ transformRequestParts?: (input: {
149
+ input: Record<string, unknown>;
150
+ method: string;
151
+ pathTemplate: string;
152
+ parts: Traits.RequestParts;
153
+ }) => Traits.RequestParts;
154
+ }
155
+
156
+ /**
157
+ * Base API error type - any error class with at least a _tag and message.
158
+ * Uses `new (...args: any[])` to accommodate error classes with extra fields (e.g. `code`).
159
+ */
160
+ export type ApiErrorClass = {
161
+ new (...args: any[]): {
162
+ readonly _tag: string;
163
+ readonly message: string;
164
+ };
165
+ };
166
+
167
+ /**
168
+ * Operation configuration with optional operation-specific errors.
169
+ * Supports both `inputSchema`/`outputSchema` and `input`/`output` aliases.
170
+ */
171
+ export interface OperationConfig<
172
+ I extends Schema.Top,
173
+ O extends Schema.Top,
174
+ E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
175
+ > {
176
+ inputSchema?: I;
177
+ outputSchema?: O;
178
+ /** Alias for inputSchema (used by Cloudflare/GCP generators) */
179
+ input?: I;
180
+ /** Alias for outputSchema (used by Cloudflare/GCP generators) */
181
+ output?: O;
182
+ errors?: E;
183
+ }
184
+
185
+ /**
186
+ * Paginated operation configuration.
187
+ */
188
+ export interface PaginatedOperationConfig<
189
+ I extends Schema.Top,
190
+ O extends Schema.Top,
191
+ E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
192
+ > extends OperationConfig<I, O, E> {
193
+ pagination?: PaginatedTrait;
194
+ }
195
+
196
+ // ============================================================================
197
+ // AST Helpers
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Check if a schema AST represents an array type.
202
+ * Follows encoding chains and Suspend wrappers.
203
+ */
204
+ function isArrayAST(ast: AST.AST): boolean {
205
+ if (ast._tag === "Arrays") return true;
206
+ if (ast._tag === "Suspend") return isArrayAST(ast.thunk());
207
+ if (ast.encoding && ast.encoding.length > 0)
208
+ return isArrayAST(ast.encoding[0].to);
209
+ return false;
210
+ }
211
+
212
+ // ============================================================================
213
+ // Form URL-Encoded Builder (Stripe deepObject style)
214
+ // ============================================================================
215
+
216
+ /**
217
+ * Recursively flatten a nested object into Stripe-style bracket notation
218
+ * for application/x-www-form-urlencoded encoding.
219
+ *
220
+ * Examples:
221
+ * { amount: 2000 } -> "amount=2000"
222
+ * { shipping: { address: { city: "SF" } } } -> "shipping[address][city]=SF"
223
+ * { expand: ["data"] } -> "expand[0]=data"
224
+ * { metadata: { key: "val" } } -> "metadata[key]=val"
225
+ */
226
+ function flattenToFormPairs(
227
+ obj: Record<string, unknown>,
228
+ prefix: string = "",
229
+ ): Array<[string, string]> {
230
+ const pairs: Array<[string, string]> = [];
231
+
232
+ for (const [key, value] of Object.entries(obj)) {
233
+ if (value === undefined || value === null) continue;
234
+
235
+ const fullKey = prefix ? `${prefix}[${key}]` : key;
236
+
237
+ if (Array.isArray(value)) {
238
+ for (let i = 0; i < value.length; i++) {
239
+ const item = value[i];
240
+ if (
241
+ item !== null &&
242
+ item !== undefined &&
243
+ typeof item === "object" &&
244
+ !Array.isArray(item)
245
+ ) {
246
+ pairs.push(
247
+ ...flattenToFormPairs(
248
+ item as Record<string, unknown>,
249
+ `${fullKey}[${i}]`,
250
+ ),
251
+ );
252
+ } else if (item !== undefined && item !== null) {
253
+ pairs.push([`${fullKey}[${i}]`, String(item)]);
254
+ }
255
+ }
256
+ } else if (typeof value === "object") {
257
+ pairs.push(
258
+ ...flattenToFormPairs(value as Record<string, unknown>, fullKey),
259
+ );
260
+ } else if (typeof value === "boolean") {
261
+ pairs.push([fullKey, value ? "true" : "false"]);
262
+ } else {
263
+ pairs.push([fullKey, String(value)]);
264
+ }
265
+ }
266
+
267
+ return pairs;
268
+ }
269
+
270
+ /**
271
+ * Build a URLSearchParams from a nested object using Stripe deepObject encoding.
272
+ */
273
+ function buildFormUrlEncoded(body: Record<string, unknown>): string {
274
+ const pairs = flattenToFormPairs(body);
275
+ const params = new URLSearchParams();
276
+ for (const [key, value] of pairs) {
277
+ params.append(key, value);
278
+ }
279
+ return params.toString();
280
+ }
281
+
282
+ // ============================================================================
283
+ // Multipart FormData Builder
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Check if a value is a File or Blob.
288
+ */
289
+ function isFileOrBlob(value: unknown): value is File | Blob {
290
+ return (
291
+ (typeof File !== "undefined" && value instanceof File) ||
292
+ (typeof Blob !== "undefined" && value instanceof Blob)
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Build a FormData from a record of body properties.
298
+ * Handles files/blobs, arrays of files, objects (as JSON blobs), and primitives.
299
+ *
300
+ * This is used for multipart operations (e.g., Cloudflare Workers script uploads)
301
+ * where the body contains a mix of metadata objects and file uploads.
302
+ */
303
+ function buildFormData(body: Record<string, unknown>): FormData {
304
+ const formData = new FormData();
305
+
306
+ for (const [key, value] of Object.entries(body)) {
307
+ if (value === undefined || value === null) continue;
308
+
309
+ if (isFileOrBlob(value)) {
310
+ // Single file/blob
311
+ formData.append(key, value, value instanceof File ? value.name : key);
312
+ } else if (
313
+ Array.isArray(value) &&
314
+ value.length > 0 &&
315
+ isFileOrBlob(value[0])
316
+ ) {
317
+ // Array of files/blobs — append each individually
318
+ for (const file of value) {
319
+ if (isFileOrBlob(file)) {
320
+ formData.append(
321
+ file instanceof File ? file.name : key,
322
+ file,
323
+ file instanceof File ? file.name : undefined,
324
+ );
325
+ }
326
+ }
327
+ } else if (typeof value === "object" && value !== null) {
328
+ // Object → append as JSON blob
329
+ formData.append(
330
+ key,
331
+ new Blob([JSON.stringify(value)], { type: "application/json" }),
332
+ key,
333
+ );
334
+ } else {
335
+ // Primitive → append as string
336
+ formData.append(key, String(value));
337
+ }
338
+ }
339
+
340
+ return formData;
341
+ }
342
+
343
+ // ============================================================================
344
+ // API Client Factory
345
+ // ============================================================================
346
+
347
+ /**
348
+ * Creates an API namespace bound to a specific SDK's client configuration.
349
+ *
350
+ * @example
351
+ * ```ts
352
+ * // In planetscale-sdk/src/client.ts
353
+ * export const API = makeAPI({
354
+ * credentials: Credentials,
355
+ * getBaseUrl: (c) => c.apiBaseUrl,
356
+ * getAuthHeaders: (c) => ({ Authorization: c.token }),
357
+ * matchError: matchPlanetScaleError,
358
+ * ParseError: PlanetScaleParseError,
359
+ * });
360
+ * ```
361
+ */
362
+ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
363
+ type _ClientErrors = HttpClientError.HttpClientError | HttpBody.HttpBodyError;
364
+ type ResolvedCreds = ResolvedClientCredentials<Creds>;
365
+
366
+ return {
367
+ make: <
368
+ I extends Schema.Top,
369
+ O extends Schema.Top,
370
+ const E extends readonly ApiErrorClass[] = readonly [],
371
+ >(
372
+ configFn: () => OperationConfig<I, O, E>,
373
+ ): any => {
374
+ const opConfig = configFn();
375
+ // Support both input/output and inputSchema/outputSchema aliases
376
+ const inputSchema = (opConfig.inputSchema ?? opConfig.input)!;
377
+ const outputSchema = (opConfig.outputSchema ?? opConfig.output)!;
378
+ const responsePath = Traits.getResponsePath(outputSchema.ast);
379
+ type Input = Schema.Schema.Type<I>;
380
+
381
+ // Read HTTP trait from input schema annotations
382
+ const httpTrait = Traits.getHttpTrait(inputSchema.ast);
383
+
384
+ if (!httpTrait) {
385
+ throw new Error("Input schema must have Http trait");
386
+ }
387
+
388
+ const method = httpTrait.method;
389
+
390
+ const fn = (input: Input): Effect.Effect<any, any, any> =>
391
+ Effect.gen(function* () {
392
+ const credentials = yield* config.credentials as any;
393
+ const creds = isEffectLike(credentials)
394
+ ? yield* credentials
395
+ : credentials;
396
+ const client = yield* HttpClient.HttpClient;
397
+
398
+ const baseUrl = config.getBaseUrl(creds as ResolvedCreds);
399
+ const authHeaders = config.getAuthHeaders(creds as ResolvedCreds);
400
+
401
+ // Use schema-aware request builder for proper camelCase → wire_name mapping
402
+ let parts = Traits.buildRequestParts(
403
+ inputSchema.ast,
404
+ httpTrait,
405
+ input as Record<string, unknown>,
406
+ inputSchema,
407
+ );
408
+
409
+ if (config.transformRequestParts) {
410
+ parts = config.transformRequestParts({
411
+ input: input as Record<string, unknown>,
412
+ method,
413
+ pathTemplate: httpTrait.path,
414
+ parts,
415
+ });
416
+ }
417
+
418
+ let request = HttpClientRequest.make(method)(
419
+ baseUrl + parts.path,
420
+ ).pipe(
421
+ HttpClientRequest.setHeaders(authHeaders),
422
+ HttpClientRequest.setHeaders(parts.headers),
423
+ HttpClientRequest.setHeader("Accept", "application/json"),
424
+ );
425
+
426
+ // Set Content-Type based on body type
427
+ // - Skip for FormData (multipart) — browser sets boundary
428
+ // - Use form-urlencoded for Stripe-style APIs
429
+ // - Default to JSON
430
+ const isFormUrlEncoded = httpTrait.contentType === "form-urlencoded";
431
+ if (parts.isMultipart) {
432
+ // browser/runtime sets Content-Type with boundary
433
+ } else if (isFormUrlEncoded) {
434
+ request = HttpClientRequest.setHeader(
435
+ "Content-Type",
436
+ "application/x-www-form-urlencoded",
437
+ )(request);
438
+ } else {
439
+ request = HttpClientRequest.setHeader(
440
+ "Content-Type",
441
+ "application/json",
442
+ )(request);
443
+ }
444
+
445
+ if (Object.keys(parts.query).length > 0) {
446
+ request = HttpClientRequest.setUrlParams(request, parts.query);
447
+ }
448
+ if (method !== "GET" && parts.body !== undefined) {
449
+ if (parts.isMultipart) {
450
+ // Build FormData from body properties for multipart operations
451
+ const formData = buildFormData(
452
+ parts.body as Record<string, unknown>,
453
+ );
454
+ request = HttpClientRequest.setBody(HttpBody.formData(formData))(
455
+ request,
456
+ );
457
+ } else if (isFormUrlEncoded) {
458
+ // Encode body as form-urlencoded with deepObject bracket notation
459
+ const encoded = buildFormUrlEncoded(
460
+ parts.body as Record<string, unknown>,
461
+ );
462
+ request = HttpClientRequest.setBody(
463
+ HttpBody.text(encoded, "application/x-www-form-urlencoded"),
464
+ )(request);
465
+ } else {
466
+ request = yield* HttpClientRequest.bodyJson(parts.body)(request);
467
+ }
468
+ } else if (method === "GET" && parts.body !== undefined) {
469
+ // For GET requests, remaining non-annotated fields go as query params
470
+ const extraQuery: Record<string, string> = {};
471
+ for (const [key, value] of Object.entries(
472
+ parts.body as Record<string, unknown>,
473
+ )) {
474
+ if (value !== undefined) {
475
+ extraQuery[key] = String(value);
476
+ }
477
+ }
478
+ if (Object.keys(extraQuery).length > 0) {
479
+ request = HttpClientRequest.setUrlParams(request, extraQuery);
480
+ }
481
+ }
482
+
483
+ const response = yield* client.execute(request).pipe(Effect.scoped);
484
+
485
+ if (response.status >= 400) {
486
+ // Try to parse error body as JSON; fall back to text if not JSON
487
+ const errorBody = yield* response.json.pipe(
488
+ Effect.catchIf(
489
+ () => true,
490
+ () =>
491
+ response.text.pipe(
492
+ Effect.map(
493
+ (text) =>
494
+ ({ _nonJsonError: true, body: text }) as unknown,
495
+ ),
496
+ Effect.catchIf(
497
+ () => true,
498
+ () =>
499
+ Effect.succeed({
500
+ _nonJsonError: true,
501
+ body: `HTTP ${response.status}`,
502
+ } as unknown),
503
+ ),
504
+ ),
505
+ ),
506
+ );
507
+ return yield* config.matchError(
508
+ response.status,
509
+ errorBody,
510
+ opConfig.errors,
511
+ );
512
+ }
513
+
514
+ // For void-returning operations (e.g. DELETE 204 No Content)
515
+ if (AST.isVoid(outputSchema.ast)) {
516
+ return undefined;
517
+ }
518
+
519
+ // For 204 No Content: if schema is not Unknown, return undefined.
520
+ // If schema IS Unknown, return empty string (so callers get a defined value).
521
+ if (response.status === 204) {
522
+ if (outputSchema.ast._tag === "Unknown") {
523
+ return "";
524
+ }
525
+ return undefined;
526
+ }
527
+
528
+ // Try to parse response as JSON; fall back to text for non-JSON responses
529
+ // (e.g., multipart/form-data worker scripts, raw KV values)
530
+ const rawBody = yield* response.json.pipe(
531
+ Effect.catchIf(
532
+ () => true,
533
+ () => response.text.pipe(Effect.map((text) => text as unknown)),
534
+ ),
535
+ );
536
+ let responseBody = config.transformResponse
537
+ ? config.transformResponse(rawBody)
538
+ : rawBody;
539
+
540
+ if (responsePath) {
541
+ const nested = getPath(responseBody, responsePath);
542
+ if (nested !== undefined) {
543
+ responseBody =
544
+ responsePath === "result" && nested === null ? {} : nested;
545
+ }
546
+ }
547
+
548
+ // Handle Cloudflare-style paginated responses where result is
549
+ // { items: [...] } but the schema expects an array
550
+ if (
551
+ isArrayAST(outputSchema.ast) &&
552
+ !Array.isArray(responseBody) &&
553
+ typeof responseBody === "object" &&
554
+ responseBody !== null &&
555
+ "items" in responseBody &&
556
+ Array.isArray((responseBody as Record<string, unknown>).items)
557
+ ) {
558
+ responseBody = (responseBody as Record<string, unknown>).items;
559
+ }
560
+
561
+ return yield* Schema.decodeUnknownEffect(outputSchema)(
562
+ responseBody,
563
+ ).pipe(
564
+ Effect.catchTag("SchemaError", (cause) =>
565
+ Effect.fail(new config.ParseError({ body: rawBody, cause })),
566
+ ),
567
+ );
568
+ });
569
+
570
+ const Proto = {
571
+ [Symbol.iterator]() {
572
+ return new SingleShotGen(this);
573
+ },
574
+ pipe() {
575
+ return pipeArguments(this.asEffect(), arguments);
576
+ },
577
+ asEffect() {
578
+ return Effect.map(
579
+ Effect.services(),
580
+ (sm) => (input: Input) => fn(input).pipe(Effect.provide(sm)),
581
+ );
582
+ },
583
+ };
584
+
585
+ return Object.assign(fn, Proto);
586
+ },
587
+
588
+ makePaginated: <
589
+ I extends Schema.Top,
590
+ O extends Schema.Top,
591
+ const E extends readonly ApiErrorClass[] = readonly [],
592
+ >(
593
+ configFn: () => PaginatedOperationConfig<I, O, E>,
594
+ paginateFn?: PaginationStrategy,
595
+ ): any => {
596
+ const opConfig = configFn();
597
+ const pagination = opConfig.pagination!;
598
+
599
+ // Create the base operation
600
+ const baseFn = makeAPI(config).make(() => ({
601
+ inputSchema: opConfig.inputSchema ?? opConfig.input,
602
+ outputSchema: opConfig.outputSchema ?? opConfig.output,
603
+ errors: opConfig.errors,
604
+ }));
605
+
606
+ type Input = Schema.Schema.Type<I>;
607
+
608
+ const paginate = paginateFn ?? paginateWithDefaults;
609
+
610
+ // Stream all pages
611
+ const pagesFn = (input: Omit<Input, string>) =>
612
+ paginate(baseFn, input, pagination);
613
+
614
+ // Stream individual items
615
+ const itemsFn = (input: Omit<Input, string>) =>
616
+ pagination.items
617
+ ? extractItems(pagesFn(input), pagination.items)
618
+ : pagesFn(input);
619
+
620
+ const result = baseFn as typeof baseFn & {
621
+ pages: typeof pagesFn;
622
+ items: typeof itemsFn;
623
+ };
624
+
625
+ result.pages = pagesFn;
626
+ result.items = itemsFn;
627
+
628
+ return result;
629
+ },
630
+ };
631
+ };