@distilled.cloud/core 0.30.3 → 1.0.0-rc.2
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/lib/api.d.ts +165 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +178 -0
- package/lib/api.js.map +1 -0
- package/lib/codegen/cli.d.ts +29 -0
- package/lib/codegen/cli.d.ts.map +1 -0
- package/lib/codegen/cli.js +165 -0
- package/lib/codegen/cli.js.map +1 -0
- package/lib/codegen/emit.d.ts +129 -0
- package/lib/codegen/emit.d.ts.map +1 -0
- package/lib/codegen/emit.js +105 -0
- package/lib/codegen/emit.js.map +1 -0
- package/lib/codegen/format.d.ts +23 -0
- package/lib/codegen/format.d.ts.map +1 -0
- package/lib/codegen/format.js +28 -0
- package/lib/codegen/format.js.map +1 -0
- package/lib/codegen/generator.d.ts +334 -0
- package/lib/codegen/generator.d.ts.map +1 -0
- package/lib/codegen/generator.js +691 -0
- package/lib/codegen/generator.js.map +1 -0
- package/lib/codegen/graph.d.ts +36 -0
- package/lib/codegen/graph.d.ts.map +1 -0
- package/lib/codegen/graph.js +136 -0
- package/lib/codegen/graph.js.map +1 -0
- package/lib/codegen/members.d.ts +25 -0
- package/lib/codegen/members.d.ts.map +1 -0
- package/lib/codegen/members.js +55 -0
- package/lib/codegen/members.js.map +1 -0
- package/lib/codegen/naming.d.ts +29 -0
- package/lib/codegen/naming.d.ts.map +1 -0
- package/lib/codegen/naming.js +74 -0
- package/lib/codegen/naming.js.map +1 -0
- package/lib/codegen/openapi-cli.d.ts +38 -0
- package/lib/codegen/openapi-cli.d.ts.map +1 -0
- package/lib/codegen/openapi-cli.js +107 -0
- package/lib/codegen/openapi-cli.js.map +1 -0
- package/lib/codegen/openapi.d.ts +115 -0
- package/lib/codegen/openapi.d.ts.map +1 -0
- package/lib/codegen/openapi.js +1220 -0
- package/lib/codegen/openapi.js.map +1 -0
- package/lib/codegen/operations.d.ts +24 -0
- package/lib/codegen/operations.d.ts.map +1 -0
- package/lib/codegen/operations.js +56 -0
- package/lib/codegen/operations.js.map +1 -0
- package/lib/codegen/pagination.d.ts +39 -0
- package/lib/codegen/pagination.d.ts.map +1 -0
- package/lib/codegen/pagination.js +33 -0
- package/lib/codegen/pagination.js.map +1 -0
- package/lib/codegen/prelude.d.ts +15 -0
- package/lib/codegen/prelude.d.ts.map +1 -0
- package/lib/codegen/prelude.js +60 -0
- package/lib/codegen/prelude.js.map +1 -0
- package/lib/error-category.d.ts +28 -0
- package/lib/error-category.d.ts.map +1 -0
- package/lib/error-category.js +46 -0
- package/lib/error-category.js.map +1 -0
- package/lib/errors.d.ts +1 -0
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +1 -0
- package/lib/errors.js.map +1 -1
- package/lib/json-patch.d.ts +25 -32
- package/lib/json-patch.d.ts.map +1 -1
- package/lib/json-patch.js +23 -95
- package/lib/json-patch.js.map +1 -1
- package/lib/pagination.d.ts +37 -51
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +72 -90
- package/lib/pagination.js.map +1 -1
- package/lib/protocol-http.d.ts +74 -0
- package/lib/protocol-http.d.ts.map +1 -0
- package/lib/protocol-http.js +554 -0
- package/lib/protocol-http.js.map +1 -0
- package/lib/protocol-rest.d.ts +124 -0
- package/lib/protocol-rest.d.ts.map +1 -0
- package/lib/protocol-rest.js +242 -0
- package/lib/protocol-rest.js.map +1 -0
- package/lib/retry.d.ts +8 -2
- package/lib/retry.d.ts.map +1 -1
- package/lib/retry.js +21 -15
- package/lib/retry.js.map +1 -1
- package/lib/schema.d.ts +7 -8
- package/lib/schema.d.ts.map +1 -1
- package/lib/schema.js +7 -8
- package/lib/schema.js.map +1 -1
- package/lib/trait.d.ts +150 -0
- package/lib/trait.d.ts.map +1 -0
- package/lib/trait.js +107 -0
- package/lib/trait.js.map +1 -0
- package/package.json +18 -75
- package/src/api.ts +446 -0
- package/src/codegen/cli.ts +268 -0
- package/src/codegen/emit.ts +207 -0
- package/src/codegen/format.ts +47 -0
- package/src/codegen/generator.ts +1153 -0
- package/src/codegen/graph.ts +151 -0
- package/src/codegen/members.ts +71 -0
- package/src/codegen/naming.ts +86 -0
- package/src/codegen/openapi-cli.ts +166 -0
- package/src/codegen/openapi.ts +1450 -0
- package/src/codegen/operations.ts +76 -0
- package/src/codegen/pagination.ts +71 -0
- package/src/codegen/prelude.ts +70 -0
- package/src/error-category.ts +84 -0
- package/src/errors.ts +2 -0
- package/src/json-patch.ts +26 -110
- package/src/pagination.ts +86 -142
- package/src/protocol-http.ts +699 -0
- package/src/protocol-rest.ts +367 -0
- package/src/retry.ts +20 -21
- package/src/schema.ts +7 -8
- package/src/trait.ts +238 -0
- package/README.md +0 -30
- package/lib/client.d.ts +0 -167
- package/lib/client.d.ts.map +0 -1
- package/lib/client.js +0 -659
- package/lib/client.js.map +0 -1
- package/lib/schemas.d.ts +0 -60
- package/lib/schemas.d.ts.map +0 -1
- package/lib/schemas.js +0 -79
- package/lib/schemas.js.map +0 -1
- package/lib/sensitive.d.ts +0 -71
- package/lib/sensitive.d.ts.map +0 -1
- package/lib/sensitive.js +0 -96
- package/lib/sensitive.js.map +0 -1
- package/lib/traits.d.ts +0 -421
- package/lib/traits.d.ts.map +0 -1
- package/lib/traits.js +0 -737
- package/lib/traits.js.map +0 -1
- package/src/client.ts +0 -1177
- package/src/schemas.ts +0 -128
- package/src/sensitive.ts +0 -119
- package/src/traits.ts +0 -996
package/src/client.ts
DELETED
|
@@ -1,1177 +0,0 @@
|
|
|
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 Config from "effect/Config";
|
|
29
|
-
import * as Context from "effect/Context";
|
|
30
|
-
import * as Effect from "effect/Effect";
|
|
31
|
-
import { pipe } from "effect/Function";
|
|
32
|
-
import * as Option from "effect/Option";
|
|
33
|
-
import { pipeArguments } from "effect/Pipeable";
|
|
34
|
-
import * as Ref from "effect/Ref";
|
|
35
|
-
import { MinimumLogLevel } from "effect/References";
|
|
36
|
-
import * as Schema from "effect/Schema";
|
|
37
|
-
import * as AST from "effect/SchemaAST";
|
|
38
|
-
import * as Stream from "effect/Stream";
|
|
39
|
-
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
40
|
-
import * as HttpBody from "effect/unstable/http/HttpBody";
|
|
41
|
-
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
42
|
-
import * as HttpClientError from "effect/unstable/http/HttpClientError";
|
|
43
|
-
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
44
|
-
import { SingleShotGen } from "effect/Utils";
|
|
45
|
-
import {
|
|
46
|
-
extractItems,
|
|
47
|
-
paginateWithDefaults,
|
|
48
|
-
type PaginatedTrait,
|
|
49
|
-
type PaginationStrategy,
|
|
50
|
-
} from "./pagination.ts";
|
|
51
|
-
import { makeDefault, type Policy as RetryPolicy } from "./retry.ts";
|
|
52
|
-
import * as Traits from "./traits.ts";
|
|
53
|
-
import { getPath } from "./traits.ts";
|
|
54
|
-
|
|
55
|
-
// `DISTILLED_DEBUG=1` forces the MinimumLogLevel to Debug for SDK operations,
|
|
56
|
-
// independent of the caller's logger config. Users who set `MinimumLogLevel`
|
|
57
|
-
// to Debug themselves get the same logs without needing the env var.
|
|
58
|
-
const distilledDebugConfig: Effect.Effect<boolean> = Config.string(
|
|
59
|
-
"DISTILLED_DEBUG",
|
|
60
|
-
)
|
|
61
|
-
.pipe(Config.map((raw) => raw === "1"))
|
|
62
|
-
.pipe(Effect.orElseSucceed(() => false));
|
|
63
|
-
|
|
64
|
-
// ============================================================================
|
|
65
|
-
// Client Types
|
|
66
|
-
// ============================================================================
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* An operation that can be used in two ways:
|
|
70
|
-
* 1. Direct call: `yield* operation(input)` - returns Effect with requirements
|
|
71
|
-
* 2. Yield first: `const fn = yield* operation` - captures services, returns requirement-free function
|
|
72
|
-
*/
|
|
73
|
-
export type OperationMethod<I, A, E, R, RequestOptions = never> = Effect.Effect<
|
|
74
|
-
(input: I, requestOptions?: RequestOptions) => Effect.Effect<A, E, never>,
|
|
75
|
-
never,
|
|
76
|
-
R
|
|
77
|
-
> &
|
|
78
|
-
((input: I, requestOptions?: RequestOptions) => Effect.Effect<A, E, R>);
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* A paginated operation that additionally has `.pages()` and `.items()` methods.
|
|
82
|
-
*/
|
|
83
|
-
type PaginatedItem<A> =
|
|
84
|
-
A extends ReadonlyArray<infer Item>
|
|
85
|
-
? Item
|
|
86
|
-
: A extends { result: ReadonlyArray<infer Item> }
|
|
87
|
-
? Item
|
|
88
|
-
: A extends { result?: ReadonlyArray<infer Item> | null | undefined }
|
|
89
|
-
? Item
|
|
90
|
-
: A extends { result: { items: ReadonlyArray<infer Item> } }
|
|
91
|
-
? Item
|
|
92
|
-
: A extends {
|
|
93
|
-
result?:
|
|
94
|
-
| {
|
|
95
|
-
items?: ReadonlyArray<infer Item> | null | undefined;
|
|
96
|
-
}
|
|
97
|
-
| null
|
|
98
|
-
| undefined;
|
|
99
|
-
}
|
|
100
|
-
? Item
|
|
101
|
-
: unknown;
|
|
102
|
-
|
|
103
|
-
export type PaginatedOperationMethod<
|
|
104
|
-
I,
|
|
105
|
-
A,
|
|
106
|
-
E,
|
|
107
|
-
R,
|
|
108
|
-
RequestOptions = never,
|
|
109
|
-
> = OperationMethod<I, A, E, R, RequestOptions> & {
|
|
110
|
-
pages: (input: I, requestOptions?: RequestOptions) => Stream.Stream<A, E, R>;
|
|
111
|
-
items: (
|
|
112
|
-
input: I,
|
|
113
|
-
requestOptions?: RequestOptions,
|
|
114
|
-
) => Stream.Stream<PaginatedItem<A>, E, R>;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
type ResolvedClientCredentials<Creds> =
|
|
118
|
-
Creds extends Effect.Effect<infer Resolved, any, any> ? Resolved : Creds;
|
|
119
|
-
|
|
120
|
-
const isEffectLike = (value: unknown): value is Effect.Effect<unknown> =>
|
|
121
|
-
typeof value === "object" &&
|
|
122
|
-
value !== null &&
|
|
123
|
-
typeof (value as { pipe?: unknown }).pipe === "function" &&
|
|
124
|
-
typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] ===
|
|
125
|
-
"function";
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Configuration for the API client factory.
|
|
129
|
-
* SDKs provide this to customize how errors are matched and credentials are applied.
|
|
130
|
-
*/
|
|
131
|
-
export interface ClientConfig<Creds, RequestOptions = never> {
|
|
132
|
-
/** The credentials service tag */
|
|
133
|
-
credentials: Context.ServiceClass<any, any, Effect.Effect<Creds>>;
|
|
134
|
-
|
|
135
|
-
/** Get the base URL from credentials */
|
|
136
|
-
getBaseUrl: (creds: ResolvedClientCredentials<Creds>) => string;
|
|
137
|
-
|
|
138
|
-
/** Get authorization header(s) from credentials */
|
|
139
|
-
getAuthHeaders: (
|
|
140
|
-
creds: ResolvedClientCredentials<Creds>,
|
|
141
|
-
) => Record<string, string>;
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Map provider-specific per-call request options into transport headers.
|
|
145
|
-
* Request options are intentionally separate from the operation input and
|
|
146
|
-
* are never passed through the body/query/path schema encoder.
|
|
147
|
-
*/
|
|
148
|
-
getRequestHeaders?: (
|
|
149
|
-
requestOptions: RequestOptions | undefined,
|
|
150
|
-
context: {
|
|
151
|
-
input: Record<string, unknown>;
|
|
152
|
-
method: string;
|
|
153
|
-
pathTemplate: string;
|
|
154
|
-
parts: Traits.RequestParts;
|
|
155
|
-
credentials: ResolvedClientCredentials<Creds>;
|
|
156
|
-
},
|
|
157
|
-
) => Record<string, string>;
|
|
158
|
-
|
|
159
|
-
/** Match an error response body to a typed error.
|
|
160
|
-
* Should return Effect.fail(error) for known errors,
|
|
161
|
-
* or Effect.fail(fallbackError) for unknown errors.
|
|
162
|
-
* The optional `errors` parameter provides per-operation typed error classes.
|
|
163
|
-
* The optional `headers` parameter is the response header bag (lowercase
|
|
164
|
-
* keys) — for retryable status codes, pass `retryAfter: parseRetryAfterForStatus(status, headers)`
|
|
165
|
-
* from `@distilled.cloud/core/retry-after` when a standard `Retry-After` /
|
|
166
|
-
* `RateLimit` hint is present; omit `retryAfter` when there is no hint (the
|
|
167
|
-
* default retry policy still uses exponential backoff). The status-gated
|
|
168
|
-
* helper avoids attaching stale `retryAfter` to non-retryable classes
|
|
169
|
-
* (BadRequest/401/404/etc.). The maximum honored hint is capped (default
|
|
170
|
-
* 60s) — override with \`DISTILLED_SERVER_RETRY_HINT_CAP_MS\` or provide
|
|
171
|
-
* \`ServerRetryHintCapMs\` via \`Layer\` from \`@distilled.cloud/core/retry\`.
|
|
172
|
-
*/
|
|
173
|
-
matchError: (
|
|
174
|
-
status: number,
|
|
175
|
-
body: unknown,
|
|
176
|
-
errors?: readonly ApiErrorClass[],
|
|
177
|
-
headers?: Record<string, string | undefined>,
|
|
178
|
-
) => Effect.Effect<never, unknown>;
|
|
179
|
-
|
|
180
|
-
/** Parse error class for schema decode failures */
|
|
181
|
-
ParseError: new (props: { body: unknown; cause: unknown }) => unknown;
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Optional transform applied to the response body before schema decoding.
|
|
185
|
-
* For example, Cloudflare wraps responses in `{ result: <data>, ... }`.
|
|
186
|
-
*/
|
|
187
|
-
transformResponse?: (body: unknown) => unknown;
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Optional predicate identifying a successful-status (2xx) response whose
|
|
191
|
-
* body is actually an error envelope. Some APIs (notably Cloudflare) return
|
|
192
|
-
* errors with HTTP 200 and a `success: false` flag instead of a 4xx status.
|
|
193
|
-
* When this returns `true`, the body is routed through {@link matchError}
|
|
194
|
-
* (with the operation's typed `errors`) exactly like a status>=400 response,
|
|
195
|
-
* so per-operation typed error matchers still apply. SDKs that always signal
|
|
196
|
-
* errors via status codes leave this unset (the default no-ops).
|
|
197
|
-
*/
|
|
198
|
-
isErrorEnvelope?: (body: unknown) => boolean;
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Optional transform applied to encoded request parts before building the
|
|
202
|
-
* outbound HTTP request.
|
|
203
|
-
*/
|
|
204
|
-
transformRequestParts?: (input: {
|
|
205
|
-
input: Record<string, unknown>;
|
|
206
|
-
method: string;
|
|
207
|
-
pathTemplate: string;
|
|
208
|
-
parts: Traits.RequestParts;
|
|
209
|
-
requestOptions: RequestOptions | undefined;
|
|
210
|
-
}) => Traits.RequestParts;
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* The SDK's `Retry` Context.Service tag. Each per-SDK client wires its
|
|
214
|
-
* own tag here so callers can install a blanket policy at the layer
|
|
215
|
-
* level (e.g. `myEffect.pipe(Cloudflare.Retry.transient)`) and have
|
|
216
|
-
* every API call below it pick it up — same pattern as
|
|
217
|
-
* `packages/aws/src/client/api.ts`.
|
|
218
|
-
*
|
|
219
|
-
* `makeAPI` reads the policy via `Effect.serviceOption(retry)` on every
|
|
220
|
-
* call and falls back to `Retry.makeDefault` (transient/throttling/server
|
|
221
|
-
* with capped exponential backoff + jitter, 5 attempts) when no policy
|
|
222
|
-
* is provided.
|
|
223
|
-
*/
|
|
224
|
-
retry: Context.Key<any, RetryPolicy>;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Base API error type - any error class with at least a _tag and message.
|
|
229
|
-
* Uses `new (...args: any[])` to accommodate error classes with extra fields (e.g. `code`).
|
|
230
|
-
*/
|
|
231
|
-
export type ApiErrorClass = {
|
|
232
|
-
new (...args: any[]): {
|
|
233
|
-
readonly _tag: string;
|
|
234
|
-
readonly message: string;
|
|
235
|
-
};
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Operation configuration with optional operation-specific errors.
|
|
240
|
-
* Supports both `inputSchema`/`outputSchema` and `input`/`output` aliases.
|
|
241
|
-
*/
|
|
242
|
-
export interface OperationConfig<
|
|
243
|
-
I extends Schema.Top,
|
|
244
|
-
O extends Schema.Top,
|
|
245
|
-
E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
|
|
246
|
-
> {
|
|
247
|
-
inputSchema?: I;
|
|
248
|
-
outputSchema?: O;
|
|
249
|
-
/** Alias for inputSchema (used by Cloudflare/GCP generators) */
|
|
250
|
-
input?: I;
|
|
251
|
-
/** Alias for outputSchema (used by Cloudflare/GCP generators) */
|
|
252
|
-
output?: O;
|
|
253
|
-
errors?: E;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Paginated operation configuration.
|
|
258
|
-
*/
|
|
259
|
-
export interface PaginatedOperationConfig<
|
|
260
|
-
I extends Schema.Top,
|
|
261
|
-
O extends Schema.Top,
|
|
262
|
-
E extends readonly ApiErrorClass[] = readonly ApiErrorClass[],
|
|
263
|
-
> extends OperationConfig<I, O, E> {
|
|
264
|
-
pagination?: PaginatedTrait;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ============================================================================
|
|
268
|
-
// AST Helpers
|
|
269
|
-
// ============================================================================
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Check if a schema AST represents an array type.
|
|
273
|
-
* Follows encoding chains and Suspend wrappers.
|
|
274
|
-
*/
|
|
275
|
-
function isArrayAST(ast: AST.AST): boolean {
|
|
276
|
-
if (ast._tag === "Arrays") return true;
|
|
277
|
-
if (ast._tag === "Suspend") return isArrayAST(ast.thunk());
|
|
278
|
-
if (ast.encoding && ast.encoding.length > 0)
|
|
279
|
-
return isArrayAST(ast.encoding[0].to);
|
|
280
|
-
return false;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Resolve a (possibly `Schema.suspend`-wrapped) AST down to the concrete
|
|
285
|
-
* underlying node by forcing the memoized thunk. Generated SDK schemas may
|
|
286
|
-
* wrap each request/response struct in `Schema.suspend(() => ...)` so the
|
|
287
|
-
* (expensive) schema construction is deferred from module-load time to the
|
|
288
|
-
* first time the operation is actually called. Trait extraction needs the
|
|
289
|
-
* real node, so we force it here. `Suspend.thunk` memoizes, so this only
|
|
290
|
-
* pays the construction cost once per operation. Returns the input AST
|
|
291
|
-
* untouched when it isn't a Suspend (the common case for non-suspended SDKs).
|
|
292
|
-
*/
|
|
293
|
-
function resolveAst(ast: AST.AST): AST.AST {
|
|
294
|
-
return ast._tag === "Suspend" ? resolveAst(ast.thunk()) : ast;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ============================================================================
|
|
298
|
-
// Form URL-Encoded Builder (Stripe deepObject style)
|
|
299
|
-
// ============================================================================
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Recursively flatten a nested object into Stripe-style bracket notation
|
|
303
|
-
* for application/x-www-form-urlencoded encoding.
|
|
304
|
-
*
|
|
305
|
-
* Examples:
|
|
306
|
-
* { amount: 2000 } -> "amount=2000"
|
|
307
|
-
* { shipping: { address: { city: "SF" } } } -> "shipping[address][city]=SF"
|
|
308
|
-
* { expand: ["data"] } -> "expand[0]=data"
|
|
309
|
-
* { metadata: { key: "val" } } -> "metadata[key]=val"
|
|
310
|
-
*/
|
|
311
|
-
function flattenToFormPairs(
|
|
312
|
-
obj: Record<string, unknown>,
|
|
313
|
-
prefix: string = "",
|
|
314
|
-
): Array<[string, string]> {
|
|
315
|
-
const pairs: Array<[string, string]> = [];
|
|
316
|
-
|
|
317
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
318
|
-
if (value === undefined || value === null) continue;
|
|
319
|
-
|
|
320
|
-
const fullKey = prefix ? `${prefix}[${key}]` : key;
|
|
321
|
-
|
|
322
|
-
if (Array.isArray(value)) {
|
|
323
|
-
for (let i = 0; i < value.length; i++) {
|
|
324
|
-
const item = value[i];
|
|
325
|
-
if (
|
|
326
|
-
item !== null &&
|
|
327
|
-
item !== undefined &&
|
|
328
|
-
typeof item === "object" &&
|
|
329
|
-
!Array.isArray(item)
|
|
330
|
-
) {
|
|
331
|
-
pairs.push(
|
|
332
|
-
...flattenToFormPairs(
|
|
333
|
-
item as Record<string, unknown>,
|
|
334
|
-
`${fullKey}[${i}]`,
|
|
335
|
-
),
|
|
336
|
-
);
|
|
337
|
-
} else if (item !== undefined && item !== null) {
|
|
338
|
-
pairs.push([`${fullKey}[${i}]`, String(item)]);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
} else if (typeof value === "object") {
|
|
342
|
-
pairs.push(
|
|
343
|
-
...flattenToFormPairs(value as Record<string, unknown>, fullKey),
|
|
344
|
-
);
|
|
345
|
-
} else if (typeof value === "boolean") {
|
|
346
|
-
pairs.push([fullKey, value ? "true" : "false"]);
|
|
347
|
-
} else {
|
|
348
|
-
pairs.push([fullKey, String(value)]);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return pairs;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Build a URLSearchParams from a nested object using Stripe deepObject encoding.
|
|
357
|
-
*/
|
|
358
|
-
function buildFormUrlEncoded(body: Record<string, unknown>): string {
|
|
359
|
-
const pairs = flattenToFormPairs(body);
|
|
360
|
-
const params = new URLSearchParams();
|
|
361
|
-
for (const [key, value] of pairs) {
|
|
362
|
-
params.append(key, value);
|
|
363
|
-
}
|
|
364
|
-
return params.toString();
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// ============================================================================
|
|
368
|
-
// Multipart FormData Builder
|
|
369
|
-
// ============================================================================
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Check if a value is a File or Blob.
|
|
373
|
-
*/
|
|
374
|
-
function isFileOrBlob(value: unknown): value is File | Blob {
|
|
375
|
-
return (
|
|
376
|
-
(typeof File !== "undefined" && value instanceof File) ||
|
|
377
|
-
(typeof Blob !== "undefined" && value instanceof Blob)
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Build a FormData from a record of body properties.
|
|
383
|
-
* Handles files/blobs, arrays of files, objects (as JSON blobs), and primitives.
|
|
384
|
-
*
|
|
385
|
-
* This is used for multipart operations (e.g., Cloudflare Workers script uploads)
|
|
386
|
-
* where the body contains a mix of metadata objects and file uploads.
|
|
387
|
-
*/
|
|
388
|
-
function buildFormData(body: Record<string, unknown>): FormData {
|
|
389
|
-
const formData = new FormData();
|
|
390
|
-
|
|
391
|
-
for (const [key, value] of Object.entries(body)) {
|
|
392
|
-
if (value === undefined || value === null) continue;
|
|
393
|
-
|
|
394
|
-
if (isFileOrBlob(value)) {
|
|
395
|
-
// Single file/blob
|
|
396
|
-
formData.append(key, value, value instanceof File ? value.name : key);
|
|
397
|
-
} else if (
|
|
398
|
-
Array.isArray(value) &&
|
|
399
|
-
value.length > 0 &&
|
|
400
|
-
isFileOrBlob(value[0])
|
|
401
|
-
) {
|
|
402
|
-
// Array of files/blobs — append each individually
|
|
403
|
-
for (const file of value) {
|
|
404
|
-
if (isFileOrBlob(file)) {
|
|
405
|
-
formData.append(
|
|
406
|
-
file instanceof File ? file.name : key,
|
|
407
|
-
file,
|
|
408
|
-
file instanceof File ? file.name : undefined,
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
} else if (typeof value === "object" && value !== null) {
|
|
413
|
-
// Object → append as JSON string (matches wrangler's formData.set(key, JSON.stringify(value)))
|
|
414
|
-
formData.append(key, JSON.stringify(value));
|
|
415
|
-
} else {
|
|
416
|
-
// Primitive → append as string
|
|
417
|
-
formData.append(key, String(value));
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return formData;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Set a raw binary HTTP request body.
|
|
426
|
-
*
|
|
427
|
-
* Used for `T.Http({ contentType: "binary" })` operations (e.g. R2 PutObject)
|
|
428
|
-
* where `parts.body` is the value of the lone `T.HttpBody()` field — wide
|
|
429
|
-
* input types are accepted: `Blob`, `Uint8Array`, `ArrayBuffer`, `string`,
|
|
430
|
-
* web `ReadableStream<Uint8Array>`, or Effect `Stream.Stream<Uint8Array>`.
|
|
431
|
-
* Stream-shaped inputs are sent as true streaming `HttpBody.stream(...)`
|
|
432
|
-
* uploads.
|
|
433
|
-
*
|
|
434
|
-
* The `Content-Type` header is left untouched (the operation's
|
|
435
|
-
* `content-type` header field already populated `parts.headers`).
|
|
436
|
-
*/
|
|
437
|
-
function setBinaryBody(
|
|
438
|
-
request: HttpClientRequest.HttpClientRequest,
|
|
439
|
-
body: unknown,
|
|
440
|
-
contentType?: string,
|
|
441
|
-
): Effect.Effect<HttpClientRequest.HttpClientRequest, HttpBody.HttpBodyError> {
|
|
442
|
-
// The body's own content-type is what the request ultimately sends — pass the
|
|
443
|
-
// resolved media type (e.g. application/x-ndjson) into the HttpBody so it is
|
|
444
|
-
// not clobbered back to the octet-stream default.
|
|
445
|
-
if (body instanceof Uint8Array) {
|
|
446
|
-
return Effect.succeed(
|
|
447
|
-
HttpClientRequest.setBody(HttpBody.uint8Array(body, contentType))(
|
|
448
|
-
request,
|
|
449
|
-
),
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
|
|
453
|
-
return Effect.succeed(
|
|
454
|
-
HttpClientRequest.setBody(
|
|
455
|
-
HttpBody.uint8Array(new Uint8Array(body), contentType),
|
|
456
|
-
)(request),
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
460
|
-
// Stream the Blob through `HttpBody.stream` rather than buffering — keeps
|
|
461
|
-
// memory bounded for large uploads.
|
|
462
|
-
const blob = body;
|
|
463
|
-
const blobStream = Stream.fromReadableStream({
|
|
464
|
-
evaluate: () => blob.stream(),
|
|
465
|
-
onError: (cause) =>
|
|
466
|
-
new HttpBody.HttpBodyError({ reason: { _tag: "JsonError" }, cause }),
|
|
467
|
-
});
|
|
468
|
-
return Effect.succeed(
|
|
469
|
-
HttpClientRequest.setBody(HttpBody.stream(blobStream, contentType))(
|
|
470
|
-
request,
|
|
471
|
-
),
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
|
475
|
-
const rs = body as ReadableStream<Uint8Array>;
|
|
476
|
-
const rsStream = Stream.fromReadableStream({
|
|
477
|
-
evaluate: () => rs,
|
|
478
|
-
onError: (cause) =>
|
|
479
|
-
new HttpBody.HttpBodyError({ reason: { _tag: "JsonError" }, cause }),
|
|
480
|
-
});
|
|
481
|
-
return Effect.succeed(
|
|
482
|
-
HttpClientRequest.setBody(HttpBody.stream(rsStream, contentType))(
|
|
483
|
-
request,
|
|
484
|
-
),
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
if (Stream.isStream(body as Stream.Stream<unknown, unknown, unknown>)) {
|
|
488
|
-
// Effect Stream — pass straight through to `HttpBody.stream`.
|
|
489
|
-
return Effect.succeed(
|
|
490
|
-
HttpClientRequest.setBody(
|
|
491
|
-
HttpBody.stream(
|
|
492
|
-
body as Stream.Stream<Uint8Array, unknown>,
|
|
493
|
-
contentType,
|
|
494
|
-
),
|
|
495
|
-
)(request),
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
if (typeof body === "string") {
|
|
499
|
-
return Effect.succeed(
|
|
500
|
-
HttpClientRequest.setBody(HttpBody.text(body, contentType))(request),
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
return Effect.fail(
|
|
504
|
-
new HttpBody.HttpBodyError({
|
|
505
|
-
reason: { _tag: "JsonError" },
|
|
506
|
-
cause: new TypeError(
|
|
507
|
-
`Binary HTTP body must be a Blob, Uint8Array, ArrayBuffer, ReadableStream, Stream<Uint8Array>, or string; got ${
|
|
508
|
-
body === null ? "null" : typeof body
|
|
509
|
-
}`,
|
|
510
|
-
),
|
|
511
|
-
}),
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ============================================================================
|
|
516
|
-
// API Client Factory
|
|
517
|
-
// ============================================================================
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Creates an API namespace bound to a specific SDK's client configuration.
|
|
521
|
-
*
|
|
522
|
-
* @example
|
|
523
|
-
* ```ts
|
|
524
|
-
* // In planetscale-sdk/src/client.ts
|
|
525
|
-
* export const API = makeAPI({
|
|
526
|
-
* credentials: Credentials,
|
|
527
|
-
* getBaseUrl: (c) => c.apiBaseUrl,
|
|
528
|
-
* getAuthHeaders: (c) => ({ Authorization: c.token }),
|
|
529
|
-
* matchError: matchPlanetScaleError,
|
|
530
|
-
* ParseError: PlanetScaleParseError,
|
|
531
|
-
* });
|
|
532
|
-
* ```
|
|
533
|
-
*/
|
|
534
|
-
export const makeAPI = <Creds, RequestOptions = never>(
|
|
535
|
-
config: ClientConfig<Creds, RequestOptions>,
|
|
536
|
-
) => {
|
|
537
|
-
type _ClientErrors = HttpClientError.HttpClientError | HttpBody.HttpBodyError;
|
|
538
|
-
type ResolvedCreds = ResolvedClientCredentials<Creds>;
|
|
539
|
-
|
|
540
|
-
return {
|
|
541
|
-
make: <
|
|
542
|
-
I extends Schema.Top,
|
|
543
|
-
O extends Schema.Top,
|
|
544
|
-
const E extends readonly ApiErrorClass[] = readonly [],
|
|
545
|
-
>(
|
|
546
|
-
configFn: () => OperationConfig<I, O, E>,
|
|
547
|
-
): OperationMethod<
|
|
548
|
-
Schema.Schema.Type<I>,
|
|
549
|
-
Schema.Schema.Type<O>,
|
|
550
|
-
InstanceType<E[number]>,
|
|
551
|
-
Creds,
|
|
552
|
-
RequestOptions
|
|
553
|
-
> => {
|
|
554
|
-
type Input = Schema.Schema.Type<I>;
|
|
555
|
-
|
|
556
|
-
// Lazily resolve the operation config + schema traits on first use,
|
|
557
|
-
// not at module-load time. Generated SDKs may wrap each request/response
|
|
558
|
-
// schema in `Schema.suspend(() => ...)`; forcing them here (rather than
|
|
559
|
-
// when the `export const` is evaluated) keeps importing a service module
|
|
560
|
-
// cheap and only pays the schema-construction cost for operations that
|
|
561
|
-
// are actually called. The result is memoized so subsequent calls are
|
|
562
|
-
// free, and non-suspended SDKs are unaffected (resolveAst is a no-op).
|
|
563
|
-
type HttpTraitResolved = NonNullable<
|
|
564
|
-
ReturnType<typeof Traits.getHttpTrait>
|
|
565
|
-
>;
|
|
566
|
-
interface Prepared {
|
|
567
|
-
opConfig: OperationConfig<I, O, E>;
|
|
568
|
-
inputSchema: I;
|
|
569
|
-
outputSchema: O;
|
|
570
|
-
inputAst: AST.AST;
|
|
571
|
-
outputAst: AST.AST;
|
|
572
|
-
responsePath: string | undefined;
|
|
573
|
-
graphqlOp: ReturnType<typeof Traits.getGraphQLOp>;
|
|
574
|
-
noFollowRedirect: ReturnType<typeof Traits.getNoFollowRedirect>;
|
|
575
|
-
httpTrait: HttpTraitResolved;
|
|
576
|
-
method: HttpTraitResolved["method"];
|
|
577
|
-
spanName: string;
|
|
578
|
-
}
|
|
579
|
-
let prepared: Prepared | undefined;
|
|
580
|
-
const prepare = (): Prepared => {
|
|
581
|
-
if (prepared) return prepared;
|
|
582
|
-
const opConfig = configFn();
|
|
583
|
-
// Support both input/output and inputSchema/outputSchema aliases
|
|
584
|
-
const inputSchema = (opConfig.inputSchema ?? opConfig.input)!;
|
|
585
|
-
const outputSchema = (opConfig.outputSchema ?? opConfig.output)!;
|
|
586
|
-
const inputAst = resolveAst(inputSchema.ast);
|
|
587
|
-
const outputAst = resolveAst(outputSchema.ast);
|
|
588
|
-
// Read trait annotations from the *unresolved* schema ASTs. A trait
|
|
589
|
-
// applied to an already-suspended schema (e.g. `T.ResponsePath` on a
|
|
590
|
-
// shared, suspended response struct) lives on the Suspend node itself,
|
|
591
|
-
// which `resolveAst` descends past — so resolving first would drop it.
|
|
592
|
-
// `getAnnotation` follows Suspend thunks, so the unresolved ast finds
|
|
593
|
-
// annotations at any suspend depth.
|
|
594
|
-
const httpTrait = Traits.getHttpTrait(inputSchema.ast);
|
|
595
|
-
if (!httpTrait) {
|
|
596
|
-
throw new Error("Input schema must have Http trait");
|
|
597
|
-
}
|
|
598
|
-
const method = httpTrait.method;
|
|
599
|
-
prepared = {
|
|
600
|
-
opConfig,
|
|
601
|
-
inputSchema,
|
|
602
|
-
outputSchema,
|
|
603
|
-
inputAst,
|
|
604
|
-
outputAst,
|
|
605
|
-
responsePath: Traits.getResponsePath(outputSchema.ast),
|
|
606
|
-
graphqlOp: Traits.getGraphQLOp(inputSchema.ast),
|
|
607
|
-
noFollowRedirect: Traits.getNoFollowRedirect(inputSchema.ast),
|
|
608
|
-
httpTrait,
|
|
609
|
-
method,
|
|
610
|
-
spanName: `${method} ${httpTrait.path}`,
|
|
611
|
-
};
|
|
612
|
-
return prepared;
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
const innerFn = (
|
|
616
|
-
input: Input,
|
|
617
|
-
requestOptions?: RequestOptions,
|
|
618
|
-
): Effect.Effect<any, any, any> =>
|
|
619
|
-
Effect.gen(function* () {
|
|
620
|
-
const {
|
|
621
|
-
opConfig,
|
|
622
|
-
inputSchema,
|
|
623
|
-
outputSchema,
|
|
624
|
-
inputAst,
|
|
625
|
-
outputAst,
|
|
626
|
-
responsePath,
|
|
627
|
-
graphqlOp,
|
|
628
|
-
noFollowRedirect,
|
|
629
|
-
httpTrait,
|
|
630
|
-
method,
|
|
631
|
-
} = prepare();
|
|
632
|
-
const credentials = yield* config.credentials;
|
|
633
|
-
const creds = isEffectLike(credentials)
|
|
634
|
-
? yield* credentials
|
|
635
|
-
: credentials;
|
|
636
|
-
const client = yield* HttpClient.HttpClient;
|
|
637
|
-
|
|
638
|
-
// Fall back to the Service trait when the consumer leaves
|
|
639
|
-
// `getBaseUrl` empty (per-service hosts rather than per-credentials).
|
|
640
|
-
let baseUrl = config.getBaseUrl(creds as ResolvedCreds);
|
|
641
|
-
if (!baseUrl) {
|
|
642
|
-
const svcTrait = Traits.getServiceTrait(inputAst);
|
|
643
|
-
if (svcTrait?.rootUrl) {
|
|
644
|
-
baseUrl = svcTrait.rootUrl + (svcTrait.servicePath ?? "");
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
const authHeaders = config.getAuthHeaders(creds as ResolvedCreds);
|
|
648
|
-
|
|
649
|
-
// Use schema-aware request builder for proper camelCase → wire_name mapping
|
|
650
|
-
let parts = Traits.buildRequestParts(
|
|
651
|
-
inputAst,
|
|
652
|
-
httpTrait,
|
|
653
|
-
input as Record<string, unknown>,
|
|
654
|
-
inputSchema,
|
|
655
|
-
);
|
|
656
|
-
|
|
657
|
-
// GraphQL: wrap variables in the standard GraphQL request envelope.
|
|
658
|
-
// All input fields become `variables`; `query` and `operationName`
|
|
659
|
-
// come from the trait (baked in at generation time).
|
|
660
|
-
if (graphqlOp) {
|
|
661
|
-
parts = {
|
|
662
|
-
...parts,
|
|
663
|
-
body: {
|
|
664
|
-
query: graphqlOp.query,
|
|
665
|
-
operationName: graphqlOp.operationName,
|
|
666
|
-
variables: parts.body ?? {},
|
|
667
|
-
},
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (config.transformRequestParts) {
|
|
672
|
-
parts = config.transformRequestParts({
|
|
673
|
-
input: input as Record<string, unknown>,
|
|
674
|
-
method,
|
|
675
|
-
pathTemplate: httpTrait.path,
|
|
676
|
-
parts,
|
|
677
|
-
requestOptions,
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Inject a baked-in `api-version` query param for versioned APIs
|
|
682
|
-
// (e.g. Azure ARM, where it is required on every call and differs per
|
|
683
|
-
// resource provider). Applied for all methods; a caller-supplied
|
|
684
|
-
// `api-version` already present in the query takes precedence.
|
|
685
|
-
if (
|
|
686
|
-
httpTrait.apiVersion &&
|
|
687
|
-
parts.query["api-version"] === undefined
|
|
688
|
-
) {
|
|
689
|
-
parts = {
|
|
690
|
-
...parts,
|
|
691
|
-
query: { ...parts.query, "api-version": httpTrait.apiVersion },
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const requestHeaders =
|
|
696
|
-
config.getRequestHeaders?.(requestOptions, {
|
|
697
|
-
input: input as Record<string, unknown>,
|
|
698
|
-
method,
|
|
699
|
-
pathTemplate: httpTrait.path,
|
|
700
|
-
parts,
|
|
701
|
-
credentials: creds as ResolvedCreds,
|
|
702
|
-
}) ?? {};
|
|
703
|
-
|
|
704
|
-
let request = HttpClientRequest.make(method)(
|
|
705
|
-
baseUrl + parts.path,
|
|
706
|
-
).pipe(
|
|
707
|
-
HttpClientRequest.setHeaders(authHeaders),
|
|
708
|
-
HttpClientRequest.setHeaders(parts.headers),
|
|
709
|
-
HttpClientRequest.setHeaders(requestHeaders),
|
|
710
|
-
HttpClientRequest.setHeader("Accept", "application/json"),
|
|
711
|
-
);
|
|
712
|
-
|
|
713
|
-
// Set Content-Type based on body type
|
|
714
|
-
// - Skip for FormData (multipart) — browser sets boundary
|
|
715
|
-
// - Skip for binary — `parts.headers` already carries a caller-supplied
|
|
716
|
-
// `content-type` header (e.g. R2 PutObject's `content-type` field)
|
|
717
|
-
// - Use form-urlencoded for Stripe-style APIs
|
|
718
|
-
// - Default to JSON
|
|
719
|
-
const isFormUrlEncoded = httpTrait.contentType === "form-urlencoded";
|
|
720
|
-
const isBinaryBody = httpTrait.contentType === "binary";
|
|
721
|
-
if (parts.isMultipart) {
|
|
722
|
-
// browser/runtime sets Content-Type with boundary
|
|
723
|
-
} else if (isBinaryBody) {
|
|
724
|
-
// Content-Type is applied via the body below (setBinaryBody), so it
|
|
725
|
-
// is not clobbered back to octet-stream by `setBody`.
|
|
726
|
-
} else if (isFormUrlEncoded) {
|
|
727
|
-
request = HttpClientRequest.setHeader(
|
|
728
|
-
"Content-Type",
|
|
729
|
-
"application/x-www-form-urlencoded",
|
|
730
|
-
)(request);
|
|
731
|
-
} else {
|
|
732
|
-
request = HttpClientRequest.setHeader(
|
|
733
|
-
"Content-Type",
|
|
734
|
-
"application/json",
|
|
735
|
-
)(request);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
if (Object.keys(parts.query).length > 0) {
|
|
739
|
-
request = HttpClientRequest.setUrlParams(request, parts.query);
|
|
740
|
-
}
|
|
741
|
-
if (method !== "GET" && parts.body !== undefined) {
|
|
742
|
-
if (parts.isMultipart) {
|
|
743
|
-
// Build FormData from body properties for multipart operations
|
|
744
|
-
const formData = buildFormData(
|
|
745
|
-
parts.body as Record<string, unknown>,
|
|
746
|
-
);
|
|
747
|
-
request = HttpClientRequest.setBody(HttpBody.formData(formData))(
|
|
748
|
-
request,
|
|
749
|
-
);
|
|
750
|
-
} else if (isBinaryBody) {
|
|
751
|
-
// Raw binary HTTP body — `parts.body` is the value of the lone
|
|
752
|
-
// `T.HttpBody()` field (e.g. a `Blob`, `Uint8Array`, or string),
|
|
753
|
-
// not a record of body fields. Caller's `content-type` header
|
|
754
|
-
// wins, then the op's `bodyMediaType`, else octet-stream.
|
|
755
|
-
const binaryContentType =
|
|
756
|
-
(parts.headers["content-type"] as string | undefined) ??
|
|
757
|
-
(parts.headers["Content-Type"] as string | undefined) ??
|
|
758
|
-
httpTrait.bodyMediaType ??
|
|
759
|
-
"application/octet-stream";
|
|
760
|
-
request = yield* setBinaryBody(
|
|
761
|
-
request,
|
|
762
|
-
parts.body,
|
|
763
|
-
binaryContentType,
|
|
764
|
-
);
|
|
765
|
-
} else if (isFormUrlEncoded) {
|
|
766
|
-
// Encode body as form-urlencoded with deepObject bracket notation
|
|
767
|
-
const encoded = buildFormUrlEncoded(
|
|
768
|
-
parts.body as Record<string, unknown>,
|
|
769
|
-
);
|
|
770
|
-
request = HttpClientRequest.setBody(
|
|
771
|
-
HttpBody.text(encoded, "application/x-www-form-urlencoded"),
|
|
772
|
-
)(request);
|
|
773
|
-
} else {
|
|
774
|
-
request = yield* HttpClientRequest.bodyJson(parts.body)(request);
|
|
775
|
-
}
|
|
776
|
-
} else if (method === "GET" && parts.body !== undefined) {
|
|
777
|
-
// For GET requests, remaining non-annotated fields go as query params
|
|
778
|
-
const extraQuery: Record<string, string> = {};
|
|
779
|
-
for (const [key, value] of Object.entries(
|
|
780
|
-
parts.body as Record<string, unknown>,
|
|
781
|
-
)) {
|
|
782
|
-
if (value !== undefined) {
|
|
783
|
-
extraQuery[key] = String(value);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
if (Object.keys(extraQuery).length > 0) {
|
|
787
|
-
request = HttpClientRequest.setUrlParams(request, extraQuery);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const requestUrl = baseUrl + parts.path;
|
|
792
|
-
yield* Effect.logDebug(`→ ${method} ${requestUrl}`);
|
|
793
|
-
|
|
794
|
-
// For operations that opt out of following redirects, hand the
|
|
795
|
-
// underlying fetch a `redirect: "manual"` request init so the
|
|
796
|
-
// 3xx surfaces here instead of being chased to the IdP.
|
|
797
|
-
const executeRequest = noFollowRedirect
|
|
798
|
-
? client.execute(request).pipe(
|
|
799
|
-
Effect.scoped,
|
|
800
|
-
Effect.provideService(FetchHttpClient.RequestInit, {
|
|
801
|
-
redirect: "manual",
|
|
802
|
-
} as RequestInit),
|
|
803
|
-
)
|
|
804
|
-
: client.execute(request).pipe(Effect.scoped);
|
|
805
|
-
|
|
806
|
-
const response = yield* executeRequest;
|
|
807
|
-
yield* Effect.logDebug(
|
|
808
|
-
`← ${response.status} ${method} ${requestUrl}`,
|
|
809
|
-
);
|
|
810
|
-
|
|
811
|
-
// For ops that opted out of redirect-following, treat 3xx as
|
|
812
|
-
// success: synthesize a body containing the Location header
|
|
813
|
-
// value at `locationField` (default `"url"`) and feed that
|
|
814
|
-
// through the normal output schema decode below.
|
|
815
|
-
if (
|
|
816
|
-
noFollowRedirect &&
|
|
817
|
-
response.status >= 300 &&
|
|
818
|
-
response.status < 400
|
|
819
|
-
) {
|
|
820
|
-
const location =
|
|
821
|
-
response.headers["location"] ?? response.headers["Location"];
|
|
822
|
-
if (location !== undefined) {
|
|
823
|
-
const synthBody = {
|
|
824
|
-
[noFollowRedirect.locationField ?? "url"]: location,
|
|
825
|
-
};
|
|
826
|
-
return yield* Schema.decodeUnknownEffect(outputSchema)(
|
|
827
|
-
synthBody,
|
|
828
|
-
).pipe(
|
|
829
|
-
Effect.catchTag("SchemaError", (cause) =>
|
|
830
|
-
Effect.fail(
|
|
831
|
-
new config.ParseError({ body: synthBody, cause }),
|
|
832
|
-
),
|
|
833
|
-
),
|
|
834
|
-
);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
if (response.status >= 400) {
|
|
839
|
-
// Try to parse error body as JSON; fall back to text if not JSON
|
|
840
|
-
const errorBody = yield* response.json.pipe(
|
|
841
|
-
Effect.catchIf(
|
|
842
|
-
() => true,
|
|
843
|
-
() =>
|
|
844
|
-
response.text.pipe(
|
|
845
|
-
Effect.map(
|
|
846
|
-
(text) =>
|
|
847
|
-
({ _nonJsonError: true, body: text }) as unknown,
|
|
848
|
-
),
|
|
849
|
-
Effect.catchIf(
|
|
850
|
-
() => true,
|
|
851
|
-
() =>
|
|
852
|
-
Effect.succeed({
|
|
853
|
-
_nonJsonError: true,
|
|
854
|
-
body: `HTTP ${response.status}`,
|
|
855
|
-
} as unknown),
|
|
856
|
-
),
|
|
857
|
-
),
|
|
858
|
-
),
|
|
859
|
-
);
|
|
860
|
-
return yield* config.matchError(
|
|
861
|
-
response.status,
|
|
862
|
-
errorBody,
|
|
863
|
-
opConfig.errors,
|
|
864
|
-
response.headers,
|
|
865
|
-
);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// For void-returning operations (e.g. DELETE 204 No Content)
|
|
869
|
-
if (AST.isVoid(outputAst)) {
|
|
870
|
-
return undefined;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Raw octet-stream download (`responseContentType: "binary"`):
|
|
874
|
-
// bypass the JSON/text decode path entirely. The output schema is
|
|
875
|
-
// a Struct shaped like `{ body: Stream<Uint8Array>, ...headers }`
|
|
876
|
-
// (see `T.BinaryResponseBody()` / `T.HttpResponseHeader()`); we
|
|
877
|
-
// populate it by reading response headers and wrapping the body
|
|
878
|
-
// bytes in `Stream.succeed`. We buffer through
|
|
879
|
-
// `response.arrayBuffer` first so the resulting stream is
|
|
880
|
-
// scope-free — callers can consume it after the underlying scope
|
|
881
|
-
// has closed. (True chunked streaming would require threading a
|
|
882
|
-
// Scope through every operation's return type, which would break
|
|
883
|
-
// the uniform `OperationMethod<I, A, E, never>` shape.)
|
|
884
|
-
if (httpTrait.responseContentType === "binary") {
|
|
885
|
-
const bytes = yield* response.arrayBuffer;
|
|
886
|
-
const stream = Stream.succeed(new Uint8Array(bytes));
|
|
887
|
-
return Traits.buildBinaryResponse(
|
|
888
|
-
outputAst,
|
|
889
|
-
stream,
|
|
890
|
-
response.headers as Record<string, string>,
|
|
891
|
-
) as unknown;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// For 204 No Content: if schema is not Unknown, return undefined.
|
|
895
|
-
// If schema IS Unknown, return empty string (so callers get a defined value).
|
|
896
|
-
if (response.status === 204) {
|
|
897
|
-
if (outputAst._tag === "Unknown") {
|
|
898
|
-
return "";
|
|
899
|
-
}
|
|
900
|
-
return undefined;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Try to parse response as JSON; fall back to text for non-JSON responses
|
|
904
|
-
// (e.g., multipart/form-data worker scripts, raw KV values)
|
|
905
|
-
const rawBody = yield* response.json.pipe(
|
|
906
|
-
Effect.catchIf(
|
|
907
|
-
() => true,
|
|
908
|
-
() => response.text.pipe(Effect.map((text) => text as unknown)),
|
|
909
|
-
),
|
|
910
|
-
);
|
|
911
|
-
let responseBody = config.transformResponse
|
|
912
|
-
? config.transformResponse(rawBody)
|
|
913
|
-
: rawBody;
|
|
914
|
-
|
|
915
|
-
// GraphQL: surface errors[] (returned with HTTP 200) via matchError,
|
|
916
|
-
// then leave unwrap to the output schema's `T.ResponsePath` trait,
|
|
917
|
-
// which the generator emits with the field path from `data`. This
|
|
918
|
-
// handles namespaced ops (e.g. `data.channels.byId`) uniformly with
|
|
919
|
-
// top-level ones (e.g. `data.me`).
|
|
920
|
-
if (graphqlOp) {
|
|
921
|
-
const envelope = responseBody as
|
|
922
|
-
| { data?: Record<string, unknown> | null; errors?: unknown[] }
|
|
923
|
-
| null
|
|
924
|
-
| undefined;
|
|
925
|
-
if (
|
|
926
|
-
envelope &&
|
|
927
|
-
Array.isArray(envelope.errors) &&
|
|
928
|
-
envelope.errors.length > 0
|
|
929
|
-
) {
|
|
930
|
-
return yield* config.matchError(
|
|
931
|
-
response.status,
|
|
932
|
-
envelope,
|
|
933
|
-
opConfig.errors,
|
|
934
|
-
response.headers,
|
|
935
|
-
);
|
|
936
|
-
}
|
|
937
|
-
responseBody = envelope?.data ?? null;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// Some APIs return a JSON *string* (double-encoded JSON). `response.json`
|
|
941
|
-
// then yields a string, `getPath` bails out, and we would decode the wrong
|
|
942
|
-
// shape (e.g. Cloudflare envelopes without unwrapping `result`).
|
|
943
|
-
if (typeof responseBody === "string") {
|
|
944
|
-
try {
|
|
945
|
-
responseBody = JSON.parse(responseBody) as unknown;
|
|
946
|
-
} catch {
|
|
947
|
-
// leave as string for callers that expect raw text
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// Some APIs (Cloudflare) answer with a 2xx status but an error
|
|
952
|
-
// envelope (`success: false`) rather than a 4xx. Route those through
|
|
953
|
-
// `matchError` with the operation's typed `errors` so per-operation
|
|
954
|
-
// matchers fire — otherwise the envelope falls through to schema
|
|
955
|
-
// decoding and surfaces as an opaque ParseError.
|
|
956
|
-
if (config.isErrorEnvelope?.(responseBody)) {
|
|
957
|
-
return yield* config.matchError(
|
|
958
|
-
response.status,
|
|
959
|
-
responseBody,
|
|
960
|
-
opConfig.errors,
|
|
961
|
-
response.headers,
|
|
962
|
-
);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// Tracks a `result: null` success that we optimistically coerce to
|
|
966
|
-
// `{}` below. Some ops legitimately return `null` (e.g. a per-zone
|
|
967
|
-
// singleton that was never configured) and declare a nullable
|
|
968
|
-
// output schema — for those, decoding `{}` fails, so we retry the
|
|
969
|
-
// decode with `null` (see the decode block).
|
|
970
|
-
let resultWasNull = false;
|
|
971
|
-
if (responsePath) {
|
|
972
|
-
const nested = getPath(responseBody, responsePath);
|
|
973
|
-
if (nested !== undefined) {
|
|
974
|
-
if (responsePath === "result" && nested === null) {
|
|
975
|
-
responseBody = {};
|
|
976
|
-
resultWasNull = true;
|
|
977
|
-
} else {
|
|
978
|
-
responseBody = nested;
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Handle Cloudflare-style paginated responses where result is
|
|
984
|
-
// { items: [...] } but the schema expects an array
|
|
985
|
-
if (
|
|
986
|
-
isArrayAST(outputAst) &&
|
|
987
|
-
!Array.isArray(responseBody) &&
|
|
988
|
-
typeof responseBody === "object" &&
|
|
989
|
-
responseBody !== null &&
|
|
990
|
-
"items" in responseBody &&
|
|
991
|
-
Array.isArray((responseBody as Record<string, unknown>).items)
|
|
992
|
-
) {
|
|
993
|
-
responseBody = (responseBody as Record<string, unknown>).items;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// A list operation whose schema is an array, but whose `result` came
|
|
997
|
-
// back `null` (coerced to `{}` above), is simply an empty collection:
|
|
998
|
-
// Cloudflare returns `result: null` instead of `[]` when there are
|
|
999
|
-
// zero items. Coerce to `[]` so the list decodes cleanly rather than
|
|
1000
|
-
// failing the array decode and surfacing as a ParseError.
|
|
1001
|
-
if (resultWasNull && isArrayAST(outputAst)) {
|
|
1002
|
-
responseBody = [];
|
|
1003
|
-
resultWasNull = false;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Distinguish two very different decode failures:
|
|
1007
|
-
// 1. NON-empty body that doesn't match the schema → a genuine
|
|
1008
|
-
// schema gap in the SDK. Surface as `ParseError` (NOT retryable)
|
|
1009
|
-
// so it gets patched (Typed Error Doctrine). Retrying it would
|
|
1010
|
-
// only mask the bug.
|
|
1011
|
-
// 2. EMPTY / null body where a structured response was expected →
|
|
1012
|
-
// there is nothing to parse. This is a transient, incomplete
|
|
1013
|
-
// transport response (e.g. the edge answering a 2xx with a bare
|
|
1014
|
-
// `null`/empty body under load), NOT a schema bug. Surface it as
|
|
1015
|
-
// a retryable `TransportError` so the bounded retry policy
|
|
1016
|
-
// re-fetches the real body. (Void/204 and nullable-schema ops
|
|
1017
|
-
// decode an empty body successfully and never reach here.)
|
|
1018
|
-
const bodyIsEmpty =
|
|
1019
|
-
rawBody === null || rawBody === undefined || rawBody === "";
|
|
1020
|
-
return yield* Schema.decodeUnknownEffect(outputSchema)(
|
|
1021
|
-
responseBody,
|
|
1022
|
-
).pipe(
|
|
1023
|
-
Effect.catchTag("SchemaError", (cause) =>
|
|
1024
|
-
// A `result: null` success coerced to `{}` that the schema
|
|
1025
|
-
// rejects: retry decoding the genuine `null` (the schema may be
|
|
1026
|
-
// a nullable union). Only then surface the parse error.
|
|
1027
|
-
resultWasNull
|
|
1028
|
-
? Schema.decodeUnknownEffect(outputSchema)(null).pipe(
|
|
1029
|
-
Effect.catchTag("SchemaError", () =>
|
|
1030
|
-
Effect.fail(
|
|
1031
|
-
new config.ParseError({ body: rawBody, cause }),
|
|
1032
|
-
),
|
|
1033
|
-
),
|
|
1034
|
-
)
|
|
1035
|
-
: bodyIsEmpty
|
|
1036
|
-
? Effect.fail(
|
|
1037
|
-
new HttpClientError.HttpClientError({
|
|
1038
|
-
reason: new HttpClientError.TransportError({
|
|
1039
|
-
request,
|
|
1040
|
-
cause,
|
|
1041
|
-
description:
|
|
1042
|
-
"Empty response body where a structured response was expected",
|
|
1043
|
-
}),
|
|
1044
|
-
}),
|
|
1045
|
-
)
|
|
1046
|
-
: Effect.fail(
|
|
1047
|
-
new config.ParseError({ body: rawBody, cause }),
|
|
1048
|
-
),
|
|
1049
|
-
),
|
|
1050
|
-
);
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
// Auto-retry every operation using the SDK's per-client `Retry`
|
|
1054
|
-
// Context.Service. The policy is read with `Effect.serviceOption`
|
|
1055
|
-
// and falls back to `Retry.makeDefault` (transient/throttling/server
|
|
1056
|
-
// errors with capped exponential backoff + jitter, 5 attempts) when
|
|
1057
|
-
// no policy has been provided in context. This mirrors the AWS
|
|
1058
|
-
// pattern in `packages/aws/src/client/api.ts` and lets callers
|
|
1059
|
-
// install a blanket policy at the layer level instead of wrapping
|
|
1060
|
-
// every call site with `Effect.retry(...)`.
|
|
1061
|
-
const retryTag = config.retry;
|
|
1062
|
-
const fn = (
|
|
1063
|
-
input: Input,
|
|
1064
|
-
requestOptions?: RequestOptions,
|
|
1065
|
-
): Effect.Effect<any, any, any> => {
|
|
1066
|
-
const { spanName, method, httpTrait } = prepare();
|
|
1067
|
-
const withRetry = Effect.gen(function* () {
|
|
1068
|
-
const lastError = yield* Ref.make<unknown>(undefined);
|
|
1069
|
-
const policy = (yield* Effect.serviceOption(retryTag)).pipe(
|
|
1070
|
-
Option.map((value) =>
|
|
1071
|
-
typeof value === "function" ? value(lastError) : value,
|
|
1072
|
-
),
|
|
1073
|
-
Option.getOrElse(() => makeDefault(lastError)),
|
|
1074
|
-
);
|
|
1075
|
-
|
|
1076
|
-
return yield* pipe(
|
|
1077
|
-
innerFn(input, requestOptions),
|
|
1078
|
-
Effect.tapError((error) => Ref.set(lastError, error)),
|
|
1079
|
-
policy.while
|
|
1080
|
-
? (eff) =>
|
|
1081
|
-
Effect.retry(eff, {
|
|
1082
|
-
while: policy.while,
|
|
1083
|
-
schedule: policy.schedule,
|
|
1084
|
-
})
|
|
1085
|
-
: (eff) => eff,
|
|
1086
|
-
);
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
const withSpan = withRetry.pipe(
|
|
1090
|
-
Effect.withSpan(spanName, {
|
|
1091
|
-
attributes: {
|
|
1092
|
-
"http.method": method,
|
|
1093
|
-
"http.route": httpTrait.path,
|
|
1094
|
-
},
|
|
1095
|
-
}),
|
|
1096
|
-
);
|
|
1097
|
-
return Effect.flatMap(distilledDebugConfig, (isDebug) =>
|
|
1098
|
-
isDebug
|
|
1099
|
-
? Effect.provideService(withSpan, MinimumLogLevel, "Debug")
|
|
1100
|
-
: withSpan,
|
|
1101
|
-
);
|
|
1102
|
-
};
|
|
1103
|
-
|
|
1104
|
-
const Proto = {
|
|
1105
|
-
[Symbol.iterator](this: any) {
|
|
1106
|
-
return new SingleShotGen(this.asEffect());
|
|
1107
|
-
},
|
|
1108
|
-
pipe(this: any) {
|
|
1109
|
-
return pipeArguments(this.asEffect(), arguments);
|
|
1110
|
-
},
|
|
1111
|
-
asEffect() {
|
|
1112
|
-
return Effect.map(
|
|
1113
|
-
Effect.context(),
|
|
1114
|
-
(context) => (input: Input, requestOptions?: RequestOptions) =>
|
|
1115
|
-
Effect.provideContext(fn(input, requestOptions), context),
|
|
1116
|
-
);
|
|
1117
|
-
},
|
|
1118
|
-
};
|
|
1119
|
-
|
|
1120
|
-
return Object.assign(fn, Proto) as any;
|
|
1121
|
-
},
|
|
1122
|
-
|
|
1123
|
-
makePaginated: <
|
|
1124
|
-
I extends Schema.Top,
|
|
1125
|
-
O extends Schema.Top,
|
|
1126
|
-
const E extends readonly ApiErrorClass[] = readonly [],
|
|
1127
|
-
>(
|
|
1128
|
-
configFn: () => PaginatedOperationConfig<I, O, E>,
|
|
1129
|
-
paginateFn?: PaginationStrategy,
|
|
1130
|
-
): PaginatedOperationMethod<
|
|
1131
|
-
Schema.Schema.Type<I>,
|
|
1132
|
-
Schema.Schema.Type<O>,
|
|
1133
|
-
InstanceType<E[number]>,
|
|
1134
|
-
Creds,
|
|
1135
|
-
RequestOptions
|
|
1136
|
-
> => {
|
|
1137
|
-
const opConfig = configFn();
|
|
1138
|
-
const pagination = opConfig.pagination!;
|
|
1139
|
-
|
|
1140
|
-
// Create the base operation
|
|
1141
|
-
const baseFn = makeAPI(config).make(() => ({
|
|
1142
|
-
inputSchema: opConfig.inputSchema ?? opConfig.input,
|
|
1143
|
-
outputSchema: opConfig.outputSchema ?? opConfig.output,
|
|
1144
|
-
errors: opConfig.errors,
|
|
1145
|
-
}));
|
|
1146
|
-
|
|
1147
|
-
type Input = Schema.Schema.Type<I>;
|
|
1148
|
-
|
|
1149
|
-
const paginate = paginateFn ?? paginateWithDefaults;
|
|
1150
|
-
|
|
1151
|
-
// Stream all pages
|
|
1152
|
-
const pagesFn = (
|
|
1153
|
-
input: Omit<Input, string>,
|
|
1154
|
-
requestOptions?: RequestOptions,
|
|
1155
|
-
) => paginate(baseFn as any, input, pagination, requestOptions);
|
|
1156
|
-
|
|
1157
|
-
// Stream individual items
|
|
1158
|
-
const itemsFn = (
|
|
1159
|
-
input: Omit<Input, string>,
|
|
1160
|
-
requestOptions?: RequestOptions,
|
|
1161
|
-
) =>
|
|
1162
|
-
pagination.items
|
|
1163
|
-
? extractItems(pagesFn(input, requestOptions), pagination.items)
|
|
1164
|
-
: pagesFn(input, requestOptions);
|
|
1165
|
-
|
|
1166
|
-
const result = baseFn as typeof baseFn & {
|
|
1167
|
-
pages: typeof pagesFn;
|
|
1168
|
-
items: typeof itemsFn;
|
|
1169
|
-
};
|
|
1170
|
-
|
|
1171
|
-
result.pages = pagesFn;
|
|
1172
|
-
result.items = itemsFn;
|
|
1173
|
-
|
|
1174
|
-
return result as any;
|
|
1175
|
-
},
|
|
1176
|
-
};
|
|
1177
|
-
};
|