From 8ecd1921760f70d930801ce319f8196e09be3a9a Mon Sep 17 00:00:00 2001 From: Richard Martens Date: Fri, 4 Nov 2022 16:48:38 +0100 Subject: [PATCH] replacing dots in Names. Closes #106 --- .vscode/launch.json | 35 ++++++ src/db/idPlugin.js | 10 -- src/metadata/ODataMetadata.js | 7 +- src/metadata/ODataServiceDocument.js | 2 +- src/parser/countParser.js | 2 +- src/parser/filterParser.js | 175 ++++++++++++++++---------- src/pipes.js | 31 ++--- src/rest/list.js | 2 +- src/writer/jsonWriter.js | 70 +++++++++++ src/{metadata => writer}/xmlWriter.js | 2 +- test/metadata.resource.complex.js | 98 ++++++++++++++- test/model.complex.filter.js | 4 +- test/rest.get.js | 18 ++- 13 files changed, 346 insertions(+), 110 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/writer/jsonWriter.js rename src/{metadata => writer}/xmlWriter.js (97%) diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..914d4ea --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // Verwendet IntelliSense zum Ermitteln möglicher Attribute. + // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. + // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Mocha single Test", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "args": [ + "--require", + "@babel/register", + "--reporter", + "dot", + "--timeout", + "300000", + "test/odata.query.filter.functions.js" + ] + }, + { + "type": "node", + "request": "launch", + "name": "Launch Complex Resource", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/examples/complex-resource/index.js" + } + ] +} \ No newline at end of file diff --git a/src/db/idPlugin.js b/src/db/idPlugin.js index 10f6881..7b4b8cd 100644 --- a/src/db/idPlugin.js +++ b/src/db/idPlugin.js @@ -2,16 +2,6 @@ import * as uuid from 'uuid'; /*eslint-disable */ export default function (schema) { - // add _id to schema. - if (!schema.paths._id) { - schema.add({ - _id: { - type: String, - unique: true, - } - }); - } - // display value of _id when request id. if (!schema.paths.id) { schema.virtual('id').get(function getId() { diff --git a/src/metadata/ODataMetadata.js b/src/metadata/ODataMetadata.js index e720b44..e9a2012 100644 --- a/src/metadata/ODataMetadata.js +++ b/src/metadata/ODataMetadata.js @@ -116,9 +116,10 @@ export default class Metadata { .filter((path) => path !== '_id') .reduce((previousProperty, curentProperty) => { const modelProperty = this.resolveModelproperty(model, curentProperty); + const propertyName = curentProperty.replace(/\./g, '-'); const result = { ...previousProperty, - [curentProperty]: this.visitor('Property', node[curentProperty], modelProperty, root), + [propertyName]: this.visitor('Property', node[curentProperty], modelProperty, root), }; return result; @@ -139,9 +140,11 @@ export default class Metadata { const properties = Object.keys(node) .filter((item) => item !== '_id') .reduce((previousProperty, curentProperty) => { + const propertyName = curentProperty.replace(/\./g, '-'); + const modelProperty = this.resolveModelproperty(model, curentProperty); const result = { ...previousProperty, - [curentProperty]: this.visitor('Property', node[curentProperty], model[curentProperty], root), + [propertyName]: this.visitor('Property', node[curentProperty], modelProperty, root), }; return result; diff --git a/src/metadata/ODataServiceDocument.js b/src/metadata/ODataServiceDocument.js index fda01ed..424e2a5 100644 --- a/src/metadata/ODataServiceDocument.js +++ b/src/metadata/ODataServiceDocument.js @@ -63,7 +63,7 @@ export default class Metadata { return new Promise((resolve) => { resolve({ status: 200, - entity: document, + serviceDocument: document, }); }); } diff --git a/src/parser/countParser.js b/src/parser/countParser.js index 8f2b275..d656d78 100644 --- a/src/parser/countParser.js +++ b/src/parser/countParser.js @@ -12,7 +12,7 @@ export default (mongooseModel, $count, $filter) => new Promise((resolve, reject) switch ($count) { case 'true': { const query = mongooseModel.find(); - filterParser(query, $filter); + filterParser(query, $filter, mongooseModel); query.count((err, count) => { resolve(count); }); diff --git a/src/parser/filterParser.js b/src/parser/filterParser.js index 84ceb9c..a0fedb2 100644 --- a/src/parser/filterParser.js +++ b/src/parser/filterParser.js @@ -36,6 +36,48 @@ const stringHelper = { }, }; +class KeyParser { + constructor(model) { + this._model = model; + } + + getConvertedKey(input) { + let key = input; + + if (key === 'id') { + key = '_id'; + return key; + } + if (this._model[key]) { + // known simple property + return key; + } + + const match = key.match(/\s*(contains|indexof|year)\(\s*([\w+-]+)/); + + if (match) { + // contains function was called with id e.g. contains(title, 'ggm') + const functionKey = match[2]; + + key = key.replace(functionKey, this.getConvertedKey(functionKey)); + } else { + key = Object.keys(this._model.model.schema.paths).find((item) => { + const replacedDots = item.replace(/\./g, '(.|-){1}'); + const regex = new RegExp(`^${replacedDots}$`); + + return key.match(regex); + }); + if (!key) { + const error = new Error(`Unknown property '${this._input}' in entity '${this._model.name}'`); + + error.status = '400'; + throw error; + } + } + return key; + } +} + const validator = { formatValue: (value) => { let val; @@ -56,85 +98,90 @@ const validator = { }, }; -export default (query, $filter) => new Promise((resolve, reject) => { +export default (query, $filter, model) => new Promise((resolve, reject) => { if (!$filter) { resolve(); return; } - const condition = split($filter, ['and', 'or']) - .filter((item) => (item !== 'and' && item !== 'or')); + try { + const condition = split($filter, ['and', 'or']) + .filter((item) => (item !== 'and' && item !== 'or')); - condition.forEach((item) => { - // parse "indexof(title,'X1ML') gt 0" - const conditionArr = split(item, OPERATORS_KEYS); - if (conditionArr.length === 0) { - // parse "contains(title,'X1ML')" - conditionArr.push(item); - } - if (conditionArr.length !== 3 && conditionArr.length !== 1) { - return reject(new Error(`Syntax error at '${item}'.`)); - } + condition.forEach((item) => { + // parse "indexof(title,'X1ML') gt 0" + const conditionArr = split(item, OPERATORS_KEYS); + if (conditionArr.length === 0) { + // parse "contains(title,'X1ML')" + conditionArr.push(item); + } + if (conditionArr.length !== 3 && conditionArr.length !== 1) { + throw new Error(`Syntax error at '${item}'.`); + } - let key = conditionArr[0]; - const [, odataOperator, value] = conditionArr; + const keyParser = new KeyParser(model); + let key = conditionArr[0]; + const [, odataOperator, value] = conditionArr; - if (key === 'id') key = '_id'; + key = keyParser.getConvertedKey(key); - let val; - if (value !== undefined) { - const result = validator.formatValue(value); - if (result.err) { - return reject(result.err); + let val; + if (value !== undefined) { + const result = validator.formatValue(value); + if (result.err) { + return reject(result.err); + } + val = result.val; } - val = result.val; - } - // function query - const functionKey = key.substring(0, key.indexOf('(')); - if (['indexof', 'year', 'contains'].indexOf(functionKey) > -1) { - functions[functionKey](query, key, odataOperator, val); - } else { - if (conditionArr.length === 1) { - return reject(new Error(`Syntax error at '${item}'.`)); - } - if (value === 'null') { + // function query + const functionKey = key.substring(0, key.indexOf('(')); + if (['indexof', 'year', 'contains'].indexOf(functionKey) > -1) { + functions[functionKey](query, key, odataOperator, val); + } else { + if (conditionArr.length === 1) { + return reject(new Error(`Syntax error at '${item}'.`)); + } + if (value === 'null') { + switch (odataOperator) { + case 'eq': + query.exists(key, false); + return resolve(); + case 'ne': + query.exists(key, true); + return resolve(); + default: + break; + } + } + // operator query switch (odataOperator) { case 'eq': - query.exists(key, false); - return resolve(); + query.where(key).equals(val); + break; case 'ne': - query.exists(key, true); - return resolve(); - default: + query.where(key).ne(val); break; + case 'gt': + query.where(key).gt(val); + break; + case 'ge': + query.where(key).gte(val); + break; + case 'lt': + query.where(key).lt(val); + break; + case 'le': + query.where(key).lte(val); + break; + default: + return reject(new Error("Incorrect operator at '#{item}'.")); } } - // operator query - switch (odataOperator) { - case 'eq': - query.where(key).equals(val); - break; - case 'ne': - query.where(key).ne(val); - break; - case 'gt': - query.where(key).gt(val); - break; - case 'ge': - query.where(key).gte(val); - break; - case 'lt': - query.where(key).lt(val); - break; - case 'le': - query.where(key).lte(val); - break; - default: - return reject(new Error("Incorrect operator at '#{item}'.")); - } - } - return query; - }); - resolve(); + return query; + }); + resolve(); + } catch (error) { + reject(error); + } }); diff --git a/src/pipes.js b/src/pipes.js index e8acd1c..c016e44 100644 --- a/src/pipes.js +++ b/src/pipes.js @@ -1,13 +1,9 @@ import http from 'http'; -import XmlWriter from './metadata/xmlWriter'; +import XmlWriter from './writer/xmlWriter'; +import JsonWirter from './writer/jsonWriter'; const xmlWriter = new XmlWriter(); - -function writeJson(res, data, status, resolve) { - res.type('application/json'); - res.status(status).jsonp(data); - resolve(data); -} +const jsonWriter = new JsonWirter(); function getMediaType(accept, data) { // reduce multi mimetypes to most weigth mimetype @@ -27,7 +23,7 @@ function getMediaType(accept, data) { return result; }, {}); - if (!data.entity && mostWeightMimetype.mimetype.match(/((application|\*)\/(xml|\*)|^xml$)/)) { + if (data.metadata && mostWeightMimetype.mimetype.match(/((application|\*)\/(xml|\*)|^xml$)/)) { return 'application/xml'; } if (mostWeightMimetype.mimetype.match(/((application|\*)\/(json|\*)|^json$)/)) { return 'application/json'; @@ -53,10 +49,10 @@ function getWriter(req, result) { // xml representation of metadata switch (mediaType) { case 'application/json': - return writeJson; + return jsonWriter.writeJson.bind(jsonWriter); case 'application/xml': - if (result.entity) { + if (!result.metadata) { // xml wirter for entities and actions is not implemented const error406 = new Error('Not acceptable'); @@ -67,8 +63,8 @@ function getWriter(req, result) { default: // no media type requested set defaults depend of context - if (result.entity) { - return writeJson; // default for entities and actions + if (result.entity || result.serviceDocument) { + return jsonWriter.writeJson.bind(jsonWriter); // default for entities and actions } return xmlWriter.writeXml.bind(xmlWriter); // default for metadata @@ -105,17 +101,8 @@ const respondPipe = (req, res, result) => new Promise((resolve, reject) => { const status = result.status || 200; const writer = getWriter(req, result); - let data; - - if (result.entity) { - // json Representation of data - data = result.entity; - } else { - // xml representation of metadata - data = result.metadata; - } - writer(res, data, status, resolve); + writer(res, result, status, resolve); } catch (error) { reject(error); } diff --git a/src/rest/list.js b/src/rest/list.js index 9258e92..f827397 100644 --- a/src/rest/list.js +++ b/src/rest/list.js @@ -19,7 +19,7 @@ function _dataQuery(model, { }, options) { return new Promise((resolve, reject) => { const query = model.find(); - filterParser(query, filter) + filterParser(query, filter, model) .then(() => orderbyParser(query, orderby || options.orderby)) .then(() => skipParser(query, skip, options.maxSkip)) .then(() => topParser(query, top, options.maxTop)) diff --git a/src/writer/jsonWriter.js b/src/writer/jsonWriter.js new file mode 100644 index 0000000..9d01f22 --- /dev/null +++ b/src/writer/jsonWriter.js @@ -0,0 +1,70 @@ +export default class { + writeJson(res, data, status, resolve) { + let normalizedData = data.entity; + + if (data.entity) { + if (data.entity.toObject) { + normalizedData = data.entity.toObject(); + } else if (Array.isArray(data.entity.value)) { + normalizedData = { + value: data.entity.value.map((item) => { + const result = item.toObject ? item.toObject() : item; + + return result; + }), + '@odata.count': data.entity['@odata.count'], + }; + } else if (data.entity.value) { + normalizedData = { + value: data.entity.value.toObject ? data.entity.value.toObject() : data.entity.value, + }; + } + normalizedData = this.replaceDot(normalizedData); + } else { + normalizedData = data.metadata || data.serviceDocument; + } + + res.type('application/json'); + res.status(status).jsonp(normalizedData); + resolve(normalizedData); + } + + replaceDot(value) { + if (!(value === null || value === undefined || typeof value === 'function')) { + if (Array.isArray(value)) { + return this.replaceDotinArray(value); + } + if (typeof value === 'object') { + return this.replaceObject(value); + } + } + + return value; + } + + replaceDotinArray(array) { + const result = array; + + result.forEach((item, index) => { + result[index] = this.replaceDot(item); + }); + return result; + } + + replaceObject(obj) { + const result = obj; + + Object.keys(result).forEach((item) => { + if (item.match(/^[^@][^.]+(\.[^.]+)+/)) { + const newPropertyName = item.replace('.', '-'); + + result[newPropertyName] = this.replaceDot(result[item]); + delete result[item]; + } else { + result[item] = this.replaceDot(result[item]); + } + }); + + return result; + } +} diff --git a/src/metadata/xmlWriter.js b/src/writer/xmlWriter.js similarity index 97% rename from src/metadata/xmlWriter.js rename to src/writer/xmlWriter.js index dcf1d18..d842f37 100644 --- a/src/metadata/xmlWriter.js +++ b/src/writer/xmlWriter.js @@ -180,7 +180,7 @@ export default class XmlWriter { } writeXml(res, data, status, resolve) { - const xml = this.visitor('document', data, '', '').replace(/\s*\s*/g, '>'); + const xml = this.visitor('document', data.metadata, '', '').replace(/\s*\s*/g, '>'); res.type('application/xml'); res.status(status).send(xml); diff --git a/test/metadata.resource.complex.js b/test/metadata.resource.complex.js index 0d3844c..72b4137 100644 --- a/test/metadata.resource.complex.js +++ b/test/metadata.resource.complex.js @@ -165,7 +165,7 @@ describe('metadata.resource.complex', () => { res.text.should.equal(xmlDocument); }); - it('should return json metadata for nested document', async function() { + it('should return json metadata for nested document in document', async function() { const jsonDocument = { $Version: '4.0', ObjectId: { @@ -180,7 +180,7 @@ describe('metadata.resource.complex', () => { $Type: "node.odata.ObjectId", $Nullable: false, }, - 'p4.p5': { + 'p4-p5': { $Type: 'Edm.String' } }, @@ -204,7 +204,7 @@ describe('metadata.resource.complex', () => { res.body.should.deepEqual(jsonDocument); }); - it('should return xml metadata for nested document', async function() { + it('should return xml metadata for nested document in document', async function() { const xmlDocument = ` @@ -216,7 +216,7 @@ describe('metadata.resource.complex', () => { - + @@ -234,4 +234,94 @@ describe('metadata.resource.complex', () => { assertSuccess(res); res.text.should.equal(xmlDocument); }); + + it('should return json metadata for nested document in array', async function() { + const jsonDocument = { + $Version: '4.0', + ObjectId: { + $Kind: "TypeDefinition", + $UnderlyingType: "Edm.String", + $MaxLength: 24 + }, + p2Child1: { + $Kind: "ComplexType", + p3: { + $Type: 'Edm.String' + }, + 'p4-p5': { + $Type: 'Edm.String' + } + }, + p1: { + $Kind: "EntityType", + $Key: ["id"], + id: { + $Type: "node.odata.ObjectId", + $Nullable: false, + }, + p2: { + $Type: 'node.odata.p2Child1', + $Collection: true + } + }, + $EntityContainer: 'node.odata', + ['node.odata']: { + $Kind: 'EntityContainer', + p1: { + $Collection: true, + $Type: `node.odata.p1`, + } + }, + }; + server.resource('p1', { + p2: [{ + p3: String, + p4: { + p5: String + } + }] + }); + httpServer = server.listen(port); + const res = await request(host).get('/$metadata?$format=json'); + res.statusCode.should.equal(200); + res.body.should.deepEqual(jsonDocument); + }); + + it('should return xml metadata for nested document in document', async function() { + const xmlDocument = + ` + + + + + + + + + + + + + + + + + + + + + `.replace(/\s*\s*/g, '>'); + server.resource('p1', { + p2: [{ + p3: String, + p4: { + p5: String + } + }] + }); + httpServer = server.listen(port); + const res = await request(host).get('/$metadata'); + assertSuccess(res); + res.text.should.equal(xmlDocument); + }); }); diff --git a/test/model.complex.filter.js b/test/model.complex.filter.js index 325038a..afb6a7b 100644 --- a/test/model.complex.filter.js +++ b/test/model.complex.filter.js @@ -13,7 +13,7 @@ describe('model.complex.filter', () => { before(() => { db = new Db(); const server = odata(db); - const resource = server.resource('complex-model-filter', { product: [{ price: Number }] }); + const resource = server.resource('complex-model-filter', { product: { price: Number } }); mock = sinon.mock(resource.model); mock.expects('where').once().withArgs('product.price').returns(resource.model); @@ -26,7 +26,7 @@ describe('model.complex.filter', () => { }); it('should work when PUT a complex entity', async function() { - const res = await request(host).get(`/complex-model-filter?$filter=product.price gt 30`); + const res = await request(host).get(`/complex-model-filter?$filter=product-price gt 30`); assertSuccess(res); mock.verify(); diff --git a/test/rest.get.js b/test/rest.get.js index 81f6de9..8b94498 100644 --- a/test/rest.get.js +++ b/test/rest.get.js @@ -5,14 +5,24 @@ import books from './support/books.json'; import FakeDb from './support/fake-db'; describe('rest.get', () => { - let data, httpServer; + let data, cdata, httpServer; before(async function() { const db = new FakeDb(); const server = odata(db); - server.resource('book', bookSchema) + server.resource('book', bookSchema); + server.resource('complex-type', { + p1: { + p2: { + type: String + } + } + }); httpServer = server.listen(port); data = db.addData('book', books); + cdata = db.addData('complex-type', [{ + "p1.p2": "p1.p2 value" + }]); }); after(() => { @@ -37,4 +47,8 @@ describe('rest.get', () => { const res = await request(host).get(`/book(not-exist-id)`); res.status.should.be.equal(404); }); + it('should replace a dot in property names with -', async function() { + const res = await request(host).get(`/complex-type(${cdata[0].id})`); + res.body.should.be.have.property('p1-p2'); + }); });