@sap/odata-v4
Version:
OData V4.0 server library
321 lines (286 loc) • 14.8 kB
JavaScript
;
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;