UNPKG

@sap_oss/odata-library

Version:

OData client for testing Netweawer OData services.

743 lines (682 loc) 22.8 kB
"use strict"; const _ = require("lodash"); const Resource = require("./Resource"); const responseType = require("./responseType"); const parsers = require("../agent/parsers"); const requestPut = require("./request/put"); const Buffer = require("node:buffer").Buffer; /** * Envelope GET/POST/PUT/DELETE methods for particular QueryableResource * * @class QueryableResource * @extends {Resource} */ class QueryableResource extends Resource { /** * Creates an instance of <code>QueryableResource</code>. * @param {Agent} agent instance of the Agent class @see Agent.js * @param {Metadata} metadata instance of the metadata object that keeps service metadata * @param {Object} entitySetModel information about EntitySet parsed from Metadata * @param {Object} entityTypeModel information about EntityType parsed from Metadata * @memberof QueryableResource */ constructor(agent, metadata, entitySetModel, entityTypeModel) { super(agent, { _query: {}, }); Object.defineProperty(this, "metadata", { value: metadata, }); Object.defineProperty(this, "entitySetModel", { value: entitySetModel, }); Object.defineProperty(this, "entityTypeModel", { value: entityTypeModel, }); } // see RequestDefinition.top top(top) { this.defaultRequest.top(top); return this; } // see RequestDefinition.select select(...args) { this.defaultRequest.select(...args); return this; } // see RequestDefinition.skip skip(skip) { this.defaultRequest.skip(skip); return this; } // see RequestDefinition.filter filter(filter) { this.defaultRequest.filter(filter); return this; } // see RequestDefinition.orderby orderby(...args) { this.defaultRequest.orderby(...args); return this; } // see RequestDefinition.expand expand(...args) { this.defaultRequest.expand(...args); return this; } // see RequestDefinition.search search(pattern) { this.defaultRequest.search(pattern); return this; } // see RequestDefinition.key key(entityKey) { return this.defaultRequest.key(entityKey); } /** * Send request to count of the EntitySet * * @param {RequestDefinition} [request] optional request definition (default entity set request is used as default) * * @return {Promise} returned promise is resolved when request is finished * * @memberof QueryableResource */ count(request = this.defaultRequest) { return request.count(); } /** * Sends GET request to the entity set. * * @param {*} args some arguments * supported argument variants * 1. one number argument -> list top query * 2. no arguments and key defined -> get entity entity * 3. one plain object argument -> get entity with key from this object * 4. no arguments and key not defined -> list query * * @return {Promise} returned promise is resolved when request is finished * @memberof QueryableResource */ get(...args) { return this.defaultRequest.get(...args); } /** * Send request to create new entity by HTTP POST method * * @param {String} body map of the new entity which is end to the new repository * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which contains res.body and body * contains newly created object. * * @memberof QueryableResource */ post(body) { let promise; const defaultBatch = this.agent.batchManager.defaultBatch; const urlQuery = this.urlQuery(); const path = urlQuery ? `/${this.getListResourcePath()}?${urlQuery}` : `/${this.getListResourcePath()}`; const error = this.checkPostParameters(body, defaultBatch); if (error) { promise = Promise.reject(error); } else if (this.isPlainRequest(body)) { promise = this.postPlainRequest(path, body); } else if (defaultBatch) { promise = this.postBatchRequest(path, body, defaultBatch); } else { promise = this.postJSONRequest(path, body); } return promise; } /** * Determine if the body is plain request * * @param {String} body map of the new entity which is end to the new repository * * @return {Boolean} true if the body is plain request */ isPlainRequest(body) { return ( body instanceof Buffer || body instanceof FormData || _.has(this.defaultRequest._headers, "slug") ); } /** * Check input parameters for post method * * @param {String} body map of the new entity which is end to the new repository * @param {Batch} defaultBatch default batch for the request * * @return {Error} error if the parameters are not valid */ checkPostParameters(body, defaultBatch) { let error; if (this.isPlainRequest(body)) { const hasStream = this.entityTypeModel.hasStream; if (defaultBatch) { error = new Error( "Plain or file request is not supported for batch requests." ); } else if (!hasStream && this.agent.settings.strict !== false) { error = new Error("Buffer is not supported for this entity type."); } } return error; } /** * Send POST request without additional processing * * @param {String} path path to the entity set * @param {String} body Buffer/FormData (or any other data with slug header) * * @return {Promise} returned promise is resolved when request is finished */ postPlainRequest(path, body) { this.defaultRequest.payload(body); this.defaultRequest._isPlain = true; return this._handleAgentCall((request) => { return this.agent.post(path, request._headers, request._payload); }); } /** * Add POST request to the batch * * @param {String} path path to the entity set * @param {String} body map of the new entity which is end to the new repository * @param {Batch} defaultBatch default batch for the request * * @return {Promise} returned promise is resolved when request is finished */ postBatchRequest(path, body, defaultBatch) { const defaultChangeSet = this.agent.batchManager.defaultChangeSet; this.defaultRequest.payload(this.bodyProperties(body)); return this._handleBatchCall(() => { this.defaultRequest.header("Accept", "application/json"); return defaultBatch.post( path, this.defaultRequest._headers, this.defaultRequest._payload, defaultChangeSet ); }, defaultBatch); } /** * Send POST request with JSON body (standard OData request) * * @param {String} path path to the entity set * @param {String} body map of the new entity which is send to the odata endpoint * * @return {Promise} returned promise is resolved when request is finished */ postJSONRequest(path, body) { this.defaultRequest.payload(this.bodyProperties(body)); return this._handleAgentCall((request) => { request.header("Content-Type", "application/json"); return this.agent.post( path, request._headers, JSON.stringify(request._payload) ); }); } /** * Send request to update an entity by HTTP PUT method * * @param {String} body map of new data for the entity * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which doesn't contain body * * @memberof QueryableResource */ put(body) { return requestPut.call(body, this); } /** * Send request to update an entity by HTTP MERGE method (update for * OData protocol version 1.0-2.0) * * @param {Object} body map of key properties and new data for the entity * @param {Object} [propertiesToChange] map of new data for the entity * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which doesn't contain body * * @memberof QueryableResource */ merge(...args) { return this.processUpdateCall("merge", ...args); } /** * Send request to update an entity by HTTP MERGE method * * @param {Object} body map of key properties and new data for the entity * @param {Object} [propertiesToChange] map of new data for the entity * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which doesn't contain body * * @memberof QueryableResource */ patch(...args) { return this.processUpdateCall("patch", ...args); } /** * Send request to update entity via MERGE or PATCH method. The method * unify code for patch and merge methods. * * @param {String} methodName name of method from agent "merge" or "patch" * @param {Object} body map of key properties and new data for the entity * @param {Object} [propertiesToChange] map of new data for the entity * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which doesn't contain body * * @private * @memberof QueryableResource */ processUpdateCall(methodName, ...args) { let keyProperties; let keyPredicate; let propertiesToChange; let entity; let path; let defaultBatch = this.agent.batchManager.defaultBatch; let defaultChangeSet = this.agent.batchManager.defaultChangeSet; if (args.length === 0 || args.length > 2) { throw new Error(`Invalid body parameter for ${methodName}.`); } else if (args.length === 1) { entity = _.assign({}, args[0], this.defaultRequest._keyValue); keyProperties = this.keyProperties(entity); keyPredicate = this.keyPredicate(keyProperties); propertiesToChange = _.pickBy( entity, (value, key) => !_.has(keyProperties, key) ); } else { keyProperties = this.keyProperties(args[0]); keyPredicate = this.keyPredicate(keyProperties); // note: following assignment allows to change also properties which are part of the key propertiesToChange = args[1]; } path = `/${this.entitySetModel.name}(${keyPredicate})`; this.defaultRequest.payload(this.bodyProperties(propertiesToChange)); return defaultBatch ? this._handleBatchCall(() => { this.defaultRequest.header("Accept", "application/json"); this.defaultRequest.header("If-Match", "*"); return defaultBatch[methodName]( path, this.defaultRequest._headers, this.defaultRequest._payload, defaultChangeSet ); }, defaultBatch) : this._handleAgentCall((request) => { request.header("Content-Type", "application/json"); request.header("If-Match", "*"); return this.agent[methodName]( path, request._headers, JSON.stringify(request._payload) ); }); } /** * Send request to delete an entity by HTTP DELETE method * * @param {String} properties map of key properties of the entity which is to be deleted * * @return {Promise} returned promise is resolved when request is finished * Promise is resolved with response object which doesn't contain body * * @memberof QueryableResource */ delete(properties) { let defaultBatch = this.agent.batchManager.defaultBatch; let defaultChangeSet = this.agent.batchManager.defaultChangeSet; let path; if (arguments.length > 0) { this.key(properties); } path = `/${this.getSingleResourcePath()}`; return defaultBatch ? this._handleBatchCall(() => { this.defaultRequest.header("If-Match", "*"); return defaultBatch.delete( path, this.defaultRequest._headers, defaultChangeSet ); }, defaultBatch) : this._handleAgentCall((request) => { request.header("If-Match", "*"); return this.agent.delete(path, request._headers); }); } /** * Send GET request to fetch entities requested by clauses like filter, top, count ... * * @private * @param {RequestDefinition} request request definition * @return {Promise} returned promise is resolved when request is finished * @memberof QueryableResource */ executeGet(request) { const defaultBatch = this.agent.batchManager.defaultBatch; const defaultChangeSet = this.agent.batchManager.defaultChangeSet; const foundResponseType = responseType.determine(this.defaultRequest, this); return defaultBatch ? this._handleBatchCall(() => { request.calculatePath(); if ( foundResponseType !== responseType.ENTITY_VALUE && foundResponseType !== responseType.PROPERTY_VALUE ) { request.header("Accept", "application/json"); } return defaultBatch.get( request._path, request._headers, defaultChangeSet, responseType.determine(request, this) ); }, defaultBatch) : this._handleAgentCall( () => this.agent.get(request._path, request._headers), request ); } /** * Filter entity object to key properties for the EntitySet * * @private * @param {Object} entity whole entity as Object * * @return {Object} the part of the entity with the key properties only * * @memberof QueryableResource */ keyProperties(entity) { return this.entityTypeModel.key.reduce((acc, keyProperty) => { let key = keyProperty.name; acc[key] = keyProperty.type.format(entity[key]); return acc; }, {}); } /** * Convert object with the entity key properties to URI component * * @protected * @param {Object} entityKey part of the entity with key properties * * @return {String} the part of URI which is used as key * * @memberof QueryableResource */ keyPredicate(entityKey) { return _.chain(entityKey) .map((value, key) => `${key}=${value}`) .join(",") .value(); } /** * Gets path to the single entity (with key defined) * @returns {string} path to the single entity * @memberof QueryableResource */ getSingleResourcePath() { let keyProperties = this.keyProperties(this.defaultRequest._keyValue); let keyPredicate = this.keyPredicate(keyProperties); return `${this.getListResourcePath()}(${keyPredicate})`; } /** * Gets path to the list of entities * @returns {string} path to the list of entities * @memberof QueryableResource */ getListResourcePath() { return this.entitySetModel.name; } /** * Convert Object with values to Object with values converted to the * OData primitives * * @param {Object} body map of key properties and new data for the entity * * @return {Object} Object with converted values * * @private * @memberof QueryableResource */ bodyProperties(body) { return _.assign( this.processProperties(body, this.entityTypeModel.properties), this.processNavigationProperties(body, this.entityTypeModel) ); } /** * Convert Object with values to Object with values converted to the * OData primitives * * @param {Object} entityTypeProperties map of key properties and new data for the entity * @param {Object} entityTypeModelProperties contains definitions of the properties from EntityType * which is used to format and check body properties * * @return {Object} Object with converted values * * @private * @memberof QueryableResource */ processProperties(entityTypeProperties, entityTypeModelProperties) { return entityTypeModelProperties .filter((entityTypeProperty) => _.has(entityTypeProperties, entityTypeProperty.name) ) .reduce((acc, entityTypeProperty) => { acc[entityTypeProperty.name] = entityTypeProperty.type.formatBody( entityTypeProperties[entityTypeProperty.name] ); return acc; }, {}); } /** * Format and check navigation properties objects * * @param {Object} entityTypeProperties is object which contains navigation properties represented * by nested objects * @param {Object} entityTypeModel model of the entity type which is used to generate navigation properties * payload * * @return {Object} Object with formatted and checked navigation properties objects * * @private * @memberof QueryableResource */ processNavigationProperties(entityTypeProperties, entityTypeModel) { let navigationProperties = _.get( entityTypeModel, "navigationProperties", [] ); if (!_.isObject(entityTypeModel)) { throw new Error( "entityTypeModel is mandatory parameter for navigation property preocessing." ); } return _.chain(navigationProperties) .filter((navigationProperty) => _.has(entityTypeProperties, navigationProperty.name) ) .map((navigationProperty) => this.processNavigationPropertyItems( navigationProperty, entityTypeProperties ) ) .reduce( (acc, processedNavigationProperties) => _.assign(acc, processedNavigationProperties), {} ) .value(); } /** * Go thru navigation propert items for active operations * (post, merge, put) * * @param {NavigationProperty} navigationProperty object which represents * navigation property definition for v2 or v4 * @param {Object} entityTypeProperties data for navigation properties * * @returns {Object} checked navigation properties items */ processNavigationPropertyItems(navigationProperty, entityTypeProperties) { const entityTypeModelEnd = _.get(navigationProperty, "type.elementType"); const navigationPropertyItems = entityTypeProperties[navigationProperty.name]; let navigationPropertyItemsProcessed; if (!_.isObject(entityTypeModelEnd)) { throw new Error( `End EntityType for navigation property ${navigationProperty.name} from EntityType ${this.entityTypeModel.name} does not exists.` ); } if (navigationProperty.isCollection && _.isArray(navigationPropertyItems)) { navigationPropertyItemsProcessed = _.map( navigationPropertyItems, (navigationPropertyItemsToProcess) => { return _.assign( this.processProperties( navigationPropertyItemsToProcess, _.get(entityTypeModelEnd, "properties", []) ), this.processNavigationProperties( navigationPropertyItemsToProcess, entityTypeModelEnd ) ); } ); } else { navigationPropertyItemsProcessed = _.assign( this.processProperties( navigationPropertyItems, _.get(entityTypeModelEnd, "properties", []) ), this.processNavigationProperties( navigationPropertyItems, entityTypeModelEnd ) ); } return { [navigationProperty.name]: navigationPropertyItemsProcessed, }; } /** * Creates a new association * * virtual method that has to be implemented in each inherited class * due to circular dependency * * @protected * @memberof QueryableResource */ createNavigationProperty() {} /** * Wraps agent call (preparation, result parsing, error handling). * * @param {function} call main call to agent (async) * @param {RequestDefinition} requestDefinition optional request definition * @returns {object} value parsed from response * * @private * @memberof QueryableResource */ _handleAgentCall(call, requestDefinition) { let request = requestDefinition || this.defaultRequest; this.reset(); this.determineRequestHeaders(request); return call(request).then((rawRes) => { return this.determineResponseResult(request, rawRes); }); } /** * Set up headers for particular request * * @param {RequestDefinition} request definition of request * * @private * @memberof QueryableResource */ determineRequestHeaders(request) { if (!request._isValue && !request._isCount && !request._isPlain) { request.header("Accept", "application/json"); } } /** * Returns value from response objects which is * * @param {RequestDefinition} request definition of request * @param {IncommingMessage} response object from OData server * * @return {*} Buffer (for stream) or IncommingMessage (for raw response) or Object for JSON response * * @private * @memberof QueryableResource */ determineResponseResult(request, response) { let resultPath; let promise; let hasStream = request._resource.entityTypeModel.hasStream; let foundResponseType = responseType.determine(request, this); if (request._isRaw || request._isPlain) { promise = Promise.resolve(response); } else if (hasStream && foundResponseType === responseType.ENTITY_VALUE) { promise = response.arrayBuffer().then((bodyAsArrayBuffer) => { return Buffer.from(bodyAsArrayBuffer); }); } else if (foundResponseType === responseType.PROPERTY_VALUE) { promise = response.text(); } else if (response.status === 204) { promise = Promise.resolve(null); } else if (request._isCount === true) { promise = response.text().then((text) => parsers.count(text)); } else { promise = response.json().then((json) => { resultPath = this.agent.getResultPath(request._isList, json); return this._unwrapNestedProperties( resultPath ? _.get(json, resultPath, response.ok) : json ); }); } return promise; } /** * Unwrap expanded navigation properties * * @param {Object} response Recieved response * @returns {Object} Response with unwrapped expanded navigation properties * * @private * @memberof QueryableResource */ _unwrapNestedProperties(response) { let splitResponse; let skipResultsProperty = (value) => _.has(value, "results") ? splitResponse(splitResponse, value.results) : value; let mapObjectProperties = (item) => _.mapValues(item, skipResultsProperty); let unwrapObject = (item) => item && _.isObject(item) ? mapObjectProperties(item) : item; splitResponse = (fn, item) => _.isArray(item) ? item.map(fn.bind(null, fn)) : unwrapObject(item); return splitResponse(splitResponse, response); } } module.exports = QueryableResource;