From 6e77ad5a2f5fdff1bd15b93b8ca29e04414723c5 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Wed, 9 Apr 2025 09:42:09 +0000 Subject: [PATCH 1/3] implement odata.context --- src/attribute.js | 160 ++++++++++++++++++++++++++++++++++++++++++++++ src/formatter.js | 83 +++++++++++++++++++++++- src/middleware.js | 49 +++++++++----- 3 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 src/attribute.js diff --git a/src/attribute.js b/src/attribute.js new file mode 100644 index 0000000..693cf96 --- /dev/null +++ b/src/attribute.js @@ -0,0 +1,160 @@ +import { ODataModelBuilder } from '@themost/data'; + +/** + * @abstract + */ +class AppendAttribute { + constructor() { + this.name = 'responseFormatter'; + } + /** + * + * @param {Request} req + * @param {Response} res + * @returns {*} + */ + // eslint-disable-next-line no-unused-vars + append(req, res) { + return {}; + } + +} + +class AppendContextAttribute extends AppendAttribute { + constructor() { + super(); + this.name = 'contextUrl'; + } + + /** + * + * @param {Request} req + * @param {Response} res + * @returns {*} + */ + // eslint-disable-next-line no-unused-vars + append(req, res) { + /** + * @type {import('./app').ExpressDataContext} + */ + const context = req.context; + // add odata attributes + // @odata.context + let serviceRoot = context.getConfiguration().getSourceAt('settings/builder/serviceRoot'); + if (serviceRoot == null) { + // try to get service root from request + const host = req.get('x-forwarded-host') || req.get('host'); + const protocol = req.get('x-forwarded-proto') || req.protocol; + /** + * @type {string} + */ + let baseUrl = req.baseUrl || req.path; + if (baseUrl.endsWith('/') === false) { + baseUrl += '/'; + } + serviceRoot = new URL(baseUrl, `${protocol}://${host}`).toString(); + } + const builder = context.application.getStrategy(ODataModelBuilder); + /** + * @type {URL} + */ + const metadataUrl = new URL('./$metadata', serviceRoot); + // entity set + if (req.route && req.route.path === '/:entitySet/:id') { + metadataUrl.hash = `${req.params.entitySet}/entity`; + // entity + } else if (req.route && req.route.path === '/:entitySet') { + metadataUrl.hash = `${req.params.entitySet}`; + // navigation property + } else if (req.entitySet && req.route && req.route.path === '/:entitySet/:id/:navigationProperty') { + const navigationProperty = builder.getEntitySet(req.entitySet.name).getEntityTypeNavigationProperty(req.params.navigationProperty); + if (navigationProperty) { + const navigationEntitySet = builder.getEntityTypeEntitySet(navigationProperty.type); + metadataUrl.hash = navigationEntitySet ? `${navigationEntitySet.name}/entity` : `${req.params.entitySet}/${req.params.id}/${req.params.navigationProperty}`; + } else { + metadataUrl.hash = `${req.params.entitySet}/${req.params.id}/${req.params.navigationProperty}`; + } + // entity set function + } else if (req.route && req.route.path === '/:entitySet/:entitySetFunction') { + const entitySet = builder.getEntitySet(req.params.entitySet); + const collection = entitySet && entitySet.entityType && entitySet.entityType.collection; + if (collection) { + /** + * get action + * @type {import('@themost/data').FunctionConfiguration} + */ + const entitySetFunction = collection.hasFunction(req.params.entitySetFunction); + // if action returns only one item + if (entitySetFunction && entitySetFunction.returnType) { + // try to find entity set of that item + const entitySetActionReturnSet = builder.getEntityTypeEntitySet(entitySetFunction.returnType); + // and format metadata url hash + if (entitySetActionReturnSet) { + metadataUrl.hash = `${entitySetActionReturnSet.name}/entity`; // e.g. $metadata#People/entity + } else { + metadataUrl.hash = `${entitySetFunction.returnType}`; // e.g. $metadata#String + } + } else if (entitySetFunction && entitySetFunction.returnCollectionType) { // if function returns a collection + // try to find entity set of that collection + const entitySetActionReturnSet = builder.getEntityTypeEntitySet(entitySetFunction.returnCollectionType); + // and format metadata url hash + if (entitySetActionReturnSet) { + metadataUrl.hash = `${entitySetActionReturnSet.name}`; // e.g. $metadata#People + } else { + metadataUrl.hash = `${entitySetFunction.returnCollectionType}`; // e.g. $metadata#String + } + } + } + if (typeof metadataUrl.hash === 'undefined') { + metadataUrl.hash = `${req.params.entitySet}${req.params.entitySetFunction}`; + } + // entity set action + } else if (req.route && req.route.path === '/:entitySet/:entitySetAction') { + // get current entity set + const entitySet = builder.getEntitySet(req.params.entitySet); + // get current entity set collection + const collection = entitySet && entitySet.entityType && entitySet.entityType.collection; + if (collection) { + /** + * get action + * @type {import('@themost/data').ActionConfiguration} + */ + const entitySetAction = collection.hasAction(req.params.entitySetAction); + // if action returns only one item + if (entitySetAction && entitySetAction.returnType) { + // try to find entity set of that item + const entitySetActionReturnSet = builder.getEntityTypeEntitySet(entitySetAction.returnType); + // and format metadata url hash + if (entitySetActionReturnSet) { + metadataUrl.hash = `${entitySetActionReturnSet.name}/entity`; // e.g. $metadata#People/entity + } else { + metadataUrl.hash = `${entitySetAction.returnType}`; // e.g. $metadata#String + } + } + // if action returns a collection + else + if (entitySetAction && entitySetAction.returnCollectionType) { + // try to find entity set of that collection + const entitySetActionReturnSet = builder.getEntityTypeEntitySet(entitySetAction.returnCollectionType); + // and format metadata url hash + if (entitySetActionReturnSet) { + metadataUrl.hash = `${entitySetActionReturnSet.name}`; // e.g. $metadata#People + } else { + metadataUrl.hash = `${entitySetAction.returnCollectionType}`; // e.g. $metadata#String + } + } + } + if (typeof metadataUrl.hash === 'undefined') { + metadataUrl.hash = `${req.params.entitySet}/${req.params.entitySetAction}`; + } + } + return { + '@odata.context': metadataUrl.toString(), + } + } +} + +export { + AppendAttribute, + AppendContextAttribute +} \ No newline at end of file diff --git a/src/formatter.js b/src/formatter.js index cd7e9ab..56fa9bb 100644 --- a/src/formatter.js +++ b/src/formatter.js @@ -1,7 +1,51 @@ // MOST Web Framework 2.0 Codename Blueshift Copyright (c) 2019-2023, THEMOST LP All rights reserved -import {AbstractMethodError, ApplicationService} from '@themost/common'; +import {AbstractMethodError, ApplicationService, HttpBadRequestError, HttpServerError} from '@themost/common'; +import { ODataModelBuilder } from '@themost/data'; import {XSerializer} from '@themost/xml'; import {Readable} from 'stream'; +import { AppendContextAttribute } from './attribute'; + +const MimeTypeRegex = '([a-zA-Z*]+)\\/([a-zA-Z0-9\\-*.+]+)'; +const MimeTypeParamRegex = '^([a-zA-Z0-9\\-._+]+)="?([a-zA-Z0-9\\-._+]+)"?$'; + +/** + * + * @param {string} format + * @returns {Map}>} + */ +function parseFormatParam(format) { + const results = new Map(); + if (format == null) { + return results; + } + if (format.length === 0) { + return results; + } + const parts = format.split(','); + return parts.reduce((parts, part) => { + const tokens = part.trim().split(';'); + const mimeType = tokens.shift(); + const matches = new RegExp(MimeTypeRegex, 'g').exec(mimeType); + if (matches) { + const [,type, subType] = matches; + const params = tokens.reduce((previous, current) => { + const paramMatches = new RegExp(MimeTypeParamRegex, 'g').exec(current); + if (paramMatches) { + const [,name, value] = paramMatches; + previous.set(name, value); + } + return previous; + }, new Map()); + parts.set(mimeType, { + type, + subType, + params + }); + return parts + } + }, results); +} + class HttpResponseFormatter { /** @@ -44,6 +88,7 @@ class XmlResponseFormatter extends HttpResponseFormatter { } } + class JsonResponseFormatter extends HttpResponseFormatter { /** * @@ -51,13 +96,47 @@ class JsonResponseFormatter extends HttpResponseFormatter { * @param {Response} res */ execute(req, res) { + // get $format query parameter or accept header + const formatParam = req.query.$format || req.get('accept'); + // get formats + const formats = parseFormatParam(formatParam); + // get application/json mime type + const mimeType = formats.get('application/json'); + // get odata.metadata parameter (the default is minimal) + let metadata = 'minimal'; + if (mimeType && mimeType.params.has('odata.metadata')) { + metadata = mimeType.params.get('odata.metadata'); + } + /** + * @type {import('./app').ExpressDataContext} + */ + const context = req.context; + if (context == null) { + throw new HttpServerError('Missing context', 'Request context cannot be empty.'); + } + const builder = context.application.getStrategy(ODataModelBuilder); + if (builder == null) { + throw new HttpServerError('Missing model builder', 'ODataModelBuilder service has not been initialized.'); + } // if data is empty if (this.data == null) { // return no content return res.status(204).type('application/json').send(); } - res.json(this.data); + res.setHeader('OData-Version', '4.0'); + const extraAttributes = {}; + if (metadata !== 'none') { + Object.assign(extraAttributes, new AppendContextAttribute().append(req, res)); + } + if (Array.isArray(this.data)) { + return res.json(Object.assign(extraAttributes, { + value: this.data + })); + } + res.json(Object.assign(extraAttributes, this.data)); + } + } class ResponseFormatterWrapper { diff --git a/src/middleware.js b/src/middleware.js index 2fd752c..3531663 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import Q from 'q'; import {ODataModelBuilder, EdmMapping, DataQueryable, EdmType} from '@themost/data'; -import {LangUtils, HttpNotFoundError, HttpBadRequestError, HttpMethodNotAllowedError, TraceUtils} from '@themost/common'; +import {LangUtils, HttpNotFoundError, HttpBadRequestError, HttpMethodNotAllowedError, TraceUtils, HttpServerError} from '@themost/common'; import { ResponseFormatter, StreamFormatter } from './formatter'; import {multerInstance} from './multer'; import fs from 'fs'; @@ -1640,26 +1640,43 @@ function postEntityAction(options) { } /** - * @returns {Function} + * @returns {import('express').RequestHandler} + * @throws {HttpServerError} */ function getEntitySetIndex() { return (req, res, next) => { - if (typeof req.context === 'undefined') { - return next(); - } - /** - * get current model builder - * @type {ODataModelBuilder|*} - */ - const builder = req.context.getApplication().getStrategy(ODataModelBuilder); - // get edm document - return builder.getEdm().then(result => { - return res.json({ - value: result.entityContainer.entitySet + try { + if (req.context == null) { + throw new HttpServerError('Invalid request state. Request context is undefined.'); + } + // get service root + let serviceRoot = req.context.application.getConfiguration().getSourceAt('settings/builder/serviceRoot'); + if (serviceRoot == null) { + // try to get service root from request + const host = req.get('x-forwarded-host') || req.get('host'); + const protocol = req.get('x-forwarded-proto') || req.protocol; + serviceRoot = new URL(req.path, `${protocol}://${host}`).toString(); + } + /** + * get current model builder + * @type {ODataModelBuilder} + */ + const builder = req.context.getApplication().getStrategy(ODataModelBuilder); + if (builder == null) { + throw new HttpServerError('Invalid request state. Model builder is undefined.'); + } + // get edm document + return builder.getEdm().then(result => { + return tryFormat({ + value: result.entityContainer.entitySet + }, req, res); + }).catch(err => { + return next(err); }); - }).catch(err => { + } catch(err) { return next(err); - }); + } + }; } From 1f298b61b9a1b3db4827e8fb84989c29d32371f2 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Wed, 9 Apr 2025 10:28:50 +0000 Subject: [PATCH 2/3] fix local db path resolution --- spec/entitySetFunction.spec.js | 7 +++++++ spec/formatter.spec.js | 8 ++------ spec/service.spec.js | 7 +++++++ spec/test/db/local.db | Bin 872448 -> 872448 bytes src/attribute.js | 4 ++-- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/spec/entitySetFunction.spec.js b/spec/entitySetFunction.spec.js index 2a0f029..2539e88 100644 --- a/spec/entitySetFunction.spec.js +++ b/spec/entitySetFunction.spec.js @@ -7,6 +7,7 @@ import {serviceRouter} from '@themost/express'; import {TestPassportStrategy} from './passport'; import request from 'supertest'; import { finalizeDataApplication } from './utils'; +import { DataConfigurationStrategy } from '@themost/data'; describe('EntitySetFunction', () => { let app; @@ -15,6 +16,12 @@ describe('EntitySetFunction', () => { app = express(); // create a new instance of data application const dataApplication = new ExpressDataApplication(path.resolve(__dirname, 'test/config')); + const dataConfiguration = dataApplication.getConfiguration().getStrategy(DataConfigurationStrategy); + // change sqlite database path + const adapter = dataConfiguration.adapters.find((adapter) => adapter.default); + if (adapter.invariantName === 'sqlite') { + adapter.options.database = path.resolve(__dirname, 'test/db/local.db'); + } app.use(express.json({ reviver: dateReviver })); diff --git a/spec/formatter.spec.js b/spec/formatter.spec.js index 575ac33..04a3d79 100644 --- a/spec/formatter.spec.js +++ b/spec/formatter.spec.js @@ -68,17 +68,13 @@ describe('ResponseFormatter', () => { .set('Accept', 'application/json'); expect(response.status).toBe(200); expect(response.body).toBeTruthy(); - expect(response.body).toEqual({ - message: 'hey' - }); + expect(response.body.message).toEqual('hey'); response = await request(app) .get('/message') expect(response.status).toBe(200); expect(response.body).toBeTruthy(); - expect(response.body).toEqual({ - message: 'hey' - }); + expect(response.body.message).toEqual('hey'); }); it('should use formatter to get xml', async () => { diff --git a/spec/service.spec.js b/spec/service.spec.js index 9165cab..58fc990 100644 --- a/spec/service.spec.js +++ b/spec/service.spec.js @@ -8,6 +8,7 @@ import {serviceRouter, getServiceRouter} from '@themost/express'; import {dateReviver} from '@themost/express'; import { ApplicationService } from '@themost/common'; import { finalizeDataApplication } from './utils'; +import { DataConfigurationStrategy } from '@themost/data'; class ServiceRouterExtension extends ApplicationService { constructor(app) { @@ -72,6 +73,12 @@ describe('serviceRouter', () => { app = express(); // create a new instance of data application const dataApplication = new ExpressDataApplication(path.resolve(__dirname, 'test/config')); + const dataConfiguration = dataApplication.getConfiguration().getStrategy(DataConfigurationStrategy); + // change sqlite database path + const adapter = dataConfiguration.adapters.find((adapter) => adapter.default); + if (adapter.invariantName === 'sqlite') { + adapter.options.database = path.resolve(__dirname, 'test/db/local.db'); + } app.use(express.json({ reviver: dateReviver })); diff --git a/spec/test/db/local.db b/spec/test/db/local.db index 0e760fa0634cdd5081453481cbc1531e7d92b2d5..3935960db1339c3dc45a30a58e8fd6de81d504bd 100644 GIT binary patch delta 160 zcmZozVA`<2bb>VF=ZP}TjGs3q6zVhDZoZ`7p}}Z7xj#a3b4kQjQJ{!c-hX}&RagIv z(YAe63L_9R0WmWWvj8zG5VHX>I}mdKF((jn0WtUXRVh5avW&LVe>C#gYFjE88dw=w lSQ(k?85o#q8yHv_7#JBCnd%yt=o%nP8X9g-XyRFL768BVF`-w8njPExl6zVftZ@#48p}}Z9xj#a3b4kQjQJ{!c-hX}&RagIv z(Yk$A3L_9R0WmWWvj8zG5VHX>I}mdKF((jn0WtUXRVh5avW(W#e>C#gYMUw;SXddF lS{WManOT@?8yHv_7#JBCnd%yt=o%nPTAFT8XyRFL767-`Hi-ZL diff --git a/src/attribute.js b/src/attribute.js index 693cf96..9f7b05c 100644 --- a/src/attribute.js +++ b/src/attribute.js @@ -101,7 +101,7 @@ class AppendContextAttribute extends AppendAttribute { if (entitySetActionReturnSet) { metadataUrl.hash = `${entitySetActionReturnSet.name}`; // e.g. $metadata#People } else { - metadataUrl.hash = `${entitySetFunction.returnCollectionType}`; // e.g. $metadata#String + metadataUrl.hash = `Collection(${entitySetFunction.returnCollectionType})`; // e.g. $metadata#Collection(String) } } } @@ -140,7 +140,7 @@ class AppendContextAttribute extends AppendAttribute { if (entitySetActionReturnSet) { metadataUrl.hash = `${entitySetActionReturnSet.name}`; // e.g. $metadata#People } else { - metadataUrl.hash = `${entitySetAction.returnCollectionType}`; // e.g. $metadata#String + metadataUrl.hash = `Collection(${entitySetAction.returnCollectionType})`; // e.g. $metadata#Collection(String) } } } From 9b4d8927841d6c9e0bd34ba458262bbbb0c215e1 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Wed, 9 Apr 2025 10:40:04 +0000 Subject: [PATCH 3/3] reset append attribute --- spec/test/db/local.db | Bin 872448 -> 872448 bytes src/attribute.js | 5 +++++ 2 files changed, 5 insertions(+) diff --git a/spec/test/db/local.db b/spec/test/db/local.db index 3935960db1339c3dc45a30a58e8fd6de81d504bd..f4f6c6a53a7f5e492b7ce304cd262c57796e54de 100644 GIT binary patch delta 155 zcmZozVA`<2bb>VF*NHODj9)h<6zVhDZN8-6p}}Z3xj#a3b4kQjQJ{!c-hX}&RagIv z(XM?}3L_9R0WmWWvj8zG5VHX>I}mdKF((jn0WtUXRVh5avW#}qe>CzqsTo^XnONwV l8kuPu7+4t?7#SFu>Kd5n8dxeA8dyOkjZL>FH1RAr3jo6uHMjr( delta 155 zcmZozVA`<2bb>VF=ZP}TjGs3q6zVhDZoZ`7p}}Z7xj#a3b4kQjQJ{!c-hX}&RagIv z(YAe63L_9R0WmWWvj8zG5VHX>I}mdKF((jn0WtUXRVh5avW&LVe>CzqsTo;V8JX)D l7?^4s7+4t?7#SFu>Kd5n8dxeA8dyOk4Gp&^H1RAr3jn+IHIo1U diff --git a/src/attribute.js b/src/attribute.js index 9f7b05c..0ffc621 100644 --- a/src/attribute.js +++ b/src/attribute.js @@ -38,6 +38,11 @@ class AppendContextAttribute extends AppendAttribute { * @type {import('./app').ExpressDataContext} */ const context = req.context; + // if entity set is not defined + if (req.entitySet == null) { + // do nothing and exit + return {}; + } // add odata attributes // @odata.context let serviceRoot = context.getConfiguration().getSourceAt('settings/builder/serviceRoot');