@alxcube/xml-mapper v1.0.1
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/xmldomUsage 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>johndoe@example.com</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,
LookupErroris 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; Infinityand-Infinitystrings.
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)); // 10Debugging
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.