@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
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as Schema from "effect/Schema";
5
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
7
+ import * as API from "./api.ts";
8
+ import { makeRestProtocol } from "./protocol-rest.ts";
9
+ import * as ResponseValidation from "./response-validation.ts";
10
+
11
+ class TestParseError extends Schema.TaggedError<TestParseError>()(
12
+ "TestParseError",
13
+ { body: Schema.Unknown, cause: Schema.Unknown },
14
+ ) {}
15
+
16
+ class TestUnknownError extends Schema.TaggedError<TestUnknownError>()(
17
+ "TestUnknownError",
18
+ { message: Schema.String },
19
+ ) {}
20
+
21
+ const TestProtocol = makeRestProtocol<{}>({
22
+ credentials: Effect.succeed({}),
23
+ baseUrl: () => "https://api.test",
24
+ headers: () => ({}),
25
+ unknownError: ({ message }) => new TestUnknownError({ message }),
26
+ parseError: ({ body, cause }) => new TestParseError({ body, cause }),
27
+ });
28
+
29
+ const Output = Schema.Struct({ id: Schema.String });
30
+
31
+ const decode = (body: string) =>
32
+ Effect.gen(function* () {
33
+ const protocol = yield* API.Protocol;
34
+ const request = HttpClientRequest.get("https://api.test/thing");
35
+ return yield* protocol.decode({
36
+ response: HttpClientResponse.fromWeb(
37
+ request,
38
+ new Response(body, { status: 200 }),
39
+ ),
40
+ outputAst: Output.ast,
41
+ errors: [],
42
+ config: {},
43
+ });
44
+ }).pipe(Effect.provide(TestProtocol));
45
+
46
+ const run = <A, E>(effect: Effect.Effect<A, E>, layer?: Layer.Layer<never>) =>
47
+ Effect.runPromise(
48
+ Effect.result(layer ? effect.pipe(Effect.provide(layer)) : effect),
49
+ );
50
+
51
+ describe("makeRestProtocol response validation", () => {
52
+ test("lenient (default) returns a non-JSON 2xx body as text", async () => {
53
+ const result = await run(decode("not json"));
54
+ expect(result).toMatchObject({ _tag: "Success", success: "not json" });
55
+ });
56
+
57
+ test("lenient (default) returns {} for an output that requires id", async () => {
58
+ const result = await run(decode("{}"));
59
+ expect(result).toMatchObject({ _tag: "Success", success: {} });
60
+ });
61
+
62
+ test("strict fails a non-JSON 2xx body with the provider's parse error", async () => {
63
+ const result = await run(decode("not json"), ResponseValidation.strict);
64
+ expect(result._tag).toBe("Failure");
65
+ const error = (result as any).failure;
66
+ expect(error).toBeInstanceOf(TestParseError);
67
+ expect(error.body).toBe("not json");
68
+ });
69
+
70
+ test("strict fails a body missing a required member", async () => {
71
+ const result = await run(decode("{}"), ResponseValidation.strict);
72
+ expect(result._tag).toBe("Failure");
73
+ expect((result as any).failure).toBeInstanceOf(TestParseError);
74
+ expect((result as any).failure.body).toEqual({});
75
+ });
76
+
77
+ test("strict passes a matching body through unchanged, extra members included", async () => {
78
+ const result = await run(
79
+ decode(JSON.stringify({ id: "a", extra: 1 })),
80
+ ResponseValidation.strict,
81
+ );
82
+ expect(result).toMatchObject({
83
+ _tag: "Success",
84
+ success: { id: "a", extra: 1 },
85
+ });
86
+ });
87
+
88
+ test("an inner lenient layer overrides an outer strict one", async () => {
89
+ const result = await run(
90
+ decode("{}").pipe(Effect.provide(ResponseValidation.lenient)),
91
+ ResponseValidation.strict,
92
+ );
93
+ expect(result).toMatchObject({ _tag: "Success", success: {} });
94
+ });
95
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Response validation mode, shared by every distilled SDK.
3
+ *
4
+ * Generated SDKs carry a schema for every operation's output. What a
5
+ * protocol does with it on a 2xx response depends on the mode:
6
+ *
7
+ * lenient (default): the response is returned as the protocol read it.
8
+ * A body that does not match the declared output type
9
+ * (a missing member, a wrong primitive, a non-JSON
10
+ * body) still succeeds.
11
+ * strict: the response is decoded against the output schema,
12
+ * and a mismatch fails the call with the SDK's
13
+ * `<Sdk>ParseError` (AWS: `ParseError`).
14
+ *
15
+ * The mode is one {@link ResponseValidation} reference keyed by a string, so
16
+ * a single `Layer` switches every distilled SDK in a program — AWS,
17
+ * Cloudflare, Neon, … — at once, and nests like any other Effect service:
18
+ *
19
+ * ```ts
20
+ * import { ResponseValidation } from "@distilled.cloud/core";
21
+ *
22
+ * // whole program
23
+ * program.pipe(Effect.provide(ResponseValidation.strict));
24
+ *
25
+ * // one call back to lenient inside a strict program
26
+ * Neon.getProject({ projectId }).pipe(Effect.provide(ResponseValidation.lenient));
27
+ * ```
28
+ *
29
+ * The mode is set only by these layers; without one, every call is lenient.
30
+ */
31
+ import * as Context from "effect/Context";
32
+ import * as Effect from "effect/Effect";
33
+ import * as Layer from "effect/Layer";
34
+ import * as Schema from "effect/Schema";
35
+ import type * as AST from "effect/SchemaAST";
36
+
37
+ export type Mode = "lenient" | "strict";
38
+
39
+ /**
40
+ * The active validation mode. Read by protocols at call time on the calling
41
+ * fiber; `lenient` unless a {@link strict} layer is provided.
42
+ */
43
+ export const ResponseValidation = Context.Reference<Mode>(
44
+ "@distilled.cloud/core/ResponseValidation",
45
+ { defaultValue: () => "lenient" },
46
+ );
47
+
48
+ /** Decode every 2xx response against its output schema; fail on mismatch. */
49
+ export const strict: Layer.Layer<never> = Layer.succeed(
50
+ ResponseValidation,
51
+ "strict",
52
+ );
53
+
54
+ /** Return 2xx responses as read, without checking them against the schema. */
55
+ export const lenient: Layer.Layer<never> = Layer.succeed(
56
+ ResponseValidation,
57
+ "lenient",
58
+ );
59
+
60
+ /** Whether the calling fiber is in strict mode. */
61
+ export const isStrict: Effect.Effect<boolean> = Effect.map(
62
+ ResponseValidation,
63
+ (mode) => mode === "strict",
64
+ );
65
+
66
+ /**
67
+ * For a 2xx body the protocol could not read into the shape it transforms
68
+ * (e.g. invalid JSON): fail with `error` in strict mode, succeed with
69
+ * `asRead` (usually the body text) in lenient mode.
70
+ */
71
+ export const failIfStrict = <A, E>(error: E, asRead: A): Effect.Effect<A, E> =>
72
+ Effect.flatMap(ResponseValidation, (mode) =>
73
+ mode === "strict" ? Effect.fail(error) : Effect.succeed(asRead),
74
+ );
75
+
76
+ const decoders = new WeakMap<
77
+ AST.AST,
78
+ (input: unknown) => Effect.Effect<unknown, Schema.SchemaError>
79
+ >();
80
+
81
+ const decoderFor = (ast: AST.AST) => {
82
+ let decode = decoders.get(ast);
83
+ if (!decode) {
84
+ decode = Schema.decodeUnknownEffect(Schema.make<Schema.Top>(ast)) as (
85
+ input: unknown,
86
+ ) => Effect.Effect<unknown, Schema.SchemaError>;
87
+ decoders.set(ast, decode);
88
+ }
89
+ return decode;
90
+ };
91
+
92
+ /**
93
+ * Check a 2xx response value against the operation's output schema.
94
+ *
95
+ * Lenient mode returns `value` untouched. Strict mode decodes it and, on a
96
+ * mismatch, fails with `onError(schemaError)` — protocols pass their SDK's
97
+ * `<Sdk>ParseError` constructor. On success the ORIGINAL value is returned
98
+ * (members the schema does not model are kept), so switching modes never
99
+ * changes what a successful call returns.
100
+ *
101
+ * `value` must already be in the schema's shape (TS member names), i.e.
102
+ * after any wire→TS key mapping and before `Redacted` wrapping.
103
+ */
104
+ export const validateResponse = <E>(
105
+ outputAst: AST.AST,
106
+ value: unknown,
107
+ onError: (cause: Schema.SchemaError) => E,
108
+ ): Effect.Effect<unknown, E> =>
109
+ Effect.flatMap(ResponseValidation, (mode) =>
110
+ mode === "lenient"
111
+ ? Effect.succeed(value)
112
+ : decoderFor(outputAst)(value).pipe(
113
+ Effect.mapError(onError),
114
+ Effect.as(value),
115
+ ),
116
+ );
package/src/testing.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Test helpers for SDK packages: a canned-response `HttpClient` and a runner
3
+ * that executes one operation under both response-validation modes.
4
+ *
5
+ * ```ts
6
+ * const { lenient, strict } = await runValidationModes(
7
+ * Pkg.getThing({ id: "a" }).pipe(Retry.none, Effect.provide(TestCredentials)),
8
+ * { body: "{}" },
9
+ * );
10
+ * expect(lenient).toMatchObject({ _tag: "Success", success: {} });
11
+ * expect(strict).toMatchObject({ _tag: "Failure" });
12
+ * ```
13
+ */
14
+ import * as Effect from "effect/Effect";
15
+ import * as Layer from "effect/Layer";
16
+ import type * as Result from "effect/Result";
17
+ import * as HttpClient from "effect/http/HttpClient";
18
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest";
19
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
20
+ import * as ResponseValidation from "./response-validation.ts";
21
+
22
+ export interface MockResponse {
23
+ /** Default 200. */
24
+ readonly status?: number;
25
+ /** Default `application/json`. */
26
+ readonly headers?: Record<string, string>;
27
+ readonly body: string;
28
+ }
29
+
30
+ /**
31
+ * An `HttpClient` that answers every request with `response` (or with what
32
+ * `response(request)` returns, for protocols that make more than one call).
33
+ */
34
+ export const mockHttpClient = (
35
+ response:
36
+ | MockResponse
37
+ | ((request: HttpClientRequest.HttpClientRequest) => MockResponse),
38
+ ): Layer.Layer<HttpClient.HttpClient> =>
39
+ Layer.succeed(
40
+ HttpClient.HttpClient,
41
+ HttpClient.make((request) =>
42
+ Effect.sync(() => {
43
+ const r = typeof response === "function" ? response(request) : response;
44
+ return HttpClientResponse.fromWeb(
45
+ request,
46
+ new Response(r.body, {
47
+ status: r.status ?? 200,
48
+ headers: r.headers ?? { "content-type": "application/json" },
49
+ }),
50
+ );
51
+ }),
52
+ ),
53
+ );
54
+
55
+ /**
56
+ * Run `effect` against `mockHttpClient(response)` once in lenient mode and
57
+ * once in strict mode.
58
+ */
59
+ export const runValidationModes = <A, E>(
60
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
61
+ response: Parameters<typeof mockHttpClient>[0],
62
+ ): Promise<{
63
+ readonly lenient: Result.Result<A, E>;
64
+ readonly strict: Result.Result<A, E>;
65
+ }> => {
66
+ const run = (mode: Layer.Layer<never>) =>
67
+ effect.pipe(
68
+ Effect.provide(mockHttpClient(response)),
69
+ Effect.provide(mode),
70
+ Effect.result,
71
+ );
72
+ return Effect.runPromise(
73
+ Effect.all({
74
+ lenient: run(ResponseValidation.lenient),
75
+ strict: run(ResponseValidation.strict),
76
+ }),
77
+ );
78
+ };
package/src/trait.ts CHANGED
@@ -109,6 +109,19 @@ export const labelSymbol = Symbol.for("@distilled.cloud/core/http/label");
109
109
  export const Label = (name?: string) =>
110
110
  makeAnnotation(labelSymbol, name ?? true);
111
111
 
112
+ export const labelEncodingSymbol = Symbol.for(
113
+ "@distilled.cloud/core/http/label-encoding",
114
+ );
115
+ /** Preserve selected RFC 3986 pchar delimiters inside a single URI label. */
116
+ export const LabelEncoding = (options: { readonly preserve: string }) => {
117
+ if (!/^[!$&'()*+,;=:@]*$/.test(options.preserve)) {
118
+ throw new TypeError(
119
+ "LabelEncoding can preserve only URI path-segment delimiters",
120
+ );
121
+ }
122
+ return makeAnnotation(labelEncodingSymbol, options.preserve);
123
+ };
124
+
112
125
  export const responseCodeSymbol = Symbol.for(
113
126
  "@distilled.cloud/core/http/response-code",
114
127
  );
@@ -237,19 +250,37 @@ export const errorMatchersSymbol = Symbol.for(
237
250
  "@distilled.cloud/core/error-matchers",
238
251
  );
239
252
 
253
+ /** Exact text, or the conjunction of substring and regular-expression constraints. */
254
+ export type ErrorTextMatcher =
255
+ | string
256
+ | { readonly includes?: string; readonly matches?: string };
257
+
240
258
  /**
241
- * One wire-matching rule for a typed error class. A matcher matches a wire
242
- * failure when every present field matches: `code` equals the wire error's
243
- * code, `status` equals the HTTP status, and `message` either equals the
244
- * error message (string form) or satisfies `includes` (substring) /
245
- * `matches` (regex). A matcher with no fields matches nothing.
259
+ * One wire-matching rule. All supplied constraints must match; separate
260
+ * matchers on a class are alternatives. Empty matchers match nothing.
261
+ *
262
+ * `body` maps RFC 6901 JSON Pointers to scalar constraints. For example,
263
+ * `{ "/success": false, "/result/status": "error" }`. Strings also accept
264
+ * `includes` / `matches`. Missing fields never match, including against null.
265
+ * The empty pointer addresses the entire body; array indices are supported.
266
+ * Header names are case-insensitive; their value constraints are case-sensitive.
267
+ * Each body path and header adds one specificity point, like code/status/message.
268
+ * Protocols evaluate these rules only after identifying a failed response.
269
+ *
270
+ * @example
271
+ * ```ts
272
+ * { status: 200, body: { "/success": false, "/result/status": "error" } }
273
+ * { status: 409, headers: { "x-error-type": { includes: "Conflict" } } }
274
+ * ```
246
275
  */
247
276
  export interface ErrorMatcher {
248
277
  readonly code?: number;
249
278
  readonly status?: number;
250
- readonly message?:
251
- | string
252
- | { readonly includes?: string; readonly matches?: string };
279
+ readonly message?: ErrorTextMatcher;
280
+ readonly body?: Readonly<
281
+ Record<string, ErrorTextMatcher | number | boolean | null>
282
+ >;
283
+ readonly headers?: Readonly<Record<string, ErrorTextMatcher>>;
253
284
  }
254
285
 
255
286
  /**
@@ -0,0 +1,269 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import * as Effect from "effect/Effect";
4
+ import fixtures from "./fixtures/xml/upstream-validator.json" with { type: "json" };
5
+ import { escapeXml, parseXml, parseXmlSync, XmlParseError } from "./xml.ts";
6
+
7
+ function parse(xml: string) {
8
+ // Compare the wire values independently of the null prototypes used for safety.
9
+ return JSON.parse(JSON.stringify(parseXmlSync(xml)));
10
+ }
11
+
12
+ describe("upstream fast-xml-parser validation corpus", () => {
13
+ for (const [index, fixture] of fixtures.entries()) {
14
+ it(`${index}: ${fixture.name}`, () => {
15
+ if (fixture.parserValid ?? fixture.valid) {
16
+ assert.doesNotThrow(() => parseXmlSync(fixture.xml));
17
+ } else {
18
+ assert.throws(() => parseXmlSync(fixture.xml), XmlParseError);
19
+ }
20
+ });
21
+ }
22
+ });
23
+
24
+ describe("XML object format", () => {
25
+ it("keeps primitives as strings and groups repeated children", () => {
26
+ assert.deepEqual(
27
+ parse("<R><x>001</x><x>false</x><x>1e3</x><empty/><empty></empty></R>"),
28
+ { R: { x: ["001", "false", "1e3"], empty: ["", ""] } },
29
+ );
30
+ });
31
+
32
+ it("preserves namespace names, attributes, and text with attributes", () => {
33
+ assert.deepEqual(
34
+ parse(
35
+ '<ns:R xmlns:ns="urn:r"><ns:x ns:id="01">value</ns:x><empty a=""/></ns:R>',
36
+ ),
37
+ {
38
+ "ns:R": {
39
+ "@_xmlns:ns": "urn:r",
40
+ "ns:x": { "@_ns:id": "01", "#text": "value" },
41
+ empty: { "@_a": "" },
42
+ },
43
+ },
44
+ );
45
+ });
46
+
47
+ it("preserves string whitespace, including attribute and CDATA values", () => {
48
+ assert.deepEqual(
49
+ parse(
50
+ '<R><Key> key </Key><blank> \t </blank><x a=" b "> text </x><c><![CDATA[ c ]]></c></R>',
51
+ ),
52
+ {
53
+ R: {
54
+ Key: " key ",
55
+ blank: " \t ",
56
+ x: { "@_a": " b ", "#text": " text " },
57
+ c: " c ",
58
+ },
59
+ },
60
+ );
61
+ });
62
+
63
+ it("discards layout-only container text but preserves mixed content", () => {
64
+ assert.deepEqual(parse("<R>\n <x/>\n <y/>\n</R>"), {
65
+ R: { x: "", y: "" },
66
+ });
67
+ assert.deepEqual(parse("<R>Hello <b>world</b> !</R>"), {
68
+ R: { b: "world", "#text": "Hello !" },
69
+ });
70
+ assert.deepEqual(parse("<R><![CDATA[ ]]><b/></R>"), {
71
+ R: { b: "", "#text": " " },
72
+ });
73
+ });
74
+
75
+ it("decodes predefined and numeric entities exactly once", () => {
76
+ assert.deepEqual(
77
+ parse('<R a="&quot;&apos;&#9;">&amp;&lt;&gt;&#65;&#x1F600;&amp;lt;</R>'),
78
+ { R: { "@_a": "\"'\t", "#text": "&<>A😀&lt;" } },
79
+ );
80
+ });
81
+
82
+ it("normalizes literal line endings and attribute whitespace, not character references", () => {
83
+ assert.deepEqual(
84
+ parse('<R a="x\r\ny\tz\r&#13;&#10;&#9;">a\r\nb\rc&#13;</R>'),
85
+ { R: { "@_a": "x y z \r\n\t", "#text": "a\nb\nc\r" } },
86
+ );
87
+ });
88
+
89
+ it("joins CDATA and text without decoding CDATA entities", () => {
90
+ assert.deepEqual(parse("<R>a<![CDATA[<b>&amp;]]><![CDATA[c]]>d&amp;</R>"), {
91
+ R: "a<b>&amp;cd&",
92
+ });
93
+ assert.deepEqual(parse("<R><![CDATA[]]></R>"), { R: "" });
94
+ });
95
+
96
+ it("ignores declarations, comments and processing instructions", () => {
97
+ assert.deepEqual(
98
+ parse(
99
+ '\uFEFF<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!--before--><?before a?><R>a<!--ignored--><?in x?>b</R><?after?><!--after-->',
100
+ ),
101
+ { R: "ab" },
102
+ );
103
+ });
104
+
105
+ it("supports XML Unicode names", () => {
106
+ assert.deepEqual(parse('<根 é="oui"><𐀀>😀</𐀀><á/></根>'), {
107
+ 根: { "@_é": "oui", 𐀀: "😀", á: "" },
108
+ });
109
+ });
110
+
111
+ it("accepts empty HTTP bodies", () => {
112
+ for (const xml of ["", " \r\n\t", "\uFEFF"])
113
+ assert.deepEqual(parse(xml), {});
114
+ });
115
+
116
+ it("stores prototype-related names as own data properties", () => {
117
+ const result = parseXmlSync(
118
+ "<__proto__><constructor>one</constructor><constructor>two</constructor><__proto__>safe</__proto__><toString/></__proto__>",
119
+ );
120
+ const root = result.__proto__;
121
+ assert.equal(Object.getPrototypeOf(result), null);
122
+ assert.equal(Object.getPrototypeOf(root), null);
123
+ assert.deepEqual(
124
+ JSON.parse(JSON.stringify(root)),
125
+ JSON.parse(
126
+ '{"constructor":["one","two"],"__proto__":"safe","toString":""}',
127
+ ),
128
+ );
129
+ assert.equal(Object.hasOwn(Object.prototype, "safe"), false);
130
+ });
131
+
132
+ it("round trips escaped strings", () => {
133
+ for (const text of [
134
+ ' <tag a="x">&\'😀 ',
135
+ "",
136
+ "false",
137
+ "001",
138
+ "&#13;",
139
+ "\t\n",
140
+ ]) {
141
+ assert.deepEqual(parse(`<R>${escapeXml(text)}</R>`), { R: text });
142
+ }
143
+ });
144
+
145
+ it("handles large lists without recursion", () => {
146
+ const result = parse(`<R>${'<item a="1">value</item>'.repeat(10_000)}</R>`);
147
+ assert.equal(result.R.item.length, 10_000);
148
+ assert.deepEqual(result.R.item[9_999], { "@_a": "1", "#text": "value" });
149
+ });
150
+ });
151
+
152
+ describe("malformed and unsupported XML", () => {
153
+ const invalid = [
154
+ '<R a="1"a="2"/>',
155
+ '<R a="<"/>',
156
+ '<R a="&bad;"/>',
157
+ "<R a=1/>",
158
+ '<R a="1" a="2"/>',
159
+ "<R/ >",
160
+ "< R/>",
161
+ "<R></ R>",
162
+ "<R></R x>",
163
+ "<R>]]></R>",
164
+ "<R>&</R>",
165
+ "<R>&amp</R>",
166
+ "<R>&nope;</R>",
167
+ "<R>&#0;</R>",
168
+ "<R>&#xD800;</R>",
169
+ "<R>&#x110000;</R>",
170
+ "<R>&#xFFFE;</R>",
171
+ "<R>&#-1;</R>",
172
+ "<R>&#X41;</R>",
173
+ "<R>&#x;</R>",
174
+ "<R>&#;</R>",
175
+ "<R>\u0000</R>",
176
+ "<R>\u000B</R>",
177
+ "<R>\uD800</R>",
178
+ "<R>\uDC00</R>",
179
+ "<R>\uFFFE</R>",
180
+ "<R>\uFFFF</R>",
181
+ "<R><!--a--b--></R>",
182
+ "<R><!--a---></R>",
183
+ "<R><!--",
184
+ "<R><![CDATA[",
185
+ "<![CDATA[x]]><R/>",
186
+ "<R/><![CDATA[x]]>",
187
+ "<R><?pi",
188
+ "<R><?1bad?></R>",
189
+ "<R><?pi!?></R>",
190
+ '<?XML version="1.0"?><R/>',
191
+ "<?xml?><R/>",
192
+ '<?xml version="1.1"?><R/>',
193
+ '<?xml encoding="UTF-8"?><R/>',
194
+ '<?xml version="1.0" junk="x"?><R/>',
195
+ '<R><?xml version="1.0"?></R>',
196
+ '<!--x--><?xml version="1.0"?><R/>',
197
+ '<?xml version="1.0"?>',
198
+ "<!--only-->",
199
+ "<!DOCTYPE R><R/>",
200
+ '<!DOCTYPE R SYSTEM "file:///etc/passwd"><R/>',
201
+ '<!DOCTYPE R [<!ENTITY a "&a;">]><R>&a;</R>',
202
+ "<R/><R/>",
203
+ "before<R/>",
204
+ "<R/>after",
205
+ "<R><x></R>",
206
+ "<R>",
207
+ "<",
208
+ '<R a="',
209
+ "<R a",
210
+ "<R a=",
211
+ "<R ",
212
+ "</R>",
213
+ "<1bad/>",
214
+ '<R\u00A0a="x"/>',
215
+ ];
216
+ for (const xml of invalid) {
217
+ it(`rejects ${JSON.stringify(xml)}`, () => {
218
+ assert.throws(
219
+ () => parseXmlSync(xml),
220
+ (error) => {
221
+ assert.ok(error instanceof XmlParseError);
222
+ assert.ok(error.offset >= 0 && error.offset <= xml.length);
223
+ return true;
224
+ },
225
+ );
226
+ });
227
+ }
228
+
229
+ it("reports original input offsets and CRLF-aware line numbers", () => {
230
+ const xml = "<R>\r\n&bad;</R>";
231
+ assert.throws(
232
+ () => parseXmlSync(xml),
233
+ (error) => {
234
+ assert.ok(error instanceof XmlParseError);
235
+ assert.equal(error.offset, 5);
236
+ assert.equal(error.line, 2);
237
+ assert.equal(error.column, 1);
238
+ return true;
239
+ },
240
+ );
241
+ });
242
+
243
+ it("enforces depth and input length limits", () => {
244
+ assert.doesNotThrow(() => parseXmlSync("<a><b/></a>", { maxDepth: 2 }));
245
+ assert.throws(
246
+ () => parseXmlSync("<a><b/></a>", { maxDepth: 1 }),
247
+ XmlParseError,
248
+ );
249
+ assert.doesNotThrow(() => parseXmlSync("<a/>", { maxLength: 4 }));
250
+ assert.throws(() => parseXmlSync("<a/>", { maxLength: 3 }), XmlParseError);
251
+ assert.throws(() => parseXmlSync("<a/>", { maxDepth: NaN }), XmlParseError);
252
+ assert.throws(() => parseXmlSync("<a/>", { maxLength: -1 }), XmlParseError);
253
+ const nested = "<a>".repeat(2_000) + "</a>".repeat(2_000);
254
+ assert.throws(() => parseXmlSync(nested), XmlParseError);
255
+ assert.doesNotThrow(() => parseXmlSync(nested, { maxDepth: 2_000 }));
256
+ });
257
+
258
+ it("exposes parser failures through Effect's typed channel", () => {
259
+ const result = Effect.runSync(Effect.flip(parseXml("<R>")));
260
+ assert.ok(result instanceof XmlParseError);
261
+ assert.match(result.message, /Unclosed element/);
262
+ });
263
+
264
+ it("can execute the same lazy Effect repeatedly", () => {
265
+ const effect = parseXml("<R>ok</R>");
266
+ assert.equal(Effect.runSync(effect).R, "ok");
267
+ assert.equal(Effect.runSync(effect).R, "ok");
268
+ });
269
+ });