@endevops/effect-codec-xml 0.0.1 → 0.1.0-beta.1
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/README.md +63 -62
- package/dist/codec.d.ts +17 -9
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +25 -16
- package/dist/codec.js.map +1 -1
- package/dist/conventions.d.ts +4 -4
- package/dist/conventions.js +7 -7
- package/dist/conventions.js.map +1 -1
- package/dist/entities/entity-decoder.d.ts +33 -33
- package/dist/entities/entity-decoder.d.ts.map +1 -1
- package/dist/entities/entity-decoder.js +63 -64
- package/dist/entities/entity-decoder.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/namespaces.js +2 -2
- package/dist/namespaces.js.map +1 -1
- package/dist/naming.d.ts +6 -6
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +3 -3
- package/dist/naming.js.map +1 -1
- package/dist/parse.d.ts +5 -5
- package/dist/parse.js +14 -14
- package/dist/parse.js.map +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +28 -28
- package/dist/render.js.map +1 -1
- package/dist/xml-error.d.ts +9 -9
- package/dist/xml-error.js +18 -18
- package/dist/xml-error.js.map +1 -1
- package/dist/xml-value.d.ts +7 -7
- package/dist/xml-value.d.ts.map +1 -1
- package/dist/xml-value.js +6 -7
- package/dist/xml-value.js.map +1 -1
- package/package.json +1 -1
- package/src/codec.ts +91 -71
- package/src/conventions.ts +7 -7
- package/src/entities/entity-decoder.ts +98 -99
- package/src/errors.ts +3 -3
- package/src/index.ts +3 -3
- package/src/namespaces.ts +16 -16
- package/src/naming.ts +34 -35
- package/src/parse.ts +26 -26
- package/src/render.ts +44 -44
- package/src/xml-error.ts +18 -18
- package/src/xml-value.ts +10 -11
package/dist/xml-error.js
CHANGED
|
@@ -8,35 +8,35 @@ import { Schema } from "effect";
|
|
|
8
8
|
*
|
|
9
9
|
* ## Why one error with a `reason`, and not one error per cause
|
|
10
10
|
*
|
|
11
|
-
* The causes would mean a class each, and a caller handling "any of these" would need `catchTags` with all of them.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The causes would mean a class each, and a caller handling "any of these" would need `catchTags` with all of them. A caller does not usually want to
|
|
12
|
+
* decide between the causes separately. They are all "this XML input was not acceptable", and the useful split is coarse: a bad _configuration_
|
|
13
|
+
* versus a bad _document_. So there is one error, and `reason` narrows to the specific cause. Recovery is a `catchReason` away:
|
|
14
14
|
*
|
|
15
15
|
* @example
|
|
16
16
|
* ```typescript
|
|
17
17
|
* import { Effect } from 'effect';
|
|
18
|
-
* import { EntityDecoder, XmlError } from '@endevops/effect-xml
|
|
18
|
+
* import { EntityDecoder, XmlError } from '@endevops/effect-codec-xml';
|
|
19
19
|
*
|
|
20
20
|
* const limited = new EntityDecoder({ limit: { maxTotalExpansions: 2 } }).decode('&&&').pipe(
|
|
21
21
|
* Effect.catchReason('XmlError', 'ExpansionLimitExceeded', reason => Effect.succeed(`gave up after ${reason.actual}`)),
|
|
22
22
|
* );
|
|
23
23
|
* ```;
|
|
24
24
|
*
|
|
25
|
-
* The message is kept alongside `reason` and is part of the schema, because the text
|
|
26
|
-
* `[EntityReplacer]` prefix in particular is documented as something callers match on, so
|
|
27
|
-
* rather than
|
|
25
|
+
* The message is kept alongside `reason` and is part of the schema, because callers depend on the text. The
|
|
26
|
+
* `[EntityReplacer]` prefix in particular is documented as something callers match on, so the codec reproduces it exactly
|
|
27
|
+
* rather than rewording it.
|
|
28
28
|
*/
|
|
29
29
|
/**
|
|
30
30
|
* @description The specific cause of an {@link XmlError}, as a tagged union. The `_tag` on each member is the discriminant `Effect.catchReason` matches on, and
|
|
31
|
-
* the payload is what a handler needs in order to decide or to report. Every member is a case the decoder or the name validators actually raise
|
|
32
|
-
*
|
|
31
|
+
* the payload is what a handler needs in order to decide or to report. Every member is a case the decoder or the name validators actually raise.
|
|
32
|
+
* There is no catch-all member, so an exhaustive `match` stays exhaustive as causes are added.
|
|
33
33
|
*/
|
|
34
34
|
const XmlErrorReason = Schema.TaggedUnion({
|
|
35
35
|
/**
|
|
36
36
|
* @description A required argument was `null` or another non-value where the package requires a real one. Raised by the factories that take caller-supplied
|
|
37
|
-
* input
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* input, {@link EntityDecoder.make} among them, when they are handed `null` for an options object that has no meaningful default. It is a distinct
|
|
38
|
+
* case from the rest because the argument is not _wrong_, it is _absent_, and a caller who wrote `make(null)` meant something the type system does
|
|
39
|
+
* not allow. A decoder with every default is `make({})`, and saying so here is more useful than silently producing one.
|
|
40
40
|
*/
|
|
41
41
|
MissingOptions: {
|
|
42
42
|
/**
|
|
@@ -45,7 +45,7 @@ const XmlErrorReason = Schema.TaggedUnion({
|
|
|
45
45
|
parameter: Schema.String },
|
|
46
46
|
/**
|
|
47
47
|
* @description A name was checked against one of the five XML name productions and given a different one. Unreachable from TypeScript, where `Production` is a
|
|
48
|
-
* closed union
|
|
48
|
+
* closed union. It is the guard for untyped JavaScript callers and for values that crossed a boundary as `unknown`.
|
|
49
49
|
*/
|
|
50
50
|
InvalidProduction: {
|
|
51
51
|
production: Schema.String,
|
|
@@ -73,7 +73,7 @@ parameter: Schema.String },
|
|
|
73
73
|
*/
|
|
74
74
|
EntityRejected: {
|
|
75
75
|
/**
|
|
76
|
-
* @description Which registration was in progress.
|
|
76
|
+
* @description Which registration was in progress. The runtime injects both, so they share a tier for limit accounting.
|
|
77
77
|
*/
|
|
78
78
|
context: Schema.Literals(["external", "input"]),
|
|
79
79
|
/**
|
|
@@ -87,7 +87,7 @@ parameter: Schema.String },
|
|
|
87
87
|
*/
|
|
88
88
|
ExpansionLimitExceeded: {
|
|
89
89
|
/**
|
|
90
|
-
* @description The count that tripped the limit.
|
|
90
|
+
* @description The count that tripped the limit. The counter is not reset on failure, so this is the real over-limit total rather than the ceiling.
|
|
91
91
|
*/
|
|
92
92
|
actual: Schema.Number,
|
|
93
93
|
/**
|
|
@@ -125,14 +125,14 @@ parameter: Schema.String },
|
|
|
125
125
|
}
|
|
126
126
|
});
|
|
127
127
|
/**
|
|
128
|
-
* @description Every failure this package can report, in the `E` channel of the effects that can fail. Carries both a `reason
|
|
128
|
+
* @description Every failure this package can report, in the `E` channel of the effects that can fail. Carries both a `reason`, the typed and matchable cause, and
|
|
129
129
|
* a `message`, which is the human-readable form the package has always produced. Both are part of the schema, so an error survives a round-trip
|
|
130
130
|
* through a serialised boundary without losing either.
|
|
131
131
|
*
|
|
132
132
|
* @example
|
|
133
133
|
* ```typescript
|
|
134
134
|
* import { Effect } from 'effect';
|
|
135
|
-
* import { EntityDecoder } from '@endevops/effect-xml
|
|
135
|
+
* import { EntityDecoder } from '@endevops/effect-codec-xml';
|
|
136
136
|
*
|
|
137
137
|
* const program = Effect.gen(function*() {
|
|
138
138
|
* const decoder = yield* EntityDecoder.make({});
|
|
@@ -147,7 +147,7 @@ var XmlError = class extends Schema.TaggedError()("XmlError", {
|
|
|
147
147
|
reason: XmlErrorReason,
|
|
148
148
|
/**
|
|
149
149
|
* @description Human-readable description. The `[EntityReplacer]` and `[EntityDecoder]` prefixes are reproduced verbatim from the original throw sites, because
|
|
150
|
-
*
|
|
150
|
+
* anything matching on them depends on them.
|
|
151
151
|
*/
|
|
152
152
|
message: Schema.String
|
|
153
153
|
}) {};
|
package/dist/xml-error.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xml-error.js","names":[],"sources":["../src/xml-error.ts"],"sourcesContent":["/**\n * @description Every way the entity decoder and the name validators can fail, as one typed error. The package used to throw plain `Error` and `TypeError` from\n * places spread across the entity and naming areas. A caller had to match on message text, and there was nothing to narrow on. All of it is now a\n * single {@link XmlError} in the `E` channel of the effects that can fail, with the specific cause in a `reason` field rather than parsed back out of\n * a string.\n *\n * ## Why one error with a `reason`, and not one error per cause\n *\n * The causes would mean a class each, and a caller handling \"any of these\" would need `catchTags` with all of them.
|
|
1
|
+
{"version":3,"file":"xml-error.js","names":[],"sources":["../src/xml-error.ts"],"sourcesContent":["/**\n * @description Every way the entity decoder and the name validators can fail, as one typed error. The package used to throw plain `Error` and `TypeError` from\n * places spread across the entity and naming areas. A caller had to match on message text, and there was nothing to narrow on. All of it is now a\n * single {@link XmlError} in the `E` channel of the effects that can fail, with the specific cause in a `reason` field rather than parsed back out of\n * a string.\n *\n * ## Why one error with a `reason`, and not one error per cause\n *\n * The causes would mean a class each, and a caller handling \"any of these\" would need `catchTags` with all of them. A caller does not usually want to\n * decide between the causes separately. They are all \"this XML input was not acceptable\", and the useful split is coarse: a bad _configuration_\n * versus a bad _document_. So there is one error, and `reason` narrows to the specific cause. Recovery is a `catchReason` away:\n *\n * @example\n * ```typescript\n * import { Effect } from 'effect';\n * import { EntityDecoder, XmlError } from '@endevops/effect-codec-xml';\n *\n * const limited = new EntityDecoder({ limit: { maxTotalExpansions: 2 } }).decode('&&&').pipe(\n * Effect.catchReason('XmlError', 'ExpansionLimitExceeded', reason => Effect.succeed(`gave up after ${reason.actual}`)),\n * );\n * ```;\n *\n * The message is kept alongside `reason` and is part of the schema, because callers depend on the text. The\n * `[EntityReplacer]` prefix in particular is documented as something callers match on, so the codec reproduces it exactly\n * rather than rewording it.\n */\n\nimport { Schema } from 'effect';\n\n/**\n * @description The specific cause of an {@link XmlError}, as a tagged union. The `_tag` on each member is the discriminant `Effect.catchReason` matches on, and\n * the payload is what a handler needs in order to decide or to report. Every member is a case the decoder or the name validators actually raise.\n * There is no catch-all member, so an exhaustive `match` stays exhaustive as causes are added.\n */\nexport const XmlErrorReason = Schema.TaggedUnion({\n /**\n * @description A required argument was `null` or another non-value where the package requires a real one. Raised by the factories that take caller-supplied\n * input, {@link EntityDecoder.make} among them, when they are handed `null` for an options object that has no meaningful default. It is a distinct\n * case from the rest because the argument is not _wrong_, it is _absent_, and a caller who wrote `make(null)` meant something the type system does\n * not allow. A decoder with every default is `make({})`, and saying so here is more useful than silently producing one.\n */\n MissingOptions: {\n /**\n * @description The parameter that was given nothing, named as it appears in the signature.\n */\n parameter: Schema.String,\n },\n\n /**\n * @description A name was checked against one of the five XML name productions and given a different one. Unreachable from TypeScript, where `Production` is a\n * closed union. It is the guard for untyped JavaScript callers and for values that crossed a boundary as `unknown`.\n */\n InvalidProduction: {\n production: Schema.String,\n /**\n * @description The productions that would have been accepted, comma-separated, as they appear in the message.\n */\n expected: Schema.String,\n },\n\n /**\n * @description An entity name cannot be written as a reference, so it is refused at registration.\n */\n InvalidEntityName: {\n /**\n * @description The rejected name, as it was passed in.\n */\n name: Schema.String,\n /**\n * @description The offending character, or `#` for a name that starts with one. A leading `#` is refused by position rather than by the character sweep,\n * because a name starting with `#` is a numeric reference's token and would collide with the numeric pipeline.\n */\n character: Schema.String,\n },\n\n /**\n * @description A registration hook refused an entity and asked for the registration to abort.\n */\n EntityRejected: {\n /**\n * @description Which registration was in progress. The runtime injects both, so they share a tier for limit accounting.\n */\n context: Schema.Literals(['external', 'input']),\n /**\n * @description The entity name, without `&` or `;`.\n */\n name: Schema.String,\n },\n\n /**\n * @description A document expanded more tracked entity references than {@link EntityDecoderLimitOptions.maxTotalExpansions} allows. A document can define an\n * entity that references another ten times over. Ten deep is a denial of service; a hundred is a fork bomb written in XML.\n */\n ExpansionLimitExceeded: {\n /**\n * @description The count that tripped the limit. The counter is not reset on failure, so this is the real over-limit total rather than the ceiling.\n */\n actual: Schema.Number,\n /**\n * @description The configured ceiling. The check is `actual > limit`, so a limit of `2` allows two expansions.\n */\n limit: Schema.Number,\n },\n\n /**\n * @description A document grew by more characters through entity expansion than {@link EntityDecoderLimitOptions.maxExpandedLength} allows. Only the surplus\n * counts: a reference whose replacement is no longer than the `&token;` it replaces contributes nothing, so this bounds growth rather than document\n * size.\n */\n ExpandedLengthLimitExceeded: {\n /**\n * @description The accumulated growth that tripped the limit.\n */\n actual: Schema.Number,\n /**\n * @description The configured ceiling.\n */\n limit: Schema.Number,\n },\n\n /**\n * @description A numeric character reference was prohibited by the configured policy.\n */\n ProhibitedCharacterReference: {\n /**\n * @description The raw token without `&` and `;`, e.g. `#38` or `#x26`.\n */\n token: Schema.String,\n /**\n * @description The codepoint the reference resolved to, so a log can name the character that was refused.\n */\n codepoint: Schema.Number,\n },\n});\n\n/**\n * @description The reason a well-formedness or policy failure occurred.\n */\nexport type XmlErrorReason = typeof XmlErrorReason.Type;\n\n/**\n * @description Every failure this package can report, in the `E` channel of the effects that can fail. Carries both a `reason`, the typed and matchable cause, and\n * a `message`, which is the human-readable form the package has always produced. Both are part of the schema, so an error survives a round-trip\n * through a serialised boundary without losing either.\n *\n * @example\n * ```typescript\n * import { Effect } from 'effect';\n * import { EntityDecoder } from '@endevops/effect-codec-xml';\n *\n * const program = Effect.gen(function*() {\n * const decoder = yield* EntityDecoder.make({});\n * return yield* decoder.decode('a & b');\n * });\n * ```;\n */\nexport class XmlError extends Schema.TaggedError<XmlError>()('XmlError', {\n /**\n * @description The specific cause. Narrow on `_tag`, or recover with `Effect.catchReason`.\n */\n reason: XmlErrorReason,\n\n /**\n * @description Human-readable description. The `[EntityReplacer]` and `[EntityDecoder]` prefixes are reproduced verbatim from the original throw sites, because\n * anything matching on them depends on them.\n */\n message: Schema.String,\n}) {}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAa,iBAAiB,OAAO,YAAY;;;;;;;CAO/C,gBAAgB;;;;AAId,WAAW,OAAO,OACpB;;;;;CAMA,mBAAmB;EACjB,YAAY,OAAO;;;;EAInB,UAAU,OAAO;CACnB;;;;CAKA,mBAAmB;;;;EAIjB,MAAM,OAAO;;;;;EAKb,WAAW,OAAO;CACpB;;;;CAKA,gBAAgB;;;;EAId,SAAS,OAAO,SAAS,CAAC,YAAY,OAAO,CAAC;;;;EAI9C,MAAM,OAAO;CACf;;;;;CAMA,wBAAwB;;;;EAItB,QAAQ,OAAO;;;;EAIf,OAAO,OAAO;CAChB;;;;;;CAOA,6BAA6B;;;;EAI3B,QAAQ,OAAO;;;;EAIf,OAAO,OAAO;CAChB;;;;CAKA,8BAA8B;;;;EAI5B,OAAO,OAAO;;;;EAId,WAAW,OAAO;CACpB;AACF,CAAC;;;;;;;;;;;;;;;;;AAuBD,IAAa,WAAb,cAA8B,OAAO,YAAsB,CAAC,CAAC,YAAY;;;;CAIvE,QAAQ;;;;;CAMR,SAAS,OAAO;AAClB,CAAC,CAAC,CAAC,CAAC"}
|
package/dist/xml-value.d.ts
CHANGED
|
@@ -10,10 +10,10 @@ export interface XmlRecord {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* @description One value in an XML document: nothing at all, character data, a repeated run of children, or a record of attributes, text and child elements.
|
|
13
|
-
* `undefined` is a value of its own rather than an omission, because that is how an absent optional field survives a round trip
|
|
14
|
-
* value stays distinguishable from a field whose value is the empty string, and the renderer writes neither of them. The shape is
|
|
15
|
-
*
|
|
16
|
-
* re-implementing the derivation.
|
|
13
|
+
* `undefined` is a value of its own rather than an omission, because that is how an absent optional field survives a round trip. A field with no
|
|
14
|
+
* value stays distinguishable from a field whose value is the empty string, and the renderer writes neither of them. The shape is the same one
|
|
15
|
+
* `Schema.toCodecStringTree` derives, and sharing that shape is what lets every schema feature Effect supports round-trip through this package
|
|
16
|
+
* without re-implementing the derivation.
|
|
17
17
|
*/
|
|
18
18
|
export type XmlValue = string | undefined | ReadonlyArray<XmlValue> | XmlRecord;
|
|
19
19
|
/**
|
|
@@ -25,13 +25,13 @@ export type XmlValue = string | undefined | ReadonlyArray<XmlValue> | XmlRecord;
|
|
|
25
25
|
*/
|
|
26
26
|
export declare const isXmlValue: (input: unknown) => input is XmlValue;
|
|
27
27
|
/**
|
|
28
|
-
* @description A schema for {@link XmlValue}, so a value can be validated on its own
|
|
29
|
-
* and the schema it belongs to is not in hand.
|
|
28
|
+
* @description A schema for {@link XmlValue}, so a value can be validated on its own. This helps when it arrives from a store or a queue rather than from
|
|
29
|
+
* {@link parseXml}, and the schema it belongs to is not in hand.
|
|
30
30
|
*
|
|
31
31
|
* @example
|
|
32
32
|
* ```typescript
|
|
33
33
|
* import { Schema } from 'effect';
|
|
34
|
-
* import { XmlValue } from '@endevops/effect-xml
|
|
34
|
+
* import { XmlValue } from '@endevops/effect-codec-xml';
|
|
35
35
|
*
|
|
36
36
|
* Schema.decodeUnknownSync(XmlValue)({ book: { '@id': '1', title: 'Dune' } }); // => { book: { '@id': '1', title: 'Dune' } }
|
|
37
37
|
* Schema.decodeUnknownSync(XmlValue)({ book: { title: 42 } }); // => throws XmlValue
|
package/dist/xml-value.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xml-value.d.ts","names":[],"sources":["../src/xml-value.ts"],"mappings":";;;;;;;iBAOiB;YACL,eAAe;;;;;;;;;YAUf,gCAAgC,cAAc,YAAY;;;;;;;;qBAezD,aAAU,mBAAqB,SAAS;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"xml-value.d.ts","names":[],"sources":["../src/xml-value.ts"],"mappings":";;;;;;;iBAOiB;YACL,eAAe;;;;;;;;;YAUf,gCAAgC,cAAc,YAAY;;;;;;;;qBAezD,aAAU,mBAAqB,SAAS;;;;;;;;;;;;;;qBAsExC,UAAU,OAAO,MAAM"}
|
package/dist/xml-value.js
CHANGED
|
@@ -14,10 +14,9 @@ const MAX_GUARD_DEPTH = 512;
|
|
|
14
14
|
*/
|
|
15
15
|
const isXmlValue = (input) => check(input, 0);
|
|
16
16
|
/**
|
|
17
|
-
* @description One level of {@link isXmlValue}, with the depth it was reached at. The depth is the whole defence against a value built to be hostile
|
|
18
|
-
* self-referential object would otherwise recurse until the stack gave out, and a value nested thousands deep would take it with it.
|
|
19
|
-
*
|
|
20
|
-
* it.
|
|
17
|
+
* @description One level of {@link isXmlValue}, with the depth it was reached at. The depth is the whole defence against a value built to be hostile. A
|
|
18
|
+
* self-referential object would otherwise recurse until the stack gave out, and a value nested thousands deep would take it with it. This rejects
|
|
19
|
+
* both, so it is a bounded recursion rather than a loop. The model is a tree, and walking a tree means walking it.
|
|
21
20
|
*
|
|
22
21
|
* @param input - The candidate value.
|
|
23
22
|
* @param depth - How many levels down this value sits.
|
|
@@ -57,13 +56,13 @@ const everyFieldIs = (input, depth) => {
|
|
|
57
56
|
return true;
|
|
58
57
|
};
|
|
59
58
|
/**
|
|
60
|
-
* @description A schema for {@link XmlValue}, so a value can be validated on its own
|
|
61
|
-
* and the schema it belongs to is not in hand.
|
|
59
|
+
* @description A schema for {@link XmlValue}, so a value can be validated on its own. This helps when it arrives from a store or a queue rather than from
|
|
60
|
+
* {@link parseXml}, and the schema it belongs to is not in hand.
|
|
62
61
|
*
|
|
63
62
|
* @example
|
|
64
63
|
* ```typescript
|
|
65
64
|
* import { Schema } from 'effect';
|
|
66
|
-
* import { XmlValue } from '@endevops/effect-xml
|
|
65
|
+
* import { XmlValue } from '@endevops/effect-codec-xml';
|
|
67
66
|
*
|
|
68
67
|
* Schema.decodeUnknownSync(XmlValue)({ book: { '@id': '1', title: 'Dune' } }); // => { book: { '@id': '1', title: 'Dune' } }
|
|
69
68
|
* Schema.decodeUnknownSync(XmlValue)({ book: { title: 42 } }); // => throws XmlValue
|
package/dist/xml-value.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xml-value.js","names":[],"sources":["../src/xml-value.ts"],"sourcesContent":["import { Predicate, Schema } from 'effect';\n\n/**\n * @description A record of child elements, attributes and character data. A key starting with `@` is an attribute, the reserved `#text` key is character data, and\n * every other key is a child element name. An `undefined` value means the field is absent, which is how an absent optional field stays\n * distinguishable from an empty one.\n */\nexport interface XmlRecord {\n readonly [name: string]: XmlValue | undefined;\n}\n\n/**\n * @description One value in an XML document: nothing at all, character data, a repeated run of children, or a record of attributes, text and child elements.\n * `undefined` is a value of its own rather than an omission, because that is how an absent optional field survives a round trip
|
|
1
|
+
{"version":3,"file":"xml-value.js","names":[],"sources":["../src/xml-value.ts"],"sourcesContent":["import { Predicate, Schema } from 'effect';\n\n/**\n * @description A record of child elements, attributes and character data. A key starting with `@` is an attribute, the reserved `#text` key is character data, and\n * every other key is a child element name. An `undefined` value means the field is absent, which is how an absent optional field stays\n * distinguishable from an empty one.\n */\nexport interface XmlRecord {\n readonly [name: string]: XmlValue | undefined;\n}\n\n/**\n * @description One value in an XML document: nothing at all, character data, a repeated run of children, or a record of attributes, text and child elements.\n * `undefined` is a value of its own rather than an omission, because that is how an absent optional field survives a round trip. A field with no\n * value stays distinguishable from a field whose value is the empty string, and the renderer writes neither of them. The shape is the same one\n * `Schema.toCodecStringTree` derives, and sharing that shape is what lets every schema feature Effect supports round-trip through this package\n * without re-implementing the derivation.\n */\nexport type XmlValue = string | undefined | ReadonlyArray<XmlValue> | XmlRecord;\n\n/**\n * @description How deep {@link isXmlValue} will walk before giving up. A document nested deeper than this is treated as invalid rather than allowed to exhaust the\n * stack.\n */\nconst MAX_GUARD_DEPTH = 512;\n\n/**\n * @description Whether an arbitrary value is a well-formed {@link XmlValue}.\n *\n * @param input - The candidate value.\n *\n * @returns Whether the value is absent, character data, an array of `XmlValue`, or a plain record of them.\n */\nexport const isXmlValue = (input: unknown): input is XmlValue => check(input, 0);\n\n/**\n * @description One level of {@link isXmlValue}, with the depth it was reached at. The depth is the whole defence against a value built to be hostile. A\n * self-referential object would otherwise recurse until the stack gave out, and a value nested thousands deep would take it with it. This rejects\n * both, so it is a bounded recursion rather than a loop. The model is a tree, and walking a tree means walking it.\n *\n * @param input - The candidate value.\n * @param depth - How many levels down this value sits.\n *\n * @returns Whether this level is legal, and the rest of the value with it.\n */\nconst check = (input: unknown, depth: number): boolean => {\n // `undefined` is a value in its own right: it is how an absent optional field survives a round trip, so a\n // record is allowed to hold one and a document is allowed to be missing a field.\n if (Predicate.isUndefined(input) || Predicate.isString(input)) return true;\n if (Predicate.isNull(input) || !Predicate.isObjectOrArray(input)) return false;\n\n if (depth > MAX_GUARD_DEPTH) return false;\n\n // Arrays and records are the only two things left, and each is a walk of its\n // own rather than another branch here: an array of them or a record of them.\n return Array.isArray(input) ? everyMemberIs(input, depth) : everyFieldIs(input, depth);\n};\n\n/**\n * @description Whether an array holds nothing but legal values one level down.\n *\n * @param members - The array's members.\n * @param depth - The depth the array itself sits at.\n *\n * @returns Whether every member is a legal `XmlValue`.\n */\nconst everyMemberIs = (members: ReadonlyArray<unknown>, depth: number): boolean => {\n for (const member of members) {\n if (!check(member, depth + 1)) return false;\n }\n return true;\n};\n\n/**\n * @description Whether an object is a plain record of legal values one level down. Plainness is checked here rather than by the caller because a `Date` or a `Map`\n * has values a record cannot hold, and walking them would be walking something the model has no way to represent.\n *\n * @param input - The object to walk.\n * @param depth - The depth the object itself sits at.\n *\n * @returns Whether the object is a plain record whose every field is a legal `XmlValue`.\n */\nconst everyFieldIs = (input: object, depth: number): boolean => {\n if (!Predicate.isReadonlyObject(input)) return false;\n for (const key of Object.keys(input)) {\n if (!check(input[key], depth + 1)) return false;\n }\n return true;\n};\n\n/**\n * @description A schema for {@link XmlValue}, so a value can be validated on its own. This helps when it arrives from a store or a queue rather than from\n * {@link parseXml}, and the schema it belongs to is not in hand.\n *\n * @example\n * ```typescript\n * import { Schema } from 'effect';\n * import { XmlValue } from '@endevops/effect-codec-xml';\n *\n * Schema.decodeUnknownSync(XmlValue)({ book: { '@id': '1', title: 'Dune' } }); // => { book: { '@id': '1', title: 'Dune' } }\n * Schema.decodeUnknownSync(XmlValue)({ book: { title: 42 } }); // => throws XmlValue\n * ```;\n */\nexport const XmlValue: Schema.Codec<XmlValue> = Schema.declare<XmlValue>(isXmlValue, {\n identifier: 'XmlValue',\n expected: 'an XML value: character data, an array of them, or a record of them',\n});\n"],"mappings":";;;;;;AAwBA,MAAM,kBAAkB;;;;;;;;AASxB,MAAa,cAAc,UAAsC,MAAM,OAAO,CAAC;;;;;;;;;;;AAY/E,MAAM,SAAS,OAAgB,UAA2B;CAGxD,IAAI,UAAU,YAAY,KAAK,KAAK,UAAU,SAAS,KAAK,GAAG,OAAO;CACtE,IAAI,UAAU,OAAO,KAAK,KAAK,CAAC,UAAU,gBAAgB,KAAK,GAAG,OAAO;CAEzE,IAAI,QAAQ,iBAAiB,OAAO;CAIpC,OAAO,MAAM,QAAQ,KAAK,IAAI,cAAc,OAAO,KAAK,IAAI,aAAa,OAAO,KAAK;AACvF;;;;;;;;;AAUA,MAAM,iBAAiB,SAAiC,UAA2B;CACjF,KAAK,MAAM,UAAU,SACnB,IAAI,CAAC,MAAM,QAAQ,QAAQ,CAAC,GAAG,OAAO;CAExC,OAAO;AACT;;;;;;;;;;AAWA,MAAM,gBAAgB,OAAe,UAA2B;CAC9D,IAAI,CAAC,UAAU,iBAAiB,KAAK,GAAG,OAAO;CAC/C,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,GACjC,IAAI,CAAC,MAAM,MAAM,MAAM,QAAQ,CAAC,GAAG,OAAO;CAE5C,OAAO;AACT;;;;;;;;;;;;;;AAeA,MAAa,WAAmC,OAAO,QAAkB,YAAY;CACnF,YAAY;CACZ,UAAU;AACZ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@endevops/effect-codec-xml",
|
|
3
|
-
"version": "0.0.1",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
4
|
"description": "A round-trip Effect Schema codec for XML, with the entity decoder and XML name validators it reads documents with. Derives an XML representation from any Effect Schema and reads it back, with attributes as `@`-prefixed fields.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codec",
|
package/src/codec.ts
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
// `toCodecXml` is this package's counterpart to `Schema.toCodecJson`. It
|
|
4
4
|
// returns a `Schema` whose `Type` is the source schema's `Type` and whose
|
|
5
5
|
// `Encoded` is XML text, so a value is written with `Schema.encodeSync(codec)`
|
|
6
|
-
// and read back with `Schema.decodeSync(codec)
|
|
7
|
-
// JSON codec
|
|
8
|
-
//
|
|
6
|
+
// and read back with `Schema.decodeSync(codec)`. That is one call each, as with
|
|
7
|
+
// the JSON codec. There is no value tree at the call site and no second call to
|
|
8
|
+
// a renderer or a parser.
|
|
9
9
|
//
|
|
10
10
|
// The derivation underneath is Effect's own `Schema.toCodecStringTree`, so
|
|
11
11
|
// every schema feature Effect supports composes here without this package
|
|
12
|
-
// re-implementing the walk over a schema AST. On
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// re-implementing the walk over a schema AST. On encode the codec runs the value
|
|
13
|
+
// tree through `renderXml`; on decode it runs the document through `parseXml`.
|
|
14
|
+
// Both are the same text layer this package exports on its own, and both
|
|
15
|
+
// failures arrive as the `SchemaIssue.Issue` a schema is expected to report,
|
|
16
|
+
// with the underlying message preserved. Those failures are an illegal name and
|
|
17
|
+
// a document that is not well-formed.
|
|
18
18
|
//
|
|
19
19
|
// The conventions stay in the keys: a key starting with `@` is an attribute,
|
|
20
20
|
// `#text` is character data, and every other key is a child element. The root
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
// has one, and from the `rootName` option otherwise; it defaults to `'root'`,
|
|
23
23
|
// the same name Effect's own XML encoder uses.
|
|
24
24
|
|
|
25
|
-
import { Effect, Predicate, Schema, SchemaAST, SchemaIssue, SchemaTransformation } from 'effect';
|
|
25
|
+
import { Effect, Function, Predicate, Schema, SchemaAST, SchemaIssue, SchemaTransformation } from 'effect';
|
|
26
26
|
|
|
27
|
+
import type { NamespacePlan } from './namespaces.ts';
|
|
27
28
|
import type { XmlParseOptions } from './parse.ts';
|
|
28
29
|
import type { XmlRenderOptions } from './render.ts';
|
|
29
30
|
import type { XmlValue } from './xml-value.ts';
|
|
@@ -36,8 +37,8 @@ import { renderXml } from './render.ts';
|
|
|
36
37
|
/**
|
|
37
38
|
* @description Options for {@link toCodecXml}.\
|
|
38
39
|
* The render options name and shape the document; the parse options decide how strictly it is read back.\
|
|
39
|
-
* `rootName` is the one the codec resolves for itself when the caller leaves it out
|
|
40
|
-
* back to `'root'`.
|
|
40
|
+
* `rootName` is the one the codec resolves for itself when the caller leaves it out. The codec takes it from the schema's `identifier` or `title` annotation and
|
|
41
|
+
* falls back to `'root'`.
|
|
41
42
|
*/
|
|
42
43
|
export type XmlCodecOptions = XmlRenderOptions & XmlParseOptions;
|
|
43
44
|
|
|
@@ -49,18 +50,40 @@ export interface toCodecXml<S extends Schema.Constraint> extends Schema.decodeTo
|
|
|
49
50
|
readonly Rebuild: toCodecXml<S>;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Whether the plan places any node at all: a rename, an attribute, a value, or a root namespace or name. A plan with none of these leaves the value tree
|
|
54
|
+
// alone on both sides, so the codec skips the name walk.
|
|
55
|
+
const isActivePlan = (plan: NamespacePlan): boolean =>
|
|
56
|
+
plan.byKey.size > 0 ||
|
|
57
|
+
plan.nameByKey.size > 0 ||
|
|
58
|
+
plan.attributeKeys.size > 0 ||
|
|
59
|
+
plan.valueByElement.size > 0 ||
|
|
60
|
+
plan.root !== undefined ||
|
|
61
|
+
plan.rootName !== undefined;
|
|
62
|
+
|
|
63
|
+
// The render options with the root name resolved and any root prefix applied. The caller's `rootName` wins, then the plan's, then the schema's `identifier`
|
|
64
|
+
// or `title` annotation, then `'root'`. A root namespace with a prefix qualifies the name unless the caller already wrote one.
|
|
65
|
+
const resolveRenderOptions = <S extends Schema.Constraint>(schema: S, plan: NamespacePlan, options: XmlCodecOptions): XmlRenderOptions => {
|
|
66
|
+
const rootName =
|
|
67
|
+
options.rootName ?? plan.rootName ?? SchemaAST.resolveIdentifier(schema.ast) ?? SchemaAST.resolveTitle(schema.ast) ?? DEFAULT_ROOT_NAME;
|
|
68
|
+
const wireRootName = plan.root !== undefined && plan.root.prefix !== '' && !rootName.includes(':') ? `${plan.root.prefix}:${rootName}` : rootName;
|
|
69
|
+
return { ...options, rootName: wireRootName };
|
|
70
|
+
};
|
|
71
|
+
|
|
52
72
|
/**
|
|
53
73
|
* @description Derives the XML codec for a schema: a `Schema` whose `Encoded` is an XML document, so `Schema.encodeSync(codec)` writes text and
|
|
54
74
|
* `Schema.decodeSync(codec)` reads it back. The derivation is Effect's `Schema.toCodecStringTree`; the text layer is this package's {@link renderXml}
|
|
55
|
-
* and {@link parseXml}.
|
|
75
|
+
* and {@link parseXml}. Call it data-first, `toCodecXml(schema, options)`, or data-last, `toCodecXml(options)(schema)`, so it drops into `pipe` beside
|
|
76
|
+
* the rest of the Effect combinators. The two forms are one function: the first argument decides the style, a schema is read as data-first and an
|
|
77
|
+
* options object as data-last. `pipe(schema, toCodecXml)` carries no options and works because a schema on its own is data-first.
|
|
56
78
|
*
|
|
57
79
|
* @example
|
|
58
80
|
* ```typescript
|
|
59
|
-
* import { Schema } from 'effect';
|
|
60
|
-
* import { toCodecXml } from '@endevops/effect-xml
|
|
81
|
+
* import { Schema, pipe } from 'effect';
|
|
82
|
+
* import { toCodecXml } from '@endevops/effect-codec-xml';
|
|
61
83
|
*
|
|
62
84
|
* const Book = Schema.Struct({ '@id': Schema.String, title: Schema.String, pages: Schema.Number });
|
|
63
85
|
* const codec = toCodecXml(Book, { rootName: 'book' });
|
|
86
|
+
* const piped = pipe(Book, toCodecXml({ rootName: 'book' }));
|
|
64
87
|
*
|
|
65
88
|
* const value = { '@id': '1', title: 'Dune', pages: 412 };
|
|
66
89
|
*
|
|
@@ -71,66 +94,63 @@ export interface toCodecXml<S extends Schema.Constraint> extends Schema.decodeTo
|
|
|
71
94
|
* // => { '@id': '1', title: 'Dune', pages: 412 }
|
|
72
95
|
* ```;
|
|
73
96
|
*
|
|
74
|
-
* @param schema - The schema describing the value.
|
|
75
|
-
* @param options - Render and parse options. `rootName` defaults to the schema's `identifier` or `title` annotation, then to `'root'`.
|
|
97
|
+
* @param schema - The schema describing the value, in the data-first form.
|
|
98
|
+
* @param options - Render and parse options. `rootName` defaults to the schema's `identifier` or `title` annotation, then to `'root'`. In the
|
|
99
|
+
* data-last form this is the only argument, and the schema arrives from `pipe`.
|
|
76
100
|
*
|
|
77
|
-
* @returns The codec, with the source schema's `Type` and the same service requirements.
|
|
101
|
+
* @returns The codec, with the source schema's `Type` and the same service requirements. In the data-last form, a function from the schema to the
|
|
102
|
+
* codec.
|
|
78
103
|
*/
|
|
79
|
-
export const toCodecXml
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
plan
|
|
90
|
-
|
|
91
|
-
plan
|
|
104
|
+
export const toCodecXml: {
|
|
105
|
+
<S extends Schema.Constraint>(schema: S, options?: XmlCodecOptions): toCodecXml<S>;
|
|
106
|
+
(options?: XmlCodecOptions): <S extends Schema.Constraint>(schema: S) => toCodecXml<S>;
|
|
107
|
+
} = Function.dual(
|
|
108
|
+
args => Schema.isSchema(args[0]),
|
|
109
|
+
<S extends Schema.Constraint>(schema: S, options: XmlCodecOptions = {}): toCodecXml<S> => {
|
|
110
|
+
const planned = namespacePlan(schema);
|
|
111
|
+
if (Predicate.hasProperty(planned, 'error')) {
|
|
112
|
+
throw new Error(`Invalid XML namespace annotation:\n\t- ${planned.error}.`);
|
|
113
|
+
}
|
|
114
|
+
const plan = planned.plan;
|
|
115
|
+
const active = isActivePlan(plan);
|
|
116
|
+
const renderOptions = resolveRenderOptions(schema, plan, options);
|
|
92
117
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
return Schema.String.pipe(
|
|
119
|
+
Schema.decodeTo(
|
|
120
|
+
Schema.toCodecStringTree(schema),
|
|
121
|
+
SchemaTransformation.transformEffect({
|
|
122
|
+
decode: (text, parseOptions) => {
|
|
123
|
+
if (!Predicate.isString(text)) {
|
|
124
|
+
return Effect.fail(new SchemaIssue.InvalidValue({ message: `Expected a string, but received ${typeof text}.` }, text, parseOptions));
|
|
125
|
+
}
|
|
96
126
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return Effect.fail(new SchemaIssue.InvalidValue({ message: `Expected a string, but received ${typeof text}.` }, text, parseOptions));
|
|
106
|
-
}
|
|
127
|
+
return parseXml(text, options).pipe(
|
|
128
|
+
Effect.map(value => (active ? decodeNames(value, plan, {}, ROOT_ELEMENT) : value)),
|
|
129
|
+
Effect.tapError(error =>
|
|
130
|
+
Effect.logError(`XML parse error: ${error.message}`).pipe(Effect.annotateLogs({ cause: error, message: 'XML parse error' }))
|
|
131
|
+
),
|
|
132
|
+
Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, text, parseOptions))
|
|
133
|
+
);
|
|
134
|
+
},
|
|
107
135
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
encode: (value, parseOptions) => {
|
|
137
|
+
if (active && Array.isArray(value) && (plan.root !== undefined || plan.rootName !== undefined)) {
|
|
138
|
+
return Effect.fail(
|
|
139
|
+
new SchemaIssue.InvalidValue(
|
|
140
|
+
{ message: 'An array at the root of a namespaced schema cannot carry the root namespace or name.' },
|
|
141
|
+
value,
|
|
142
|
+
parseOptions
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
116
146
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
new SchemaIssue.InvalidValue(
|
|
121
|
-
{ message: 'An array at the root of a namespaced schema cannot carry the root namespace or name.' },
|
|
122
|
-
value,
|
|
123
|
-
parseOptions
|
|
124
|
-
)
|
|
147
|
+
const wire = active ? encodeNames(value as XmlValue, plan, plan.root, {}, ROOT_ELEMENT) : (value as XmlValue);
|
|
148
|
+
return renderXml(wire, renderOptions).pipe(
|
|
149
|
+
Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, value, parseOptions))
|
|
125
150
|
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
)
|
|
135
|
-
);
|
|
136
|
-
};
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
);
|
package/src/conventions.ts
CHANGED
|
@@ -87,9 +87,9 @@ export const isTextKey = (key: string): boolean => key === TEXT_KEY;
|
|
|
87
87
|
export const isReservedKey = (key: string): boolean => isTextKey(key);
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* @description Resolves a name to something legal in an XML document, synchronously. The renderer and the parser both resolve a name per element and per
|
|
91
|
-
*
|
|
92
|
-
* an {@link XmlParseError}; the callers that need it in a typed channel use {@link resolveName}, which wraps this.
|
|
90
|
+
* @description Resolves a name to something legal in an XML document, synchronously. The renderer and the parser both resolve a name per element and per
|
|
91
|
+
* attribute, and that is the codec's hot path, so the walk calls this directly and keeps the work in plain JavaScript. `'error'` mode reports an
|
|
92
|
+
* illegal name by throwing an {@link XmlParseError}; the callers that need it in a typed channel use {@link resolveName}, which wraps this.
|
|
93
93
|
*
|
|
94
94
|
* @param name - The candidate element or attribute name.
|
|
95
95
|
* @param options - Repair mode and XML version.
|
|
@@ -131,10 +131,10 @@ const resolveNameResult = (name: string, options: ResolveNameOptions): Result.Re
|
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
* @description Resolves a name to something legal in an XML document. The renderer and the parser both resolve a name per element and per attribute
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
134
|
+
* @description Resolves a name to something legal in an XML document. The renderer and the parser both resolve a name per element and per attribute. An illegal
|
|
135
|
+
* name under `'error'` mode is a rejection rather than a value, so this returns an `Effect` with the `XmlParseError` in its error channel instead of
|
|
136
|
+
* throwing it. Callers `yield*` it and the failure composes with `catchTag` and the rest. The two internal call sites in `parse.ts` and `render.ts`
|
|
137
|
+
* use {@link resolveNameSync} directly, because their walks are synchronous hot paths.
|
|
138
138
|
*
|
|
139
139
|
* @param name - The candidate element or attribute name.
|
|
140
140
|
* @param options - Repair mode and XML version.
|