@distilled.cloud/core 1.0.0-rc.11 → 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.
Files changed (149) hide show
  1. package/lib/api.d.ts +4 -4
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +1 -1
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.js +1 -1
  6. package/lib/category.js.map +1 -1
  7. package/lib/codegen/boolean-string-enums.d.ts +8 -8
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
  9. package/lib/codegen/boolean-string-enums.js +84 -27
  10. package/lib/codegen/boolean-string-enums.js.map +1 -1
  11. package/lib/codegen/boolean-string-enums.test.js +130 -0
  12. package/lib/codegen/boolean-string-enums.test.js.map +1 -1
  13. package/lib/codegen/cli.js +2 -2
  14. package/lib/codegen/cli.js.map +1 -1
  15. package/lib/codegen/generator.d.ts +20 -3
  16. package/lib/codegen/generator.d.ts.map +1 -1
  17. package/lib/codegen/generator.js +36 -9
  18. package/lib/codegen/generator.js.map +1 -1
  19. package/lib/codegen/generator.test.d.ts +2 -0
  20. package/lib/codegen/generator.test.d.ts.map +1 -0
  21. package/lib/codegen/generator.test.js +126 -0
  22. package/lib/codegen/generator.test.js.map +1 -0
  23. package/lib/codegen/graphql-client.d.ts +7 -1
  24. package/lib/codegen/graphql-client.d.ts.map +1 -1
  25. package/lib/codegen/graphql-client.js +262 -125
  26. package/lib/codegen/graphql-client.js.map +1 -1
  27. package/lib/codegen/graphql-client.test.js +104 -236
  28. package/lib/codegen/graphql-client.test.js.map +1 -1
  29. package/lib/codegen/openapi-binary.test.d.ts +2 -0
  30. package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
  31. package/lib/codegen/openapi-binary.test.js +67 -0
  32. package/lib/codegen/openapi-binary.test.js.map +1 -0
  33. package/lib/codegen/openapi.d.ts +7 -1
  34. package/lib/codegen/openapi.d.ts.map +1 -1
  35. package/lib/codegen/openapi.js +15 -4
  36. package/lib/codegen/openapi.js.map +1 -1
  37. package/lib/codegen/patches.d.ts +16 -1
  38. package/lib/codegen/patches.d.ts.map +1 -1
  39. package/lib/codegen/patches.js +70 -4
  40. package/lib/codegen/patches.js.map +1 -1
  41. package/lib/codegen/patches.test.js +48 -2
  42. package/lib/codegen/patches.test.js.map +1 -1
  43. package/lib/codegen/untagged-unions.test.d.ts +2 -0
  44. package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
  45. package/lib/codegen/untagged-unions.test.js +80 -0
  46. package/lib/codegen/untagged-unions.test.js.map +1 -0
  47. package/lib/errors.d.ts +2 -1
  48. package/lib/errors.d.ts.map +1 -1
  49. package/lib/errors.js +5 -2
  50. package/lib/errors.js.map +1 -1
  51. package/lib/fixtures/xml/upstream-validator.json +397 -0
  52. package/lib/graphql.d.ts +296 -205
  53. package/lib/graphql.d.ts.map +1 -1
  54. package/lib/graphql.js +968 -631
  55. package/lib/graphql.js.map +1 -1
  56. package/lib/graphql.test.js +134 -763
  57. package/lib/graphql.test.js.map +1 -1
  58. package/lib/graphql.types.d.ts +10 -0
  59. package/lib/graphql.types.d.ts.map +1 -1
  60. package/lib/graphql.types.js +4 -44
  61. package/lib/graphql.types.js.map +1 -1
  62. package/lib/index.d.ts +27 -0
  63. package/lib/index.d.ts.map +1 -0
  64. package/lib/index.js +27 -0
  65. package/lib/index.js.map +1 -0
  66. package/lib/pagination.d.ts +2 -1
  67. package/lib/pagination.d.ts.map +1 -1
  68. package/lib/pagination.js +24 -16
  69. package/lib/pagination.js.map +1 -1
  70. package/lib/pagination.test.d.ts +2 -0
  71. package/lib/pagination.test.d.ts.map +1 -0
  72. package/lib/pagination.test.js +134 -0
  73. package/lib/pagination.test.js.map +1 -0
  74. package/lib/protocol-http.d.ts +12 -17
  75. package/lib/protocol-http.d.ts.map +1 -1
  76. package/lib/protocol-http.js +100 -45
  77. package/lib/protocol-http.js.map +1 -1
  78. package/lib/protocol-http.test.js +130 -0
  79. package/lib/protocol-http.test.js.map +1 -1
  80. package/lib/protocol-rest.d.ts +18 -1
  81. package/lib/protocol-rest.d.ts.map +1 -1
  82. package/lib/protocol-rest.js +24 -4
  83. package/lib/protocol-rest.js.map +1 -1
  84. package/lib/query.d.ts +29 -0
  85. package/lib/query.d.ts.map +1 -0
  86. package/lib/query.js +27 -0
  87. package/lib/query.js.map +1 -0
  88. package/lib/response-validation.d.ts +67 -0
  89. package/lib/response-validation.d.ts.map +1 -0
  90. package/lib/response-validation.js +76 -0
  91. package/lib/response-validation.js.map +1 -0
  92. package/lib/response-validation.test.d.ts +2 -0
  93. package/lib/response-validation.test.d.ts.map +1 -0
  94. package/lib/response-validation.test.js +67 -0
  95. package/lib/response-validation.test.js.map +1 -0
  96. package/lib/testing.d.ts +39 -0
  97. package/lib/testing.d.ts.map +1 -0
  98. package/lib/testing.js +41 -0
  99. package/lib/testing.js.map +1 -0
  100. package/lib/trait.d.ts +29 -9
  101. package/lib/trait.d.ts.map +1 -1
  102. package/lib/trait.js +8 -0
  103. package/lib/trait.js.map +1 -1
  104. package/lib/xml.d.ts +44 -0
  105. package/lib/xml.d.ts.map +1 -0
  106. package/lib/xml.js +315 -0
  107. package/lib/xml.js.map +1 -0
  108. package/lib/xml.test.d.ts +2 -0
  109. package/lib/xml.test.d.ts.map +1 -0
  110. package/lib/xml.test.js +211 -0
  111. package/lib/xml.test.js.map +1 -0
  112. package/package.json +4 -4
  113. package/src/api.ts +4 -4
  114. package/src/category.ts +1 -1
  115. package/src/codegen/boolean-string-enums.test.ts +147 -0
  116. package/src/codegen/boolean-string-enums.ts +84 -27
  117. package/src/codegen/cli.ts +2 -2
  118. package/src/codegen/generator.test.ts +151 -0
  119. package/src/codegen/generator.ts +70 -12
  120. package/src/codegen/graphql-client.test.ts +116 -283
  121. package/src/codegen/graphql-client.ts +326 -169
  122. package/src/codegen/openapi-binary.test.ts +98 -0
  123. package/src/codegen/openapi.ts +26 -4
  124. package/src/codegen/patches.test.ts +66 -1
  125. package/src/codegen/patches.ts +84 -4
  126. package/src/codegen/untagged-unions.test.ts +89 -0
  127. package/src/errors.ts +5 -2
  128. package/src/fixtures/xml/upstream-validator.json +397 -0
  129. package/src/graphql.test.ts +174 -948
  130. package/src/graphql.ts +1592 -1167
  131. package/src/graphql.types.ts +92 -163
  132. package/src/index.ts +26 -0
  133. package/src/pagination.test.ts +191 -0
  134. package/src/pagination.ts +35 -25
  135. package/src/protocol-http.test.ts +163 -0
  136. package/src/protocol-http.ts +122 -46
  137. package/src/protocol-rest.ts +62 -13
  138. package/src/query.ts +39 -0
  139. package/src/response-validation.test.ts +95 -0
  140. package/src/response-validation.ts +116 -0
  141. package/src/testing.ts +78 -0
  142. package/src/trait.ts +39 -8
  143. package/src/xml.test.ts +269 -0
  144. package/src/xml.ts +339 -0
  145. package/lib/graphql.fixture.d.ts +0 -249
  146. package/lib/graphql.fixture.d.ts.map +0 -1
  147. package/lib/graphql.fixture.js +0 -240
  148. package/lib/graphql.fixture.js.map +0 -1
  149. 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"],
