UNPKG

xml-class-transformer

Version:

Fluently parse XML into beautiful JS/TS classes and serialize them. GoLang's encoding/xml alternative for JS/TS world.

783 lines (769 loc) 29.2 kB
/*! * xml-class-transformer v3.0.1 * (c) Edgar Pogosyan * Released under the MIT License. */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var xmljs = require('xml-js-v2'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var xmljs__default = /*#__PURE__*/_interopDefaultLegacy(xmljs); class DefaultNumberMarshaller { marshal(value) { if (value === undefined) { return undefined; } return value === null ? '' : `${value}`; } unmarshal(chardata) { if (chardata === undefined) { return undefined; } const castToStr = `${chardata}`; // parse empty strings to nulls when the specified type is Number // because there is no convenient way to represent an empty string as a number, // there is an idea to convert them to 0, but it's an implicit and non obvious behaviour. // Maybe a better idea would be to convert them to NaN just as parseFloat does. return castToStr === '' ? null : castToStr ? parseFloat(castToStr) : undefined; } } class DefaultBigIntMarshaller { marshal(value) { if (value === undefined) { return undefined; } return value === null ? '' : `${value}`; } unmarshal(chardata) { if (chardata === undefined) { return undefined; } const castToStr = `${chardata}`; // parse empty strings to nulls when the specified type is BigInt // because there is no convenient way to represent an empty string as a number, // there is an idea to convert them to 0, but it's an implicit and non obvious behaviour. // Maybe a better idea would be to convert them to NaN just as parseFloat does. return castToStr === '' ? null : castToStr ? BigInt(castToStr) : undefined; } } class DefaultStringMarshaller { marshal(value) { if (value === undefined) { return undefined; } return value === null ? '' : `${value}`; } unmarshal(chardata) { if (chardata === undefined) { return undefined; } return `${chardata}`; } } class DefaultBooleanMarshaller { marshal(value) { if (value === undefined) { return undefined; } return value === null ? '' : `${value}`; } unmarshal(chardata) { if (chardata === undefined) { return undefined; } const castToStr = `${chardata}`; return castToStr === '' ? null : castToStr ? castToStr === 'true' : undefined; } } class DefaultDateMarshaller { marshal(value) { if (value === undefined) { return undefined; } return value === null ? '' : value.toISOString(); } unmarshal(chardata) { if (chardata === undefined) { return undefined; } return chardata === '' ? null : new Date(chardata); } } const defaultNumberMarshaller = new DefaultNumberMarshaller(); const defaultBigIntMarshaller = new DefaultBigIntMarshaller(); const defaultStringMarshaller = new DefaultStringMarshaller(); const defaultBooleanMarshaller = new DefaultBooleanMarshaller(); const defaultDateMarshaller = new DefaultDateMarshaller(); function errUnknownClass(classConstructor) { return new Error(`xml-class-transformer: class "${classConstructor}" not found. Make sure there is a @XmlElem({...}) ` + `decorator on it, or XmlChildElem, XmlAttribute, XmlChardata or XmlComments decorator on its properties.`); } function errNoXmlNameForClass(xmlClass) { return new Error(`xml-class-transformer: no XML name is specified for ${xmlClass === null || xmlClass === void 0 ? void 0 : xmlClass.name}. ` + `Specify it with the @XmlElem({ name: '...' }) decorator.`); } function serializeUnionForLog(union) { return '[' + union.map((t) => { var _a; return (_a = t === null || t === void 0 ? void 0 : t.name) !== null && _a !== void 0 ? _a : `${union}`; }).join(', ') + ']'; } function isPrimitiveType(type) { return (type === String || type === Number || type === BigInt || type === Boolean || type === Date); } function getDefaultMarshaller(type) { switch (type) { case String: return defaultStringMarshaller; case Number: return defaultNumberMarshaller; case BigInt: return defaultBigIntMarshaller; case Boolean: return defaultBooleanMarshaller; case Date: return defaultDateMarshaller; } throw new Error('unknown primitive type ' + type); } class ClassMetadataRegistry { constructor() { this.registry = new Map(); } setEntityOptions(classConstructor, opts) { const metadata = this.registry.get(classConstructor); if (metadata) { metadata.entity = opts; } else { this.registry.set(classConstructor, { entity: opts, properties: new Map(), }); } } setPropertyOptions(classConstr, propertyKey, opts) { const metadata = this.getOrCreate(classConstr); if (opts.comments) { for (const [searchingPropKey, searchingOpts] of metadata.properties) { if (searchingOpts.comments) { throw new Error(`xml-class-transformer: only one @XmlComment() decorator is allowed per class. ` + `Can not define @XmlComment() decorator for ` + `${classConstr.name}#${propertyKey} since it's already used for ` + `${classConstr.name}#${searchingPropKey}.`); } } } if (opts.name) { for (const [searchingPropKey, searchingOpts] of metadata.properties) { if (searchingOpts.name === opts.name) { throw new Error(`xml-class-transformer: can't use XML element name defined in ` + `{ name: ${JSON.stringify(opts.name)} } for ` + `${classConstr.name}#${propertyKey} since it's already used for ` + `${classConstr.name}#${searchingPropKey}. Change it to something else.`); } // TODO: maybe support multiple chardata for multiple child text nodes inside an xml element. // each of those chardata properties whould match the text node at the same position as the property itself. // The same goes for not yet implemented comments and cdata. if (opts.chardata && searchingOpts.chardata) { throw new Error(`xml-class-transformer: an XML element can have only one chardata property. ` + `Both ${classConstr.name}#${propertyKey} and ${classConstr.name}#${searchingOpts.name} ` + `are defined as chardata, which is not valid.`); } } } metadata.properties.set(propertyKey, opts); } getOrCreate(classConstr) { const existing = this.registry.get(classConstr); if (existing) { return existing; } else { const newMetadatas = { entity: { name: classConstr === null || classConstr === void 0 ? void 0 : classConstr.name, }, properties: new Map(), }; this.registry.set(classConstr, newMetadatas); return newMetadatas; } } get(classConstr) { return this.registry.get(classConstr); } resolveUnionComponents(union) { // TODO: optimize and cache this map: const tagNameToClassType = new Map(); for (const classType of union) { const classTypeMetadata = registry.get(classType); if (!classTypeMetadata) { throw errUnknownClass(classType); } const tagName = classTypeMetadata.entity.name; if (!tagName) { throw errNoXmlNameForClass(classType); } tagNameToClassType.set(tagName, classType); } return tagNameToClassType; } } const registry = new ClassMetadataRegistry(); function classToXml(entity, options) { const tree = classToXmlInternal(entity, '', entity.constructor); const rootElem = { elements: [tree] }; if ((options === null || options === void 0 ? void 0 : options.declaration) !== false) { if (typeof (options === null || options === void 0 ? void 0 : options.declaration) === 'object' && (options === null || options === void 0 ? void 0 : options.declaration) !== null) { rootElem.declaration = options.declaration; } else { rootElem.declaration = { attributes: { version: '1.0', encoding: 'UTF-8' }, }; } } return xmljs__default["default"].js2xml(rootElem, options); } function classToXmlInternal(entity, name, entityConstructor) { if (isPrimitiveType(entityConstructor)) { return primitiveTypeToXml(entity, name, entityConstructor); } const metadatas = registry.get(entityConstructor); if (!metadatas) { throw errUnknownClass(entityConstructor); } const elemName = name || metadatas.entity.name; if (!elemName) { throw errNoXmlNameForClass(entityConstructor); } const mutChildren = []; const mutAttributes = {}; for (const [classKey, opts] of metadatas.properties) { marshalProperty(entityConstructor, entity, opts, classKey, mutChildren, mutAttributes); } if (metadatas.entity.xmlns) { mutAttributes['xmlns'] = metadatas.entity.xmlns; } return { type: 'element', name: elemName, attributes: mutAttributes, elements: mutChildren, }; } function primitiveTypeToXml(value, name, entityConstructor) { const defaultMarshaller = getDefaultMarshaller(entityConstructor); const text = defaultMarshaller.marshal(value); if (text === undefined) { // should never happen, but just in case return {}; } return { type: 'element', name: name, elements: [ { type: 'text', text, }, ], }; } function marshalProperty(containerEntityConstructor, entity, opts, classKey, mutChildren, mutAttributes) { if (opts.comments) { marshalCommentsProperty(entity, classKey, mutChildren); } else if (opts.attr) { marshalAttributeProperty(containerEntityConstructor, entity, opts, classKey, mutAttributes); } else if (opts.chardata) { marshalChardataProperty(entity, opts, classKey, mutChildren); } else if (opts.array) { marshalArrayProperty(entity, opts, classKey, mutChildren); } else if (opts.marshaller || opts.isPrimitiveType()) { marshalPrimitiveTypeProperty(containerEntityConstructor, entity, opts, classKey, mutChildren); } else if (opts.union) { marshalUnionProperty(entity, classKey, mutChildren); } else { marshalChildClassElemProperty(entity, opts, classKey, mutChildren); } } function marshalCommentsProperty(entity, classKey, mutChildren) { if (Array.isArray(entity[classKey])) { for (const comment of entity[classKey]) { mutChildren.push({ type: 'comment', comment: comment === null || comment === undefined ? '' : `${comment}`, }); } } } function marshalAttributeProperty(containerEntityConstructor, entity, opts, classKey, mutAttributes) { if (!opts.name) { throw new Error(`xml-class-transformer: no name is specified for the property ${containerEntityConstructor === null || containerEntityConstructor === void 0 ? void 0 : containerEntityConstructor.name}#${classKey}. ` + `Specify it with the @XmlAttribute({ name: '...' }) decorator.`); } const value = marshal(entity[classKey], opts); if (value === undefined) { return; } mutAttributes[opts.name] = `${value}`; } function marshalChardataProperty(entity, opts, classKey, mutChildren) { const value = marshal(entity[classKey], opts); if (value === undefined) { return; } mutChildren.push({ type: 'text', text: value, }); } function marshalArrayProperty(entity, opts, classKey, mutChildren) { if (entity[classKey] === null || entity[classKey] === undefined) { return; } for (const entityFromArray of entity[classKey]) { if (opts.marshaller || opts.isPrimitiveType()) { const marshalledValue = marshal(entityFromArray, opts); if (marshalledValue === undefined) { continue; } mutChildren.push(primitiveTypeToXml(entityFromArray, opts.name, String)); } else { // Do not process null and undefined values in the array. // When we impl support for primitive unions maybe this should change if (entityFromArray === null || entityFromArray === undefined) { continue; } // If it is a union then we can't guess required class out of it. // In those cases users should give to the library actual class instances (aka new MyEntity({...})) // so the library can guess the class type just by looking at the myEntity.constructor const classConstructor = opts.union ? entityFromArray.constructor : opts.type(); // The opts.name will be undefined if !!opts.union, but thats ok. mutChildren.push(classToXmlInternal(entityFromArray, opts.name, classConstructor)); } } } function marshalPrimitiveTypeProperty(containerEntityConstructor, entity, opts, classKey, mutChildren) { if (!opts.name) { throw new Error(`xml-class-transformer: no name is specified for property ${containerEntityConstructor === null || containerEntityConstructor === void 0 ? void 0 : containerEntityConstructor.name}#${classKey}. ` + `Specify it with @XmlChildElem({ name: '...' }) decorator.`); } const value = marshal(entity[classKey], opts); if (value === undefined) { return; } mutChildren.push(primitiveTypeToXml(value, opts.name, String)); } function marshalUnionProperty(entity, classKey, mutChildren) { // should work with primitive types also const classConstructor = entity[classKey].constructor; mutChildren.push(classToXmlInternal(entity[classKey], undefined, classConstructor)); } function marshalChildClassElemProperty(entity, opts, classKey, mutChildren) { // If null then just skip this embedded element for the current impl // TODO: maybe non array unions are broken if (entity[classKey] !== undefined && entity[classKey] !== null) { mutChildren.push(classToXmlInternal(entity[classKey], opts.name, opts.type())); } } function marshal(value, opts) { let marshalled = value; if (opts.marshaller) { marshalled = opts.marshaller.marshal(value); } else if (opts.type) { const type = opts.type(); if (isPrimitiveType(type)) { marshalled = getDefaultMarshaller(type).marshal(value); } } return marshalled; } function xmlToClass(xml, classConstructor, options) { var _a; const parsed = xmljs__default["default"].xml2js(xml, Object.assign(Object.assign({}, options), { compact: false, alwaysArray: true })); const firstElement = (_a = parsed.elements) === null || _a === void 0 ? void 0 : _a[0]; if (!firstElement) { throw new Error('xml-class-transformer: No elements found in the xml, make sure its valid.'); } return xmlToClassInternal(firstElement, classConstructor); } function xmlToClassInternal(xmlElement, classConstructor) { if (isPrimitiveType(classConstructor)) { const text = getChardataFromElem(xmlElement); return unmarshalPrimitiveType(text, classConstructor); } const metadatas = registry.get(classConstructor); if (!metadatas) { throw errUnknownClass(classConstructor); } const classInstance = new classConstructor(); for (const [classKey, opts] of metadatas.properties) { unmarshalProperty(xmlElement, opts, classKey, classInstance); } return classInstance; } function unmarshalProperty(xmlElement, opts, classKey, mutClassInstance) { if (opts.comments) { unmarshalCommentsProperty(xmlElement, classKey, mutClassInstance); } else if (opts.attr) { unmarshalAttributeProperty(xmlElement, classKey, opts, mutClassInstance); } else if (opts.chardata) { unmarshalChardataProperty(xmlElement, classKey, opts, mutClassInstance); } else if (opts.array) { unmarshalArrayProperty(xmlElement, classKey, opts, mutClassInstance); } else if (opts.union) { unmarshalUnionProperty(xmlElement, classKey, opts, mutClassInstance); } else { unmarshalPrimitiveOrChildClassElemProperty(xmlElement, classKey, opts, mutClassInstance); } } function unmarshalCommentsProperty(xmlElement, classKey, mutClassInstance) { const commentsAccumulated = []; for (const childElem of xmlElement.elements || []) { if (childElem.type === 'comment') { commentsAccumulated.push(childElem.comment || ''); } } mutClassInstance[classKey] = commentsAccumulated; } function unmarshalAttributeProperty(xmlElement, classKey, opts, mutClassInstance) { var _a; if (!opts.name) { throw new Error(`xml-class-transformer: no name is specified for attribute ${classKey}. ` + `Specify it with @XmlAttribute({ name: '...' }) decorator.`); } let attrValue = (_a = xmlElement.attributes) === null || _a === void 0 ? void 0 : _a[opts.name]; if (typeof attrValue === 'number') { attrValue = `${attrValue}`; } const unmarshalled = unmarshal(attrValue, opts); mutClassInstance[classKey] = unmarshalled; } function unmarshalChardataProperty(xmlElement, classKey, opts, mutClassInstance) { const chardata = getChardataFromElem(xmlElement); const unmarshalled = unmarshal(chardata, opts); mutClassInstance[classKey] = unmarshalled; } function unmarshalArrayProperty(xmlElement, classKey, opts, mutClassInstance) { var _a; if (opts.union) { const mapTagToClassConstr = registry.resolveUnionComponents(opts.union()); const unmarshalledValues = []; for (const el of xmlElement.elements || []) { if (el.name && mapTagToClassConstr.has(el.name)) { const classConstr = mapTagToClassConstr.get(el.name); const entity = xmlToClassInternal(el, classConstr); unmarshalledValues.push(entity); } } mutClassInstance[classKey] = unmarshalledValues; } else { const elems = ((_a = xmlElement.elements) === null || _a === void 0 ? void 0 : _a.filter((e) => e.name === opts.name)) || []; const unmarshalledValues = []; for (const el of elems) { const entity = xmlToClassInternal(el, opts.type()); unmarshalledValues.push(entity); } mutClassInstance[classKey] = unmarshalledValues; } } function unmarshalUnionProperty(xmlElement, classKey, opts, mutClassInstance) { var _a; const mapTagToClassConstr = registry.resolveUnionComponents(opts.union()); const matchingXmlElement = (_a = xmlElement.elements) === null || _a === void 0 ? void 0 : _a.find((el) => { return el.name ? mapTagToClassConstr.has(el.name) : false; }); mutClassInstance[classKey] = matchingXmlElement ? xmlToClassInternal(matchingXmlElement, mapTagToClassConstr.get(matchingXmlElement.name)) : undefined; } function unmarshalPrimitiveOrChildClassElemProperty(xmlElement, classKey, opts, mutClassInstance) { var _a; // is primitive type, marshaller or child class elem const el = (_a = xmlElement.elements) === null || _a === void 0 ? void 0 : _a.find((el) => el.name === opts.name); if (opts.marshaller || opts.isPrimitiveType()) { const chardata = el ? getChardataFromElem(el) : undefined; const unmarshalled = unmarshal(chardata, opts); mutClassInstance[classKey] = unmarshalled; } else if (el) { mutClassInstance[classKey] = xmlToClassInternal(el, opts.type()); } else { mutClassInstance[classKey] = undefined; } } function getChardataFromElem(el) { let chardata = ''; for (const child of el.elements || []) { if (child.type === 'text' && child.text) { chardata += child.text || ''; } } return chardata; } function unmarshal( // Support numbers also value, opts) { let unmarshalled = value; if (opts.marshaller) { unmarshalled = opts.marshaller.unmarshal(value); } else if (opts.type) { const type = opts.type(); if (isPrimitiveType(type)) { unmarshalled = getDefaultMarshaller(type).unmarshal(value); } } return unmarshalled; } function unmarshalPrimitiveType( // Support numbers also value, classConstructor) { const defaultMarshaller = classConstructor ? getDefaultMarshaller(classConstructor) : defaultStringMarshaller; const strValue = typeof value === 'number' ? `${value}` : value; const unmarshalled = defaultMarshaller.unmarshal(strValue); return unmarshalled; } class Fields { } /** * Used to accumulate the metadatas from all of the property decorators: * `XmlChildElem`, `XmlAttribute`, `XmlChardata`, `XmlComments` */ class InternalXmlPropertyOptions extends Fields { constructor(init) { super(); Object.assign(this, init); } isPrimitiveType() { return this.type ? isPrimitiveType(this.type()) : false; } } /** * A class decorator. * It can be omitted, but only if at least one Xml* property decorator is used on it's properties. * * @example * \@XmlElem() * class EmptyXmlElement {} * * \@XmlElem({ name: 'some-xml-element' }) * class SomeXmlElement { * \@XmlChildElem() * child: string; * } * * \@XmlElem({ xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' }) * class SomeXmlElement { * \@XmlChildElem() * child: string; * } */ function XmlElem(opts) { return (target) => { opts = opts || {}; opts.name = opts.name || target.name; if (!opts.name) { throw errNoXmlNameForClass(target); } registry.setEntityOptions(target, opts); return target; }; } /** * Class property decorator. * * @example * class SomeElement { * \@XmlChildElem({ type: () => String }) * stringElem: string; * * \@XmlChildElem({ name: 'someOtherName', type: () => Number }) * numberElem: string; * * \@XmlChildElem({ type: () => NestedElem }) * nestedElem: NestedElem; * } */ function XmlChildElem(opts) { return propertyDecoratorFactory('XmlChildElem', new InternalXmlPropertyOptions(opts)); } /** * Class property decorator. * For more details on options see {@link XmlAttributeOptions} * * @example * // a basic example * class SomeXmlElement { * \@XmlAttribute({ name: 'attributeName', type: () => String }) * attributeName: string; * } */ function XmlAttribute(opts) { return propertyDecoratorFactory('XmlAttribute', new InternalXmlPropertyOptions(Object.assign(Object.assign({}, opts), { attr: true }))); } /** * This decorator, when used on a class property, collects all the comments * from the provided XML, turns them into an array of strings and puts them into * that property. And vice-versa: at serialization that array of strings gets serialized to set of comments * in the resulting XML. * * @example * ```ts * class SomeElement { * \@XmlComments() * comments?: string[]; * } * * classToXml( * new SomeElement({ * comments: ['some comment', 'some other comment'] * }) * ) * ``` * * Output: * ```xml * <SomeElement> * <!-- some comment --> * <!-- some other comment --> * </SomeElement> * * ``` */ function XmlComments() { return (target, propertyKey) => { if (typeof propertyKey !== 'string') { // Dont support symbols for now throw new TypeError(`xml-class-transformer: Can't use @XmlComments({...}) decorator on a symbol property ` + `at ${target.constructor.name}#${propertyKey.toString()}`); } registry.setPropertyOptions(target.constructor, propertyKey, new InternalXmlPropertyOptions({ comments: true, })); }; } /** * The property will be parsed and serialized as a character data. * The "type" parameter can only be a primitive type: String, Number, Boolean. * * ```ts * \@XmlElem({ name: 'Comment' }) * class Comment { * \@XmlChardata({ type: () => String }) * text: string; * * \@XmlAttribute({ type: () => String, name: 'lang' }) * language: string; * * constructor(d?: Comment) { * Object.assign(this, d || {}); * } * } * * classToXml( * new Comment({ * text: 'This is awesome', * language: 'en', * }), * ) * ``` * * Output: * ```xml * <Comment lang="en">This is awesome</Comment> * ``` */ function XmlChardata(opts) { return propertyDecoratorFactory('XmlChardata', new InternalXmlPropertyOptions(Object.assign(Object.assign({}, opts), { chardata: true }))); } function propertyDecoratorFactory(decoratorName, opts) { return (target, propertyKey) => { if (typeof propertyKey !== 'string') { // Dont support symbols for now throw new TypeError(`xml-class-transformer: Can't use @${decoratorName}({...}) decorator on a symbol property ` + `at ${target.constructor.name}#${propertyKey.toString()}`); } if (!opts.union && !opts.type && !opts.marshaller) { throw new TypeError(`xml-class-transformer: No "type", "union" or "marshaller" was specified for the ` + `${target.constructor.name}#${propertyKey.toString()}. Add it to ` + `the @${decoratorName}({...}) decorator.`); } if ((opts.union && opts.type) || (opts.union && opts.marshaller) || (opts.type && opts.marshaller)) { throw new TypeError(`xml-class-transformer: The "union", "type" or "marshaller" options are not compatible with each other at ` + `${target.constructor.name}#${propertyKey.toString()}. ` + `You can specify only one of them.`); } if (opts.union && !opts.union().length) { throw new TypeError(`xml-class-transformer: The "union" option in @${decoratorName}({ ... }) can't be empty ` + `at ${target.constructor.name}#${propertyKey.toString()}. ` + `Either remove the "union" option or provide it with at least one type.`); } if (opts.union) { if (opts.name) { throw new TypeError(`xml-class-transformer: The "union" option is not compatible with the "name" option at ` + `${target.constructor.name}#${propertyKey.toString()}. ` + `XML element names for the union members should be specified at ` + `the union member classes.`); } } else { opts.name = opts.name || propertyKey; } if (opts.union) { // opts.union() at the moment of running this peace of code can potentially return // undefineds as the value of some elements in case if there are circular dependencies. // But the primitive values constructors (String, Number and Boolean) are always defined. // So it's okay to check them like this: const unionArr = opts.union(); const foundPrimitiveType = unionArr.find((type) => isPrimitiveType(type)); if (foundPrimitiveType) { throw new TypeError(`xml-class-transformer: unions of primitive types (String, Number, Boolean, BigInt or Date) are not supported. ` + `Fix it in the decorator @${decoratorName}({ ` + `union: ${serializeUnionForLog(unionArr)}, ... }) ` + `at "${target.constructor.name}#${propertyKey.toString()}".`); } } registry.setPropertyOptions(target.constructor, propertyKey, opts); }; } exports.XmlAttribute = XmlAttribute; exports.XmlChardata = XmlChardata; exports.XmlChildElem = XmlChildElem; exports.XmlComments = XmlComments; exports.XmlElem = XmlElem; exports.classToXml = classToXml; exports.xmlToClass = xmlToClass;