@distilled.cloud/core 1.0.0-rc.12 → 1.0.0-rc.13
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 +4 -4
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/category.js +1 -1
- package/lib/category.js.map +1 -1
- package/lib/codegen/boolean-string-enums.d.ts +8 -8
- package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
- package/lib/codegen/boolean-string-enums.js +84 -27
- package/lib/codegen/boolean-string-enums.js.map +1 -1
- package/lib/codegen/boolean-string-enums.test.js +130 -0
- package/lib/codegen/boolean-string-enums.test.js.map +1 -1
- package/lib/codegen/cli.js +2 -2
- package/lib/codegen/cli.js.map +1 -1
- package/lib/codegen/generator.d.ts +20 -3
- package/lib/codegen/generator.d.ts.map +1 -1
- package/lib/codegen/generator.js +36 -9
- package/lib/codegen/generator.js.map +1 -1
- package/lib/codegen/generator.test.d.ts +2 -0
- package/lib/codegen/generator.test.d.ts.map +1 -0
- package/lib/codegen/generator.test.js +126 -0
- package/lib/codegen/generator.test.js.map +1 -0
- package/lib/codegen/graphql-client.d.ts +7 -1
- package/lib/codegen/graphql-client.d.ts.map +1 -1
- package/lib/codegen/graphql-client.js +262 -125
- package/lib/codegen/graphql-client.js.map +1 -1
- package/lib/codegen/graphql-client.test.js +104 -236
- package/lib/codegen/graphql-client.test.js.map +1 -1
- package/lib/codegen/openapi-binary.test.d.ts +2 -0
- package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
- package/lib/codegen/openapi-binary.test.js +67 -0
- package/lib/codegen/openapi-binary.test.js.map +1 -0
- package/lib/codegen/openapi.d.ts +7 -1
- package/lib/codegen/openapi.d.ts.map +1 -1
- package/lib/codegen/openapi.js +15 -4
- package/lib/codegen/openapi.js.map +1 -1
- package/lib/codegen/patches.d.ts +16 -1
- package/lib/codegen/patches.d.ts.map +1 -1
- package/lib/codegen/patches.js +70 -4
- package/lib/codegen/patches.js.map +1 -1
- package/lib/codegen/patches.test.js +48 -2
- package/lib/codegen/patches.test.js.map +1 -1
- package/lib/codegen/untagged-unions.test.d.ts +2 -0
- package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
- package/lib/codegen/untagged-unions.test.js +80 -0
- package/lib/codegen/untagged-unions.test.js.map +1 -0
- package/lib/errors.d.ts +2 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +5 -2
- package/lib/errors.js.map +1 -1
- package/lib/fixtures/xml/upstream-validator.json +397 -0
- package/lib/graphql.d.ts +296 -205
- package/lib/graphql.d.ts.map +1 -1
- package/lib/graphql.js +968 -631
- package/lib/graphql.js.map +1 -1
- package/lib/graphql.test.js +134 -763
- package/lib/graphql.test.js.map +1 -1
- package/lib/graphql.types.d.ts +10 -0
- package/lib/graphql.types.d.ts.map +1 -1
- package/lib/graphql.types.js +4 -44
- package/lib/graphql.types.js.map +1 -1
- package/lib/index.d.ts +27 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +27 -0
- package/lib/index.js.map +1 -0
- package/lib/pagination.d.ts +2 -1
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +24 -16
- package/lib/pagination.js.map +1 -1
- package/lib/pagination.test.d.ts +2 -0
- package/lib/pagination.test.d.ts.map +1 -0
- package/lib/pagination.test.js +134 -0
- package/lib/pagination.test.js.map +1 -0
- package/lib/protocol-http.d.ts +12 -17
- package/lib/protocol-http.d.ts.map +1 -1
- package/lib/protocol-http.js +100 -45
- package/lib/protocol-http.js.map +1 -1
- package/lib/protocol-http.test.js +130 -0
- package/lib/protocol-http.test.js.map +1 -1
- package/lib/protocol-rest.d.ts +18 -1
- package/lib/protocol-rest.d.ts.map +1 -1
- package/lib/protocol-rest.js +24 -4
- package/lib/protocol-rest.js.map +1 -1
- package/lib/query.d.ts +29 -0
- package/lib/query.d.ts.map +1 -0
- package/lib/query.js +27 -0
- package/lib/query.js.map +1 -0
- package/lib/response-validation.d.ts +67 -0
- package/lib/response-validation.d.ts.map +1 -0
- package/lib/response-validation.js +76 -0
- package/lib/response-validation.js.map +1 -0
- package/lib/response-validation.test.d.ts +2 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +67 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/testing.d.ts +39 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +41 -0
- package/lib/testing.js.map +1 -0
- package/lib/trait.d.ts +29 -9
- package/lib/trait.d.ts.map +1 -1
- package/lib/trait.js +8 -0
- package/lib/trait.js.map +1 -1
- package/lib/xml.d.ts +44 -0
- package/lib/xml.d.ts.map +1 -0
- package/lib/xml.js +315 -0
- package/lib/xml.js.map +1 -0
- package/lib/xml.test.d.ts +2 -0
- package/lib/xml.test.d.ts.map +1 -0
- package/lib/xml.test.js +211 -0
- package/lib/xml.test.js.map +1 -0
- package/package.json +4 -4
- package/src/api.ts +4 -4
- package/src/category.ts +1 -1
- package/src/codegen/boolean-string-enums.test.ts +147 -0
- package/src/codegen/boolean-string-enums.ts +84 -27
- package/src/codegen/cli.ts +2 -2
- package/src/codegen/generator.test.ts +151 -0
- package/src/codegen/generator.ts +70 -12
- package/src/codegen/graphql-client.test.ts +116 -283
- package/src/codegen/graphql-client.ts +326 -169
- package/src/codegen/openapi-binary.test.ts +98 -0
- package/src/codegen/openapi.ts +26 -4
- package/src/codegen/patches.test.ts +66 -1
- package/src/codegen/patches.ts +84 -4
- package/src/codegen/untagged-unions.test.ts +89 -0
- package/src/errors.ts +5 -2
- package/src/fixtures/xml/upstream-validator.json +397 -0
- package/src/graphql.test.ts +174 -948
- package/src/graphql.ts +1592 -1167
- package/src/graphql.types.ts +92 -163
- package/src/index.ts +26 -0
- package/src/pagination.test.ts +191 -0
- package/src/pagination.ts +35 -25
- package/src/protocol-http.test.ts +163 -0
- package/src/protocol-http.ts +122 -46
- package/src/protocol-rest.ts +62 -13
- package/src/query.ts +39 -0
- package/src/response-validation.test.ts +95 -0
- package/src/response-validation.ts +116 -0
- package/src/testing.ts +78 -0
- package/src/trait.ts +39 -8
- package/src/xml.test.ts +269 -0
- package/src/xml.ts +339 -0
- package/lib/graphql.fixture.d.ts +0 -249
- package/lib/graphql.fixture.d.ts.map +0 -1
- package/lib/graphql.fixture.js +0 -240
- package/lib/graphql.fixture.js.map +0 -1
- package/src/graphql.fixture.ts +0 -371
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as Redacted from "effect/Redacted";
|
|
2
3
|
import { buildRequest, mapKeys } from "./protocol-http.ts";
|
|
4
|
+
import { SensitiveValue, wrapSensitive } from "./protocol-rest.ts";
|
|
3
5
|
import * as S from "./schema.ts";
|
|
4
6
|
import * as T from "./trait.ts";
|
|
5
7
|
|
|
@@ -17,6 +19,14 @@ const JsonInput = S.Struct({
|
|
|
17
19
|
flags: S.optional(
|
|
18
20
|
S.Array(S.Boolean).pipe(T.Body("flags"), T.StringEncoded()),
|
|
19
21
|
),
|
|
22
|
+
profile: S.optional(
|
|
23
|
+
S.Struct({
|
|
24
|
+
nested: S.optional(
|
|
25
|
+
S.Boolean.pipe(T.Body("nested_flag"), T.StringEncoded()),
|
|
26
|
+
),
|
|
27
|
+
plain: S.optional(S.Boolean.pipe(T.Body("plain_flag"))),
|
|
28
|
+
}).pipe(T.Body("profile")),
|
|
29
|
+
),
|
|
20
30
|
}).pipe(T.Http({ method: "POST", uri: "/things" }));
|
|
21
31
|
|
|
22
32
|
const jsonBodyOf = (input: unknown): unknown => {
|
|
@@ -47,12 +57,165 @@ describe("StringEncoded members", () => {
|
|
|
47
57
|
});
|
|
48
58
|
});
|
|
49
59
|
|
|
60
|
+
test("a member nested in a body struct stringifies too", () => {
|
|
61
|
+
expect(jsonBodyOf({ profile: { nested: true, plain: true } })).toEqual({
|
|
62
|
+
profile: { nested_flag: "true", plain_flag: true },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
50
66
|
test("null stays null and an omitted member stays omitted", () => {
|
|
51
67
|
expect(jsonBodyOf({ nullable: null })).toEqual({ nullable: null });
|
|
52
68
|
expect(jsonBodyOf({})).toEqual({});
|
|
53
69
|
});
|
|
54
70
|
});
|
|
55
71
|
|
|
72
|
+
describe("URI label encoding", () => {
|
|
73
|
+
const input = (preserve?: string) =>
|
|
74
|
+
S.Struct({
|
|
75
|
+
scope:
|
|
76
|
+
preserve === undefined
|
|
77
|
+
? S.String.pipe(T.Label())
|
|
78
|
+
: S.String.pipe(T.Label(), T.LabelEncoding({ preserve })),
|
|
79
|
+
}).pipe(T.Http({ method: "POST", uri: "/runtime/{scope}" }));
|
|
80
|
+
const url = (scope: string, preserve?: string) =>
|
|
81
|
+
buildRequest({
|
|
82
|
+
input: { scope },
|
|
83
|
+
inputAst: input(preserve).ast,
|
|
84
|
+
baseUrl: "https://example.test",
|
|
85
|
+
}).url;
|
|
86
|
+
|
|
87
|
+
test("keeps the existing escaping unless a model opts in", () => {
|
|
88
|
+
expect(url("__KV:abc")).toBe("https://example.test/runtime/__KV%3Aabc");
|
|
89
|
+
expect(url("__KV:abc", ":")).toBe("https://example.test/runtime/__KV:abc");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("preserves only modeled pchar delimiters, not separators or escapes", () => {
|
|
93
|
+
expect(url("__KV:a/b?c#d%3Aé", ":")).toBe(
|
|
94
|
+
"https://example.test/runtime/__KV:a%2Fb%3Fc%23d%253A%C3%A9",
|
|
95
|
+
);
|
|
96
|
+
expect(url("$&:@", "$&:")).toBe("https://example.test/runtime/$&:%40");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("refuses unsafe preservation rules", () => {
|
|
100
|
+
for (const preserve of ["/", "?", "#", "%", "\\r", "[", "]"]) {
|
|
101
|
+
expect(() => T.LabelEncoding({ preserve })).toThrow(TypeError);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("multipart binary parts", () => {
|
|
107
|
+
const schema = S.Struct({ zip: S.Unknown, environment: S.String }).pipe(
|
|
108
|
+
T.Http({ method: "POST", uri: "/deployments", contentType: "multipart" }),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
test("preserves typed-array slices and an already JSON-encoded environment", async () => {
|
|
112
|
+
const input = new Uint8Array([99, 80, 75, 0, 255, 99]).subarray(1, 5);
|
|
113
|
+
const environment = JSON.stringify({ SECRET: "value", REMOVED: "" });
|
|
114
|
+
const request = buildRequest({
|
|
115
|
+
input: { zip: input, environment },
|
|
116
|
+
inputAst: schema.ast,
|
|
117
|
+
baseUrl: "https://example.test",
|
|
118
|
+
});
|
|
119
|
+
if (request.body._tag !== "FormData") throw new Error("Expected multipart");
|
|
120
|
+
const part = request.body.formData.get("zip");
|
|
121
|
+
expect(part).toBeInstanceOf(File);
|
|
122
|
+
expect(new Uint8Array(await (part as File).arrayBuffer())).toEqual(input);
|
|
123
|
+
expect(request.body.formData.getAll("environment")).toEqual([environment]);
|
|
124
|
+
const wire = new Request(request.url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: request.body.formData,
|
|
127
|
+
});
|
|
128
|
+
expect(wire.headers.get("content-type")).toContain(
|
|
129
|
+
"multipart/form-data; boundary=",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("preserves File names and ArrayBuffer bytes", async () => {
|
|
134
|
+
for (const zip of [
|
|
135
|
+
new File([new Uint8Array([80, 75, 255])], "bundle.zip"),
|
|
136
|
+
new Uint8Array([80, 75, 255]).buffer,
|
|
137
|
+
]) {
|
|
138
|
+
const request = buildRequest({
|
|
139
|
+
input: { zip, environment: "{}" },
|
|
140
|
+
inputAst: schema.ast,
|
|
141
|
+
baseUrl: "https://example.test",
|
|
142
|
+
});
|
|
143
|
+
if (request.body._tag !== "FormData")
|
|
144
|
+
throw new Error("Expected multipart");
|
|
145
|
+
const part = request.body.formData.get("zip") as File;
|
|
146
|
+
expect(part.name).toBe(zip instanceof File ? "bundle.zip" : "zip");
|
|
147
|
+
expect(new Uint8Array(await part.arrayBuffer())).toEqual(
|
|
148
|
+
new Uint8Array([80, 75, 255]),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("sensitive union responses", () => {
|
|
155
|
+
const schema = S.suspend(() =>
|
|
156
|
+
S.Union([
|
|
157
|
+
S.Struct({
|
|
158
|
+
type: S.Literal("standard"),
|
|
159
|
+
password: S.String.pipe(SensitiveValue()),
|
|
160
|
+
}),
|
|
161
|
+
S.Struct({
|
|
162
|
+
type: S.Literal("token"),
|
|
163
|
+
token: S.String.pipe(SensitiveValue()),
|
|
164
|
+
}),
|
|
165
|
+
S.Struct({ type: S.Literal("shared") }),
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
test("all arms redact their secrets, even in partial or ambiguous responses", () => {
|
|
170
|
+
for (const value of [
|
|
171
|
+
{ type: "standard", password: "fixture-password" },
|
|
172
|
+
{ type: "token", token: "fixture-token" },
|
|
173
|
+
{ password: "fixture-password", token: "fixture-token" },
|
|
174
|
+
]) {
|
|
175
|
+
const wrapped = wrapSensitive(schema.ast, value);
|
|
176
|
+
expect(wrapped).toEqual(
|
|
177
|
+
Object.fromEntries(
|
|
178
|
+
Object.entries(value).map(([key, value]) => [
|
|
179
|
+
key,
|
|
180
|
+
key === "type" ? value : Redacted.make(value),
|
|
181
|
+
]),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
expect(JSON.stringify(wrapped)).not.toContain("fixture-password");
|
|
185
|
+
expect(JSON.stringify(wrapped)).not.toContain("fixture-token");
|
|
186
|
+
expect(wrapSensitive(schema.ast, wrapped)).toEqual(wrapped);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("nested arrays and optional nullable unions preserve nonsecret values", () => {
|
|
191
|
+
const nested = S.Struct({
|
|
192
|
+
provider: S.optional(S.NullOr(schema)),
|
|
193
|
+
providers: S.Array(schema),
|
|
194
|
+
});
|
|
195
|
+
for (const value of [
|
|
196
|
+
{},
|
|
197
|
+
{ provider: null },
|
|
198
|
+
{ provider: { type: "shared" } },
|
|
199
|
+
]) {
|
|
200
|
+
expect(
|
|
201
|
+
wrapSensitive(nested.ast, {
|
|
202
|
+
...value,
|
|
203
|
+
providers: [
|
|
204
|
+
{ type: "standard", password: "fixture-password" },
|
|
205
|
+
{ type: "shared" },
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
208
|
+
).toEqual({
|
|
209
|
+
...value,
|
|
210
|
+
providers: [
|
|
211
|
+
{ type: "standard", password: Redacted.make("fixture-password") },
|
|
212
|
+
{ type: "shared" },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
56
219
|
describe("UnionCases decoding", () => {
|
|
57
220
|
const cases = [
|
|
58
221
|
["id", "type", "zoneName"],
|
package/src/protocol-http.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* {@link matchTypedError}).
|
|
10
10
|
*/
|
|
11
11
|
import type * as AST from "effect/SchemaAST";
|
|
12
|
-
import * as HttpBody from "effect/
|
|
13
|
-
import * as HttpClientRequest from "effect/
|
|
12
|
+
import * as HttpBody from "effect/http/HttpBody";
|
|
13
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
14
14
|
import {
|
|
15
15
|
bodySymbol,
|
|
16
16
|
deepQuerySymbol,
|
|
@@ -21,10 +21,12 @@ import {
|
|
|
21
21
|
httpSymbol,
|
|
22
22
|
keyDictionarySymbol,
|
|
23
23
|
labelSymbol,
|
|
24
|
+
labelEncodingSymbol,
|
|
24
25
|
querySymbol,
|
|
25
26
|
stringEncodedSymbol,
|
|
26
27
|
unionCasesSymbol,
|
|
27
28
|
type ErrorMatcher,
|
|
29
|
+
type ErrorTextMatcher,
|
|
28
30
|
type HttpTrait,
|
|
29
31
|
type KeyDictionaryEntries,
|
|
30
32
|
type UnionDiscriminator,
|
|
@@ -149,6 +151,18 @@ export const mapKeysByDictionary = (
|
|
|
149
151
|
* TS-cased content still reaches the wire in wire case. Content with no
|
|
150
152
|
* dictionary in scope passes through verbatim.
|
|
151
153
|
*/
|
|
154
|
+
/**
|
|
155
|
+
* Value form for a `StringEncoded()` member: the string spelling of the
|
|
156
|
+
* value, element-wise for lists. `null` stays `null` — an API that models a
|
|
157
|
+
* flag as `"true" | "false"` still means "unset" by null, not `"null"`.
|
|
158
|
+
*/
|
|
159
|
+
const stringEncode = (value: unknown): unknown =>
|
|
160
|
+
value === null
|
|
161
|
+
? null
|
|
162
|
+
: Array.isArray(value)
|
|
163
|
+
? value.map(stringEncode)
|
|
164
|
+
: String(value);
|
|
165
|
+
|
|
152
166
|
export const mapKeys = (
|
|
153
167
|
ast: AST.AST,
|
|
154
168
|
value: unknown,
|
|
@@ -294,7 +308,13 @@ export const mapKeys = (
|
|
|
294
308
|
consumed.add(from);
|
|
295
309
|
const v = (value as Record<string, unknown>)[from];
|
|
296
310
|
if (v === undefined) continue;
|
|
297
|
-
|
|
311
|
+
// A `StringEncoded()` member nested in a body struct (e.g. Azure's
|
|
312
|
+
// `hardwareProfile.dynamicMemoryEnabled`) stringifies here — the
|
|
313
|
+
// top-level pass in `buildRequest` only sees the outermost member.
|
|
314
|
+
out[to] =
|
|
315
|
+
direction === "encode" && hasPropAnn(p, stringEncodedSymbol)
|
|
316
|
+
? stringEncode(v)
|
|
317
|
+
: mapKeys(p.type, v, direction, dict);
|
|
298
318
|
}
|
|
299
319
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
300
320
|
if (consumed.has(k) || v === undefined) continue;
|
|
@@ -334,18 +354,6 @@ const BODYLESS = new Set(["GET", "HEAD"]);
|
|
|
334
354
|
* filter matching nothing — the call "succeeds" with zero results and the
|
|
335
355
|
* bug is invisible to the caller.
|
|
336
356
|
*/
|
|
337
|
-
/**
|
|
338
|
-
* Value form for a `StringEncoded()` member: the string spelling of the
|
|
339
|
-
* value, element-wise for lists. `null` stays `null` — an API that models a
|
|
340
|
-
* flag as `"true" | "false"` still means "unset" by null, not `"null"`.
|
|
341
|
-
*/
|
|
342
|
-
const stringEncode = (value: unknown): unknown =>
|
|
343
|
-
value === null
|
|
344
|
-
? null
|
|
345
|
-
: Array.isArray(value)
|
|
346
|
-
? value.map(stringEncode)
|
|
347
|
-
: String(value);
|
|
348
|
-
|
|
349
357
|
const appendQuery = (
|
|
350
358
|
query: URLSearchParams,
|
|
351
359
|
name: string,
|
|
@@ -479,7 +487,18 @@ export const buildRequest = ({
|
|
|
479
487
|
|
|
480
488
|
if (hasPropAnn(prop, labelSymbol)) {
|
|
481
489
|
const token = nameOf(prop, labelSymbol);
|
|
482
|
-
|
|
490
|
+
const preserve = getPropAnn(prop, labelEncodingSymbol);
|
|
491
|
+
const encoded = encodeURIComponent(String(value));
|
|
492
|
+
const label =
|
|
493
|
+
typeof preserve === "string"
|
|
494
|
+
? encoded.replace(/%[0-9A-F]{2}/g, (escape) => {
|
|
495
|
+
const character = String.fromCharCode(
|
|
496
|
+
Number.parseInt(escape.slice(1), 16),
|
|
497
|
+
);
|
|
498
|
+
return preserve.includes(character) ? character : escape;
|
|
499
|
+
})
|
|
500
|
+
: encoded;
|
|
501
|
+
uri = uri.replace(`{${token}}`, () => label);
|
|
483
502
|
} else if (hasPropAnn(prop, headerSymbol)) {
|
|
484
503
|
const hName = nameOf(prop, headerSymbol).toLowerCase();
|
|
485
504
|
const hVal = String(value);
|
|
@@ -536,12 +555,7 @@ export const buildRequest = ({
|
|
|
536
555
|
const qs = query.toString();
|
|
537
556
|
const url = `${baseUrl}${uri}${qs ? `?${qs}` : ""}`;
|
|
538
557
|
if (process.env.DISTILLED_DEBUG_HTTP) {
|
|
539
|
-
console.error(
|
|
540
|
-
`[distilled] ${http.method} ${url}` +
|
|
541
|
-
(Object.keys(body).length
|
|
542
|
-
? ` body=${JSON.stringify(body).slice(0, 400)}`
|
|
543
|
-
: ""),
|
|
544
|
-
);
|
|
558
|
+
console.error(`[distilled] -> ${http.method} ${http.uri}`);
|
|
545
559
|
}
|
|
546
560
|
|
|
547
561
|
let request = HttpClientRequest.make(http.method)(url).pipe(
|
|
@@ -552,8 +566,7 @@ export const buildRequest = ({
|
|
|
552
566
|
// JSON-encoded under their wire name), each file appends under its own
|
|
553
567
|
// filename. A whole-body member (T.HttpBody) that is a record of files
|
|
554
568
|
// becomes one part per entry (e.g. asset upload: { <hash>: File }).
|
|
555
|
-
//
|
|
556
|
-
// appended, object → JSON string, primitive → string.
|
|
569
|
+
// Files and byte buffers become binary parts; objects become JSON parts.
|
|
557
570
|
const form = new FormData();
|
|
558
571
|
const parts =
|
|
559
572
|
rawBody !== undefined && typeof rawBody === "object"
|
|
@@ -565,6 +578,10 @@ export const buildRequest = ({
|
|
|
565
578
|
if (value === undefined || value === null) continue;
|
|
566
579
|
if (isFileOrBlob(value)) {
|
|
567
580
|
form.append(key, value, value instanceof File ? value.name : key);
|
|
581
|
+
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
582
|
+
const bytes =
|
|
583
|
+
value instanceof Uint8Array ? new Uint8Array(value).buffer : value;
|
|
584
|
+
form.append(key, new Blob([bytes]), key);
|
|
568
585
|
} else if (
|
|
569
586
|
Array.isArray(value) &&
|
|
570
587
|
value.length > 0 &&
|
|
@@ -666,59 +683,116 @@ export const buildRequest = ({
|
|
|
666
683
|
|
|
667
684
|
//#region Typed error matching
|
|
668
685
|
|
|
669
|
-
/**
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
686
|
+
/** Parsed response metadata supplied by a protocol after it detects a failure. */
|
|
687
|
+
export interface ErrorResponse {
|
|
688
|
+
readonly body?: unknown;
|
|
689
|
+
readonly headers?: Readonly<Record<string, string | undefined>>;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const matchesText = (matcher: ErrorTextMatcher, value: unknown): boolean => {
|
|
693
|
+
if (typeof value !== "string") return false;
|
|
694
|
+
if (typeof matcher === "string") return matcher === value;
|
|
695
|
+
const { includes, matches } = matcher;
|
|
696
|
+
return (
|
|
697
|
+
(includes !== undefined || matches !== undefined) &&
|
|
698
|
+
(includes === undefined || value.includes(includes)) &&
|
|
699
|
+
(matches === undefined || new RegExp(matches).test(value))
|
|
700
|
+
);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// Own properties only: inherited properties are not JSON response members.
|
|
704
|
+
const atPointer = (body: unknown, pointer: string): unknown => {
|
|
705
|
+
if (pointer === "") return body;
|
|
706
|
+
if (!pointer.startsWith("/") || /~(?![01])/u.test(pointer)) return undefined;
|
|
707
|
+
let value = body;
|
|
708
|
+
for (const segment of pointer.slice(1).split("/")) {
|
|
709
|
+
const key = segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
710
|
+
if (
|
|
711
|
+
value === null ||
|
|
712
|
+
typeof value !== "object" ||
|
|
713
|
+
!Object.hasOwn(value, key) ||
|
|
714
|
+
(Array.isArray(value) && !/^(0|[1-9][0-9]*)$/.test(key))
|
|
715
|
+
)
|
|
716
|
+
return undefined;
|
|
717
|
+
value = (value as Record<string, unknown>)[key];
|
|
718
|
+
}
|
|
719
|
+
return value;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const matcherSpecificity = (m: ErrorMatcher): number =>
|
|
723
|
+
(m.code !== undefined ? 1 : 0) +
|
|
724
|
+
(m.status !== undefined ? 1 : 0) +
|
|
725
|
+
(m.message !== undefined ? 1 : 0) +
|
|
726
|
+
(m.body === undefined ? 0 : Object.keys(m.body).length) +
|
|
727
|
+
(m.headers === undefined ? 0 : Object.keys(m.headers).length);
|
|
728
|
+
|
|
729
|
+
/** Every supplied constraint must match; an unconstrained matcher matches nothing. */
|
|
674
730
|
export const matchesExpression = (
|
|
675
731
|
m: ErrorMatcher,
|
|
676
732
|
code: number | undefined,
|
|
677
733
|
status: number,
|
|
678
734
|
message: string,
|
|
735
|
+
response: ErrorResponse = {},
|
|
679
736
|
): boolean => {
|
|
680
|
-
if (
|
|
737
|
+
if (
|
|
738
|
+
m.code === undefined &&
|
|
739
|
+
m.status === undefined &&
|
|
740
|
+
m.message === undefined &&
|
|
741
|
+
matcherSpecificity(m) === 0
|
|
742
|
+
)
|
|
681
743
|
return false;
|
|
682
744
|
if (m.code !== undefined && m.code !== code) return false;
|
|
683
745
|
if (m.status !== undefined && m.status !== status) return false;
|
|
684
|
-
if (m.message !== undefined)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return false;
|
|
746
|
+
if (m.message !== undefined && !matchesText(m.message, message)) return false;
|
|
747
|
+
if (m.body !== undefined) {
|
|
748
|
+
for (const [pointer, expected] of Object.entries(m.body)) {
|
|
749
|
+
const actual = atPointer(response.body, pointer);
|
|
750
|
+
if (
|
|
751
|
+
typeof expected === "string" ||
|
|
752
|
+
(expected !== null && typeof expected === "object")
|
|
753
|
+
) {
|
|
754
|
+
if (!matchesText(expected, actual)) return false;
|
|
755
|
+
} else if (actual !== expected) return false;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (m.headers !== undefined) {
|
|
759
|
+
for (const [name, expected] of Object.entries(m.headers)) {
|
|
760
|
+
if (response.headers === undefined) return false;
|
|
761
|
+
const actual = Object.entries(response.headers).find(
|
|
762
|
+
([key]) => key.toLowerCase() === name.toLowerCase(),
|
|
763
|
+
)?.[1];
|
|
764
|
+
if (!matchesText(expected, actual)) return false;
|
|
693
765
|
}
|
|
694
766
|
}
|
|
695
767
|
return true;
|
|
696
768
|
};
|
|
697
769
|
|
|
698
|
-
const matcherSpecificity = (m: ErrorMatcher): number =>
|
|
699
|
-
(m.code !== undefined ? 1 : 0) +
|
|
700
|
-
(m.status !== undefined ? 1 : 0) +
|
|
701
|
-
(m.message !== undefined ? 1 : 0);
|
|
702
|
-
|
|
703
770
|
/**
|
|
704
771
|
* Pick the operation's typed error class for a failed response: among all
|
|
705
772
|
* declared classes whose matchers (see `applyErrorMatchers`) match the wire
|
|
706
773
|
* failure, the most specific matcher wins (ties break by declaration order).
|
|
774
|
+
* Empty error lists use an HTTP-status message so body/header-only failures can
|
|
775
|
+
* still match. The constructor receives the raw body and headers as well as
|
|
776
|
+
* code/message; error schemas can declare these fields to expose them to callers.
|
|
707
777
|
*/
|
|
708
778
|
export const matchTypedError = (
|
|
709
779
|
errorClasses: ReadonlyArray<unknown>,
|
|
710
780
|
status: number,
|
|
711
781
|
errors: ReadonlyArray<{ code?: number; message: string }>,
|
|
782
|
+
response: ErrorResponse = {},
|
|
712
783
|
): unknown | undefined => {
|
|
713
784
|
let best:
|
|
714
785
|
| { cls: unknown; specificity: number; code?: number; message: string }
|
|
715
786
|
| undefined;
|
|
787
|
+
const wireErrors =
|
|
788
|
+
errors.length > 0 ? errors : [{ message: `HTTP ${status}` }];
|
|
716
789
|
for (const cls of errorClasses) {
|
|
717
790
|
const matchers = getErrorMatchers(cls);
|
|
718
791
|
if (!matchers) continue;
|
|
719
792
|
for (const m of matchers) {
|
|
720
|
-
for (const e of
|
|
721
|
-
if (!matchesExpression(m, e.code, status, e.message))
|
|
793
|
+
for (const e of wireErrors) {
|
|
794
|
+
if (!matchesExpression(m, e.code, status, e.message, response))
|
|
795
|
+
continue;
|
|
722
796
|
const specificity = matcherSpecificity(m);
|
|
723
797
|
if (!best || specificity > best.specificity) {
|
|
724
798
|
best = { cls, specificity, code: e.code, message: e.message };
|
|
@@ -730,6 +804,8 @@ export const matchTypedError = (
|
|
|
730
804
|
return new (best.cls as new (args: any) => unknown)({
|
|
731
805
|
code: best.code ?? 0,
|
|
732
806
|
message: best.message,
|
|
807
|
+
body: response.body,
|
|
808
|
+
headers: response.headers,
|
|
733
809
|
});
|
|
734
810
|
};
|
|
735
811
|
//#endregion
|
package/src/protocol-rest.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* values first)
|
|
13
13
|
*
|
|
14
14
|
* response: 2xx JSON → optional `transformResponse` → recursive wire→TS
|
|
15
|
-
* key mapping (`mapKeys`) →
|
|
15
|
+
* key mapping (`mapKeys`) → in strict mode, a schema check
|
|
16
|
+
* failing with the provider's `parseError` (see
|
|
17
|
+
* `core/response-validation`) → `Redacted` wrapping of members
|
|
16
18
|
* marked with {@link SensitiveValue}; non-2xx → typed error:
|
|
17
19
|
* per-op matcher classes (`matchTypedError`), then the status
|
|
18
20
|
* map (default `HTTP_STATUS_MAP`), then an `InternalServerError`
|
|
@@ -27,8 +29,8 @@ import * as Effect from "effect/Effect";
|
|
|
27
29
|
import * as Layer from "effect/Layer";
|
|
28
30
|
import * as Redacted from "effect/Redacted";
|
|
29
31
|
import type * as AST from "effect/SchemaAST";
|
|
30
|
-
import type * as HttpClientRequest from "effect/
|
|
31
|
-
import type * as HttpClientResponse from "effect/
|
|
32
|
+
import type * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
33
|
+
import type * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
32
34
|
import * as API from "./api.ts";
|
|
33
35
|
import { httpSymbol, makeAnnotation, type HttpTrait } from "./trait.ts";
|
|
34
36
|
import {
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
} from "./protocol-http.ts";
|
|
44
46
|
import { HTTP_STATUS_MAP, InternalServerError } from "./errors.ts";
|
|
45
47
|
import { parseRetryAfterForStatus } from "./retry-after.ts";
|
|
48
|
+
import { validateResponse } from "./response-validation.ts";
|
|
46
49
|
|
|
47
50
|
// =============================================================================
|
|
48
51
|
// Traits
|
|
@@ -83,6 +86,13 @@ export const rawResponseRootSymbol = Symbol.for(
|
|
|
83
86
|
export const RawResponseRoot = () =>
|
|
84
87
|
makeAnnotation(rawResponseRootSymbol, true);
|
|
85
88
|
|
|
89
|
+
export const binaryResponseSymbol = Symbol.for(
|
|
90
|
+
"@distilled.cloud/core/binary-response",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/** Decode a successful raw binary response as bytes, without text conversion. */
|
|
94
|
+
export const BinaryResponse = () => makeAnnotation(binaryResponseSymbol, true);
|
|
95
|
+
|
|
86
96
|
// =============================================================================
|
|
87
97
|
// Value helpers
|
|
88
98
|
// =============================================================================
|
|
@@ -125,6 +135,13 @@ export const wrapSensitive = (ast: AST.AST, value: unknown): unknown => {
|
|
|
125
135
|
return value;
|
|
126
136
|
}
|
|
127
137
|
const node = resolveNode(ast);
|
|
138
|
+
if (node._tag === "Union") {
|
|
139
|
+
// Redact every possible sensitive member, including in partial responses.
|
|
140
|
+
return node.types.reduce<unknown>(
|
|
141
|
+
(redacted, arm) => wrapSensitive(arm, redacted),
|
|
142
|
+
value,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
128
145
|
if (node._tag === "Arrays") {
|
|
129
146
|
if (!Array.isArray(value)) return value;
|
|
130
147
|
const elem = (node as any).rest?.[0] as AST.AST | undefined;
|
|
@@ -169,6 +186,12 @@ export interface RestErrorInfo {
|
|
|
169
186
|
readonly headers: Record<string, string | undefined>;
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
/** A 2xx body that failed strict validation, for the `parseError` option. */
|
|
190
|
+
export interface RestParseErrorInfo {
|
|
191
|
+
readonly body: unknown;
|
|
192
|
+
readonly cause: unknown;
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
export interface RestErrorEnvelope {
|
|
173
196
|
readonly code?: string | number;
|
|
174
197
|
readonly message?: string;
|
|
@@ -215,6 +238,13 @@ export interface RestProtocolOptions<C> {
|
|
|
215
238
|
readonly statusMap?: Readonly<Record<number, new (args: any) => any>>;
|
|
216
239
|
/** Fallback error for failures nothing else matched. */
|
|
217
240
|
readonly unknownError: (info: RestErrorInfo) => unknown;
|
|
241
|
+
/**
|
|
242
|
+
* The SDK's `<Sdk>ParseError`, raised when a 2xx body does not match the
|
|
243
|
+
* operation's output schema in strict mode (see
|
|
244
|
+
* `core/response-validation`). `body` is the parsed JSON, or the raw text
|
|
245
|
+
* when the body wasn't JSON; `cause` is the schema error.
|
|
246
|
+
*/
|
|
247
|
+
readonly parseError: (info: RestParseErrorInfo) => unknown;
|
|
218
248
|
/** Transform the parsed 2xx JSON before decoding (e.g. stripNulls). */
|
|
219
249
|
readonly transformResponse?: (body: unknown) => unknown;
|
|
220
250
|
/** Passed through to `buildRequest` (member-header transforms). */
|
|
@@ -301,12 +331,18 @@ export const makeRestProtocol = <C>(
|
|
|
301
331
|
readonly errors: ReadonlyArray<unknown>;
|
|
302
332
|
}) =>
|
|
303
333
|
Effect.gen(function* () {
|
|
334
|
+
if (
|
|
335
|
+
response.status >= 200 &&
|
|
336
|
+
response.status < 300 &&
|
|
337
|
+
getAnn(outputAst, binaryResponseSymbol) !== undefined
|
|
338
|
+
) {
|
|
339
|
+
const bytes = yield* response.arrayBuffer.pipe(Effect.orDie);
|
|
340
|
+
return new Uint8Array(bytes);
|
|
341
|
+
}
|
|
304
342
|
// Read as text and parse tolerantly — error pages are often non-JSON.
|
|
305
343
|
const text = (yield* response.text.pipe(Effect.orDie)) ?? "";
|
|
306
344
|
if (process.env.DISTILLED_DEBUG_HTTP) {
|
|
307
|
-
console.error(
|
|
308
|
-
`[distilled] <- ${response.status} ${text.slice(0, 400)}`,
|
|
309
|
-
);
|
|
345
|
+
console.error(`[distilled] <- ${response.status}`);
|
|
310
346
|
}
|
|
311
347
|
let json: unknown;
|
|
312
348
|
let nonJson = false;
|
|
@@ -327,12 +363,17 @@ export const makeRestProtocol = <C>(
|
|
|
327
363
|
(nonJson && text.trim() ? text.trim() : `HTTP ${status}`);
|
|
328
364
|
|
|
329
365
|
// 1. Per-operation typed error (matcher metadata on the class).
|
|
330
|
-
const typed = matchTypedError(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
366
|
+
const typed = matchTypedError(
|
|
367
|
+
errorClasses,
|
|
368
|
+
status,
|
|
369
|
+
[
|
|
370
|
+
{
|
|
371
|
+
code: typeof env.code === "number" ? env.code : undefined,
|
|
372
|
+
message,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
{ body: nonJson ? text : json, headers },
|
|
376
|
+
);
|
|
336
377
|
if (typed !== undefined) return yield* fail(typed);
|
|
337
378
|
|
|
338
379
|
// 2. Status-mapped class (retryAfter only stamps on retryable
|
|
@@ -373,9 +414,17 @@ export const makeRestProtocol = <C>(
|
|
|
373
414
|
// 2xx: the response body IS the payload (no envelope). Wire→TS key
|
|
374
415
|
// mapping is schema-driven; `RawResponseRoot` responses are the body
|
|
375
416
|
// verbatim (mapKeys handles arrays/scalars structurally either way).
|
|
417
|
+
// Strict mode (core/response-validation) checks the mapped body against
|
|
418
|
+
// the output schema — a non-JSON body reaches it as a string and fails
|
|
419
|
+
// there unless the operation's output is itself a string.
|
|
376
420
|
let body: unknown = nonJson ? text : (json ?? {});
|
|
377
421
|
if (options.transformResponse) body = options.transformResponse(body);
|
|
378
|
-
|
|
422
|
+
const mapped = yield* validateResponse(
|
|
423
|
+
outputAst,
|
|
424
|
+
mapKeys(outputAst, body, "decode"),
|
|
425
|
+
(cause) => options.parseError({ body: nonJson ? text : json, cause }),
|
|
426
|
+
).pipe(Effect.catch(fail));
|
|
427
|
+
return wrapSensitive(outputAst, mapped);
|
|
379
428
|
});
|
|
380
429
|
|
|
381
430
|
return Layer.succeed(
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query combinators for GraphQL SDKs.
|
|
3
|
+
*
|
|
4
|
+
* Import this module next to a generated client:
|
|
5
|
+
*
|
|
6
|
+
* import { Query } from "@distilled.cloud/core/query"
|
|
7
|
+
* import { Railway } from "@distilled.cloud/railway"
|
|
8
|
+
*
|
|
9
|
+
* `Query.fn` takes a **builder** (plain function, not `Effect.gen`) that
|
|
10
|
+
* returns a plan of `Query<Value>` and `Effect` values. It walks Map /
|
|
11
|
+
* Filter / FlatMap / Of, posts one GraphQL document, then interprets the
|
|
12
|
+
* plan. Roots (`Railway.me`) come from the generated SDK; they are not
|
|
13
|
+
* combinators.
|
|
14
|
+
*
|
|
15
|
+
* @see {@link file://./graphql.ts} for the algebra, compiler, and transport.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
filterQuery,
|
|
19
|
+
flatMapQuery,
|
|
20
|
+
itemsQuery,
|
|
21
|
+
mapQuery,
|
|
22
|
+
ofQuery,
|
|
23
|
+
pagesQuery,
|
|
24
|
+
queryFn,
|
|
25
|
+
type Query as QueryType,
|
|
26
|
+
} from "./graphql.ts";
|
|
27
|
+
|
|
28
|
+
export type Query<Value, Error = never> = QueryType<Value, Error>;
|
|
29
|
+
export type { PlanError, QueryError, UnwrapPlan } from "./graphql.ts";
|
|
30
|
+
|
|
31
|
+
export const Query = {
|
|
32
|
+
fn: queryFn,
|
|
33
|
+
filter: filterQuery,
|
|
34
|
+
map: mapQuery,
|
|
35
|
+
flatMap: flatMapQuery,
|
|
36
|
+
of: ofQuery,
|
|
37
|
+
pages: pagesQuery,
|
|
38
|
+
items: itemsQuery,
|
|
39
|
+
};
|