@@ -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/unstable/http/HttpBody";
13
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
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
- out[to] = mapKeys(p.type, v, direction, dict);
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
- uri = uri.replace(`{${token}}`, encodeURIComponent(String(value)));
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
- // File/Blob → binary part (filename = File.name), array of files → each
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
- * Whether one matcher matches one wire error: every present field must
671
- * match; a matcher (or a message object) with no constraints matches
672
- * nothing.
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 (m.code === undefined && m.status === undefined && m.message === undefined)
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
- if (typeof m.message === "string") {
686
- if (m.message !== message) return false;
687
- } else {
688
- const { includes, matches } = m.message;
689
- if (includes === undefined && matches === undefined) return false;
690
- if (includes !== undefined && !message.includes(includes)) return false;
691
- if (matches !== undefined && !new RegExp(matches).test(message))
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 errors) {
721
- if (!matchesExpression(m, e.code, status, e.message)) continue;
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
@@ -12,7 +12,9 @@
12
12
  * values first)
13
13
  *
14
14
  * response: 2xx JSON → optional `transformResponse` → recursive wire→TS
15
- * key mapping (`mapKeys`) → `Redacted` wrapping of members
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/unstable/http/HttpClientRequest";
31
- import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
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(errorClasses, status, [
331
- {
332
- code: typeof env.code === "number" ? env.code : undefined,
333
- message,
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
- return wrapSensitive(outputAst, mapKeys(outputAst, body, "decode"));
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
+ };