diff --git a/packages/rest/docs.json b/packages/rest/docs.json index 08666ae8dfd1..1ca795540eec 100644 --- a/packages/rest/docs.json +++ b/packages/rest/docs.json @@ -26,7 +26,8 @@ "src/providers/reject.provider.ts", "src/providers/send.provider.ts", "src/router/routing-table.ts", - "src/validation/request-body.validator.ts" + "src/validation/request-body.validator.ts", + "src/validation/request-query.validator.ts" ], "codeSectionDepth": 4 } diff --git a/packages/rest/src/__tests__/acceptance/file-upload/file-upload-with-parser.acceptance.ts b/packages/rest/src/__tests__/acceptance/file-upload/file-upload-with-parser.acceptance.ts index 18ba8bfb9bf1..7c916030bce0 100644 --- a/packages/rest/src/__tests__/acceptance/file-upload/file-upload-with-parser.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/file-upload/file-upload-with-parser.acceptance.ts @@ -16,7 +16,7 @@ import { post, Request, requestBody, - RequestBody, + ValueWithSchema, RestApplication, } from '../../..'; @@ -97,10 +97,10 @@ class MultipartFormDataBodyParser implements BodyParser { return mediaType.startsWith(FORM_DATA); } - async parse(request: Request): Promise { + async parse(request: Request): Promise { const storage = multer.memoryStorage(); const upload = multer({storage}); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { // tslint:disable-next-line:no-any upload.any()(request, {} as any, err => { if (err) reject(err); diff --git a/packages/rest/src/__tests__/unit/body-parser.unit.ts b/packages/rest/src/__tests__/unit/body-parser.unit.ts index 9acfd631909c..334d3b9863e7 100644 --- a/packages/rest/src/__tests__/unit/body-parser.unit.ts +++ b/packages/rest/src/__tests__/unit/body-parser.unit.ts @@ -14,7 +14,7 @@ import { JsonBodyParser, RawBodyParser, Request, - RequestBody, + ValueWithSchema, RequestBodyParser, RequestBodyParserOptions, StreamBodyParser, @@ -254,7 +254,7 @@ describe('body parser', () => { describe('x-parser extension', () => { let spec: OperationObject; let req: Request; - let requestBody: RequestBody; + let requestBody: ValueWithSchema; it('skips body parsing', async () => { await loadRequestBodyWithXStream('stream'); diff --git a/packages/rest/src/__tests__/unit/coercion/paramObject.unit.ts b/packages/rest/src/__tests__/unit/coercion/paramObject.unit.ts index c948e296a18b..74bd3669cfe8 100644 --- a/packages/rest/src/__tests__/unit/coercion/paramObject.unit.ts +++ b/packages/rest/src/__tests__/unit/coercion/paramObject.unit.ts @@ -87,7 +87,7 @@ describe('coerce object param - optional', function() { test(OPTIONAL_ANY_OBJECT, {key: 'value'}, {key: 'value'}); test(OPTIONAL_ANY_OBJECT, undefined, undefined); test(OPTIONAL_ANY_OBJECT, '', undefined); - test(OPTIONAL_ANY_OBJECT, 'null', null); + test(OPTIONAL_ANY_OBJECT, {key: 'null'}, {key: 'null'}); }); context('nested values are not coerced', () => { diff --git a/packages/rest/src/__tests__/unit/request.query.validator.test.ts b/packages/rest/src/__tests__/unit/request.query.validator.test.ts new file mode 100644 index 000000000000..ac2eac31de82 --- /dev/null +++ b/packages/rest/src/__tests__/unit/request.query.validator.test.ts @@ -0,0 +1,193 @@ +import {expect} from '@loopback/testlab'; +import {validateRequestQuery} from '../../validation/request-query.validator'; +import {RestHttpErrors} from '../../'; +import {aBodySpec} from '../helpers'; +import { + ReferenceObject, + SchemaObject, + SchemasObject, +} from '@loopback/openapi-v3-types'; + +const INVALID_MSG = RestHttpErrors.INVALID_REQUEST_QUERY_MESSAGE; + +const PING_SCHEMA = { + properties: { + pageSize: {type: 'integer', minimum: 0, maximum: 100, multipleOf: 5}, + pageNumber: {type: 'number', minimum: 10, maximum: 200, multipleOf: 3}, + pageBool: {type: 'boolean'}, + pageName: {type: 'string', maxLength: 5, minLength: 1, pattern: '[abc]+'}, + }, + required: ['pageSize'], +}; + +describe('validateRequestQuery', () => { + it('accepts valid data omitting optional properties', () => { + validateRequestQuery( + {value: {pageSize: 5}, schema: PING_SCHEMA}, + aBodySpec(PING_SCHEMA), + ); + }); + + it('rejects data missing a required property', () => { + const details: RestHttpErrors.ValidationErrorDetails[] = [ + { + path: '', + code: 'required', + message: "should have required property 'pageSize'", + info: {missingProperty: 'pageSize'}, + }, + ]; + verifyValidationRejectsInputWithError( + INVALID_MSG, + 'VALIDATION_FAILED', + details, + { + description: 'missing required "pageSize"', + }, + PING_SCHEMA, + ); + }); + + it('rejects data containing values of a wrong type', () => { + const details: RestHttpErrors.ValidationErrorDetails[] = [ + { + path: '.pageBool', + code: 'type', + message: 'should be boolean', + info: {type: 'boolean'}, + }, + ]; + verifyValidationRejectsInputWithError( + INVALID_MSG, + 'VALIDATION_FAILED', + details, + { + pageSize: 5, + pageBool: 1111, + }, + PING_SCHEMA, + ); + }); + + it('rejects invalid values for number properties', () => { + const details: RestHttpErrors.ValidationErrorDetails[] = [ + { + path: '.pageNumber', + code: 'type', + message: 'should be number', + info: {type: 'number'}, + }, + ]; + const schema: SchemaObject = { + properties: { + pageNumber: {type: 'number'}, + }, + }; + verifyValidationRejectsInputWithError( + INVALID_MSG, + 'VALIDATION_FAILED', + details, + {pageNumber: 'string value'}, + schema, + ); + }); + + it('rejects invalid values for number properties', () => { + const details: RestHttpErrors.ValidationErrorDetails[] = [ + { + path: '.pageNumber', + code: 'type', + message: 'should be number', + info: {type: 'number'}, + }, + ]; + const schema: SchemaObject = { + properties: { + pageNumber: {type: 'number'}, + }, + }; + verifyValidationRejectsInputWithError( + INVALID_MSG, + 'VALIDATION_FAILED', + details, + {pageNumber: 'string value'}, + schema, + ); + }); + + it('rejects invalid values for number properties', () => { + const details: RestHttpErrors.ValidationErrorDetails[] = [ + { + path: '.pageSize', + code: 'type', + message: 'should be number', + info: {type: 'number'}, + }, + { + path: '.pageNumber', + code: 'type', + message: 'should be number', + info: {type: 'number'}, + }, + { + path: '.pageBool', + code: 'type', + message: 'should be boolean', + info: {type: 'boolean'}, + }, + { + path: '.pageName', + code: 'type', + message: 'should be string', + info: {type: 'string'}, + }, + ]; + const schema: SchemaObject = { + properties: { + pageSize: {type: 'number'}, + pageNumber: {type: 'number'}, + pageBool: {type: 'boolean'}, + pageName: {type: 'string'}, + }, + }; + verifyValidationRejectsInputWithError( + INVALID_MSG, + 'VALIDATION_FAILED', + details, + { + pageSize: 'string value', + pageNumber: 'string value', + pageBool: 1111, + pageName: 123, + }, + schema, + ); + }); +}); + +// ----- HELPERS ----- / + +function verifyValidationRejectsInputWithError( + expectedMessage: string, + expectedCode: string, + expectedDetails: RestHttpErrors.ValidationErrorDetails[] | undefined, + query: object | null, + schema: SchemaObject | ReferenceObject, + schemas?: SchemasObject, + required?: boolean, +) { + try { + validateRequestQuery( + {value: query, schema}, + aBodySpec(schema, {required}), + schemas, + ); + throw new Error( + "expected Function { name: 'validateRequestQuery' } to throw exception", + ); + } catch (err) { + expect(err.message).to.equal(expectedMessage); + expect(err.code).to.equal(expectedCode); + expect(err.details).to.deepEqual(expectedDetails); + } +} diff --git a/packages/rest/src/body-parsers/body-parser.json.ts b/packages/rest/src/body-parsers/body-parser.json.ts index c75489890c93..e18ed72e07e4 100644 --- a/packages/rest/src/body-parsers/body-parser.json.ts +++ b/packages/rest/src/body-parsers/body-parser.json.ts @@ -14,7 +14,7 @@ import { invokeBodyParserMiddleware, builtinParsers, } from './body-parser.helpers'; -import {BodyParser, RequestBody} from './types'; +import {BodyParser, ValueWithSchema} from './types'; import {sanitizeJsonParse} from '../parse-json'; export class JsonBodyParser implements BodyParser { @@ -34,7 +34,7 @@ export class JsonBodyParser implements BodyParser { return !!is(mediaType, '*/json', '*/*+json'); } - async parse(request: Request): Promise { + async parse(request: Request): Promise { let body = await invokeBodyParserMiddleware(this.jsonParser, request); // https://github.com/expressjs/body-parser/blob/master/lib/types/json.js#L71-L76 const contentLength = request.get('content-length'); diff --git a/packages/rest/src/body-parsers/body-parser.raw.ts b/packages/rest/src/body-parsers/body-parser.raw.ts index 94a64fbd4ba5..b9d942998336 100644 --- a/packages/rest/src/body-parsers/body-parser.raw.ts +++ b/packages/rest/src/body-parsers/body-parser.raw.ts @@ -14,7 +14,7 @@ import { invokeBodyParserMiddleware, builtinParsers, } from './body-parser.helpers'; -import {BodyParser, RequestBody} from './types'; +import {BodyParser, ValueWithSchema} from './types'; /** * Parsing the request body into Buffer @@ -35,7 +35,7 @@ export class RawBodyParser implements BodyParser { return !!is(mediaType, 'application/octet-stream'); } - async parse(request: Request): Promise { + async parse(request: Request): Promise { const body = await invokeBodyParserMiddleware(this.rawParser, request); return {value: body}; } diff --git a/packages/rest/src/body-parsers/body-parser.stream.ts b/packages/rest/src/body-parsers/body-parser.stream.ts index 2cf8aee4b318..76281bb218fd 100644 --- a/packages/rest/src/body-parsers/body-parser.stream.ts +++ b/packages/rest/src/body-parsers/body-parser.stream.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Request} from '../types'; -import {BodyParser, RequestBody} from './types'; +import {BodyParser, ValueWithSchema} from './types'; import {builtinParsers} from './body-parser.helpers'; /** @@ -21,7 +21,7 @@ export class StreamBodyParser implements BodyParser { return false; } - async parse(request: Request): Promise { + async parse(request: Request): Promise { return {value: request}; } } diff --git a/packages/rest/src/body-parsers/body-parser.text.ts b/packages/rest/src/body-parsers/body-parser.text.ts index ed540e8bd839..a051ee0550b1 100644 --- a/packages/rest/src/body-parsers/body-parser.text.ts +++ b/packages/rest/src/body-parsers/body-parser.text.ts @@ -14,7 +14,7 @@ import { invokeBodyParserMiddleware, builtinParsers, } from './body-parser.helpers'; -import {BodyParser, RequestBody} from './types'; +import {BodyParser, ValueWithSchema} from './types'; export class TextBodyParser implements BodyParser { name = builtinParsers.text; @@ -37,7 +37,7 @@ export class TextBodyParser implements BodyParser { return !!is(mediaType, 'text/*'); } - async parse(request: Request): Promise { + async parse(request: Request): Promise { const body = await invokeBodyParserMiddleware(this.textParser, request); return {value: body}; } diff --git a/packages/rest/src/body-parsers/body-parser.ts b/packages/rest/src/body-parsers/body-parser.ts index 0d31b79ce2eb..1adae98686b1 100644 --- a/packages/rest/src/body-parsers/body-parser.ts +++ b/packages/rest/src/body-parsers/body-parser.ts @@ -22,7 +22,7 @@ import { import { BodyParser, BodyParserFunction, - RequestBody, + ValueWithSchema, REQUEST_BODY_PARSER_TAG, } from './types'; @@ -45,7 +45,7 @@ export class RequestBodyParser { async loadRequestBodyIfNeeded( operationSpec: OperationObject, request: Request, - ): Promise { + ): Promise { const {requestBody, customParser} = await this._matchRequestBodySpec( operationSpec, request, @@ -78,7 +78,7 @@ export class RequestBodyParser { operationSpec: OperationObject, request: Request, ) { - const requestBody: RequestBody = { + const requestBody: ValueWithSchema = { value: undefined, }; if (!operationSpec.requestBody) return {requestBody}; diff --git a/packages/rest/src/body-parsers/body-parser.urlencoded.ts b/packages/rest/src/body-parsers/body-parser.urlencoded.ts index c264e4ba5bd0..5de7b9847516 100644 --- a/packages/rest/src/body-parsers/body-parser.urlencoded.ts +++ b/packages/rest/src/body-parsers/body-parser.urlencoded.ts @@ -14,7 +14,7 @@ import { invokeBodyParserMiddleware, builtinParsers, } from './body-parser.helpers'; -import {BodyParser, RequestBody} from './types'; +import {BodyParser, ValueWithSchema} from './types'; export class UrlEncodedBodyParser implements BodyParser { name = builtinParsers.urlencoded; @@ -32,7 +32,7 @@ export class UrlEncodedBodyParser implements BodyParser { return !!is(mediaType, 'urlencoded'); } - async parse(request: Request): Promise { + async parse(request: Request): Promise { const body = await invokeBodyParserMiddleware( this.urlencodedParser, request, diff --git a/packages/rest/src/body-parsers/types.ts b/packages/rest/src/body-parsers/types.ts index aa6c2d74349f..6ea2348ac451 100644 --- a/packages/rest/src/body-parsers/types.ts +++ b/packages/rest/src/body-parsers/types.ts @@ -8,7 +8,7 @@ import {Request} from '../types'; /** * Request body with metadata */ -export type RequestBody = { +export type ValueWithSchema = { /** * Parsed value of the request body */ @@ -46,13 +46,13 @@ export interface BodyParser { * Parse the request body * @param request http request */ - parse(request: Request): Promise; + parse(request: Request): Promise; } /** * Plain function for body parsing */ -export type BodyParserFunction = (request: Request) => Promise; +export type BodyParserFunction = (request: Request) => Promise; /** * Binding tag for request body parser extensions diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index 87d7452a73ca..704ce960fe46 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -11,12 +11,13 @@ import { SchemasObject, } from '@loopback/openapi-v3-types'; import * as debugFactory from 'debug'; -import {RequestBody, RequestBodyParser} from './body-parsers'; +import {ValueWithSchema, RequestBodyParser} from './body-parsers'; import {coerceParameter} from './coercion/coerce-parameter'; import {RestHttpErrors} from './rest-http-error'; import {ResolvedRoute} from './router'; import {OperationArgs, PathParameterValues, Request} from './types'; import {validateRequestBody} from './validation/request-body.validator'; +import {validateRequestQuery} from './validation/request-query.validator'; const debug = debugFactory('loopback:rest:parser'); /** @@ -38,11 +39,16 @@ export async function parseOperationArgs( operationSpec, request, ); + const query = await requestBodyParser.loadRequestBodyIfNeeded( + operationSpec, + request, + ); return buildOperationArguments( operationSpec, request, pathParams, body, + query, route.schemas, ); } @@ -51,7 +57,8 @@ function buildOperationArguments( operationSpec: OperationObject, request: Request, pathParams: PathParameterValues, - body: RequestBody, + body: ValueWithSchema, + query: ValueWithSchema, globalSchemas: SchemasObject, ): OperationArgs { let requestBodyIndex: number = -1; @@ -67,6 +74,12 @@ function buildOperationArguments( const paramArgs: OperationArgs = []; + let isQuery = false; + let paramName = ''; + let paramSchema = {}; + let queryValue = {}; + let schemasValue = {}; + for (const paramSpec of operationSpec.parameters || []) { if (isReferenceObject(paramSpec)) { // TODO(bajtos) implement $ref parameters @@ -77,11 +90,30 @@ function buildOperationArguments( const rawValue = getParamFromRequest(spec, request, pathParams); const coercedValue = coerceParameter(rawValue, spec); paramArgs.push(coercedValue); - } - debug('Validating request body - value %j', body); - validateRequestBody(body, operationSpec.requestBody, globalSchemas); + if (spec.in === 'query' && paramSpec.schema != null) { + isQuery = true; + paramName = paramSpec.name; + paramSchema = paramSpec.schema || []; + // tslint:disable-next-line:no-any + (queryValue)[paramName] = coercedValue; + // tslint:disable-next-line:no-any + (schemasValue)[paramName] = paramSchema; + } + } + //if query parameters from URL - send to query validation + if (isQuery) { + query.value = queryValue; + globalSchemas = {properties: schemasValue}; + query.schema = globalSchemas; + validateRequestQuery(query, operationSpec.requestBody, globalSchemas); + } + //if body parameters - send to body validation + else { + debug('Validating request body - value %j', body); + validateRequestBody(body, operationSpec.requestBody, globalSchemas); + } if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body.value); return paramArgs; } diff --git a/packages/rest/src/rest-http-error.ts b/packages/rest/src/rest-http-error.ts index 125806bcfb54..e1c2db3045d0 100644 --- a/packages/rest/src/rest-http-error.ts +++ b/packages/rest/src/rest-http-error.ts @@ -61,6 +61,17 @@ export namespace RestHttpErrors { ); } + export const INVALID_REQUEST_QUERY_MESSAGE = + 'The request query is invalid. See error object details property for more info.'; + export function invalidRequestQuery(): HttpErrors.HttpError { + return Object.assign( + new HttpErrors.UnprocessableEntity(INVALID_REQUEST_QUERY_MESSAGE), + { + code: 'VALIDATION_FAILED', + }, + ); + } + /** * An invalid request body error contains a `details` property as the machine-readable error. * Each entry in `error.details` contains 4 attributes: `path`, `code`, `info` and `message`. diff --git a/packages/rest/src/validation/request-body.validator.ts b/packages/rest/src/validation/request-body.validator.ts index 6e1be22fd3be..4858fdd0b355 100644 --- a/packages/rest/src/validation/request-body.validator.ts +++ b/packages/rest/src/validation/request-body.validator.ts @@ -12,7 +12,7 @@ import { import * as AJV from 'ajv'; import * as debugModule from 'debug'; import * as util from 'util'; -import {HttpErrors, RestHttpErrors, RequestBody} from '..'; +import {HttpErrors, RestHttpErrors, ValueWithSchema} from '..'; import * as _ from 'lodash'; const toJsonSchema = require('openapi-schema-to-json-schema'); @@ -30,7 +30,7 @@ export type RequestBodyValidationOptions = AJV.Options; * @param globalSchemas The referenced schemas generated from `OpenAPISpec.components.schemas`. */ export function validateRequestBody( - body: RequestBody, + body: ValueWithSchema, requestBodySpec?: RequestBodyObject, globalSchemas: SchemasObject = {}, options: RequestBodyValidationOptions = {}, diff --git a/packages/rest/src/validation/request-query.validator.ts b/packages/rest/src/validation/request-query.validator.ts new file mode 100644 index 000000000000..2e9996ec8d37 --- /dev/null +++ b/packages/rest/src/validation/request-query.validator.ts @@ -0,0 +1,150 @@ +import { + RequestBodyObject, + SchemaObject, + ReferenceObject, + SchemasObject, +} from '@loopback/openapi-v3-types'; +import * as AJV from 'ajv'; +import * as debugModule from 'debug'; +import * as util from 'util'; +import {HttpErrors, RestHttpErrors, ValueWithSchema} from '..'; +import * as _ from 'lodash'; + +const toJsonSchema = require('openapi-schema-to-json-schema'); +const debug = debugModule('loopback:rest:validation'); + +export type RequestQueryValidationOptions = AJV.Options; + +/** + * Check whether the request query is valid according to the provided OpenAPI schema. + * The JSON schema is generated from the OpenAPI schema which is typically defined + * by `@requestQuery()`. + * The validation leverages AJS schema validator. + * @param query The request query parsed from an HTTP request. + * @param requestQuerySpec The OpenAPI requestQuery specification defined in `@requestQuery()`. + * @param globalSchemas The referenced schemas generated from `OpenAPISpec.components.schemas`. + */ +export function validateRequestQuery( + query: ValueWithSchema, + requestQuerySpec?: RequestBodyObject, + globalSchemas: SchemasObject = {}, + options: RequestQueryValidationOptions = {}, +) { + const required = requestQuerySpec && requestQuerySpec.required; + + if (required && query.value == undefined) { + const err = Object.assign( + new HttpErrors.BadRequest('Request query is required'), + { + code: 'MISSING_REQUIRED_PARAMETER', + parameterName: 'request query', + }, + ); + throw err; + } + + const schema = query.schema; + /* istanbul ignore if */ + if (debug.enabled) { + debug('Request query schema: %j', util.inspect(schema, {depth: null})); + } + if (!schema) return; + + options = Object.assign({coerceTypes: query.coercionRequired}, options); + validateValueAgainstSchema(query.value, schema, globalSchemas, options); +} + +/** + * Convert an OpenAPI schema to the corresponding JSON schema. + * @param openapiSchema The OpenAPI schema to convert. + */ +function convertToJsonSchema(openapiSchema: SchemaObject) { + const jsonSchema = toJsonSchema(openapiSchema); + delete jsonSchema['$schema']; + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Converted OpenAPI schema to JSON schema: %s', + util.inspect(jsonSchema, {depth: null}), + ); + } + return jsonSchema; +} + +/** + * Validate the request query data against JSON schema. + * @param query The request query data. + * @param schema The JSON schema used to perform the validation. + * @param globalSchemas Schema references. + */ + +const compiledSchemaCache = new WeakMap(); + +export function validateValueAgainstSchema( + // tslint:disable-next-line:no-any + query: any, + schema: SchemaObject | ReferenceObject, + globalSchemas?: SchemasObject, + options?: RequestQueryValidationOptions, +) { + let validate; + + if (compiledSchemaCache.has(schema)) { + validate = compiledSchemaCache.get(schema); + } else { + validate = createValidator(schema, globalSchemas, options); + compiledSchemaCache.set(schema, validate); + } + + if (validate(query)) { + debug('Request query passed AJV validation.'); + return; + } + + const validationErrors = validate.errors; + + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Invalid request query: %s. Errors: %s', + util.inspect(query, {depth: null}), + util.inspect(validationErrors), + ); + } + + const error = RestHttpErrors.invalidRequestQuery(); + error.details = _.map(validationErrors, e => { + return { + path: e.dataPath, + code: e.keyword, + message: e.message, + info: e.params, + }; + }); + throw error; +} + +function createValidator( + schema: SchemaObject, + globalSchemas?: SchemasObject, + options?: RequestQueryValidationOptions, +): Function { + const jsonSchema = convertToJsonSchema(schema); + + const schemaWithRef = Object.assign({components: {}}, jsonSchema); + schemaWithRef.components = { + schemas: globalSchemas, + }; + + const ajv = new AJV( + Object.assign( + {}, + { + allErrors: true, + }, + options, + ), + ); + + return ajv.compile(schemaWithRef); +}