UNPKG

@sap/odata-v4

Version:

OData V4.0 server library

321 lines (286 loc) 14.8 kB
'use strict'; const ResourceKind = require('../uri/UriResource').ResourceKind; const QueryOptions = require('../uri/UriInfo').QueryOptions; const HttpMethods = require('../http/HttpMethod').Methods; const EdmTypeKind = require('../edm/EdmType').TypeKind; const EdmPrimitiveTypeKind = require('../edm/EdmPrimitiveTypeKind'); const HeaderValidator = require('./HeaderValidator'); const OperationValidator = require('./OperationValidator'); const UriSyntaxError = require('../errors/UriSyntaxError'); const BadRequestError = require('../errors/BadRequestError'); const UriQueryOptionSemanticError = require('../errors/UriQueryOptionSemanticError'); const FeatureSupport = require('../FeatureSupport'); /** * The defined odata query options * @type string[] * @private */ const odataSystemQueryOptions = Object.keys(QueryOptions).filter(name => name !== 'ODATA_DEBUG').map(name => QueryOptions[name]); const queryOptionsBlackList = new Map() .set(QueryOptions.DELTATOKEN, FeatureSupport.features.QueryParameterDeltatoken) .set(QueryOptions.ID, FeatureSupport.features.QueryParameterId); /** * Internal resource kind to system query options mapping * @type Map * @private */ const queryOptionsPerResourceKindWhiteList = new Map() .set(ResourceKind.SERVICE, [QueryOptions.FORMAT]) .set(ResourceKind.ALL, [ QueryOptions.SEARCH, QueryOptions.COUNT, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.FORMAT ]) .set(ResourceKind.METADATA, [QueryOptions.FORMAT]) .set(ResourceKind.CROSSJOIN, [ QueryOptions.SEARCH, QueryOptions.COUNT, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.FORMAT, QueryOptions.FILTER, QueryOptions.APPLY, QueryOptions.ORDERBY, QueryOptions.EXPAND, QueryOptions.SELECT ]) .set(ResourceKind.ENTITY_COLLECTION, [ QueryOptions.SEARCH, QueryOptions.COUNT, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.FORMAT, QueryOptions.FILTER, QueryOptions.APPLY, QueryOptions.ORDERBY, QueryOptions.EXPAND, QueryOptions.SELECT ]) .set(ResourceKind.ENTITY_COLLECTION + '/' + ResourceKind.COUNT, [ QueryOptions.APPLY, QueryOptions.SEARCH, QueryOptions.FILTER ]) .set(ResourceKind.ENTITY, [QueryOptions.EXPAND, QueryOptions.SELECT, QueryOptions.FORMAT]) .set(ResourceKind.ENTITY + '/' + ResourceKind.VALUE, [QueryOptions.FORMAT]) .set(ResourceKind.SINGLETON, [QueryOptions.EXPAND, QueryOptions.SELECT, QueryOptions.FORMAT]) .set(ResourceKind.REF, [QueryOptions.FORMAT]) .set(ResourceKind.REF_COLLECTION, [ QueryOptions.SEARCH, QueryOptions.FILTER, QueryOptions.COUNT, QueryOptions.ORDERBY, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.FORMAT ]) .set(ResourceKind.COMPLEX_PROPERTY, [QueryOptions.EXPAND, QueryOptions.SELECT, QueryOptions.FORMAT]) .set(ResourceKind.COMPLEX_COLLECTION_PROPERTY, [ QueryOptions.APPLY, QueryOptions.FILTER, QueryOptions.COUNT, QueryOptions.ORDERBY, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.EXPAND, QueryOptions.SELECT, QueryOptions.FORMAT ]) .set(ResourceKind.COMPLEX_COLLECTION_PROPERTY + '/' + ResourceKind.COUNT, [ QueryOptions.APPLY, QueryOptions.FILTER ]) .set(ResourceKind.PRIMITIVE_PROPERTY, [QueryOptions.FORMAT]) .set(ResourceKind.PRIMITIVE_COLLECTION_PROPERTY, [ QueryOptions.FILTER, QueryOptions.COUNT, QueryOptions.ORDERBY, QueryOptions.SKIP, QueryOptions.SKIPTOKEN, QueryOptions.TOP, QueryOptions.FORMAT ]) .set(ResourceKind.PRIMITIVE_COLLECTION_PROPERTY + '/' + ResourceKind.COUNT, [QueryOptions.FILTER]) .set(ResourceKind.PRIMITIVE_PROPERTY + '/' + ResourceKind.VALUE, [QueryOptions.FORMAT]) .set(ResourceKind.ACTION_IMPORT, []) // used only for actions without return type .set(ResourceKind.BOUND_ACTION, []) // used only for actions without return type .set(ResourceKind.ENTITY_ID, [QueryOptions.ID]); /** * The RequestValidator should validate the incoming requests. */ class RequestValidator { /** * Sets the logger. * @param {LoggerFacade} logger the logger * @returns {RequestValidator} this instance */ setLogger(logger) { this._logger = logger; return this; } /** * Validates if the query options have a debug option and if the debug option is valid. * The debug options is valid if the url contains "odata-debug=json|html" * * @param {Object} queryOptions The query options to validate * @throws {UriSyntaxError} Thrown if the debug option is not valid. */ validateDebugOption(queryOptions) { this._logger.path('Entering RequestValidator.validateDebugOption()...'); if (queryOptions) { const debugQueryOption = queryOptions[QueryOptions.ODATA_DEBUG]; if (debugQueryOption && debugQueryOption !== 'json' && debugQueryOption !== 'html') { throw new UriSyntaxError("Only 'json' or 'html' is valid for odata-debug query option"); } } } /** * Validates the provided url query options against a defined whitelist. If the validation * fails, the error thrown will give information which query option was not allowed and which * are are allowed for this type of request. If uri info param is undefined or query options * are undefined this methods returns immediately. * * @param {Object} queryOptions The query options as key:value pairs * @param {UriInfo} uriInfo the uri info object. The result of the UriParser. * @throws {UriSyntaxError} thrown if the validation fails */ validateQueryOptions(queryOptions, uriInfo) { this._logger.path('Entering RequestValidator.validateQueryOptions()...'); if (!uriInfo || !queryOptions) { return; } const lastSegmentKind = RequestValidator._resolveUriResourceSegmentKind(uriInfo.getLastSegment()); let key = lastSegmentKind; // If $count or $value is used the segment before last is the one we must validate for query options. // $count and $value also change the available query options for this segment. if (key === ResourceKind.COUNT || key === ResourceKind.VALUE) { key = RequestValidator._resolveUriResourceSegmentKind(uriInfo.getLastSegment(-1)) + '/' + key; } const whiteList = queryOptionsPerResourceKindWhiteList.get(key); for (const queryOptionName of Object.keys(queryOptions)) { if (odataSystemQueryOptions.includes(queryOptionName) && !whiteList.includes(queryOptionName)) { throw new UriSyntaxError(UriSyntaxError.Message.OPTION_NOT_ALLOWED, queryOptionName, whiteList.toString()); } } if (queryOptions[QueryOptions.SKIPTOKEN]) { // Skiptoken is currently only allowed if the request returns an EntityCollection or ReferenceCollection // This check can be removed, as soon as server side paging is supported for all ResourceKinds if (lastSegmentKind !== ResourceKind.ENTITY_COLLECTION && lastSegmentKind !== ResourceKind.REF_COLLECTION) { FeatureSupport.failUnsupported(FeatureSupport.features.QueryParameterSkipToken); } const segment = (lastSegmentKind === ResourceKind.REF_COLLECTION) ? uriInfo.getLastSegment(-1) : uriInfo.getLastSegment(); // Skiptoken is only allowed for EntitySets that have a maxPageSize configured if ((lastSegmentKind === ResourceKind.ENTITY_COLLECTION || lastSegmentKind === ResourceKind.REF_COLLECTION) && (segment.getEntitySet() && !segment.getEntitySet().getMaxPageSize() || segment.getTarget() && !segment.getTarget().getMaxPageSize())) { throw new UriQueryOptionSemanticError(UriQueryOptionSemanticError.Message.OPTION_NOT_ALLOWED, QueryOptions.SKIPTOKEN); } } } /** * Resolves the result uri resource segment kind regarding to query option validation. * This means, e.g., that an segment with kind <code>ResourceKind.NAVIGATION_TO_ONE</code> resolves to * <code>ResourceKind.ENTITY</code>. * * @param {UriResource} segment The resource segment * @returns {string} The corresponding resolved resource kind. Value from UriResource.ResourceKind. * @private */ static _resolveUriResourceSegmentKind(segment) { const segmentKind = segment.getKind(); switch (segmentKind) { case ResourceKind.NAVIGATION_TO_ONE: return ResourceKind.ENTITY; case ResourceKind.NAVIGATION_TO_MANY: return ResourceKind.ENTITY_COLLECTION; case ResourceKind.ACTION_IMPORT: case ResourceKind.BOUND_ACTION: case ResourceKind.FUNCTION_IMPORT: case ResourceKind.BOUND_FUNCTION: { const type = segment.getEdmType(); if (!type) return segmentKind; const isCollection = segment.isCollection(); switch (type.getKind()) { case EdmTypeKind.PRIMITIVE: case EdmTypeKind.ENUM: case EdmTypeKind.DEFINITION: return isCollection ? ResourceKind.PRIMITIVE_COLLECTION_PROPERTY : ResourceKind.PRIMITIVE_PROPERTY; case EdmTypeKind.COMPLEX: return isCollection ? ResourceKind.COMPLEX_COLLECTION_PROPERTY : ResourceKind.COMPLEX_PROPERTY; case EdmTypeKind.ENTITY: return isCollection ? ResourceKind.ENTITY_COLLECTION : ResourceKind.ENTITY; default: return segmentKind; } } default: return segmentKind; } } /** * Validate that the provided system query options are allowed * for the provided HTTP method (not GET) and request URI. * @param {Object} queryOptions The query options as key:value pairs * @param {HttpMethod.Methods} method the HTTP method * @param {UriInfo} uriInfo the uri info object. The result of the UriParser. * @throws {UriSyntaxError} thrown if the validation fails */ validateQueryOptionsForNonGetHttpMethod(queryOptions, method, uriInfo) { if (!uriInfo || method === HttpMethods.GET) return; const kind = uriInfo.getLastSegment().getKind(); if (method === HttpMethods.DELETE && kind === ResourceKind.REF_COLLECTION && (!queryOptions || !queryOptions[QueryOptions.ID])) { throw new UriSyntaxError(UriSyntaxError.Message.OPTION_EXPECTED, QueryOptions.ID); } if (!queryOptions) return; for (const name of Object.keys(queryOptions)) { if (odataSystemQueryOptions.indexOf(name) === -1) continue; if (method === HttpMethods.POST) { if (kind === ResourceKind.ACTION_IMPORT || kind === ResourceKind.BOUND_ACTION) { // The allowed query options for actions depend on their return type. this.validateQueryOptions(queryOptions, uriInfo); continue; } } else if (method === HttpMethods.DELETE) { // Only $id is allowed for DELETE on entity-references collections. if (name === QueryOptions.ID && kind === ResourceKind.REF_COLLECTION) continue; } throw new UriSyntaxError(UriSyntaxError.Message.OPTION_NOT_ALLOWED, name, ''); } } /** * Validates the given header(s) * * @param {string} version the OData version of this service * @param {Object} headers headers as object with header:headerValue * @param {...string} headersToValidate - Any number of header names to be validated */ validateHeaders(version, headers, ...headersToValidate) { this._logger.path('Entering RequestValidator.validateHeaders()...'); const headerValidator = new HeaderValidator(version); for (const headerName of headersToValidate) { headerValidator.validate(headerName, headers); } } /** * Validates the parsed preferences from Prefer header. * @param {Preferences} preferences * @param {HttpMethod.Methods} httpMethod * @param {UriResource[]} pathSegments */ validatePreferences(preferences, httpMethod, pathSegments) { this._logger.path('Entering RequestValidator.validatePreferences()...'); // Only 'return' is relevant here. // TODO: 8.2.8.7 Preference return...: any request to a stream property, SHOULD return a 4xx Client Error. if (preferences.getReturn()) { if (httpMethod === HttpMethods.GET || httpMethod === HttpMethods.DELETE) { throw new BadRequestError(`The 'return' preference is not allowed in ${httpMethod} requests`); } if (pathSegments[pathSegments.length - 1].getKind() === ResourceKind.BATCH) { throw new BadRequestError("The 'return' preference is not allowed in batch requests"); } if (pathSegments[pathSegments.length - 1].getEdmType() === EdmPrimitiveTypeKind.Stream) { throw new BadRequestError("The 'return' preference is not allowed in requests to stream properties"); } } } /** * Checks that no currently forbidden query options are there. * @param {Object} queryOptions the query options as key-value pairs */ checkForForbiddenQueryOptions(queryOptions) { if (queryOptions) { for (const queryOptionsName of Object.keys(queryOptions)) { const feature = queryOptionsBlackList.get(queryOptionsName); if (feature) { FeatureSupport.failUnsupported(feature); } } } } /** * Validates the requested CRUD operation against the given resource kind, * using the corresponding operation validator. * @param {OdataRequest} request the current request */ validateOperationOnResource(request) { this._logger.path('Entering RequestValidator.validateOperationOnResource()...'); const httpMethod = request.getMethod(); const segments = request.getUriInfo().getPathSegments(); new OperationValidator(this._logger).validate(httpMethod, segments); } } module.exports = RequestValidator;