@endevops/effect-codec-xml 0.0.1 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +81 -62
  2. package/dist/codec.d.ts +17 -9
  3. package/dist/codec.d.ts.map +1 -1
  4. package/dist/codec.js +29 -18
  5. package/dist/codec.js.map +1 -1
  6. package/dist/conventions.d.ts +4 -4
  7. package/dist/conventions.js +7 -7
  8. package/dist/conventions.js.map +1 -1
  9. package/dist/entities/entity-decoder.d.ts +33 -33
  10. package/dist/entities/entity-decoder.d.ts.map +1 -1
  11. package/dist/entities/entity-decoder.js +63 -64
  12. package/dist/entities/entity-decoder.js.map +1 -1
  13. package/dist/errors.d.ts +2 -2
  14. package/dist/errors.js +2 -2
  15. package/dist/errors.js.map +1 -1
  16. package/dist/namespaces.js +40 -13
  17. package/dist/namespaces.js.map +1 -1
  18. package/dist/naming.d.ts +6 -6
  19. package/dist/naming.d.ts.map +1 -1
  20. package/dist/naming.js +3 -3
  21. package/dist/naming.js.map +1 -1
  22. package/dist/parse.d.ts +5 -5
  23. package/dist/parse.js +14 -14
  24. package/dist/parse.js.map +1 -1
  25. package/dist/plain-value.js +242 -0
  26. package/dist/plain-value.js.map +1 -0
  27. package/dist/render.d.ts +1 -1
  28. package/dist/render.d.ts.map +1 -1
  29. package/dist/render.js +28 -28
  30. package/dist/render.js.map +1 -1
  31. package/dist/xml-error.d.ts +9 -9
  32. package/dist/xml-error.js +18 -18
  33. package/dist/xml-error.js.map +1 -1
  34. package/dist/xml-value.d.ts +7 -7
  35. package/dist/xml-value.d.ts.map +1 -1
  36. package/dist/xml-value.js +6 -7
  37. package/dist/xml-value.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/codec.ts +98 -71
  40. package/src/conventions.ts +7 -7
  41. package/src/entities/entity-decoder.ts +98 -99
  42. package/src/errors.ts +3 -3
  43. package/src/index.ts +3 -3
  44. package/src/namespaces.ts +62 -36
  45. package/src/naming.ts +34 -35
  46. package/src/parse.ts +26 -26
  47. package/src/plain-value.ts +312 -0
  48. package/src/render.ts +44 -44
  49. package/src/xml-error.ts +18 -18
  50. package/src/xml-value.ts +10 -11
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)` — one call each, the way the
7
- // JSON codec works. There is no value tree at the call site and no second call
8
- // to a renderer or a parser.
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 the way out the codec runs the
13
- // value tree through `renderXml`; on the way back in it runs the document
14
- // through `parseXml`. Both are the same text layer this package exports on
15
- // their own, and both failures — an illegal name, a document that is not
16
- // well-formed — arrive as the `SchemaIssue.Issue` a schema is expected to
17
- // report, with the underlying message preserved.
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';
@@ -31,13 +32,14 @@ import type { XmlValue } from './xml-value.ts';
31
32
  import { DEFAULT_ROOT_NAME } from './conventions.ts';
32
33
  import { decodeNames, encodeNames, namespacePlan, ROOT_ELEMENT } from './namespaces.ts';
33
34
  import { parseXml } from './parse.ts';
35
+ import { normalizePlainValue } from './plain-value.ts';
34
36
  import { renderXml } from './render.ts';
35
37
 
36
38
  /**
37
39
  * @description Options for {@link toCodecXml}.\
38
40
  * 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, taking it from the schema's `identifier` or `title` annotation and falling
40
- * back to `'root'`.
41
+ * `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
42
+ * falls back to `'root'`.
41
43
  */
42
44
  export type XmlCodecOptions = XmlRenderOptions & XmlParseOptions;
43
45
 
@@ -49,18 +51,40 @@ export interface toCodecXml<S extends Schema.Constraint> extends Schema.decodeTo
49
51
  readonly Rebuild: toCodecXml<S>;
50
52
  }
51
53
 
