diff --git a/@vates/xml-rpc/.USAGE.md b/@vates/xml-rpc/.USAGE.md new file mode 100644 index 0000000000..b5165ee986 --- /dev/null +++ b/@vates/xml-rpc/.USAGE.md @@ -0,0 +1,128 @@ +### Formatting + +```js +import { xmlRpcFormatter } from '@vates/xml-rpc/formatter' + +// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.) +xmlRpcFormatter.format_methodCall({ + methodName: 'examples.getStateName', + params: [40], +}) +// → { +// name: 'methodCall', +// children: [ +// { +// name: 'methodName', +// children: [ 'examples.getStateName' ] +// }, +// { +// name: 'params', +// children: [ +// { +// name: 'param', +// children: [ { name: 'value', children: [ '40' ] } ] +// } +// ] +// } +// ] +// } + +// There is a dedicated method to format a JS value +xmlRpcFormatter.format_js(new Date(900684535000)) +// → { +// name: 'dateTime.iso8601', +// children: [ '1998-07-17T14:08:55.000Z' ] +// } +``` + +Formatting XML is out of this library's scope, but you can achieve it easily with +`@vates/xml`: + +```js +import { formatXml } from '@vates/xml/format' + +// it can now be passed to the XML-RPC parser +const tree = xmlRpcFormatter.format_methodCall({ + methodName: 'examples.getStateName', + params: [40], +}) + +// avoid any indentation or new lines as it will confuse many XML-RPC parsers. +const xml = formatXml(tree, { indent: 0 }) +``` + +### Parsing + +```js +import { xmlRpcParser } from '@vates/xml-rpc/parser' + +xmlRpcParser.parse({ + name: 'methodResponse', + children: [ + { + name: 'params', + children: [ + { + name: 'param', + children: [ + { + name: 'value', + children: [ + { + name: 'string', + children: ['South Dakota'], + }, + ], + }, + ], + }, + ], + }, + ], +}) +// { params: [ 'South Dakota' ] } + +// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.) +xmlRpcParser.parse_array({ + name: 'array', + children: [ + { + name: 'data', + children: [ + { + name: 'value', + children: [{ name: 'boolean', children: ['1'] }], + }, + { + name: 'value', + children: [{ name: 'int', children: ['42'] }], + }, + ], + }, + ], +}) +// [ true, 42 ] +``` + +Parsing XML is out of this library's scope, but you can achieve it easily with +`@vates/xml`: + +```js +import { parseXml } from '@vates/xml/parse' + +const xml = ` + + + + + South Dakota + + + +` + +const tree = parseXml(xml) + +// it can now be passed to the XML-RPC parser +xmlRpcParser.parse(tree) +``` diff --git a/@vates/xml-rpc/.npmignore b/@vates/xml-rpc/.npmignore new file mode 120000 index 0000000000..008d1b9b98 --- /dev/null +++ b/@vates/xml-rpc/.npmignore @@ -0,0 +1 @@ +../../scripts/npmignore \ No newline at end of file diff --git a/@vates/xml-rpc/README.md b/@vates/xml-rpc/README.md new file mode 100644 index 0000000000..149d05a7e7 --- /dev/null +++ b/@vates/xml-rpc/README.md @@ -0,0 +1,161 @@ + + +# @vates/xml-rpc + +[![Package Version](https://badgen.net/npm/v/@vates/xml-rpc)](https://npmjs.org/package/@vates/xml-rpc) ![License](https://badgen.net/npm/license/@vates/xml-rpc) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/xml-rpc)](https://bundlephobia.com/result?p=@vates/xml-rpc) [![Node compatibility](https://badgen.net/npm/node/@vates/xml-rpc)](https://npmjs.org/package/@vates/xml-rpc) + +> Parse and format XML-RPC messages, do not handle transport or XML + +## Install + +Installation of the [npm package](https://npmjs.org/package/@vates/xml-rpc): + +```sh +npm install --save @vates/xml-rpc +``` + +## Usage + +### Formatting + +```js +import { xmlRpcFormatter } from '@vates/xml-rpc/formatter' + +// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.) +xmlRpcFormatter.format_methodCall({ + methodName: 'examples.getStateName', + params: [40], +}) +// → { +// name: 'methodCall', +// children: [ +// { +// name: 'methodName', +// children: [ 'examples.getStateName' ] +// }, +// { +// name: 'params', +// children: [ +// { +// name: 'param', +// children: [ { name: 'value', children: [ '40' ] } ] +// } +// ] +// } +// ] +// } + +// There is a dedicated method to format a JS value +xmlRpcFormatter.format_js(new Date(900684535000)) +// → { +// name: 'dateTime.iso8601', +// children: [ '1998-07-17T14:08:55.000Z' ] +// } +``` + +Formatting XML is out of this library's scope, but you can achieve it easily with +`@vates/xml`: + +```js +import { formatXml } from '@vates/xml/format' + +// it can now be passed to the XML-RPC parser +const tree = xmlRpcFormatter.format_methodCall({ + methodName: 'examples.getStateName', + params: [40], +}) + +// avoid any indentation or new lines as it will confuse many XML-RPC parsers. +const xml = formatXml(tree, { indent: 0 }) +``` + +### Parsing + +```js +import { xmlRpcParser } from '@vates/xml-rpc/parser' + +xmlRpcParser.parse({ + name: 'methodResponse', + children: [ + { + name: 'params', + children: [ + { + name: 'param', + children: [ + { + name: 'value', + children: [ + { + name: 'string', + children: ['South Dakota'], + }, + ], + }, + ], + }, + ], + }, + ], +}) +// { params: [ 'South Dakota' ] } + +// There is a dedicated method per XML-RPC tag (`array`, `string`, etc.) +xmlRpcParser.parse_array({ + name: 'array', + children: [ + { + name: 'data', + children: [ + { + name: 'value', + children: [{ name: 'boolean', children: ['1'] }], + }, + { + name: 'value', + children: [{ name: 'int', children: ['42'] }], + }, + ], + }, + ], +}) +// [ true, 42 ] +``` + +Parsing XML is out of this library's scope, but you can achieve it easily with +`@vates/xml`: + +```js +import { parseXml } from '@vates/xml/parse' + +const xml = ` + + + + + South Dakota + + + +` + +const tree = parseXml(xml) + +// it can now be passed to the XML-RPC parser +xmlRpcParser.parse(tree) +``` + +## Contributions + +Contributions are _very_ welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xen-orchestra/issues) + you've encountered; +- fork and create a pull request. + +## License + +[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr) diff --git a/@vates/xml-rpc/formatter.js b/@vates/xml-rpc/formatter.js new file mode 100644 index 0000000000..2d5f042e15 --- /dev/null +++ b/@vates/xml-rpc/formatter.js @@ -0,0 +1,104 @@ +'use strict' + +function node(name, attributes, children) { + if (Array.isArray(attributes)) { + children = attributes + attributes = undefined + } + + return { name, attributes, children } +} + +class XmlRpcFormatter { + format_js(value) { + const type = typeof value + if (type === 'object') { + return value === null + ? this.format_nil() + : value instanceof Date + ? this['format_dateTime.iso8601'](value) + : Array.isArray(value) + ? this.format_array(value) + : Buffer.isBuffer(value) + ? this.format_base64(value) + : this.format_struct(value) + } + + return this['format_' + type](value) + } + + format_array(values) { + return node('array', [node('data', values.map(this.format_value, this))]) + } + + format_base64(buf) { + return node('base64', [buf.toString('base64')]) + } + + format_boolean(bool) { + return node('boolean', [bool ? '1' : '0']) + } + + 'format_dateTime.iso8601'(date) { + return node('dateTime.iso8601', [date.toISOString()]) + } + + format_double(str) { + return node('double', [String(str)]) + } + + format_fault(val) { + return node('fault', [this.format_value(val)]) + } + + format_i4(str) { + return node('i4', [String(str)]) + } + + format_int(str) { + return node('int', [String(str)]) + } + + format_member([name, value]) { + return node('member', [node('name', [name]), this.format_value(value)]) + } + + format_methodCall({ methodName, params }) { + return node('methodCall', [node('methodName', [methodName]), this.format_params(params)]) + } + + format_methodResponse({ fault, params }) { + return node('methodResponse', [fault !== undefined ? this.format_fault(fault) : this.format_params(params)]) + } + + format_nil() { + return node('nil') + } + + format_number(num) { + return Number.isInteger(num) ? this.format_int(num) : this.format_double(num) + } + + format_param(value) { + return node('param', [this.format_value(value)]) + } + + format_params(params) { + return node('params', params.map(this.format_param, this)) + } + + format_string(str) { + return str + } + + format_struct(struct) { + return node('struct', Object.entries(struct).map(this.format_member, this)) + } + + format_value(value) { + return node('value', [this.format_js(value)]) + } +} +exports.Formatter = XmlRpcFormatter + +exports.xmlRpcFormatter = new XmlRpcFormatter() diff --git a/@vates/xml-rpc/package.json b/@vates/xml-rpc/package.json new file mode 100644 index 0000000000..0bd88ebd9e --- /dev/null +++ b/@vates/xml-rpc/package.json @@ -0,0 +1,33 @@ +{ + "private": false, + "name": "@vates/xml-rpc", + "description": "Parse and format XML-RPC messages, do not handle transport or XML", + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/xml-rpc", + "bugs": "https://github.com/vatesfr/xen-orchestra/issues", + "repository": { + "directory": "@vates/xml-rpc", + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "author": { + "name": "Vates SAS", + "url": "https://vates.fr" + }, + "license": "ISC", + "version": "0.0.0", + "engines": { + "node": ">=12" + }, + "devDependencies": { + "@vates/xml": "0.0.0", + "test": "^3.3.0" + }, + "exports": { + "./formatter": "./formatter.js", + "./parser": "./parser.js" + }, + "scripts": { + "postversion": "npm publish --access public", + "test": "node--test" + } +} diff --git a/@vates/xml-rpc/parser.js b/@vates/xml-rpc/parser.js new file mode 100644 index 0000000000..1fcf76f3d1 --- /dev/null +++ b/@vates/xml-rpc/parser.js @@ -0,0 +1,133 @@ +'use strict' + +function toValue(node) { + const obj = { __proto__: null } + for (const child of node.children) { + if (typeof child === 'string') { + return child + } + + const { name } = child + if (Object.hasOwn(obj, name)) { + const prev = obj[name] + if (Array.isArray(obj, name)) { + prev.push(child) + } else { + obj[name] = [prev, child] + } + } else { + obj[name] = child + } + } + return obj +} + +// https://en.wikipedia.org/wiki/XML-RPC#Data_types +class XmlRpcParser { + parse(node) { + return this['parse_' + node.name](node) + } + + parse_array({ children: [data] }) { + return data.children.map(this.parse_value, this) + } + + parse_base64({ children: [str] }) { + return Buffer.from(str, 'base64') + } + + parse_boolean({ children: [str] }) { + return Boolean(Number(str)) + } + + 'parse_dateTime.iso8601'({ children: [str] }) { + const date = new Date(str) + + // date successfully parsed + if (!Number.isNaN(+date)) { + return date + } + + // XML-RPC dates may use YYYYMMDD instead of YYYY-MM-DD which ECMAScript + // does not parse + return new Date(str.slice(0, 4) + '-' + str.slice(4, 6) + '-' + str.slice(6)) + } + + parse_double({ children: [str] }) { + return Number(str) + } + + parse_fault({ children: [value] }) { + return this.parse_value(value) + } + + parse_i4({ children: [str] }) { + return Number(str) + } + + parse_int({ children: [str] }) { + return Number(str) + } + + parse_member(member) { + member = toValue(member) + return [member.name.children[0], this.parse_value(member.value)] + } + + parse_methodCall(methodCall) { + methodCall = toValue(methodCall) + return { + methodName: methodCall.methodName.children[0], + params: this.parse_params(methodCall.params), + } + } + + parse_methodResponse(methodResponse) { + methodResponse = toValue(methodResponse) + + const { fault } = methodResponse + if (fault !== undefined) { + return { fault: this.parse_fault(fault) } + } + + return { params: this.parse_params(methodResponse.params) } + } + + parse_nil() { + return null + } + + parse_param(param) { + return this.parse_value(param.children[0]) + } + + parse_params(params) { + return params.children.map(this.parse_param, this) + } + + parse_string({ children: [str] }) { + return str + } + + parse_struct(struct) { + return Object.fromEntries(struct.children.map(this.parse_member, this)) + } + + parse_value({ children }) { + if (children.length === 0) { + return '' + } + + const child = children[0] + + // can be a plain string + if (typeof child === 'string') { + return child + } + + return this.parse(child) + } +} +exports.XmlRpcParser = XmlRpcParser + +exports.xmlRpcParser = new XmlRpcParser() diff --git a/@vates/xml-rpc/test/formatter.js b/@vates/xml-rpc/test/formatter.js new file mode 100644 index 0000000000..17fc11ff87 --- /dev/null +++ b/@vates/xml-rpc/test/formatter.js @@ -0,0 +1,19 @@ +'use strict' + +const { formatXml } = require('@vates/xml/format') +const { join } = require('node:path') +const { parseXml } = require('@vates/xml/parse') +const { readFileSync } = require('node:fs') +const { xmlRpcFormatter } = require('@vates/xml-rpc/formatter') +const assert = require('node:assert/strict') +const test = require('test') + +const methodCall = require('./methodCall.js') + +test(function () { + const xmlTree = parseXml(readFileSync(join(__dirname, 'methodCall.xml'))) + + const xmlRpc = xmlRpcFormatter.format_methodCall(methodCall) + + assert.deepEqual(parseXml(formatXml(xmlRpc)), xmlTree) +}) diff --git a/@vates/xml-rpc/test/methodCall.js b/@vates/xml-rpc/test/methodCall.js new file mode 100644 index 0000000000..4ff407d97e --- /dev/null +++ b/@vates/xml-rpc/test/methodCall.js @@ -0,0 +1,16 @@ +'use strict' + +module.exports = { + methodName: 'examples.getStateName', + params: [ + [1404, 'Something here'], + Buffer.from([121, 111, 117, 32, 99, 97, 110, 39, 116, 32, 114, 101, 97, 100, 32, 116, 104, 105, 115, 33]), + true, + new Date('1998-07-17T14:08:55.000Z'), + -12.53, + 42, + 'Hello world!', + { foo: 1, bar: 2 }, + null, + ], +} diff --git a/@vates/xml-rpc/test/methodCall.xml b/@vates/xml-rpc/test/methodCall.xml new file mode 100644 index 0000000000..e163f678db --- /dev/null +++ b/@vates/xml-rpc/test/methodCall.xml @@ -0,0 +1,73 @@ + + + examples.getStateName + + + + + + + 1404 + + + Something here + + + + + + + + eW91IGNhbid0IHJlYWQgdGhpcyE= + + + + + 1 + + + + + 1998-07-17T14:08:55.000Z + + + + + -12.53 + + + + + 42 + + + + + Hello world! + + + + + + + foo + + 1 + + + + bar + + 2 + + + + + + + + + + + + diff --git a/@vates/xml-rpc/test/parser.js b/@vates/xml-rpc/test/parser.js new file mode 100644 index 0000000000..8976078f4f --- /dev/null +++ b/@vates/xml-rpc/test/parser.js @@ -0,0 +1,32 @@ +'use strict' + +const { join } = require('node:path') +const { parseXml } = require('@vates/xml/parse') +const { readFileSync } = require('node:fs') +const { xmlRpcParser } = require('@vates/xml-rpc/parser') +const assert = require('node:assert/strict') +const test = require('test') + +const methodCall = require('./methodCall.js') + +test('parser.parse()', function () { + const xml = String(readFileSync(join(__dirname, 'methodCall.xml'))) + const xmlRpc = parseXml(xml) + + assert.deepEqual(xmlRpcParser.parse(xmlRpc), methodCall) +}) + +test('datetime without dashes nor milliseconds', function () { + assert.deepEqual( + xmlRpcParser.parse({ name: 'dateTime.iso8601', children: ['19980717T14:08:55Z'] }), + new Date('1998-07-17T14:08:55Z') + ) +}) + +test('i4 support', function () { + assert.equal(xmlRpcParser.parse({ name: 'i4', children: ['42'] }), 42) +}) + +test('string support', function () { + assert.equal(xmlRpcParser.parse({ name: 'string', children: ['foo'] }), 'foo') +}) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index d23d4449b3..91b22a0865 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -28,5 +28,6 @@ - @vates/xml major +- @vates/xml-rpc major