@distilled.cloud/core 0.30.2 → 1.0.0-rc.1
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/traits.ts
DELETED
|
@@ -1,996 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Annotation-based traits for declarative operation definitions.
|
|
3
|
-
*
|
|
4
|
-
* This module provides a type-safe annotation system for defining HTTP operations
|
|
5
|
-
* using Schema annotations. Traits can be applied via `.pipe()` or composed with `all()`.
|
|
6
|
-
*
|
|
7
|
-
* The annotation system is shared across all SDKs. Individual SDKs can extend it
|
|
8
|
-
* with provider-specific traits (e.g., AWS Smithy traits, Cloudflare-specific traits).
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```ts
|
|
12
|
-
* import * as T from "@distilled.cloud/core/traits";
|
|
13
|
-
*
|
|
14
|
-
* const GetDatabaseInput = Schema.Struct({
|
|
15
|
-
* organization: Schema.String.pipe(T.PathParam()),
|
|
16
|
-
* database: Schema.String.pipe(T.PathParam()),
|
|
17
|
-
* }).pipe(
|
|
18
|
-
* T.Http({ method: "GET", path: "/organizations/{organization}/databases/{database}" })
|
|
19
|
-
* );
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
import * as Redacted from "effect/Redacted";
|
|
23
|
-
import * as Schema from "effect/Schema";
|
|
24
|
-
import * as AST from "effect/SchemaAST";
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// Annotation Primitives
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Internal symbol for annotation metadata storage.
|
|
32
|
-
*/
|
|
33
|
-
const annotationMetaSymbol = Symbol.for("@distilled.cloud/annotation-meta");
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Any type that has an .annotate() method returning itself.
|
|
37
|
-
* This includes Schema.Schema and Schema.PropertySignature.
|
|
38
|
-
*/
|
|
39
|
-
type Annotatable = {
|
|
40
|
-
annotate(annotations: unknown): Annotatable;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* An Annotation is a callable that can be used with .pipe() AND
|
|
45
|
-
* has symbol properties so it works directly with Schema.Struct/Class.
|
|
46
|
-
*
|
|
47
|
-
* The index signatures allow TypeScript to accept this as a valid annotations object.
|
|
48
|
-
*/
|
|
49
|
-
export interface Annotation {
|
|
50
|
-
<A extends Annotatable>(schema: A): A;
|
|
51
|
-
readonly [annotationMetaSymbol]: Array<{ symbol: symbol; value: unknown }>;
|
|
52
|
-
// Index signatures for compatibility with Schema.Annotations
|
|
53
|
-
readonly [key: symbol]: unknown;
|
|
54
|
-
readonly [key: string]: unknown;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Create an annotation builder for a given symbol and value.
|
|
59
|
-
* This is the core primitive used to build all trait annotations.
|
|
60
|
-
*/
|
|
61
|
-
export function makeAnnotation<T>(sym: symbol, value: T): Annotation {
|
|
62
|
-
const fn = <A extends Annotatable>(schema: A): A =>
|
|
63
|
-
schema.annotate({ [sym]: value }) as A;
|
|
64
|
-
|
|
65
|
-
(fn as any)[annotationMetaSymbol] = [{ symbol: sym, value }];
|
|
66
|
-
(fn as any)[sym] = value;
|
|
67
|
-
|
|
68
|
-
return fn as Annotation;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Combine multiple annotations into one.
|
|
73
|
-
* Use when you need multiple annotations on the same schema.
|
|
74
|
-
*
|
|
75
|
-
* @example
|
|
76
|
-
* ```ts
|
|
77
|
-
* const MyInput = Schema.Struct({
|
|
78
|
-
* id: Schema.String.pipe(T.all(T.PathParam(), T.Required())),
|
|
79
|
-
* });
|
|
80
|
-
* ```
|
|
81
|
-
*/
|
|
82
|
-
export function all(...annotations: Annotation[]): Annotation {
|
|
83
|
-
const entries: Array<{ symbol: symbol; value: unknown }> = [];
|
|
84
|
-
const raw: Record<symbol, unknown> = {};
|
|
85
|
-
|
|
86
|
-
for (const a of annotations) {
|
|
87
|
-
for (const entry of a[annotationMetaSymbol]) {
|
|
88
|
-
entries.push(entry);
|
|
89
|
-
raw[entry.symbol] = entry.value;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const fn = <A extends Annotatable>(schema: A): A => schema.annotate(raw) as A;
|
|
94
|
-
|
|
95
|
-
(fn as any)[annotationMetaSymbol] = entries;
|
|
96
|
-
|
|
97
|
-
for (const { symbol, value } of entries) {
|
|
98
|
-
(fn as any)[symbol] = value;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return fn as Annotation;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// =============================================================================
|
|
105
|
-
// HTTP Operation Traits
|
|
106
|
-
// =============================================================================
|
|
107
|
-
|
|
108
|
-
/** Symbol for HTTP operation metadata (method + path template) */
|
|
109
|
-
export const httpSymbol = Symbol.for("@distilled.cloud/http");
|
|
110
|
-
|
|
111
|
-
/** HTTP method type */
|
|
112
|
-
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
113
|
-
|
|
114
|
-
/** HTTP trait configuration */
|
|
115
|
-
export interface HttpTrait {
|
|
116
|
-
/** HTTP method */
|
|
117
|
-
method: HttpMethod;
|
|
118
|
-
/** Path template with {param} placeholders */
|
|
119
|
-
path: string;
|
|
120
|
-
/**
|
|
121
|
-
* Request body content type. Recognized values:
|
|
122
|
-
* - `"multipart"` — `multipart/form-data` (file uploads)
|
|
123
|
-
* - `"form-urlencoded"` — `application/x-www-form-urlencoded`
|
|
124
|
-
* - `"binary"` — `application/octet-stream` (raw byte body
|
|
125
|
-
* accepting `Blob | Uint8Array | ArrayBuffer |
|
|
126
|
-
* string | Stream<Uint8Array> | ReadableStream`)
|
|
127
|
-
* - `undefined` — JSON
|
|
128
|
-
*/
|
|
129
|
-
contentType?: string;
|
|
130
|
-
/**
|
|
131
|
-
* Explicit `Content-Type` for a `contentType: "binary"` request body when the
|
|
132
|
-
* API requires a specific media type rather than the generic
|
|
133
|
-
* `application/octet-stream` (e.g. `application/x-ndjson` for Vectorize
|
|
134
|
-
* insert/upsert). Ignored unless `contentType === "binary"`; a caller-supplied
|
|
135
|
-
* `content-type` header still wins.
|
|
136
|
-
*/
|
|
137
|
-
bodyMediaType?: string;
|
|
138
|
-
/**
|
|
139
|
-
* Response body content type. Recognized values:
|
|
140
|
-
* - `"binary"` — `application/octet-stream` download. The runtime
|
|
141
|
-
* bypasses JSON decoding and returns the body as an
|
|
142
|
-
* Effect `Stream.Stream<Uint8Array>`.
|
|
143
|
-
* - `undefined` — JSON / text decoded through the operation's output
|
|
144
|
-
* schema.
|
|
145
|
-
*/
|
|
146
|
-
responseContentType?: string;
|
|
147
|
-
/** Whether the request has a body (used by GCP generator) */
|
|
148
|
-
hasBody?: boolean;
|
|
149
|
-
/**
|
|
150
|
-
* Default value for a versioned API's `api-version` query parameter, baked in
|
|
151
|
-
* at generation time. When set, the client injects `?api-version=<value>` on
|
|
152
|
-
* every request for this operation unless the caller already supplied one.
|
|
153
|
-
* Used by APIs like Azure ARM where `api-version` is required on every call
|
|
154
|
-
* and differs per resource provider.
|
|
155
|
-
*/
|
|
156
|
-
apiVersion?: string;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Http trait - defines the HTTP method and path template for an operation.
|
|
161
|
-
* Path parameters are specified using {paramName} syntax.
|
|
162
|
-
*
|
|
163
|
-
* @example
|
|
164
|
-
* ```ts
|
|
165
|
-
* const GetDatabaseInput = Schema.Struct({
|
|
166
|
-
* organization: Schema.String.pipe(T.PathParam()),
|
|
167
|
-
* database: Schema.String.pipe(T.PathParam()),
|
|
168
|
-
* }).pipe(
|
|
169
|
-
* T.Http({ method: "GET", path: "/organizations/{organization}/databases/{database}" })
|
|
170
|
-
* );
|
|
171
|
-
* ```
|
|
172
|
-
*/
|
|
173
|
-
export const Http = (trait: HttpTrait) => makeAnnotation(httpSymbol, trait);
|
|
174
|
-
|
|
175
|
-
// =============================================================================
|
|
176
|
-
// Path Parameter Traits
|
|
177
|
-
// =============================================================================
|
|
178
|
-
|
|
179
|
-
/** Symbol for path parameter annotation */
|
|
180
|
-
export const pathParamSymbol = Symbol.for("@distilled.cloud/path-param");
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* PathParam trait - marks a field as a path parameter.
|
|
184
|
-
* The field name is used as the placeholder name in the path template.
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* ```ts
|
|
188
|
-
* const Input = Schema.Struct({
|
|
189
|
-
* organization: Schema.String.pipe(T.PathParam()),
|
|
190
|
-
* }).pipe(
|
|
191
|
-
* T.Http({ method: "GET", path: "/organizations/{organization}" })
|
|
192
|
-
* );
|
|
193
|
-
* ```
|
|
194
|
-
*/
|
|
195
|
-
export const PathParam = () => makeAnnotation(pathParamSymbol, true);
|
|
196
|
-
|
|
197
|
-
// =============================================================================
|
|
198
|
-
// Query Parameter Traits
|
|
199
|
-
// =============================================================================
|
|
200
|
-
|
|
201
|
-
/** Symbol for query parameter annotation */
|
|
202
|
-
export const queryParamSymbol = Symbol.for("@distilled.cloud/query-param");
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* QueryParam trait - marks a field as a query parameter.
|
|
206
|
-
* Optionally specify a different wire name.
|
|
207
|
-
*
|
|
208
|
-
* @example
|
|
209
|
-
* ```ts
|
|
210
|
-
* const Input = Schema.Struct({
|
|
211
|
-
* perPage: Schema.optional(Schema.Number).pipe(T.QueryParam("per_page")),
|
|
212
|
-
* });
|
|
213
|
-
* ```
|
|
214
|
-
*/
|
|
215
|
-
export const QueryParam = (name?: string) =>
|
|
216
|
-
makeAnnotation(queryParamSymbol, name ?? true);
|
|
217
|
-
|
|
218
|
-
// =============================================================================
|
|
219
|
-
// Header Parameter Traits
|
|
220
|
-
// =============================================================================
|
|
221
|
-
|
|
222
|
-
/** Symbol for header parameter annotation */
|
|
223
|
-
export const headerParamSymbol = Symbol.for("@distilled.cloud/header-param");
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* HeaderParam trait - marks a field as a header parameter.
|
|
227
|
-
* Specify the header name.
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* ```ts
|
|
231
|
-
* const Input = Schema.Struct({
|
|
232
|
-
* apiToken: Schema.String.pipe(T.HeaderParam("X-API-Token")),
|
|
233
|
-
* });
|
|
234
|
-
* ```
|
|
235
|
-
*/
|
|
236
|
-
export const HeaderParam = (name: string) =>
|
|
237
|
-
makeAnnotation(headerParamSymbol, name);
|
|
238
|
-
|
|
239
|
-
// =============================================================================
|
|
240
|
-
// Convenience Aliases (used by Cloudflare/GCP generators)
|
|
241
|
-
// =============================================================================
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* HttpPath - alias for PathParam that also carries the wire name.
|
|
245
|
-
* Used in generated code: `Schema.String.pipe(T.HttpPath("account_id"))`
|
|
246
|
-
*/
|
|
247
|
-
export const HttpPath = (name: string) => makeAnnotation(pathParamSymbol, name);
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* HttpQuery - alias for QueryParam with an explicit wire name.
|
|
251
|
-
* Used in generated code: `Schema.optional(Schema.String).pipe(T.HttpQuery("per_page"))`
|
|
252
|
-
*/
|
|
253
|
-
export const HttpQuery = (name: string) =>
|
|
254
|
-
makeAnnotation(queryParamSymbol, name);
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* HttpHeader - alias for HeaderParam.
|
|
258
|
-
* Used in generated code: `Schema.String.pipe(T.HttpHeader("X-Custom-Header"))`
|
|
259
|
-
*/
|
|
260
|
-
export const HttpHeader = (name: string) =>
|
|
261
|
-
makeAnnotation(headerParamSymbol, name);
|
|
262
|
-
|
|
263
|
-
/** Symbol for HTTP body annotation */
|
|
264
|
-
export const httpBodySymbol = Symbol.for("@distilled.cloud/http-body");
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* HttpBody - marks a field as the raw HTTP body.
|
|
268
|
-
* Used for operations where a field IS the entire request body
|
|
269
|
-
* (not a named field within a JSON body).
|
|
270
|
-
*/
|
|
271
|
-
export const HttpBody = () => makeAnnotation(httpBodySymbol, true);
|
|
272
|
-
|
|
273
|
-
/** Symbol for binary response body annotation */
|
|
274
|
-
export const binaryResponseBodySymbol = Symbol.for(
|
|
275
|
-
"@distilled.cloud/binary-response-body",
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* BinaryResponseBody - marks a response struct field as the raw binary body
|
|
280
|
-
* of an `application/octet-stream` download. The runtime fills the field
|
|
281
|
-
* with an Effect `Stream.Stream<Uint8Array>` of the response body. Sibling
|
|
282
|
-
* fields annotated with `T.HttpResponseHeader` are populated from the
|
|
283
|
-
* matching response headers.
|
|
284
|
-
*/
|
|
285
|
-
export const BinaryResponseBody = () =>
|
|
286
|
-
makeAnnotation(binaryResponseBodySymbol, true);
|
|
287
|
-
|
|
288
|
-
/** Symbol for response header annotation */
|
|
289
|
-
export const responseHeaderSymbol = Symbol.for(
|
|
290
|
-
"@distilled.cloud/response-header",
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* HttpResponseHeader - marks a response struct field as a value pulled from
|
|
295
|
-
* a named HTTP response header (e.g. `etag`, `content-type`). The runtime
|
|
296
|
-
* populates the field with the (lowercased) header value if present, and
|
|
297
|
-
* leaves it `undefined` otherwise. Mirror of `T.HttpHeader` for response-
|
|
298
|
-
* side header extraction.
|
|
299
|
-
*
|
|
300
|
-
* Currently consumed by binary-download operations (operations whose
|
|
301
|
-
* `T.Http({ responseContentType: "binary" })` trait is set), where the
|
|
302
|
-
* response struct's shape is `{ body: Stream.Stream<Uint8Array>, ...headers }`.
|
|
303
|
-
*/
|
|
304
|
-
export const HttpResponseHeader = (name: string) =>
|
|
305
|
-
makeAnnotation(responseHeaderSymbol, name);
|
|
306
|
-
|
|
307
|
-
/** Symbol for GraphQL operation metadata (query string + operation name) */
|
|
308
|
-
export const graphqlOpSymbol = Symbol.for("@distilled.cloud/graphql-op");
|
|
309
|
-
|
|
310
|
-
/** GraphQL operation type */
|
|
311
|
-
export type GraphQLOpType = "query" | "mutation";
|
|
312
|
-
|
|
313
|
-
/** GraphQL operation trait configuration */
|
|
314
|
-
export interface GraphQLOpTrait {
|
|
315
|
-
/** The full GraphQL operation document (e.g., `query getUser($id: ID!) { ... }`) */
|
|
316
|
-
query: string;
|
|
317
|
-
/** The operation name — used to extract the response from `data.<operationName>` */
|
|
318
|
-
operationName: string;
|
|
319
|
-
/** Whether this is a query or a mutation */
|
|
320
|
-
type: GraphQLOpType;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* GraphQLOp trait - declares an operation as GraphQL. When present, the client
|
|
325
|
-
* wraps the request body as `{ query, operationName, variables: <inputs> }` and
|
|
326
|
-
* extracts the response from `data.<operationName>`. The Http trait's path is
|
|
327
|
-
* still used as the GraphQL endpoint (typically `/graphql`).
|
|
328
|
-
*
|
|
329
|
-
* @example
|
|
330
|
-
* ```ts
|
|
331
|
-
* const GetUserInput = Schema.Struct({
|
|
332
|
-
* id: Schema.String,
|
|
333
|
-
* }).pipe(
|
|
334
|
-
* T.Http({ method: "POST", path: "/graphql" }),
|
|
335
|
-
* T.GraphQLOp({
|
|
336
|
-
* query: "query getUser($id: ID!) { user(id: $id) { id name } }",
|
|
337
|
-
* operationName: "getUser",
|
|
338
|
-
* type: "query",
|
|
339
|
-
* }),
|
|
340
|
-
* );
|
|
341
|
-
* ```
|
|
342
|
-
*/
|
|
343
|
-
export const GraphQLOp = (trait: GraphQLOpTrait) =>
|
|
344
|
-
makeAnnotation(graphqlOpSymbol, trait);
|
|
345
|
-
|
|
346
|
-
/** Symbol for response body path transformation */
|
|
347
|
-
export const responsePathSymbol = Symbol.for("@distilled.cloud/response-path");
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* ResponsePath - decode the response from a nested path within the raw body.
|
|
351
|
-
* Useful for providers that wrap successful responses in envelopes like
|
|
352
|
-
* `{ result: <payload>, result_info: ... }`.
|
|
353
|
-
*/
|
|
354
|
-
export const ResponsePath = (path: string) =>
|
|
355
|
-
makeAnnotation(responsePathSymbol, path);
|
|
356
|
-
|
|
357
|
-
/** Symbol for form data file annotation */
|
|
358
|
-
export const httpFormDataFileSymbol = Symbol.for(
|
|
359
|
-
"@distilled.cloud/http-form-data-file",
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* HttpFormDataFile - marks a field as a file upload in multipart form data.
|
|
364
|
-
*/
|
|
365
|
-
export const HttpFormDataFile = () =>
|
|
366
|
-
makeAnnotation(httpFormDataFileSymbol, true);
|
|
367
|
-
|
|
368
|
-
// =============================================================================
|
|
369
|
-
// Redirect-as-Response Trait
|
|
370
|
-
// =============================================================================
|
|
371
|
-
|
|
372
|
-
/** Symbol for "do not follow redirects; treat 3xx Location header as success body" */
|
|
373
|
-
export const httpNoFollowRedirectSymbol = Symbol.for(
|
|
374
|
-
"@distilled.cloud/http-no-follow-redirect",
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Configuration for the `NoFollowRedirect` trait.
|
|
379
|
-
*/
|
|
380
|
-
export interface NoFollowRedirectTrait {
|
|
381
|
-
/**
|
|
382
|
-
* The field name on the success-side output schema that should receive the
|
|
383
|
-
* value of the response's `Location` header when the server returns a
|
|
384
|
-
* 3xx redirect. Defaults to `"url"`.
|
|
385
|
-
*
|
|
386
|
-
* Concretely: when an operation carrying this trait gets a 3xx response,
|
|
387
|
-
* the runtime client constructs `{ [locationField]: <Location header> }`
|
|
388
|
-
* and feeds that into the output schema decoder, instead of trying to
|
|
389
|
-
* decode the redirect body (which would normally be HTML or empty).
|
|
390
|
-
*/
|
|
391
|
-
locationField?: string;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* NoFollowRedirect trait - opts an operation out of the underlying HTTP
|
|
396
|
-
* client's automatic redirect-following.
|
|
397
|
-
*
|
|
398
|
-
* Some endpoints — notably OAuth/SSO authorize endpoints — return their
|
|
399
|
-
* useful result as a `Location` header on a 302 response. The default
|
|
400
|
-
* `fetch` follows that redirect to the IdP's HTML login page, so by the
|
|
401
|
-
* time the client sees a body it's no longer the SDK's expected JSON.
|
|
402
|
-
*
|
|
403
|
-
* Marking an operation with this trait makes the runtime client:
|
|
404
|
-
* 1. Issue the request with `redirect: "manual"` so the 3xx surfaces
|
|
405
|
-
* to user code.
|
|
406
|
-
* 2. On a 3xx, build a synthetic body `{ [locationField]: <Location> }`
|
|
407
|
-
* and decode that into the output schema (default field name `"url"`).
|
|
408
|
-
*
|
|
409
|
-
* @example
|
|
410
|
-
* ```ts
|
|
411
|
-
* const SsoAuthorizeInput = Schema.Struct({...}).pipe(
|
|
412
|
-
* T.Http({ method: "GET", path: "/sso/authorize" }),
|
|
413
|
-
* T.NoFollowRedirect(),
|
|
414
|
-
* );
|
|
415
|
-
* const SsoAuthorizeOutput = Schema.Struct({ url: Schema.String });
|
|
416
|
-
* ```
|
|
417
|
-
*/
|
|
418
|
-
export const NoFollowRedirect = (trait: NoFollowRedirectTrait = {}) =>
|
|
419
|
-
makeAnnotation(httpNoFollowRedirectSymbol, trait);
|
|
420
|
-
|
|
421
|
-
// =============================================================================
|
|
422
|
-
// API Error Code Trait
|
|
423
|
-
// =============================================================================
|
|
424
|
-
|
|
425
|
-
/** Symbol for API error code mapping */
|
|
426
|
-
export const apiErrorCodeSymbol = Symbol.for("@distilled.cloud/api-error-code");
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* ApiErrorCode trait - maps an error class to an API error code.
|
|
430
|
-
* Used to match API error responses to typed error classes.
|
|
431
|
-
*
|
|
432
|
-
* @example
|
|
433
|
-
* ```ts
|
|
434
|
-
* class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()(
|
|
435
|
-
* "NotFoundError",
|
|
436
|
-
* { message: Schema.String },
|
|
437
|
-
* ).pipe(T.ApiErrorCode("not_found")) {}
|
|
438
|
-
* ```
|
|
439
|
-
*/
|
|
440
|
-
export const ApiErrorCode = (code: string) =>
|
|
441
|
-
makeAnnotation(apiErrorCodeSymbol, code);
|
|
442
|
-
|
|
443
|
-
// =============================================================================
|
|
444
|
-
// Service Metadata Trait
|
|
445
|
-
// =============================================================================
|
|
446
|
-
|
|
447
|
-
/** Symbol for service metadata */
|
|
448
|
-
export const serviceSymbol = Symbol.for("@distilled.cloud/service");
|
|
449
|
-
|
|
450
|
-
/** Service metadata */
|
|
451
|
-
export interface ServiceTrait {
|
|
452
|
-
name: string;
|
|
453
|
-
version?: string;
|
|
454
|
-
baseUrl?: string;
|
|
455
|
-
/** GCP-specific: Root URL for the service */
|
|
456
|
-
rootUrl?: string;
|
|
457
|
-
/** GCP-specific: Service path appended to root URL */
|
|
458
|
-
servicePath?: string;
|
|
459
|
-
/** Allow additional properties for provider-specific metadata */
|
|
460
|
-
[key: string]: unknown;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Service trait - attaches service metadata to a schema.
|
|
465
|
-
*/
|
|
466
|
-
export const Service = (trait: ServiceTrait) =>
|
|
467
|
-
makeAnnotation(serviceSymbol, trait);
|
|
468
|
-
|
|
469
|
-
// =============================================================================
|
|
470
|
-
// Annotation Retrieval Helpers
|
|
471
|
-
// =============================================================================
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Get annotation value from an AST node, following encoding chain if needed.
|
|
475
|
-
*/
|
|
476
|
-
export const getAnnotation = <T>(
|
|
477
|
-
ast: AST.AST,
|
|
478
|
-
symbol: symbol,
|
|
479
|
-
): T | undefined => {
|
|
480
|
-
// Direct annotation
|
|
481
|
-
const annotations = ast.annotations as Record<symbol, unknown> | undefined;
|
|
482
|
-
const direct = annotations?.[symbol] as T | undefined;
|
|
483
|
-
if (direct !== undefined) return direct;
|
|
484
|
-
|
|
485
|
-
// Follow encoding chain (replaces v3 Transformation handling)
|
|
486
|
-
if (ast.encoding && ast.encoding.length > 0) {
|
|
487
|
-
return getAnnotation<T>(ast.encoding[0].to, symbol);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Follow `Schema.suspend` thunks. Generated SDKs defer schema construction
|
|
491
|
-
// by wrapping structs in `Schema.suspend(() => ...)`; a trait applied to an
|
|
492
|
-
// already-suspended schema (e.g. `T.ResponsePath` on a shared, suspended
|
|
493
|
-
// response struct) lands on the Suspend node itself, so we must force the
|
|
494
|
-
// thunk to discover annotations attached beneath it.
|
|
495
|
-
if (ast._tag === "Suspend") {
|
|
496
|
-
return getAnnotation<T>(ast.thunk(), symbol);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return undefined;
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Get HTTP trait from a schema's AST.
|
|
504
|
-
*/
|
|
505
|
-
export const getHttpTrait = (ast: AST.AST): HttpTrait | undefined =>
|
|
506
|
-
getAnnotation<HttpTrait>(ast, httpSymbol);
|
|
507
|
-
|
|
508
|
-
export const getResponsePath = (ast: AST.AST): string | undefined =>
|
|
509
|
-
getAnnotation<string>(ast, responsePathSymbol);
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Get GraphQL operation trait from a schema's AST.
|
|
513
|
-
*/
|
|
514
|
-
export const getGraphQLOp = (ast: AST.AST): GraphQLOpTrait | undefined =>
|
|
515
|
-
getAnnotation<GraphQLOpTrait>(ast, graphqlOpSymbol);
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Get the `NoFollowRedirect` trait config from an input schema's AST, if any.
|
|
519
|
-
*/
|
|
520
|
-
export const getNoFollowRedirect = (
|
|
521
|
-
ast: AST.AST,
|
|
522
|
-
): NoFollowRedirectTrait | undefined =>
|
|
523
|
-
getAnnotation<NoFollowRedirectTrait>(ast, httpNoFollowRedirectSymbol);
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Check if a PropertySignature has the pathParam annotation.
|
|
527
|
-
* Works for both PathParam() (annotation value = true) and HttpPath("wire_name") (annotation value = string).
|
|
528
|
-
*/
|
|
529
|
-
export const isPathParam = (prop: AST.PropertySignature): boolean => {
|
|
530
|
-
const value = getAnnotation<string | boolean>(prop.type, pathParamSymbol);
|
|
531
|
-
return value !== undefined;
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Get query param name from a PropertySignature (returns true if unnamed, string if named).
|
|
536
|
-
*/
|
|
537
|
-
export const getQueryParam = (
|
|
538
|
-
prop: AST.PropertySignature,
|
|
539
|
-
): string | boolean | undefined => {
|
|
540
|
-
return getAnnotation<string | boolean>(prop.type, queryParamSymbol);
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Get header param name from a PropertySignature.
|
|
545
|
-
*/
|
|
546
|
-
export const getHeaderParam = (
|
|
547
|
-
prop: AST.PropertySignature,
|
|
548
|
-
): string | undefined => {
|
|
549
|
-
return getAnnotation<string>(prop.type, headerParamSymbol);
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Get API error code from an error class AST.
|
|
554
|
-
*/
|
|
555
|
-
export const getApiErrorCode = (ast: AST.AST): string | undefined =>
|
|
556
|
-
getAnnotation<string>(ast, apiErrorCodeSymbol);
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Get service metadata from a schema's AST.
|
|
560
|
-
*/
|
|
561
|
-
export const getServiceTrait = (ast: AST.AST): ServiceTrait | undefined =>
|
|
562
|
-
getAnnotation<ServiceTrait>(ast, serviceSymbol);
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Extract path parameters from a schema's struct properties.
|
|
566
|
-
* Returns an array of field names that have the PathParam annotation.
|
|
567
|
-
*/
|
|
568
|
-
export const getPathParams = (ast: AST.AST): string[] => {
|
|
569
|
-
// Handle Objects (struct) - v4 renamed from TypeLiteral
|
|
570
|
-
if (ast._tag === "Objects") {
|
|
571
|
-
return ast.propertySignatures
|
|
572
|
-
.filter((prop) => isPathParam(prop))
|
|
573
|
-
.map((prop) => String(prop.name));
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Follow encoding chain (replaces v3 Transformation handling)
|
|
577
|
-
if (ast.encoding && ast.encoding.length > 0) {
|
|
578
|
-
return getPathParams(ast.encoding[0].to);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
return [];
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Build the request path by substituting path parameters into the template.
|
|
586
|
-
* Simple version that assumes input keys match template placeholders.
|
|
587
|
-
* For schema-aware path building (with camelCase → wire_name mapping), use buildPathFromSchema.
|
|
588
|
-
*/
|
|
589
|
-
export const buildPath = (
|
|
590
|
-
template: string,
|
|
591
|
-
input: Record<string, unknown>,
|
|
592
|
-
): string => {
|
|
593
|
-
return template.replace(/\{(\w+)\}/g, (_, name) => {
|
|
594
|
-
const value = input[name];
|
|
595
|
-
if (value === undefined || value === null) {
|
|
596
|
-
throw new Error(`Missing path parameter: ${name}`);
|
|
597
|
-
}
|
|
598
|
-
return encodeURIComponent(String(value));
|
|
599
|
-
});
|
|
600
|
-
};
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Extract AST property signatures from a schema AST, following encoding chain and suspends.
|
|
604
|
-
*/
|
|
605
|
-
export const getStructProps = (ast: AST.AST): AST.PropertySignature[] => {
|
|
606
|
-
if (ast.encoding && ast.encoding.length > 0) {
|
|
607
|
-
return getStructProps(ast.encoding[0].to);
|
|
608
|
-
}
|
|
609
|
-
if (ast._tag === "Suspend") {
|
|
610
|
-
return getStructProps(ast.thunk());
|
|
611
|
-
}
|
|
612
|
-
if (ast._tag === "Objects") {
|
|
613
|
-
return [...ast.propertySignatures];
|
|
614
|
-
}
|
|
615
|
-
return [];
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Get the path parameter wire name from a PropertySignature.
|
|
620
|
-
* - For HttpPath("wire_name"), returns the wire name string.
|
|
621
|
-
* - For PathParam(), returns the property name (since annotation is `true`).
|
|
622
|
-
* - Returns undefined if not a path param.
|
|
623
|
-
*/
|
|
624
|
-
export const getPathParamWireName = (
|
|
625
|
-
prop: AST.PropertySignature,
|
|
626
|
-
): string | undefined => {
|
|
627
|
-
const value = getAnnotation<string | boolean>(prop.type, pathParamSymbol);
|
|
628
|
-
if (value === undefined) return undefined;
|
|
629
|
-
if (typeof value === "string") return value;
|
|
630
|
-
// PathParam() stores `true` — use property name as wire name
|
|
631
|
-
return String(prop.name);
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Result of categorizing a schema's input properties by their HTTP binding.
|
|
636
|
-
*/
|
|
637
|
-
export interface RequestParts {
|
|
638
|
-
/** Resolved path with all parameters substituted */
|
|
639
|
-
path: string;
|
|
640
|
-
/** Query parameters: wire_name → string value */
|
|
641
|
-
query: Record<string, string | string[]>;
|
|
642
|
-
/** Header parameters: header-name → string value */
|
|
643
|
-
headers: Record<string, string>;
|
|
644
|
-
/** Body: remaining non-path/query/header properties, with wire-name keys where applicable */
|
|
645
|
-
body: Record<string, unknown> | undefined;
|
|
646
|
-
/** Whether the body should use multipart/form-data */
|
|
647
|
-
isMultipart: boolean;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Schema-aware request builder. Categorizes input properties into path, query, header,
|
|
652
|
-
* and body parts using annotations on the schema AST.
|
|
653
|
-
*
|
|
654
|
-
* Handles camelCase → wire_name mapping for path params (HttpPath), query params (HttpQuery),
|
|
655
|
-
* and header params (HttpHeader).
|
|
656
|
-
*
|
|
657
|
-
* When `inputSchema` is provided, uses `Schema.encodeSync` to encode the input through the
|
|
658
|
-
* schema's encoding pipeline (e.g., `encodeKeys` for camelCase → snake_case mapping).
|
|
659
|
-
* The encoded output is used for body construction, ensuring wire-format key names.
|
|
660
|
-
*/
|
|
661
|
-
/**
|
|
662
|
-
* Recursively unwrap any `Redacted<T>` values in a structure into their
|
|
663
|
-
* underlying `T`.
|
|
664
|
-
*
|
|
665
|
-
* Sensitive response fields surface to callers as `Redacted<string>` (so
|
|
666
|
-
* they don't accidentally land in logs). Those same values often need to be
|
|
667
|
-
* passed straight back into a follow-up request — e.g. WorkOS's password
|
|
668
|
-
* reset flow returns a redacted `password_reset_token` whose shape is
|
|
669
|
-
* `Redacted<string>` and which is then sent into `ResetPassword({ token })`,
|
|
670
|
-
* where `token` is declared as plain `Schema.String`. Without this unwrap
|
|
671
|
-
* the input encoder sees a `Redacted` (whose toString is `"<redacted>"`)
|
|
672
|
-
* and rejects the value with `Expected string, got <redacted>`.
|
|
673
|
-
*
|
|
674
|
-
* Doing the unwrap right before `Schema.encodeSync` keeps the contract
|
|
675
|
-
* simple: callers can hand a `Redacted` to any field and the wire payload
|
|
676
|
-
* still contains the underlying value. Schemas that *want* a `Redacted` on
|
|
677
|
-
* the wire (i.e. `SensitiveString`) handle it through their own encode
|
|
678
|
-
* transform, so they're unaffected — by the time encodeSync sees the value
|
|
679
|
-
* it's already a plain string, which is what the wire format wants anyway.
|
|
680
|
-
*/
|
|
681
|
-
const unwrapRedactedDeep = (value: unknown): unknown => {
|
|
682
|
-
if (Redacted.isRedacted(value)) return Redacted.value(value);
|
|
683
|
-
if (Array.isArray(value)) return value.map(unwrapRedactedDeep);
|
|
684
|
-
if (value !== null && typeof value === "object") {
|
|
685
|
-
// Only walk into plain objects. Builtins like File, Blob, FormData,
|
|
686
|
-
// ArrayBuffer, TypedArrays, Map/Set, Date, Streams, etc. don't contain
|
|
687
|
-
// Redacted values and would be destroyed by Object.entries() — `new
|
|
688
|
-
// File([], "x")` has no own enumerable keys, so a recursive copy returns
|
|
689
|
-
// `{}` and the schema encoder later rejects the empty object with
|
|
690
|
-
// "Expected File, got {}". Preserve any non-plain object as-is.
|
|
691
|
-
const proto = Object.getPrototypeOf(value);
|
|
692
|
-
if (proto !== null && proto !== Object.prototype) return value;
|
|
693
|
-
const out: Record<string, unknown> = {};
|
|
694
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
695
|
-
out[k] = unwrapRedactedDeep(v);
|
|
696
|
-
}
|
|
697
|
-
return out;
|
|
698
|
-
}
|
|
699
|
-
return value;
|
|
700
|
-
};
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* RFC 6570 §3.2.3 reserved-expansion: encode everything outside the RFC 3986
|
|
704
|
-
* unreserved (`A-Za-z0-9-._~`) and reserved (`:/?#[]@!$&'()*+,;=`) sets.
|
|
705
|
-
*/
|
|
706
|
-
const RFC3986_NEEDS_ENCODING = /[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=]/g;
|
|
707
|
-
const encodeReserved = (v: string): string =>
|
|
708
|
-
v.replace(RFC3986_NEEDS_ENCODING, encodeURIComponent);
|
|
709
|
-
|
|
710
|
-
const isPlainObject = (v: unknown): v is Record<string, unknown> => {
|
|
711
|
-
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
712
|
-
const proto = Object.getPrototypeOf(v);
|
|
713
|
-
return proto === Object.prototype || proto === null;
|
|
714
|
-
};
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* Serialize a query-param value onto `query`, flattening plain objects
|
|
718
|
-
* using OpenAPI `deepObject`-style dot notation.
|
|
719
|
-
*
|
|
720
|
-
* Several Cloudflare list endpoints model their filters as nested structs
|
|
721
|
-
* (e.g. DNS `listRecords` takes `name: { exact, contains, ... }`) that must
|
|
722
|
-
* go over the wire as `name.exact=value`. Previously these fell through to
|
|
723
|
-
* `String(value)` and were sent as `name=[object Object]`, which the server
|
|
724
|
-
* happily treats as a filter that matches nothing — the call "succeeds"
|
|
725
|
-
* with zero results and the bug is invisible to the caller.
|
|
726
|
-
*
|
|
727
|
-
* Scalars and arrays keep their existing serialization (`k=v` /
|
|
728
|
-
* repeated `k=v` pairs). Nested plain objects recurse, so deeper filter
|
|
729
|
-
* shapes flatten to `a.b.c=value`. `undefined`/`null` members are skipped
|
|
730
|
-
* like top-level params. Non-plain objects (class instances, `Date`, …)
|
|
731
|
-
* keep the legacy `String(value)` behavior.
|
|
732
|
-
*/
|
|
733
|
-
const setQueryValue = (
|
|
734
|
-
query: Record<string, string | string[]>,
|
|
735
|
-
wireName: string,
|
|
736
|
-
value: unknown,
|
|
737
|
-
): void => {
|
|
738
|
-
if (Array.isArray(value)) {
|
|
739
|
-
query[wireName] = value.map(String);
|
|
740
|
-
} else if (isPlainObject(value)) {
|
|
741
|
-
for (const [key, member] of Object.entries(value)) {
|
|
742
|
-
if (member === undefined || member === null) continue;
|
|
743
|
-
setQueryValue(query, `${wireName}.${key}`, member);
|
|
744
|
-
}
|
|
745
|
-
} else {
|
|
746
|
-
query[wireName] = String(value);
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
export const buildRequestParts = (
|
|
751
|
-
ast: AST.AST,
|
|
752
|
-
httpTrait: HttpTrait,
|
|
753
|
-
rawInput: Record<string, unknown>,
|
|
754
|
-
// biome-ignore lint: using any for generic schema parameter
|
|
755
|
-
inputSchema?: any,
|
|
756
|
-
): RequestParts => {
|
|
757
|
-
// Unwrap any `Redacted<T>` values in the input up front so the schema
|
|
758
|
-
// encoder never sees them. See `unwrapRedactedDeep` for rationale.
|
|
759
|
-
const input = unwrapRedactedDeep(rawInput) as Record<string, unknown>;
|
|
760
|
-
let path = httpTrait.path;
|
|
761
|
-
const query: Record<string, string | string[]> = {};
|
|
762
|
-
const headers: Record<string, string> = {};
|
|
763
|
-
let rawBody: unknown = undefined;
|
|
764
|
-
let hasRawBody = false;
|
|
765
|
-
const isMultipart = httpTrait.contentType === "multipart";
|
|
766
|
-
|
|
767
|
-
// Track which TS property names are path/query/header params (not body)
|
|
768
|
-
const nonBodyKeys = new Set<string>();
|
|
769
|
-
|
|
770
|
-
const props = getStructProps(ast);
|
|
771
|
-
|
|
772
|
-
for (const prop of props) {
|
|
773
|
-
const tsName = String(prop.name);
|
|
774
|
-
const value = input[tsName];
|
|
775
|
-
|
|
776
|
-
if (value === undefined || value === null) {
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Path parameter — `{+name}` is RFC 6570 reserved-expansion (preserves
|
|
781
|
-
// `/` and other RFC 3986 reserved chars); `{name}` is simple expansion.
|
|
782
|
-
const pathWireName = getPathParamWireName(prop);
|
|
783
|
-
if (pathWireName !== undefined) {
|
|
784
|
-
nonBodyKeys.add(tsName);
|
|
785
|
-
const reservedPlaceholder = `{+${pathWireName}}`;
|
|
786
|
-
if (path.includes(reservedPlaceholder)) {
|
|
787
|
-
path = path.replace(reservedPlaceholder, encodeReserved(String(value)));
|
|
788
|
-
} else {
|
|
789
|
-
path = path.replace(
|
|
790
|
-
`{${pathWireName}}`,
|
|
791
|
-
encodeURIComponent(String(value)),
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Query parameter
|
|
798
|
-
const queryParam = getQueryParam(prop);
|
|
799
|
-
if (queryParam !== undefined) {
|
|
800
|
-
nonBodyKeys.add(tsName);
|
|
801
|
-
const wireName = typeof queryParam === "string" ? queryParam : tsName;
|
|
802
|
-
setQueryValue(query, wireName, value);
|
|
803
|
-
continue;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// Header parameter
|
|
807
|
-
const headerParam = getHeaderParam(prop);
|
|
808
|
-
if (headerParam !== undefined) {
|
|
809
|
-
nonBodyKeys.add(tsName);
|
|
810
|
-
headers[headerParam] = String(value);
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Body field (HttpBody annotation means this IS the entire body)
|
|
815
|
-
const isBodyField = getAnnotation<boolean>(prop.type, httpBodySymbol);
|
|
816
|
-
if (isBodyField) {
|
|
817
|
-
rawBody = value;
|
|
818
|
-
hasRawBody = true;
|
|
819
|
-
nonBodyKeys.add(tsName);
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Build body from remaining (non-path/query/header) properties
|
|
825
|
-
let finalBody: Record<string, unknown> | undefined;
|
|
826
|
-
|
|
827
|
-
if (hasRawBody) {
|
|
828
|
-
// For HttpBody fields, encode through the schema to get wire-format keys
|
|
829
|
-
// (e.g., camelCase → snake_case via encodeKeys on nested schemas)
|
|
830
|
-
if (inputSchema) {
|
|
831
|
-
const encoded = Schema.encodeSync(inputSchema)(input);
|
|
832
|
-
const encodedRecord = encoded as Record<string, unknown>;
|
|
833
|
-
// Find the body field name in the encoded output
|
|
834
|
-
for (const prop of props) {
|
|
835
|
-
const tsName = String(prop.name);
|
|
836
|
-
const isBody = getAnnotation<boolean>(prop.type, httpBodySymbol);
|
|
837
|
-
if (isBody && encodedRecord[tsName] !== undefined) {
|
|
838
|
-
finalBody = encodedRecord[tsName] as Record<string, unknown>;
|
|
839
|
-
break;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
// Fallback to raw body if encoding didn't produce it
|
|
843
|
-
if (finalBody === undefined) {
|
|
844
|
-
finalBody = rawBody as Record<string, unknown> | undefined;
|
|
845
|
-
}
|
|
846
|
-
} else {
|
|
847
|
-
finalBody = rawBody as Record<string, unknown> | undefined;
|
|
848
|
-
}
|
|
849
|
-
} else {
|
|
850
|
-
// Encode the input through the schema to get wire-format keys
|
|
851
|
-
// This handles encodeKeys (camelCase → snake_case) and any other encoding transforms
|
|
852
|
-
if (inputSchema) {
|
|
853
|
-
const encoded = Schema.encodeSync(inputSchema)(input);
|
|
854
|
-
const encodedRecord = encoded as Record<string, unknown>;
|
|
855
|
-
|
|
856
|
-
// Build a mapping from tsName → encoded key name
|
|
857
|
-
// by encoding a minimal test object to discover key mappings
|
|
858
|
-
const bodyFromEncoded: Record<string, unknown> = {};
|
|
859
|
-
let hasBodyFields = false;
|
|
860
|
-
|
|
861
|
-
for (const [key, value] of Object.entries(encodedRecord)) {
|
|
862
|
-
// Check if this encoded key corresponds to a non-body TS property
|
|
863
|
-
// by seeing if any non-body prop encodes to this key
|
|
864
|
-
let isNonBody = false;
|
|
865
|
-
for (const nbKey of nonBodyKeys) {
|
|
866
|
-
// Simple heuristic: if the encoded key matches the non-body key or its encoding
|
|
867
|
-
if (key === nbKey) {
|
|
868
|
-
isNonBody = true;
|
|
869
|
-
break;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
if (!isNonBody && value !== undefined) {
|
|
873
|
-
bodyFromEncoded[key] = value;
|
|
874
|
-
hasBodyFields = true;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
finalBody = hasBodyFields ? bodyFromEncoded : undefined;
|
|
879
|
-
} else {
|
|
880
|
-
// Fallback: no schema encoding, use TS property names as-is (for backwards compat)
|
|
881
|
-
const body: Record<string, unknown> = {};
|
|
882
|
-
let hasBody = false;
|
|
883
|
-
for (const prop of props) {
|
|
884
|
-
const tsName = String(prop.name);
|
|
885
|
-
if (nonBodyKeys.has(tsName)) continue;
|
|
886
|
-
const value = input[tsName];
|
|
887
|
-
if (value === undefined || value === null) continue;
|
|
888
|
-
body[tsName] = value;
|
|
889
|
-
hasBody = true;
|
|
890
|
-
}
|
|
891
|
-
finalBody = hasBody ? body : undefined;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return {
|
|
896
|
-
path,
|
|
897
|
-
query,
|
|
898
|
-
headers,
|
|
899
|
-
body: finalBody,
|
|
900
|
-
isMultipart,
|
|
901
|
-
};
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* Build the response object for a binary-download operation
|
|
906
|
-
* (`T.Http({ responseContentType: "binary" })`).
|
|
907
|
-
*
|
|
908
|
-
* The output schema is expected to be a Struct with one field annotated
|
|
909
|
-
* `T.BinaryResponseBody()` (the `body` field — populated with the supplied
|
|
910
|
-
* `Stream`) and zero-or-more sibling fields annotated
|
|
911
|
-
* `T.HttpResponseHeader(name)` (populated by reading the named response
|
|
912
|
-
* header). Header lookups are case-insensitive.
|
|
913
|
-
*
|
|
914
|
-
* Header values are coerced to the field's declared primitive type so that
|
|
915
|
-
* `content-length` lands as a `number`, `last-modified` as a `Date` (when
|
|
916
|
-
* the schema declares `Schema.Date`), etc. Anything more exotic stays a
|
|
917
|
-
* raw string.
|
|
918
|
-
*/
|
|
919
|
-
export const buildBinaryResponse = (
|
|
920
|
-
ast: AST.AST,
|
|
921
|
-
body: unknown,
|
|
922
|
-
responseHeaders: Record<string, string>,
|
|
923
|
-
): Record<string, unknown> => {
|
|
924
|
-
const lowercased: Record<string, string> = {};
|
|
925
|
-
for (const [k, v] of Object.entries(responseHeaders)) {
|
|
926
|
-
lowercased[k.toLowerCase()] = v;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const props = getStructProps(ast);
|
|
930
|
-
const result: Record<string, unknown> = {};
|
|
931
|
-
|
|
932
|
-
for (const prop of props) {
|
|
933
|
-
const tsName = String(prop.name);
|
|
934
|
-
const isBody = getAnnotation<boolean>(prop.type, binaryResponseBodySymbol);
|
|
935
|
-
if (isBody) {
|
|
936
|
-
result[tsName] = body;
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
const headerName = getAnnotation<string>(prop.type, responseHeaderSymbol);
|
|
940
|
-
if (headerName !== undefined) {
|
|
941
|
-
const raw = lowercased[headerName.toLowerCase()];
|
|
942
|
-
if (raw === undefined) continue;
|
|
943
|
-
result[tsName] = coerceHeaderValue(raw, prop.type);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
return result;
|
|
948
|
-
};
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Best-effort coercion of a raw HTTP header string to whichever primitive
|
|
952
|
-
* the response schema declares. Falls back to the original string for
|
|
953
|
-
* anything we don't recognize — the schema's decode step (which the binary
|
|
954
|
-
* runtime path skips) would have done the same coercion for JSON bodies.
|
|
955
|
-
*/
|
|
956
|
-
const coerceHeaderValue = (raw: string, propType: AST.AST): unknown => {
|
|
957
|
-
// Walk through Suspends / Optionals / Encoded chains to the underlying type.
|
|
958
|
-
let inner: AST.AST = propType;
|
|
959
|
-
while (inner.encoding && inner.encoding.length > 0) {
|
|
960
|
-
inner = inner.encoding[0].to;
|
|
961
|
-
}
|
|
962
|
-
if (inner._tag === "Suspend") inner = inner.thunk();
|
|
963
|
-
// `Schema.optional(Schema.X)` shows up as a Union with `undefined`/void.
|
|
964
|
-
if (inner._tag === "Union") {
|
|
965
|
-
const nonOptional = (inner as unknown as { types: AST.AST[] }).types.find(
|
|
966
|
-
(t) => t._tag !== "Void" && t._tag !== "Never",
|
|
967
|
-
);
|
|
968
|
-
if (nonOptional) inner = nonOptional;
|
|
969
|
-
}
|
|
970
|
-
if (inner._tag === "Number") {
|
|
971
|
-
const n = Number(raw);
|
|
972
|
-
return Number.isFinite(n) ? n : raw;
|
|
973
|
-
}
|
|
974
|
-
if (inner._tag === "Boolean") {
|
|
975
|
-
if (raw === "true") return true;
|
|
976
|
-
if (raw === "false") return false;
|
|
977
|
-
return raw;
|
|
978
|
-
}
|
|
979
|
-
return raw;
|
|
980
|
-
};
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Helper to get a value from an object using a dot-separated path.
|
|
984
|
-
* Used for pagination traits and nested property access.
|
|
985
|
-
*/
|
|
986
|
-
export const getPath = (obj: unknown, path: string): unknown => {
|
|
987
|
-
const parts = path.split(".");
|
|
988
|
-
let current: unknown = obj;
|
|
989
|
-
for (const part of parts) {
|
|
990
|
-
if (current == null || typeof current !== "object") {
|
|
991
|
-
return undefined;
|
|
992
|
-
}
|
|
993
|
-
current = (current as Record<string, unknown>)[part];
|
|
994
|
-
}
|
|
995
|
-
return current;
|
|
996
|
-
};
|