54
+ // 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
55
+ // alone on both sides, so the codec skips the name walk.
56
+ const isActivePlan = (plan: NamespacePlan): boolean =>
57
+ plan.byKey.size > 0 ||
58
+ plan.nameByKey.size > 0 ||
59
+ plan.attributeKeys.size > 0 ||
60
+ plan.valueByElement.size > 0 ||
61
+ plan.root !== undefined ||
62
+ plan.rootName !== undefined;
63
+
64
+ // 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`
65
+ // or `title` annotation, then `'root'`. A root namespace with a prefix qualifies the name unless the caller already wrote one.
66
+ const resolveRenderOptions = <S extends Schema.Constraint>(schema: S, plan: NamespacePlan, options: XmlCodecOptions): XmlRenderOptions => {
67
+ const rootName =
68
+ options.rootName ?? plan.rootName ?? SchemaAST.resolveIdentifier(schema.ast) ?? SchemaAST.resolveTitle(schema.ast) ?? DEFAULT_ROOT_NAME;
69
+ const wireRootName = plan.root !== undefined && plan.root.prefix !== '' && !rootName.includes(':') ? `${plan.root.prefix}:${rootName}` : rootName;
70
+ return { ...options, rootName: wireRootName };
71
+ };
72
+
52
73
  /**
53
74
  * @description Derives the XML codec for a schema: a `Schema` whose `Encoded` is an XML document, so `Schema.encodeSync(codec)` writes text and
54
75
  * `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}.
76
+ * and {@link parseXml}. Call it data-first, `toCodecXml(schema, options)`, or data-last, `toCodecXml(options)(schema)`, so it drops into `pipe` beside
77
+ * 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
78
+ * options object as data-last. `pipe(schema, toCodecXml)` carries no options and works because a schema on its own is data-first.
56
79
  *
57
80
  * @example
58
81
  * ```typescript
59
- * import { Schema } from 'effect';
60
- * import { toCodecXml } from '@endevops/effect-xml-codec';
82
+ * import { Schema, pipe } from 'effect';
83
+ * import { toCodecXml } from '@endevops/effect-codec-xml';
61
84
  *
62
85
  * const Book = Schema.Struct({ '@id': Schema.String, title: Schema.String, pages: Schema.Number });
63
86
  * const codec = toCodecXml(Book, { rootName: 'book' });
87
+ * const piped = pipe(Book, toCodecXml({ rootName: 'book' }));
64
88
  *
65
89
  * const value = { '@id': '1', title: 'Dune', pages: 412 };
66
90
  *
@@ -71,66 +95,69 @@ export interface toCodecXml<S extends Schema.Constraint> extends Schema.decodeTo
71
95
  * // => { '@id': '1', title: 'Dune', pages: 412 }
72
96
  * ```;
73
97
  *
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'`.
98
+ * @param schema - The schema describing the value, in the data-first form.
99
+ * @param options - Render and parse options. `rootName` defaults to the schema's `identifier` or `title` annotation, then to `'root'`. In the
100
+ * data-last form this is the only argument, and the schema arrives from `pipe`.
76
101
  *
77
- * @returns The codec, with the source schema's `Type` and the same service requirements.
102
+ * @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
103
+ * codec.
78
104
  */
