@endevops/effect-codec-xml 0.0.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/LICENSE +21 -0
- package/LICENSE-is-entities +21 -0
- package/LICENSE-is-xml-naming +21 -0
- package/README.md +415 -0
- package/dist/codec.d.ts +48 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +63 -0
- package/dist/codec.js.map +1 -0
- package/dist/conventions.d.ts +88 -0
- package/dist/conventions.d.ts.map +1 -0
- package/dist/conventions.js +113 -0
- package/dist/conventions.js.map +1 -0
- package/dist/entities/entity-decoder.d.ts +333 -0
- package/dist/entities/entity-decoder.d.ts.map +1 -0
- package/dist/entities/entity-decoder.js +841 -0
- package/dist/entities/entity-decoder.js.map +1 -0
- package/dist/entities/entity-tables.js +16 -0
- package/dist/entities/entity-tables.js.map +1 -0
- package/dist/errors.d.ts +49 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +48 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/namespaces.d.ts +101 -0
- package/dist/namespaces.d.ts.map +1 -0
- package/dist/namespaces.js +663 -0
- package/dist/namespaces.js.map +1 -0
- package/dist/naming.d.ts +149 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +296 -0
- package/dist/naming.js.map +1 -0
- package/dist/parse.d.ts +75 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +437 -0
- package/dist/parse.js.map +1 -0
- package/dist/render.d.ts +99 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +509 -0
- package/dist/render.js.map +1 -0
- package/dist/xml-error.d.ts +172 -0
- package/dist/xml-error.d.ts.map +1 -0
- package/dist/xml-error.js +157 -0
- package/dist/xml-error.js.map +1 -0
- package/dist/xml-value.d.ts +42 -0
- package/dist/xml-value.d.ts.map +1 -0
- package/dist/xml-value.js +79 -0
- package/dist/xml-value.js.map +1 -0
- package/package.json +69 -0
- package/src/codec.ts +136 -0
- package/src/conventions.ts +145 -0
- package/src/entities/entity-decoder.ts +1248 -0
- package/src/entities/entity-tables.ts +18 -0
- package/src/errors.ts +55 -0
- package/src/index.ts +79 -0
- package/src/namespaces.ts +968 -0
- package/src/naming.ts +519 -0
- package/src/parse.ts +597 -0
- package/src/render.ts +708 -0
- package/src/xml-error.ts +168 -0
- package/src/xml-value.ts +108 -0
package/src/xml-error.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @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
|
|
3
|
+
* 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
|
|
4
|
+
* 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
|
|
5
|
+
* a string.
|
|
6
|
+
*
|
|
7
|
+
* ## Why one error with a `reason`, and not one error per cause
|
|
8
|
+
*
|
|
9
|
+
* The causes would mean a class each, and a caller handling "any of these" would need `catchTags` with all of them. The causes are not independent
|
|
10
|
+
* decisions a caller usually wants to make separately — they are all "this XML input was not acceptable", and the useful split is coarse: a bad
|
|
11
|
+
* _configuration_ versus a bad _document_. So there is one error, and `reason` narrows to the specific cause. Recovery is a `catchReason` away:
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { Effect } from 'effect';
|
|
16
|
+
* import { EntityDecoder, XmlError } from '@endevops/effect-xml-codec';
|
|
17
|
+
*
|
|
18
|
+
* const limited = new EntityDecoder({ limit: { maxTotalExpansions: 2 } }).decode('&&&').pipe(
|
|
19
|
+
* Effect.catchReason('XmlError', 'ExpansionLimitExceeded', reason => Effect.succeed(`gave up after ${reason.actual}`)),
|
|
20
|
+
* );
|
|
21
|
+
* ```;
|
|
22
|
+
*
|
|
23
|
+
* The message is kept alongside `reason` and is part of the schema, because the text is load-bearing: the
|
|
24
|
+
* `[EntityReplacer]` prefix in particular is documented as something callers match on, so it is reproduced exactly
|
|
25
|
+
* rather than reworded.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Schema } from 'effect';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @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
|
|
32
|
+
* 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 —
|
|
33
|
+
* there is no catch-all member, so an exhaustive `match` stays exhaustive as causes are added.
|
|
34
|
+
*/
|
|
35
|
+
export const XmlErrorReason = Schema.TaggedUnion({
|
|
36
|
+
/**
|
|
37
|
+
* @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
|
|
38
|
+
* input — {@link EntityDecoder.make} among them — when they are handed `null` for an options object that has no meaningful default. It is a
|
|
39
|
+
* distinct case from the rest because the argument is not _wrong_, it is _absent_, and a caller who wrote `make(null)` meant something the type
|
|
40
|
+
* system does not allow: a decoder with every default is `make({})`, and saying so here is more useful than silently producing one.
|
|
41
|
+
*/
|
|
42
|
+
MissingOptions: {
|
|
43
|
+
/**
|
|
44
|
+
* @description The parameter that was given nothing, named as it appears in the signature.
|
|
45
|
+
*/
|
|
46
|
+
parameter: Schema.String,
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @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
|
|
51
|
+
* closed union — it is the guard for untyped JavaScript callers and for values that crossed a boundary as `unknown`.
|
|
52
|
+
*/
|
|
53
|
+
InvalidProduction: {
|
|
54
|
+
production: Schema.String,
|
|
55
|
+
/**
|
|
56
|
+
* @description The productions that would have been accepted, comma-separated, as they appear in the message.
|
|
57
|
+
*/
|
|
58
|
+
expected: Schema.String,
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @description An entity name cannot be written as a reference, so it is refused at registration.
|
|
63
|
+
*/
|
|
64
|
+
InvalidEntityName: {
|
|
65
|
+
/**
|
|
66
|
+
* @description The rejected name, as it was passed in.
|
|
67
|
+
*/
|
|
68
|
+
name: Schema.String,
|
|
69
|
+
/**
|
|
70
|
+
* @description The offending character, or `#` for a name that starts with one. A leading `#` is refused by position rather than by the character sweep,
|
|
71
|
+
* because a name starting with `#` is a numeric reference's token and would collide with the numeric pipeline.
|
|
72
|
+
*/
|
|
73
|
+
character: Schema.String,
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @description A registration hook refused an entity and asked for the registration to abort.
|
|
78
|
+
*/
|
|
79
|
+
EntityRejected: {
|
|
80
|
+
/**
|
|
81
|
+
* @description Which registration was in progress. Both are runtime-injected, which is why they share a tier for limit accounting.
|
|
82
|
+
*/
|
|
83
|
+
context: Schema.Literals(['external', 'input']),
|
|
84
|
+
/**
|
|
85
|
+
* @description The entity name, without `&` or `;`.
|
|
86
|
+
*/
|
|
87
|
+
name: Schema.String,
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @description A document expanded more tracked entity references than {@link EntityDecoderLimitOptions.maxTotalExpansions} allows. A document can define an
|
|
92
|
+
* entity that references another ten times over. Ten deep is a denial of service; a hundred is a fork bomb written in XML.
|
|
93
|
+
*/
|
|
94
|
+
ExpansionLimitExceeded: {
|
|
95
|
+
/**
|
|
96
|
+
* @description The count that tripped the limit. Deliberately not reset on failure, so this is the real over-limit total rather than the ceiling.
|
|
97
|
+
*/
|
|
98
|
+
actual: Schema.Number,
|
|
99
|
+
/**
|
|
100
|
+
* @description The configured ceiling. The check is `actual > limit`, so a limit of `2` allows two expansions.
|
|
101
|
+
*/
|
|
102
|
+
limit: Schema.Number,
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @description A document grew by more characters through entity expansion than {@link EntityDecoderLimitOptions.maxExpandedLength} allows. Only the surplus
|
|
107
|
+
* counts: a reference whose replacement is no longer than the `&token;` it replaces contributes nothing, so this bounds growth rather than document
|
|
108
|
+
* size.
|
|
109
|
+
*/
|
|
110
|
+
ExpandedLengthLimitExceeded: {
|
|
111
|
+
/**
|
|
112
|
+
* @description The accumulated growth that tripped the limit.
|
|
113
|
+
*/
|
|
114
|
+
actual: Schema.Number,
|
|
115
|
+
/**
|
|
116
|
+
* @description The configured ceiling.
|
|
117
|
+
*/
|
|
118
|
+
limit: Schema.Number,
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @description A numeric character reference was prohibited by the configured policy.
|
|
123
|
+
*/
|
|
124
|
+
ProhibitedCharacterReference: {
|
|
125
|
+
/**
|
|
126
|
+
* @description The raw token without `&` and `;`, e.g. `#38` or `#x26`.
|
|
127
|
+
*/
|
|
128
|
+
token: Schema.String,
|
|
129
|
+
/**
|
|
130
|
+
* @description The codepoint the reference resolved to, so a log can name the character that was refused.
|
|
131
|
+
*/
|
|
132
|
+
codepoint: Schema.Number,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @description The reason a well-formedness or policy failure occurred.
|
|
138
|
+
*/
|
|
139
|
+
export type XmlErrorReason = typeof XmlErrorReason.Type;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @description Every failure this package can report, in the `E` channel of the effects that can fail. Carries both a `reason` — the typed, matchable cause — and
|
|
143
|
+
* 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
|
|
144
|
+
* through a serialised boundary without losing either.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* import { Effect } from 'effect';
|
|
149
|
+
* import { EntityDecoder } from '@endevops/effect-xml-codec';
|
|
150
|
+
*
|
|
151
|
+
* const program = Effect.gen(function*() {
|
|
152
|
+
* const decoder = yield* EntityDecoder.make({});
|
|
153
|
+
* return yield* decoder.decode('a & b');
|
|
154
|
+
* });
|
|
155
|
+
* ```;
|
|
156
|
+
*/
|
|
157
|
+
export class XmlError extends Schema.TaggedError<XmlError>()('XmlError', {
|
|
158
|
+
/**
|
|
159
|
+
* @description The specific cause. Narrow on `_tag`, or recover with `Effect.catchReason`.
|
|
160
|
+
*/
|
|
161
|
+
reason: XmlErrorReason,
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @description Human-readable description. The `[EntityReplacer]` and `[EntityDecoder]` prefixes are reproduced verbatim from the original throw sites, because
|
|
165
|
+
* they are documented as load-bearing for anything matching on them.
|
|
166
|
+
*/
|
|
167
|
+
message: Schema.String,
|
|
168
|
+
}) {}
|
package/src/xml-value.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Predicate, Schema } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @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
|
|
5
|
+
* every other key is a child element name. An `undefined` value means the field is absent, which is how an absent optional field stays
|
|
6
|
+
* distinguishable from an empty one.
|
|
7
|
+
*/
|
|
8
|
+
export interface XmlRecord {
|
|
9
|
+
readonly [name: string]: XmlValue | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @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.
|
|
14
|
+
* `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
|
|
15
|
+
* value stays distinguishable from a field whose value is the empty string, and the renderer writes neither of them. The shape is deliberately the
|
|
16
|
+
* same one `Schema.toCodecStringTree` derives, which is what lets every schema feature Effect supports round-trip through this package without
|
|
17
|
+
* re-implementing the derivation.
|
|
18
|
+
*/
|
|
19
|
+
export type XmlValue = string | undefined | ReadonlyArray<XmlValue> | XmlRecord;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @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
|
|
23
|
+
* stack.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_GUARD_DEPTH = 512;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @description Whether an arbitrary value is a well-formed {@link XmlValue}.
|
|
29
|
+
*
|
|
30
|
+
* @param input - The candidate value.
|
|
31
|
+
*
|
|
32
|
+
* @returns Whether the value is absent, character data, an array of `XmlValue`, or a plain record of them.
|
|
33
|
+
*/
|
|
34
|
+
export const isXmlValue = (input: unknown): input is XmlValue => check(input, 0);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @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
|
|
38
|
+
* self-referential object would otherwise recurse until the stack gave out, and a value nested thousands deep would take it with it. Both are
|
|
39
|
+
* rejected here instead, which is why this is a real recursion with a bound rather than a loop — the model is a tree, and a tree is walked by walking
|
|
40
|
+
* it.
|
|
41
|
+
*
|
|
42
|
+
* @param input - The candidate value.
|
|
43
|
+
* @param depth - How many levels down this value sits.
|
|
44
|
+
*
|
|
45
|
+
* @returns Whether this level is legal, and the rest of the value with it.
|
|
46
|
+
*/
|
|
47
|
+
const check = (input: unknown, depth: number): boolean => {
|
|
48
|
+
// `undefined` is a value in its own right: it is how an absent optional field survives a round trip, so a
|
|
49
|
+
// record is allowed to hold one and a document is allowed to be missing a field.
|
|
50
|
+
if (Predicate.isUndefined(input) || Predicate.isString(input)) return true;
|
|
51
|
+
if (Predicate.isNull(input) || !Predicate.isObjectOrArray(input)) return false;
|
|
52
|
+
|
|
53
|
+
if (depth > MAX_GUARD_DEPTH) return false;
|
|
54
|
+
|
|
55
|
+
// Arrays and records are the only two things left, and each is a walk of its
|
|
56
|
+
// own rather than another branch here: an array of them or a record of them.
|
|
57
|
+
return Array.isArray(input) ? everyMemberIs(input, depth) : everyFieldIs(input, depth);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @description Whether an array holds nothing but legal values one level down.
|
|
62
|
+
*
|
|
63
|
+
* @param members - The array's members.
|
|
64
|
+
* @param depth - The depth the array itself sits at.
|
|
65
|
+
*
|
|
66
|
+
* @returns Whether every member is a legal `XmlValue`.
|
|
67
|
+
*/
|
|
68
|
+
const everyMemberIs = (members: ReadonlyArray<unknown>, depth: number): boolean => {
|
|
69
|
+
for (const member of members) {
|
|
70
|
+
if (!check(member, depth + 1)) return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @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`
|
|
77
|
+
* has values a record cannot hold, and walking them would be walking something the model has no way to represent.
|
|
78
|
+
*
|
|
79
|
+
* @param input - The object to walk.
|
|
80
|
+
* @param depth - The depth the object itself sits at.
|
|
81
|
+
*
|
|
82
|
+
* @returns Whether the object is a plain record whose every field is a legal `XmlValue`.
|
|
83
|
+
*/
|
|
84
|
+
const everyFieldIs = (input: object, depth: number): boolean => {
|
|
85
|
+
if (!Predicate.isReadonlyObject(input)) return false;
|
|
86
|
+
for (const key of Object.keys(input)) {
|
|
87
|
+
if (!check(input[key], depth + 1)) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @description A schema for {@link XmlValue}, so a value can be validated on its own — when it arrives from a store or a queue rather than from {@link parseXml},
|
|
94
|
+
* and the schema it belongs to is not in hand.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* import { Schema } from 'effect';
|
|
99
|
+
* import { XmlValue } from '@endevops/effect-xml-codec';
|
|
100
|
+
*
|
|
101
|
+
* Schema.decodeUnknownSync(XmlValue)({ book: { '@id': '1', title: 'Dune' } }); // => { book: { '@id': '1', title: 'Dune' } }
|
|
102
|
+
* Schema.decodeUnknownSync(XmlValue)({ book: { title: 42 } }); // => throws XmlValue
|
|
103
|
+
* ```;
|
|
104
|
+
*/
|
|
105
|
+
export const XmlValue: Schema.Codec<XmlValue> = Schema.declare<XmlValue>(isXmlValue, {
|
|
106
|
+
identifier: 'XmlValue',
|
|
107
|
+
expected: 'an XML value: character data, an array of them, or a record of them',
|
|
108
|
+
});
|