@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.
- 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
|
@@ -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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* `
|
|
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
|
-
|
|
252
|
-
|
|
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
|
/**
|
package/src/xml.test.ts
ADDED
|
@@ -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=""'	">&<>A😀&lt;</R>'),
|
|
78
|
+
{ R: { "@_a": "\"'\t", "#text": "&<>A😀<" } },
|
|
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 	">a\r\nb\rc </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>&]]><![CDATA[c]]>d&</R>"), {
|
|
91
|
+
R: "a<b>&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
|
+
" ",
|
|
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>&</R>",
|
|
166
|
+
"<R>&nope;</R>",
|
|
167
|
+
"<R>�</R>",
|
|
168
|
+
"<R>�</R>",
|
|
169
|
+
"<R>�</R>",
|
|
170
|
+
"<R></R>",
|
|
171
|
+
"<R>&#-1;</R>",
|
|
172
|
+
"<R>A</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
|
+
});
|