79
- export const toCodecXml = <S extends Schema.Constraint>(schema: S, options: XmlCodecOptions = {}): toCodecXml<S> => {
80
- const planned = namespacePlan(schema);
81
- if (Predicate.hasProperty(planned, 'error')) {
82
- throw new Error(`Invalid XML namespace annotation:\n\t- ${planned.error}.`);
83
- }
84
- const plan = planned.plan;
85
- const active =
86
- plan.byKey.size > 0 ||
87
- plan.nameByKey.size > 0 ||
88
- plan.attributeKeys.size > 0 ||
89
- plan.valueByElement.size > 0 ||
90
- plan.root !== undefined ||
91
- plan.rootName !== undefined;
105
+ export const toCodecXml: {
106
+ <S extends Schema.Constraint>(schema: S, options?: XmlCodecOptions): toCodecXml<S>;
107
+ (options?: XmlCodecOptions): <S extends Schema.Constraint>(schema: S) => toCodecXml<S>;
108
+ } = Function.dual(
109
+ args => Schema.isSchema(args[0]),
110
+ <S extends Schema.Constraint>(schema: S, options: XmlCodecOptions = {}): toCodecXml<S> => {
111
+ const planned = namespacePlan(schema);
112
+ if (Predicate.hasProperty(planned, 'error')) {
113
+ throw new Error(`Invalid XML namespace annotation:\n\t- ${planned.error}.`);
114
+ }
115
+ const plan = planned.plan;
116
+ const active = isActivePlan(plan);
117
+ const renderOptions = resolveRenderOptions(schema, plan, options);
118
+ const stringTree = Schema.toCodecStringTree(schema);
92
119
 
93
- const rootName =
94
- options.rootName ?? plan.rootName ?? SchemaAST.resolveIdentifier(schema.ast) ?? SchemaAST.resolveTitle(schema.ast) ?? DEFAULT_ROOT_NAME;
95
- const wireRootName = plan.root !== undefined && plan.root.prefix !== '' && !rootName.includes(':') ? `${plan.root.prefix}:${rootName}` : rootName;
96
-
97
- const renderOptions: XmlRenderOptions = { ...options, rootName: wireRootName };
98
-
99
- return Schema.String.pipe(
100
- Schema.decodeTo(
101
- Schema.toCodecStringTree(schema),
102
- SchemaTransformation.transformEffect({
103
- decode: (text, parseOptions) => {
104
- if (!Predicate.isString(text)) {
105
- return Effect.fail(new SchemaIssue.InvalidValue({ message: `Expected a string, but received ${typeof text}.` }, text, parseOptions));
106
- }
107
-
108
- return parseXml(text, options).pipe(
109
- Effect.map(value => (active ? decodeNames(value, plan, {}, ROOT_ELEMENT) : value)),
110
- Effect.tapError(error =>
111
- Effect.logError(`XML parse error: ${error.message}`).pipe(Effect.annotateLogs({ cause: error, message: 'XML parse error' }))
112
- ),
113
- Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, text, parseOptions))
114
- );
115
- },
120
+ return Schema.String.pipe(
121
+ Schema.decodeTo(
122
+ stringTree,
123
+ SchemaTransformation.transformEffect({
124
+ decode: (text, parseOptions) => {
125
+ if (!Predicate.isString(text)) {
126
+ return Effect.fail(new SchemaIssue.InvalidValue({ message: `Expected a string, but received ${typeof text}.` }, text, parseOptions));
127
+ }
116
128
 
117
- encode: (value, parseOptions) => {
118
- if (active && Array.isArray(value) && (plan.root !== undefined || plan.rootName !== undefined)) {
119
- return Effect.fail(
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
129
+ return parseXml(text, options).pipe(
130
+ Effect.map(value => (active ? decodeNames(value, plan, {}, ROOT_ELEMENT) : value)),
131
+ Effect.tapError(error =>
132
+ Effect.logError(`XML parse error: ${error.message}`).pipe(Effect.annotateLogs({ cause: error, message: 'XML parse error' }))
133
+ ),
134
+ Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, text, parseOptions)),
135
+ Effect.flatMap(value =>
136
+ Effect.fromResult(normalizePlainValue(value, stringTree.ast, '')).pipe(
137
+ Effect.mapError(message => new SchemaIssue.InvalidValue({ message }, value, parseOptions))
138
+ )
124
139
  )
125
140
  );
126
- }
141
+ },
127
142
 
128
- const wire = active ? encodeNames(value as XmlValue, plan, plan.root, {}, ROOT_ELEMENT) : (value as XmlValue);
129
- return renderXml(wire, renderOptions).pipe(
130
- Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, value, parseOptions))
131
- );
132
- },
133
- })
134
- )
135
- );
136
- };
143
+ encode: (value, parseOptions) => {
144
+ if (active && Array.isArray(value) && (plan.root !== undefined || plan.rootName !== undefined)) {
145
+ return Effect.fail(
146
+ new SchemaIssue.InvalidValue(
147
+ { message: 'An array at the root of a namespaced schema cannot carry the root namespace or name.' },
148
+ value,
149
+ parseOptions
150
+ )
151
+ );
152
+ }
153
+
154
+ const wire = active ? encodeNames(value as XmlValue, plan, plan.root, {}, ROOT_ELEMENT) : (value as XmlValue);
155
+ return renderXml(wire, renderOptions).pipe(
156
+ Effect.mapError(error => new SchemaIssue.InvalidValue({ message: error.message }, value, parseOptions))
157
+ );
158
+ },
159
+ })
160
+ )
161
+ );
162
+ }
163
+ );
@@ -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 attribute
91
- * — the codec's hot path — so the walk calls this directly and keeps the work in plain JavaScript. `'error'` mode reports an illegal name by throwing
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, and an
135
- * illegal name under `'error'` mode is a rejection rather than a value, so this returns an `Effect` with the `XmlParseError` in its error channel
136
- * rather than throwing it. Callers `yield*` it and the failure composes with `catchTag` and the rest; the two internal call sites in `parse.ts` and
137
- * `render.ts` use {@link resolveNameSync} directly, because their walks are synchronous hot paths.
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.