@alxcube/xml-mapper
v1.0.1
Published
Library designed for mapping XML documents to JavaScript objects using a declarative builder with XPath expressions.
Downloads
12
Maintainers
Readme
xml-mapper
xml-mapper
is a library designed for mapping XML documents to JavaScript objects
using a declarative builder with XPath expressions. It provides a type-safe approach
to mapping XML data to JavaScript objects.
Features
- Declarative mapping: Define mappings using a fluent API with XPath expressions.
- Type-safe: Ensure type safety throughout the mapping process.
- Flexibility: Offers support for complex data structures, including recursive mapping, enabling the handling of intricate data hierarchies.
Quick start
Installation
xml-mapper
requires xpath package.
For use in environments without native DOM support, you can use @xmldom/xmldom package.
npm i @alxcube/xml-mapper xpath @xmldom/xmldom
Usage example
import { DOMParser } from "@xmldom/xmldom";
import { createObjectMapper, map } from "@alxcube/xml-mapper";
const xml = `
<User id="123" verified="false">
<FirstName>John</FirstName>
<LastName>Doe</LastName>
<ContactData>
<Email>[email protected]</Email>
<Phone>+1234567890</Phone>
</ContactData>
<Groups>
<Group id="1">Registered</Group>
<Group id="2">Customers</Group>
</Groups>
<RegistrationDate year="2024" month="2" day="28"/>
</User>
`;
/**
* Example interface
*/
interface User {
id: number;
isVerified: boolean;
firstName: string;
lastName: string;
contacts?: {
email?: string;
phone?: string;
};
groups: { id: number; title: string }[];
registeredAt: Date;
}
// Define mapper function with 'createObjectMapper()'
const userMapper = createObjectMapper<User>({
id: map()
.toNode("/User/@id") // Search for 'id' attribute in User element
.mandatory() // Make sure that reference node is present in xml.
.asNumber(), // Use attribute value as number,
isVerified: map().toNode("/User/@verified").asBoolean().withDefault(false), // Assign default value in case of reference node is not found
firstName: map().toNode("/User/FirstName").mandatory().asString(),
lastName: map().toNode("/User/LastName").mandatory().asString(),
contacts: map()
.toNode("/User/ContactData") // Search for Element node
.asObject({
email: map()
.toNode("Email") // Nested objects xpath expression can be relative to reference element
.asString(),
phone: map().toNode("Phone").asString(),
}),
groups: map()
.toNodesArray("/User/Groups/Group") // Search for array of elements
.mandatory()
.asArray()
.ofObjects({
id: map().toNode("@id").mandatory().asNumber(),
title: map().toNode(".").mandatory().asString(),
}),
registeredAt: map()
.toNode("/User/RegistrationDate")
.mandatory()
.callback((node, select) => {
// Use custom callback for complex case: select attribute values, using xpath expression and return Date
const year = select("number(@year)", node) as number;
const month = select("number(@month)", node) as number;
const day = select("number(@day)", node) as number;
return new Date(year, month - 1, day);
}),
});
// Parse XML
const doc = new DOMParser().parseFromString(xml);
// Get User object from parsed Document, using created mapper
const user: User = userMapper(doc);
console.log(user);
Key Concepts:
SingleNodeDataExtractorFn
import type { XPathSelect } from "xpath";
interface SingleNodeDataExtractorFn<DataExtractorReturnType> {
(node: Node, xpathSelect: XPathSelect): DataExtractorReturnType;
}
A function that takes two parameters: the context DOM node
and the XPathSelect
interface from the xpath
library. The purpose of this function
is to return data of a specific type from the context node or its child nodes.
SingleNodeDataExtractorFnFactory
interface SingleNodeDataExtractorFnFactory<DataExtractorReturnType> {
createNodeDataExtractor(): SingleNodeDataExtractorFn<DataExtractorReturnType>;
}
An interface whose method createNodeDataExtractor()
returns a function of type
SingleNodeDataExtractorFn
.
SingleNodeLookupFn
import type { XPathSelect } from "xpath";
interface SingleNodeLookupFn<NodeLookupResult extends Node | undefined> {
(contextNode: Node, xpathSelect: XPathSelect): NodeLookupResult;
}
A function that takes two parameters: the context DOM node relative to which the
search is performed and the XPathSelect
interface from the xpath
library.
The returned result is a new context node for data extraction or undefined
if the
searched node is absent.
NodesArrayDataExtractorFn
import type { XPathSelect } from "xpath";
interface NodesArrayDataExtractorFn<ArrayDataExtractorReturnType> {
(nodes: Node[], xpathSelect: XPathSelect): ArrayDataExtractorReturnType;
}
A function that takes two parameters: an array of context DOM nodes and the XPathSelect
interface from the xpath
library. The purpose of this function is to return data of
a specific type from the array of context nodes.
NodesArrayDataExtractorFnFactory
interface NodesArrayDataExtractorFnFactory<ArrayDataExtractorReturnType> {
createNodesArrayDataExtractor(): NodesArrayDataExtractorFn<ArrayDataExtractorReturnType>;
}
An interface whose method createNodesArrayDataExtractor()
returns a function of type
NodesArrayDataExtractorFn
.
NodesArrayLookupFn
import type { XPathSelect } from "xpath";
interface NodesArrayLookupFn<NodesLookupResult extends Node[] | undefined> {
(contextNode: Node, xpathSelect: XPathSelect): NodesLookupResult;
}
A function that takes two parameters: the context DOM node relative to which the search
is performed and the XPathSelect
interface from the xpath
library. The returned
result is an array of context nodes for data extraction or undefined if the searched
nodes are absent.
Binding
Binding is a conceptual term for a function of type SingleNodeDataExtractorFn
that
combines context node/array lookup and data extraction from the results of such a
search. Bindings are constructed using the built-in builder. The map()
function
is used to build bindings, returning a MappingBuilder
interface. In subsequent
steps, the type of search (single node / array of nodes), the type of return value,
etc., are chosen (see detailed description below).
Mapping
Mapping is a conceptual term for a function of type SingleNodeDataExtractorFn
or
object of type SingleNodeDataExtractorFnFactory
, being assigned to property of
ObjectBlueprint
object, and represents a mapping of such property to a value,
which is result of executing data extractor.
ObjectBlueprint
type ObjectBlueprint<T extends object> = {
[K in keyof T]:
| SingleNodeDataExtractorFnFactory<T[K]>
| SingleNodeDataExtractorFn<T[K]>;
};
An object whose property names correspond to the names of properties in the constructed
interface, and whose property values are either functions of type
SingleNodeDataExtractorFn
or objects of the SingleNodeDataExtractorFnFactory
interface. Typically, these are bindings or objects of the
LookupToDataExtractorBindingBuilder
interface, which inherits
SingleNodeDataExtractorFnFactory
, created using the map()
helper.
To create a mapper, the createObjectMapper()
helper is used, which takes an
ObjectBlueprint
and returns a function – the mapper, similar in type to
SingleNodeDataExtractorFn
, except that the second parameter is optional and
defaults to xpath.select
.
To build an object of the required interface, you need to pass a top-level context
node (typically, a Document
, but other nodes are also allowed) to this function.
If necessary, a second parameter - the XPathSelect
interface, can be passed,
for example, if the document contains namespaces – using xpath.useNamespaces()
.
Under the hood, this mapper function iterates through all the keys of the passed
ObjectBlueprint
, calling the SingleNodeDataExtractorFn
functions lying under these
keys with the passed top-level node and XPathSelect
interface as arguments, and
writes the returned values to the properties with the same names in the resulting
object. Thus, the output is an object constructed "according to the blueprint."
Binding Workflow
The following steps are performed inside binding:
- Lookup is performed to find reference node(s).
- If reference node(s) is not found, and node is mandatory,
LookupError
is THROWN. - If reference node(s) is not found, and node is not mandatory, default value is RETURNED.
- If reference node(s) is not found, and node is mandatory,
- If reference node(s) was found, data extractor function is called with that node(s).
- If extracted value is
undefined
, default value is RETURNED. - If conversion callback is set, extracted value is passed to it to get converted value.
- If converted value is
undefined
, default value is RETURNED. - Converted value is RETURNED.
- Extracted value is RETURNED.
In above steps, default value is undefined, unless it was set using .withDefault()
method.
Building mappings
Creating object mapper
To create object mapper function, createObjectMapper()
helper is used:
import type { XPathSelect } from "xpath";
declare function createObjectMapper<ObjectType extends object>(
blueprint: ObjectBlueprint<ObjectType>
): (node: Node, xpathSelect?: XPathSelect) => ObjectType;
It takes single argument of type ObjectBlueprint and returns
mapper function. This returned function accepts root context node (typically of
Document
type), and optionally XPathSelect
interface. The latter is useful
when namespaces are used in document, and xpath
should be configured for namespaces
support:
import { DOMParser } from "@xmldom/xmldom";
import xpath from "xpath";
import { createObjectMapper, map } from "@alxcube/xml-mapper";
const xml = `<Link xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="https://example.com"/>`;
const doc = new DOMParser().parseFromString(xml);
const mapper = createObjectMapper({
url: map().toNode("/Link@xlink:href").asString(),
});
const select = xpath.useNamespaces({
xlink: "http://www.w3.org/1999/xlink",
});
const result = mapper(doc, select);
Defining ObjectBlueprint
For object blueprint definition, map()
helper is used.
Single mapping consists of two main steps: definition of reference node(s) lookup and definition of return type:
const objectBlueprint = {
string: map()
.toNode("//Element/@id") // Looks for attribute "id" in <Element> node
.asString(), // Returns attribute value as string
arrayOfNumbers: map()
.toNodesArray("//List/Item/@ordering") // Looks for array of attributes "ordering"
.asArray()
.ofNumbers(), // Returns array of attributes values as array of numbers
};
After return type is specified, instance of LookupToDataExtractorBindingBuilder
interface is returned. This interface extends SingleNodeDataExtractorFnFactory
,
so the objectBlueprint
inferred type in above example will be as follows:
type TypeofExampleObjectBlueprint = {
string: SingleNodeDataExtractorFnFactory<string | undefined>;
arrayOfNumbers: SingleNodeDataExtractorFnFactory<number[] | undefined>;
};
and the result object type will be:
type TypeOfExampleResultObject = {
string: string | undefined;
arrayOfNumbers: number[] | undefined;
};
Non-nullable types inference
As seen above, the default mapping inferred type may be undefined
. But your mapped
interface probably have required members. There are two ways, that remove undefined
from inferred types: mandatory reference node(s) lookup and default value.
Mandatory lookup
You can use .mandatory()
method after setting lookup:
interface NonNullableObject {
string: string;
arrayOfNumbers: number[];
}
const blueprint: ObjectBlueprint<NonNullableObject> = {
string: map()
.toNode("/Path/To/Node")
.mandatory() // Mandatory node lookup
.asString(),
arrayOfNumbers: map()
.toNodesArray("/Path/To/NodesArray")
.mandatory() // Mandatory array of nodes lookup
.asArray()
.ofNumbers(),
};
When lookup is made mandatory, an error will be thrown if reference node is not found.
This allows to exclude undefined
from inferred type union, and guarantees that
mapped value will not be undefined
. It worth noting that when using custom data
extraction callback, which has undefined
in its return type union, the inferred
type of mapping will still have undefined
, unless you set a default value.
Default value
The other way to remove undefined
from inferred mapping type union is using
.withDefault()
method:
interface NonNullableObject {
string: string;
arrayOfNumbers: number[];
}
const blueprint: ObjectBlueprint<NonNullableObject> = {
string: map().toNode("/Path/To/Node").asString().withDefault(""),
arrayOfNumbers: map()
.toNodesArray("/Path/To/NodesArray")
.asArray()
.ofNumbers()
.withDefault([]),
};
Default value is returned when reference node(s) is not found, when extracted data is
undefined
or when converted data (see below) is undefined
.
Converting extracted data
You can use .withConversion()
method to convert extracted value to other type. This
method accepts a conversion callback, which should accept data of data extractor return
type and return converted data.
Default value type, set after setting conversion callback, should be of same type as conversion callback returns.
If any default value was set before calling .withConversion()
method, it is reset to
undefined
.
type YesNo = "yes" | "no";
interface Example {
yesno: YesNo;
date?: Date;
num: number;
}
const blueprint: ObjectBlueprint<Example> = {
yesno: map()
.toNode("/Path/To/Boolean")
.asBoolean()
.withDefault(false) // this default value will be reset by .withConversion() call
.withConversion((val) => (val ? "yes" : "no"))
.withDefault("no"), // default value should have type, compatible with converted value
date: map()
.toNode("/Path/To/Date/String")
.asString()
.withConversion((strVal) => new Date(Date.parse(strVal))),
num: map()
.toNode("/Path/To/Exponential/Number")
.asString()
.withConversion(parseFloat)
.withDefault(0),
};
Available mappings
Single node mappings
asString()
Extracts string value from any node.
map().toNode("path").asString();
asNumber()
Extracts number value from any node. Supported formats:
- Numbers in decimal format:
0
,-0
,1
,-1.23
; - Fractional numbers without an integer part:
.75
,-.75
; Infinity
and-Infinity
strings.
When value is not numeric, returns NaN
.
map().toNode("path").asNumber();
asBoolean()
Extracts boolean values. The following string values are cast to false
:
"false"
in any case;"null"
in any case;- empty string;
- any numeric string, that equals to
0
:"0"
,"-0"
,"0.00"
All other non-empty string values are cast to true
.
map().toNode("path").asBoolean();
asObject()
Accepts ObjectBlueprint
as argument. XPath expressions of mappings in such blueprint
may be relative to reference (context) node.
map()
.toNode("path/to/context/node")
.asObject({
num: map().toNode("@numeric-attribute").asNumber(),
str: map().toNode("ChildElement").asString(),
});
asRecursiveObject()
Accepts callback, which should return ObjectBlueprint
. A single argument of type
RecursiveObjectFactoryScope
. You can use its getDepth()
method inside callback
to get recursion depth. This given argument should be passed to .asRecursiveObject()
method in nested mapping definition.
const xml = `
<Root>
<Child>
<Title>Level 0</Title>
<Child>
<Title>Level 1</Title>
<Child>
<Title>Level 2</Title>
</Child>
</Child>
</Child>
</Root>
`;
interface TestRecursion {
title: string;
level: number;
child?: TestRecursion;
}
const blueprint: ObjectBlueprint<{ recursiveObject: TestRecursion }> = {
recursiveObject: map()
.toNode("/Root/Child")
.mandatory()
.asRecursiveObject((recursion) => {
return {
title: map().toNode("Title").mandatory().asString(),
level: map().constant(recursion.getDepth()),
child: map().toNode("Child").asRecursiveObject(recursion),
};
})
.createNodeDataExtractor()(doc, xs),
};
callback() - Custom data extractor
You can pass callback to .callback()
method to extract custom data. Inferred type
of mapping becomes return type of callback, so when mapping required interface
property, give a default value to mapping if callback may return undefined
.
import { isElement } from "xpath";
map()
.toNode("/Path/To/Date")
.mandatory()
.callback((node, select) => {
if (!isElement(node)) {
return undefined;
}
const year = select("number(@year)", node) as number;
const month = select("number(@month)", node) as number;
const day = select("number(@day)", node) as number;
return new Date(year, month - 1, day);
})
.withDefault(new Date());
Array mappings
There are 2 ways of mapping arrays of nodes: array mapper and custom array callback.
Array mapper internally uses .map()
method of Array
, calling
SingleNodeDataExtractorFn for each node in lookup result.
The result array is then filtered to eliminate all undefined
values.
For mapping array, .asArray()
method should be called after setting lookup.
asArray().ofStrings()
Extracts array of strings from array of nodes.
map().toNodesArray("/Path/To/Nodes").asArray().ofStrings();
asArray().ofNumbers()
Extracts array of numbers from array of nodes.
map().toNodesArray("/Path/To/Nodes").asArray().ofNumbers();
asArray().ofBooleans()
Extracts array of boolean values from array of nodes.
map().toNodesArray("/Path/To/Nodes").asArray().ofBooleans();
asArray().ofObjects()
Accepts ObjectBlueprint
as argument and extracts array of objects of given shape.
interface User {
id: number;
name: string;
}
map()
.toNodesArray("/Path/To/Node")
.asArray()
.ofObjects<User>({
id: map().toNode("@id").mandatory().asNumber(),
name: map().toNode("Name").mandatory().asString(),
});
asArray().ofRecursiveObjects()
Accepts callback, that should return ObjectBlueprint
. Same rules as in single node
.asRecursiveObject()
mapping applied.
import { DOMParser } from "@xmldom/xmldom";
const xml = `
<Categories>
<Category id="1">
<Name>Category 1</Name>
</Category>
<Category id="2">
<Name>Category 2</Name>
<Subcategories>
<Category id="3">
<Name>Category 3</Name>
<Subcategories>
<Category id="4">
<Name>Category 4</Name>
</Category>
<Category id="5">
<Name>Category 5</Name>
</Category>
</Subcategories>
</Category>
</Subcategories>
</Category>
<Category id="6">
<Name>Category 6</Name>
</Category>
</Categories>
`;
interface Category {
id: number;
name: string;
level: number;
subcategories?: Category[];
}
const doc = new DOMParser.parseFromString(xml);
const mapper = createObjectMapper<{ categories: Category[] }>({
categories: map()
.toNodesArray("/Categories/Category")
.asArray()
.ofRecursiveObjects((recursion) => ({
id: map().toNode("@id").mandatory().asNumber(),
name: map().toNode("Name").mandatory().asString(),
level: map().constant(recursion.getDepth()), // Use constant recursion depth
subcategories: map()
.toNodesArray("Subcategories/Category")
.asArray()
.ofRecursiveObjects(recursion), // Close recursion
})),
});
console.log(mapper(doc));
asArray().usingMapper()
Accepts SingleNodeDataExtractorFn callback and uses it to map nodes array.
import xpath, { type, XPathSelect } from "xpath";
import { DOMParser } from "@xmldom/xmldom";
import { map } from "@alxcube/xml-mapper";
const xml = `
<Dates>
<Date y="2024" m="2" d="25" />
<Date y="2024" m="2" d="26" />
</Dates>
`;
const doc = new DOMParser.parseFromString(xml);
function getDateFromAttributes(node: Node, xpathSelect: XPathSelect): Date {
return new Date(
xpathSelect("number(@y)", node) as number,
(xpathSelect("number(@m)", node) as number) - 1,
xpathSelect("number(@d)", node) as number
);
}
const mapper = map()
.toNodesArray("/Dates/Date")
.asArray()
.usingMapper(getDateFromAttributes)
.createNodeDataExtractor(); // Calling factory method explicitly to get SingleNodeDataExtractorFn
console.log(mapper(doc, xpath.select));
Another way of mapping array of nodes is custom callback, in which you can do whatever you want with nodes.
import { DOMParser } from "@xmldom/xmldom";
import xpath from "xpath";
import { type NodesArrayDataExtractorFn, map } from "@alxcube/xml-mapper";
const xml = `
<Numbers>
<Number>1</Number>
<Number>2</Number>
<Number>3</Number>
<Number>4</Number>
</Numbers>
`;
const doc = new DOMParser().parseFromString(xml);
const sumExtractor: NodesArrayDataExtractorFn<number> = (nodes, xpathSelect) =>
nodes.reduce(
(sum, node) => sum + (xpathSelect("number(.)", node) as number),
0
);
const mapper = map()
.toNodesArray("/Numbers/Number")
.callback(sumExtractor)
.createNodeDataExtractor(); // Calling factory method explicitly to get SingleNodeDataExtractorFn
console.log(mapper(doc, xpath.select)); // 10
Debugging
Mappers, created createObjectMapper()
helper throws special kind of errors -
MappingError
. This error objects are verbose and have failed mapping path in its
message
text. Additionally, there is mappingPath
property, of type (string | number)[]
,
which is mapping path segments array, and cause
property, which contains initial
error object.