@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
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic REST protocol machinery, shared by SDK protocol layers.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is driven purely by the trait annotations in
|
|
5
|
+
* `core/trait.ts` — nothing is provider-specific. An SDK protocol (e.g.
|
|
6
|
+
* cloudflare's) supplies credentials/base-URL, calls {@link buildRequest} to
|
|
7
|
+
* encode, and layers its own response-envelope handling on top of the
|
|
8
|
+
* decode-side helpers ({@link getProps}/{@link mapKeys}/
|
|
9
|
+
* {@link matchTypedError}).
|
|
10
|
+
*/
|
|
11
|
+
import type * as AST from "effect/SchemaAST";
|
|
12
|
+
import * as HttpBody from "effect/unstable/http/HttpBody";
|
|
13
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
14
|
+
import {
|
|
15
|
+
bodySymbol,
|
|
16
|
+
deepQuerySymbol,
|
|
17
|
+
formDataFileSymbol,
|
|
18
|
+
getErrorMatchers,
|
|
19
|
+
headerSymbol,
|
|
20
|
+
httpBodySymbol,
|
|
21
|
+
httpSymbol,
|
|
22
|
+
keyDictionarySymbol,
|
|
23
|
+
labelSymbol,
|
|
24
|
+
querySymbol,
|
|
25
|
+
unionCasesSymbol,
|
|
26
|
+
type ErrorMatcher,
|
|
27
|
+
type HttpTrait,
|
|
28
|
+
type KeyDictionaryEntries,
|
|
29
|
+
} from "./trait.ts";
|
|
30
|
+
|
|
31
|
+
//#region AST helpers (survive S.optional / Suspend / transforms)
|
|
32
|
+
|
|
33
|
+
export const getProps = (ast: AST.AST): readonly AST.PropertySignature[] => {
|
|
34
|
+
if (ast._tag === "Objects") return ast.propertySignatures;
|
|
35
|
+
if (ast._tag === "Suspend") return getProps(ast.thunk());
|
|
36
|
+
if (ast.encoding && ast.encoding.length > 0)
|
|
37
|
+
return getProps(ast.encoding[0]!.to);
|
|
38
|
+
return [];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const getAnn = (ast: AST.AST, symbol: symbol): unknown => {
|
|
42
|
+
const direct = (ast.annotations as Record<symbol, unknown> | undefined)?.[
|
|
43
|
+
symbol
|
|
44
|
+
];
|
|
45
|
+
if (direct !== undefined) return direct;
|
|
46
|
+
if (ast._tag === "Suspend") return getAnn(ast.thunk(), symbol);
|
|
47
|
+
if (ast.encoding && ast.encoding.length > 0)
|
|
48
|
+
return getAnn(ast.encoding[0]!.to, symbol);
|
|
49
|
+
// S.optional → Union[self, Undefined]; descend into the single real member.
|
|
50
|
+
if (ast._tag === "Union") {
|
|
51
|
+
const real = (ast as AST.Union).types.filter(
|
|
52
|
+
(t) =>
|
|
53
|
+
t._tag !== "Undefined" &&
|
|
54
|
+
!(t._tag === "Literal" && (t as any).literal === null),
|
|
55
|
+
);
|
|
56
|
+
if (real.length === 1) return getAnn(real[0]!, symbol);
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getPropAnn = (
|
|
62
|
+
prop: AST.PropertySignature,
|
|
63
|
+
symbol: symbol,
|
|
64
|
+
): unknown => getAnn(prop.type, symbol);
|
|
65
|
+
export const hasPropAnn = (
|
|
66
|
+
prop: AST.PropertySignature,
|
|
67
|
+
symbol: symbol,
|
|
68
|
+
): boolean => getPropAnn(prop, symbol) !== undefined;
|
|
69
|
+
export const nameOf = (prop: AST.PropertySignature, symbol: symbol): string => {
|
|
70
|
+
const v = getPropAnn(prop, symbol);
|
|
71
|
+
return typeof v === "string" ? v : String(prop.name);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Resolve wrappers to the real node: Suspend, encoding, optional/null unions. */
|
|
75
|
+
export const resolveNode = (ast: AST.AST): AST.AST => {
|
|
76
|
+
if (ast._tag === "Suspend") return resolveNode(ast.thunk());
|
|
77
|
+
if (ast.encoding && ast.encoding.length > 0)
|
|
78
|
+
return resolveNode(ast.encoding[0]!.to);
|
|
79
|
+
if (ast._tag === "Union") {
|
|
80
|
+
const real = (ast as AST.Union).types.filter(
|
|
81
|
+
(t) =>
|
|
82
|
+
t._tag !== "Undefined" &&
|
|
83
|
+
t._tag !== "Null" &&
|
|
84
|
+
!(t._tag === "Literal" && (t as any).literal === null),
|
|
85
|
+
);
|
|
86
|
+
if (real.length === 1) return resolveNode(real[0]!);
|
|
87
|
+
}
|
|
88
|
+
return ast;
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
|
|
92
|
+
//#region Recursive wire-name mapping
|
|
93
|
+
|
|
94
|
+
/** Binary/opaque values that must never be treated as key-value objects. */
|
|
95
|
+
export const isOpaqueValue = (v: unknown): boolean =>
|
|
96
|
+
v instanceof Blob ||
|
|
97
|
+
v instanceof File ||
|
|
98
|
+
v instanceof ArrayBuffer ||
|
|
99
|
+
v instanceof Uint8Array ||
|
|
100
|
+
v instanceof Date;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Deep-rename keys via a plain dictionary (see `T.KeyDictionary`). An entry
|
|
104
|
+
* may list several wire spellings; encode uses the first (canonical) one and
|
|
105
|
+
* decode maps every listed spelling back to the TS name.
|
|
106
|
+
*/
|
|
107
|
+
export const mapKeysByDictionary = (
|
|
108
|
+
dict: KeyDictionaryEntries,
|
|
109
|
+
value: unknown,
|
|
110
|
+
direction: "encode" | "decode",
|
|
111
|
+
): unknown => {
|
|
112
|
+
if (value === null || typeof value !== "object" || isOpaqueValue(value)) {
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
return value.map((v) => mapKeysByDictionary(dict, v, direction));
|
|
117
|
+
}
|
|
118
|
+
const rename: Record<string, string> =
|
|
119
|
+
direction === "decode"
|
|
120
|
+
? Object.fromEntries(
|
|
121
|
+
Object.entries(dict).flatMap(([ts, wire]) =>
|
|
122
|
+
typeof wire === "string"
|
|
123
|
+
? [[wire, ts] as const]
|
|
124
|
+
: wire.map((w) => [w, ts] as const),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
: Object.fromEntries(
|
|
128
|
+
Object.entries(dict).map(([ts, wire]) => [
|
|
129
|
+
ts,
|
|
130
|
+
typeof wire === "string" ? wire : wire[0]!,
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
const out: Record<string, unknown> = {};
|
|
134
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
135
|
+
if (v === undefined) continue;
|
|
136
|
+
out[rename[k] ?? k] = mapKeysByDictionary(dict, v, direction);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recursively rename keys between TS names and wire names, per the schema.
|
|
143
|
+
*
|
|
144
|
+
* `fallback` is the nearest ancestor `T.KeyDictionary` — schema-known members
|
|
145
|
+
* always map via their own annotations, but opaque values (Document members,
|
|
146
|
+
* keys the schema doesn't know) deep-rename via the dictionary so nested
|
|
147
|
+
* TS-cased content still reaches the wire in wire case. Content with no
|
|
148
|
+
* dictionary in scope passes through verbatim.
|
|
149
|
+
*/
|
|
150
|
+
export const mapKeys = (
|
|
151
|
+
ast: AST.AST,
|
|
152
|
+
value: unknown,
|
|
153
|
+
direction: "encode" | "decode",
|
|
154
|
+
fallback?: KeyDictionaryEntries,
|
|
155
|
+
): unknown => {
|
|
156
|
+
if (
|
|
157
|
+
value === null ||
|
|
158
|
+
value === undefined ||
|
|
159
|
+
typeof value !== "object" ||
|
|
160
|
+
isOpaqueValue(value)
|
|
161
|
+
) {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
const dict =
|
|
165
|
+
(getAnn(ast, keyDictionarySymbol) as KeyDictionaryEntries | undefined) ??
|
|
166
|
+
fallback;
|
|
167
|
+
|
|
168
|
+
// Discriminated union whose cases the API returns merged (every case's
|
|
169
|
+
// keys present, `null` for the inactive ones). Map wire names, then keep
|
|
170
|
+
// only the active case's keys. A case does NOT need every key present —
|
|
171
|
+
// arms carry optional members the wire may omit — so the active case is
|
|
172
|
+
// the one that best EXPLAINS the value: most present, non-null keys
|
|
173
|
+
// covered, minus present keys the case cannot explain (ties break by
|
|
174
|
+
// coverage, then declaration order).
|
|
175
|
+
const unionCases = getAnn(ast, unionCasesSymbol) as
|
|
176
|
+
| ReadonlyArray<ReadonlyArray<string>>
|
|
177
|
+
| undefined;
|
|
178
|
+
if (unionCases && direction === "decode" && !Array.isArray(value)) {
|
|
179
|
+
const obj = (
|
|
180
|
+
dict ? mapKeysByDictionary(dict, value, "decode") : value
|
|
181
|
+
) as Record<string, unknown>;
|
|
182
|
+
const present = Object.keys(obj).filter(
|
|
183
|
+
(k) => obj[k] !== undefined && obj[k] !== null,
|
|
184
|
+
);
|
|
185
|
+
let best:
|
|
186
|
+
| { keys: ReadonlyArray<string>; score: number; matched: number }
|
|
187
|
+
| undefined;
|
|
188
|
+
for (const keys of unionCases) {
|
|
189
|
+
const set = new Set(keys);
|
|
190
|
+
let matched = 0;
|
|
191
|
+
let excess = 0;
|
|
192
|
+
for (const k of present) {
|
|
193
|
+
if (set.has(k)) matched++;
|
|
194
|
+
else excess++;
|
|
195
|
+
}
|
|
196
|
+
const score = matched - excess;
|
|
197
|
+
if (
|
|
198
|
+
matched > 0 &&
|
|
199
|
+
(!best ||
|
|
200
|
+
score > best.score ||
|
|
201
|
+
(score === best.score && matched > best.matched))
|
|
202
|
+
) {
|
|
203
|
+
best = { keys, score, matched };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (best) {
|
|
207
|
+
const out: Record<string, unknown> = {};
|
|
208
|
+
for (const k of best.keys) out[k] = obj[k];
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
return obj;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const node = resolveNode(ast);
|
|
215
|
+
|
|
216
|
+
// Schema-level union member (a merged response struct whose arms disagree
|
|
217
|
+
// on a member's type — see the codegen's response-union merge): pick the
|
|
218
|
+
// arm that explains the value best and map with it. Object values score
|
|
219
|
+
// arms by how many of their declared keys are present; array values take
|
|
220
|
+
// the first array arm. Anything unmatched deep-renames via the dictionary
|
|
221
|
+
// fallback, like other opaque content. (Single-real-member unions —
|
|
222
|
+
// optional/null wrappers — were already collapsed by resolveNode; the
|
|
223
|
+
// opaque `T.UnionCases` mechanism never produces a Union node and is
|
|
224
|
+
// handled above.)
|
|
225
|
+
if (node._tag === "Union") {
|
|
226
|
+
const arms = (node as AST.Union).types.map(resolveNode);
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
const arr = arms.find((t) => t._tag === "Arrays");
|
|
229
|
+
if (arr) return mapKeys(arr, value, direction, dict);
|
|
230
|
+
} else {
|
|
231
|
+
let best: AST.AST | undefined;
|
|
232
|
+
let bestScore = 0;
|
|
233
|
+
for (const t of arms) {
|
|
234
|
+
if (t._tag !== "Objects") continue;
|
|
235
|
+
let score = 0;
|
|
236
|
+
for (const p of (t as any)
|
|
237
|
+
.propertySignatures as readonly AST.PropertySignature[]) {
|
|
238
|
+
const from =
|
|
239
|
+
direction === "encode" ? String(p.name) : nameOf(p, bodySymbol);
|
|
240
|
+
if ((value as Record<string, unknown>)[from] !== undefined) score++;
|
|
241
|
+
}
|
|
242
|
+
if (score > bestScore) {
|
|
243
|
+
best = t;
|
|
244
|
+
bestScore = score;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (best) return mapKeys(best, value, direction, dict);
|
|
248
|
+
}
|
|
249
|
+
return dict ? mapKeysByDictionary(dict, value, direction) : value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (node._tag === "Arrays") {
|
|
253
|
+
if (!Array.isArray(value)) return value;
|
|
254
|
+
const elem = (node as any).rest?.[0] as AST.AST | undefined;
|
|
255
|
+
return elem
|
|
256
|
+
? value.map((v) => mapKeys(elem, v, direction, dict))
|
|
257
|
+
: dict
|
|
258
|
+
? mapKeysByDictionary(dict, value, direction)
|
|
259
|
+
: value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (node._tag === "Objects" && !Array.isArray(value)) {
|
|
263
|
+
const props = (node as any)
|
|
264
|
+
.propertySignatures as readonly AST.PropertySignature[];
|
|
265
|
+
const isigs = (node as any).indexSignatures as
|
|
266
|
+
| readonly { type: AST.AST }[]
|
|
267
|
+
| undefined;
|
|
268
|
+
if (props.length === 0 && !(isigs && isigs.length)) {
|
|
269
|
+
// opaque object — dictionary fallback or verbatim
|
|
270
|
+
return dict ? mapKeysByDictionary(dict, value, direction) : value;
|
|
271
|
+
}
|
|
272
|
+
const out: Record<string, unknown> = {};
|
|
273
|
+
const consumed = new Set<string>();
|
|
274
|
+
for (const p of props) {
|
|
275
|
+
const tsName = String(p.name);
|
|
276
|
+
const wire = nameOf(p, bodySymbol);
|
|
277
|
+
const from = direction === "encode" ? tsName : wire;
|
|
278
|
+
const to = direction === "encode" ? wire : tsName;
|
|
279
|
+
consumed.add(from);
|
|
280
|
+
const v = (value as Record<string, unknown>)[from];
|
|
281
|
+
if (v === undefined) continue;
|
|
282
|
+
out[to] = mapKeys(p.type, v, direction, dict);
|
|
283
|
+
}
|
|
284
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
285
|
+
if (consumed.has(k) || v === undefined) continue;
|
|
286
|
+
const renamed = dict
|
|
287
|
+
? direction === "encode"
|
|
288
|
+
? (dict[k] ?? k)
|
|
289
|
+
: (Object.entries(dict).find(([, w]) => w === k)?.[0] ?? k)
|
|
290
|
+
: k;
|
|
291
|
+
|
|
292
|
+
out[typeof renamed === "string" ? renamed : renamed[0]] =
|
|
293
|
+
isigs && isigs.length
|
|
294
|
+
? mapKeys(isigs[0]!.type, v, direction, dict)
|
|
295
|
+
: dict
|
|
296
|
+
? mapKeysByDictionary(dict, v, direction)
|
|
297
|
+
: v;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Scalar-typed schema node holding object content (spec drift) —
|
|
303
|
+
// dictionary fallback or verbatim.
|
|
304
|
+
return dict ? mapKeysByDictionary(dict, value, direction) : value;
|
|
305
|
+
};
|
|
306
|
+
//#endregion
|
|
307
|
+
|
|
308
|
+
//#region Request building
|
|
309
|
+
|
|
310
|
+
const BODYLESS = new Set(["GET", "HEAD"]);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Serialize one query member. Scalars go as `k=v`, arrays as repeated `k=v`
|
|
314
|
+
* pairs, and nested plain objects recurse as DOTTED params — several list
|
|
315
|
+
* endpoints model their filters as structs (e.g. Cloudflare DNS listRecords
|
|
316
|
+
* takes `name: { exact, contains, ... }`) that must go over the wire as
|
|
317
|
+
* `name.exact=value`. Without this they fell through to `String(value)` and
|
|
318
|
+
* were sent as `name=[object Object]`, which servers happily treat as a
|
|
319
|
+
* filter matching nothing — the call "succeeds" with zero results and the
|
|
320
|
+
* bug is invisible to the caller.
|
|
321
|
+
*/
|
|
322
|
+
const appendQuery = (
|
|
323
|
+
query: URLSearchParams,
|
|
324
|
+
name: string,
|
|
325
|
+
value: unknown,
|
|
326
|
+
): void => {
|
|
327
|
+
if (Array.isArray(value)) {
|
|
328
|
+
for (const v of value) appendQuery(query, name, v);
|
|
329
|
+
} else if (
|
|
330
|
+
value !== null &&
|
|
331
|
+
typeof value === "object" &&
|
|
332
|
+
!(value instanceof Date)
|
|
333
|
+
) {
|
|
334
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
335
|
+
if (v !== undefined) appendQuery(query, `${name}.${k}`, v);
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
query.append(name, String(value));
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export interface BuildRequestOptions {
|
|
343
|
+
readonly input: unknown;
|
|
344
|
+
readonly inputAst: AST.AST;
|
|
345
|
+
/** Base URL the operation's URI template is appended to. */
|
|
346
|
+
readonly baseUrl: string;
|
|
347
|
+
/** Headers applied before member-bound headers (credentials etc.). */
|
|
348
|
+
readonly headers?: Record<string, string>;
|
|
349
|
+
/**
|
|
350
|
+
* Transform a member-bound header value before it is set (e.g.
|
|
351
|
+
* Bearer-prefixing a raw token supplied as an Authorization member).
|
|
352
|
+
*/
|
|
353
|
+
readonly mapMemberHeader?: (name: string, value: string) => string;
|
|
354
|
+
/**
|
|
355
|
+
* Wire name for input keys the schema doesn't declare (spec drift —
|
|
356
|
+
* unknown keys pass through as body fields rather than being dropped).
|
|
357
|
+
* Defaults to the key verbatim.
|
|
358
|
+
*/
|
|
359
|
+
readonly unknownKeyToWire?: (key: string) => string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Build an HTTP request from an operation input and its schema, driven by
|
|
364
|
+
* the trait annotations: `Http()` supplies method + URI template, `Label()`
|
|
365
|
+
* members fill URI placeholders, `Header()`/`Query()` members bind to
|
|
366
|
+
* headers/query params, `DeepQuery()` struct members expand into dotted
|
|
367
|
+
* query params, `HttpBody()` sends the member as the whole body,
|
|
368
|
+
* `FormDataFile()` members become multipart file parts, and everything else
|
|
369
|
+
* is a JSON body field (wire-named via `Body()` / `KeyDictionary`).
|
|
370
|
+
*
|
|
371
|
+
* Throws when the input schema is missing the `Http()` trait — a codegen
|
|
372
|
+
* bug, surfaced as a defect by the calling protocol's Effect context.
|
|
373
|
+
*/
|
|
374
|
+
/**
|
|
375
|
+
* Flatten a nested object into Stripe-style bracket notation pairs for
|
|
376
|
+
* form-urlencoded bodies: nested objects become `a[b][c]`, arrays index as
|
|
377
|
+
* `a[0]`; null/undefined dropped, booleans as "true"/"false".
|
|
378
|
+
*/
|
|
379
|
+
const flattenToFormPairs = (
|
|
380
|
+
obj: Record<string, unknown>,
|
|
381
|
+
prefix = "",
|
|
382
|
+
): Array<[string, string]> => {
|
|
383
|
+
const pairs: Array<[string, string]> = [];
|
|
384
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
385
|
+
if (value === undefined || value === null) continue;
|
|
386
|
+
const fullKey = prefix ? `${prefix}[${key}]` : key;
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
for (let i = 0; i < value.length; i++) {
|
|
389
|
+
const item = value[i];
|
|
390
|
+
if (item === undefined || item === null) continue;
|
|
391
|
+
if (typeof item === "object" && !Array.isArray(item)) {
|
|
392
|
+
pairs.push(
|
|
393
|
+
...flattenToFormPairs(
|
|
394
|
+
item as Record<string, unknown>,
|
|
395
|
+
`${fullKey}[${i}]`,
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
} else {
|
|
399
|
+
pairs.push([`${fullKey}[${i}]`, String(item)]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} else if (typeof value === "object") {
|
|
403
|
+
pairs.push(
|
|
404
|
+
...flattenToFormPairs(value as Record<string, unknown>, fullKey),
|
|
405
|
+
);
|
|
406
|
+
} else {
|
|
407
|
+
pairs.push([fullKey, String(value)]);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return pairs;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const buildRequest = ({
|
|
414
|
+
input,
|
|
415
|
+
inputAst,
|
|
416
|
+
baseUrl,
|
|
417
|
+
headers: baseHeaders,
|
|
418
|
+
mapMemberHeader,
|
|
419
|
+
unknownKeyToWire,
|
|
420
|
+
}: BuildRequestOptions): HttpClientRequest.HttpClientRequest => {
|
|
421
|
+
const inputObj = (input ?? {}) as Record<string, unknown>;
|
|
422
|
+
const http = getAnn(inputAst, httpSymbol) as HttpTrait | undefined;
|
|
423
|
+
if (!http) {
|
|
424
|
+
throw new Error("operation input is missing the Http() trait");
|
|
425
|
+
}
|
|
426
|
+
// Root key dictionary: fallback wire mapping for opaque/unknown content
|
|
427
|
+
// the schema doesn't model.
|
|
428
|
+
const rootDict = getAnn(inputAst, keyDictionarySymbol) as
|
|
429
|
+
| KeyDictionaryEntries
|
|
430
|
+
| undefined;
|
|
431
|
+
|
|
432
|
+
const headers: Record<string, string> = { ...baseHeaders };
|
|
433
|
+
const body: Record<string, unknown> = {};
|
|
434
|
+
let rawBody: unknown; // whole-body member (T.HttpBody) — sent as-is
|
|
435
|
+
const files: Array<Blob | File> = [];
|
|
436
|
+
const query = new URLSearchParams();
|
|
437
|
+
let uri = http.uri;
|
|
438
|
+
const consumed = new Set<string>();
|
|
439
|
+
let hasBodyMembers = false;
|
|
440
|
+
|
|
441
|
+
for (const prop of getProps(inputAst)) {
|
|
442
|
+
const key = String(prop.name);
|
|
443
|
+
consumed.add(key);
|
|
444
|
+
const value = inputObj[key];
|
|
445
|
+
const isBodyMember =
|
|
446
|
+
!hasPropAnn(prop, labelSymbol) &&
|
|
447
|
+
!hasPropAnn(prop, headerSymbol) &&
|
|
448
|
+
!hasPropAnn(prop, querySymbol) &&
|
|
449
|
+
!hasPropAnn(prop, deepQuerySymbol);
|
|
450
|
+
if (isBodyMember) hasBodyMembers = true;
|
|
451
|
+
if (value === undefined) continue;
|
|
452
|
+
|
|
453
|
+
if (hasPropAnn(prop, labelSymbol)) {
|
|
454
|
+
const token = nameOf(prop, labelSymbol);
|
|
455
|
+
uri = uri.replace(`{${token}}`, encodeURIComponent(String(value)));
|
|
456
|
+
} else if (hasPropAnn(prop, headerSymbol)) {
|
|
457
|
+
const hName = nameOf(prop, headerSymbol).toLowerCase();
|
|
458
|
+
const hVal = String(value);
|
|
459
|
+
headers[hName] = mapMemberHeader ? mapMemberHeader(hName, hVal) : hVal;
|
|
460
|
+
} else if (hasPropAnn(prop, querySymbol)) {
|
|
461
|
+
appendQuery(query, nameOf(prop, querySymbol), value);
|
|
462
|
+
} else if (hasPropAnn(prop, deepQuerySymbol)) {
|
|
463
|
+
// Deep-object query member (T.DeepQuery): a nested struct expands into
|
|
464
|
+
// dotted query keys (`account.id=…`) under the member's wire name.
|
|
465
|
+
// Wire-name the nested content via the schema first, skip null/
|
|
466
|
+
// undefined entries; deeper nesting recurses through appendQuery.
|
|
467
|
+
const base = nameOf(prop, deepQuerySymbol);
|
|
468
|
+
const mapped = mapKeys(prop.type, value, "encode", rootDict);
|
|
469
|
+
if (mapped !== null && typeof mapped === "object") {
|
|
470
|
+
for (const [k, v] of Object.entries(
|
|
471
|
+
mapped as Record<string, unknown>,
|
|
472
|
+
)) {
|
|
473
|
+
if (v !== undefined && v !== null) {
|
|
474
|
+
appendQuery(query, `${base}.${k}`, v);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} else if (hasPropAnn(prop, formDataFileSymbol)) {
|
|
479
|
+
for (const f of Array.isArray(value) ? value : [value]) {
|
|
480
|
+
files.push(f as Blob | File);
|
|
481
|
+
}
|
|
482
|
+
} else if (hasPropAnn(prop, httpBodySymbol)) {
|
|
483
|
+
rawBody = mapKeys(prop.type, value, "encode", rootDict);
|
|
484
|
+
} else {
|
|
485
|
+
body[nameOf(prop, bodySymbol)] = mapKeys(
|
|
486
|
+
prop.type,
|
|
487
|
+
value,
|
|
488
|
+
"encode",
|
|
489
|
+
rootDict,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Input keys the schema doesn't know pass through as body fields — the
|
|
495
|
+
// generated schemas can lag the real API, and silently dropping them
|
|
496
|
+
// would break working callers. The key dictionary maps them when it knows
|
|
497
|
+
// the key, otherwise `unknownKeyToWire` decides the wire name.
|
|
498
|
+
for (const [key, value] of Object.entries(inputObj)) {
|
|
499
|
+
if (consumed.has(key) || value === undefined) continue;
|
|
500
|
+
const wire =
|
|
501
|
+
rootDict?.[key] ?? (unknownKeyToWire ? unknownKeyToWire(key) : key);
|
|
502
|
+
body[typeof wire === "string" ? wire : wire[0]] = rootDict
|
|
503
|
+
? mapKeysByDictionary(rootDict, value, "encode")
|
|
504
|
+
: value;
|
|
505
|
+
hasBodyMembers = true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const qs = query.toString();
|
|
509
|
+
const url = `${baseUrl}${uri}${qs ? `?${qs}` : ""}`;
|
|
510
|
+
if (process.env.DISTILLED_DEBUG_HTTP) {
|
|
511
|
+
console.error(
|
|
512
|
+
`[distilled] ${http.method} ${url}` +
|
|
513
|
+
(Object.keys(body).length
|
|
514
|
+
? ` body=${JSON.stringify(body).slice(0, 400)}`
|
|
515
|
+
: ""),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let request = HttpClientRequest.make(http.method)(url).pipe(
|
|
520
|
+
HttpClientRequest.setHeaders(headers),
|
|
521
|
+
);
|
|
522
|
+
if (http.contentType === "multipart") {
|
|
523
|
+
// Multipart upload: each body member is a form part (objects
|
|
524
|
+
// JSON-encoded under their wire name), each file appends under its own
|
|
525
|
+
// filename. A whole-body member (T.HttpBody) that is a record of files
|
|
526
|
+
// becomes one part per entry (e.g. asset upload: { <hash>: File }).
|
|
527
|
+
// File/Blob → binary part (filename = File.name), array of files → each
|
|
528
|
+
// appended, object → JSON string, primitive → string.
|
|
529
|
+
const form = new FormData();
|
|
530
|
+
const parts =
|
|
531
|
+
rawBody !== undefined && typeof rawBody === "object"
|
|
532
|
+
? (rawBody as Record<string, unknown>)
|
|
533
|
+
: body;
|
|
534
|
+
const isFileOrBlob = (v: unknown): v is Blob =>
|
|
535
|
+
v instanceof Blob || v instanceof File;
|
|
536
|
+
for (const [key, value] of Object.entries(parts)) {
|
|
537
|
+
if (value === undefined || value === null) continue;
|
|
538
|
+
if (isFileOrBlob(value)) {
|
|
539
|
+
form.append(key, value, value instanceof File ? value.name : key);
|
|
540
|
+
} else if (
|
|
541
|
+
Array.isArray(value) &&
|
|
542
|
+
value.length > 0 &&
|
|
543
|
+
isFileOrBlob(value[0])
|
|
544
|
+
) {
|
|
545
|
+
for (const file of value as Blob[]) {
|
|
546
|
+
if (isFileOrBlob(file)) {
|
|
547
|
+
form.append(
|
|
548
|
+
file instanceof File ? file.name : key,
|
|
549
|
+
file,
|
|
550
|
+
file instanceof File ? file.name : undefined,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
} else if (typeof value === "object") {
|
|
555
|
+
form.append(key, JSON.stringify(value));
|
|
556
|
+
} else {
|
|
557
|
+
form.append(key, String(value));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const f of files) {
|
|
561
|
+
const filename = (f as File).name ?? "file";
|
|
562
|
+
form.append(filename, f, filename);
|
|
563
|
+
}
|
|
564
|
+
request = request.pipe(HttpClientRequest.bodyFormData(form));
|
|
565
|
+
} else if (
|
|
566
|
+
http.contentType === "form-urlencoded" &&
|
|
567
|
+
!BODYLESS.has(http.method)
|
|
568
|
+
) {
|
|
569
|
+
// application/x-www-form-urlencoded with Stripe-style deepObject bracket
|
|
570
|
+
// notation: { shipping: { address: { city } } } → shipping[address][city],
|
|
571
|
+
// arrays indexed ({ expand: ["a"] } → expand[0]=a).
|
|
572
|
+
const source =
|
|
573
|
+
rawBody !== undefined && typeof rawBody === "object"
|
|
574
|
+
? (rawBody as Record<string, unknown>)
|
|
575
|
+
: body;
|
|
576
|
+
const params = new URLSearchParams();
|
|
577
|
+
for (const [k, v] of flattenToFormPairs(source)) params.append(k, v);
|
|
578
|
+
request = request.pipe(
|
|
579
|
+
HttpClientRequest.setBody(
|
|
580
|
+
HttpBody.text(params.toString(), "application/x-www-form-urlencoded"),
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
} else if (rawBody !== undefined && !BODYLESS.has(http.method)) {
|
|
584
|
+
// Whole-body member (raw arrays/scalars) — sent as the body itself.
|
|
585
|
+
// Binary payloads (Blob / ArrayBuffer / Uint8Array) send verbatim
|
|
586
|
+
// (raw object uploads — the Content-Type header member, when modeled,
|
|
587
|
+
// rides alongside). With a bodyMediaType, the member is a
|
|
588
|
+
// preserialized payload (string / bytes) sent verbatim under that
|
|
589
|
+
// media type (e.g. application/x-ndjson for Vectorize
|
|
590
|
+
// insert/upsert); otherwise it's JSON.
|
|
591
|
+
if (rawBody instanceof Blob || rawBody instanceof ArrayBuffer) {
|
|
592
|
+
// Honor the declared media type (e.g. application/x-ndjson for
|
|
593
|
+
// Vectorize insert/upsert) — without it the server may fall back to
|
|
594
|
+
// JSON parsing. When no bodyMediaType is modeled the header is left
|
|
595
|
+
// untouched (a modeled Content-Type header member rides alongside).
|
|
596
|
+
request = request.pipe(
|
|
597
|
+
HttpClientRequest.setBody(
|
|
598
|
+
HttpBody.raw(rawBody, { contentType: http.bodyMediaType }),
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
} else if (rawBody instanceof Uint8Array) {
|
|
602
|
+
request = request.pipe(
|
|
603
|
+
HttpClientRequest.setBody(
|
|
604
|
+
HttpBody.uint8Array(rawBody, http.bodyMediaType),
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
} else if (http.bodyMediaType) {
|
|
608
|
+
request = request.pipe(
|
|
609
|
+
HttpClientRequest.setBody(
|
|
610
|
+
typeof rawBody === "string"
|
|
611
|
+
? HttpBody.text(rawBody, http.bodyMediaType)
|
|
612
|
+
: HttpBody.uint8Array(rawBody as Uint8Array, http.bodyMediaType),
|
|
613
|
+
),
|
|
614
|
+
);
|
|
615
|
+
} else {
|
|
616
|
+
request = request.pipe(HttpClientRequest.bodyJsonUnsafe(rawBody));
|
|
617
|
+
}
|
|
618
|
+
} else if (
|
|
619
|
+
!BODYLESS.has(http.method) &&
|
|
620
|
+
(Object.keys(body).length > 0 ||
|
|
621
|
+
(hasBodyMembers && http.method !== "DELETE"))
|
|
622
|
+
) {
|
|
623
|
+
// Send `{}` rather than no body when the schema declares body members —
|
|
624
|
+
// some endpoints reject a missing JSON body outright.
|
|
625
|
+
request = request.pipe(HttpClientRequest.bodyJsonUnsafe(body));
|
|
626
|
+
}
|
|
627
|
+
return request;
|
|
628
|
+
};
|
|
629
|
+
//#endregion
|
|
630
|
+
|
|
631
|
+
//#region Typed error matching
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Whether one matcher matches one wire error: every present field must
|
|
635
|
+
* match; a matcher (or a message object) with no constraints matches
|
|
636
|
+
* nothing.
|
|
637
|
+
*/
|
|
638
|
+
export const matchesExpression = (
|
|
639
|
+
m: ErrorMatcher,
|
|
640
|
+
code: number | undefined,
|
|
641
|
+
status: number,
|
|
642
|
+
message: string,
|
|
643
|
+
): boolean => {
|
|
644
|
+
if (m.code === undefined && m.status === undefined && m.message === undefined)
|
|
645
|
+
return false;
|
|
646
|
+
if (m.code !== undefined && m.code !== code) return false;
|
|
647
|
+
if (m.status !== undefined && m.status !== status) return false;
|
|
648
|
+
if (m.message !== undefined) {
|
|
649
|
+
if (typeof m.message === "string") {
|
|
650
|
+
if (m.message !== message) return false;
|
|
651
|
+
} else {
|
|
652
|
+
const { includes, matches } = m.message;
|
|
653
|
+
if (includes === undefined && matches === undefined) return false;
|
|
654
|
+
if (includes !== undefined && !message.includes(includes)) return false;
|
|
655
|
+
if (matches !== undefined && !new RegExp(matches).test(message))
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const matcherSpecificity = (m: ErrorMatcher): number =>
|
|
663
|
+
(m.code !== undefined ? 1 : 0) +
|
|
664
|
+
(m.status !== undefined ? 1 : 0) +
|
|
665
|
+
(m.message !== undefined ? 1 : 0);
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Pick the operation's typed error class for a failed response: among all
|
|
669
|
+
* declared classes whose matchers (see `applyErrorMatchers`) match the wire
|
|
670
|
+
* failure, the most specific matcher wins (ties break by declaration order).
|
|
671
|
+
*/
|
|
672
|
+
export const matchTypedError = (
|
|
673
|
+
errorClasses: ReadonlyArray<unknown>,
|
|
674
|
+
status: number,
|
|
675
|
+
errors: ReadonlyArray<{ code?: number; message: string }>,
|
|
676
|
+
): unknown | undefined => {
|
|
677
|
+
let best:
|
|
678
|
+
| { cls: unknown; specificity: number; code?: number; message: string }
|
|
679
|
+
| undefined;
|
|
680
|
+
for (const cls of errorClasses) {
|
|
681
|
+
const matchers = getErrorMatchers(cls);
|
|
682
|
+
if (!matchers) continue;
|
|
683
|
+
for (const m of matchers) {
|
|
684
|
+
for (const e of errors) {
|
|
685
|
+
if (!matchesExpression(m, e.code, status, e.message)) continue;
|
|
686
|
+
const specificity = matcherSpecificity(m);
|
|
687
|
+
if (!best || specificity > best.specificity) {
|
|
688
|
+
best = { cls, specificity, code: e.code, message: e.message };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!best) return undefined;
|
|
694
|
+
return new (best.cls as new (args: any) => unknown)({
|
|
695
|
+
code: best.code ?? 0,
|
|
696
|
+
message: best.message,
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
//#endregion
|