diff --git a/README.md b/README.md index 8a979b6b..82d8eb30 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,37 @@ getUsers(@QueryParam("limit") limit: number) { ``` If you want to inject all query parameters use `@QueryParams()` decorator. +The bigest benefit of this approach is that you can perform validation of the params. + +```typescript +enum Roles { + Admin = "admin", + User = "user", + Guest = "guest", +} + +class GetUsersQuery { + + @IsPositive() + limit: number; + + @IsAlpha() + city: string; + + @IsEnum(Roles) + role: string; + + @IsBoolean() + isActive: boolean; + +} + +@Get("/users") +getUsers(@QueryParams() query: GetUsersQuery) { + // here you can access query.role, query.limit + // and others valid query parameters +} +``` #### Inject request body diff --git a/src/ActionParameterHandler.ts b/src/ActionParameterHandler.ts index 1c5d364e..ca4007b9 100644 --- a/src/ActionParameterHandler.ts +++ b/src/ActionParameterHandler.ts @@ -103,32 +103,32 @@ export class ActionParameterHandler { /** * Normalizes parameter value. */ - protected normalizeParamValue(value: any, param: ParamMetadata): Promise|any { + protected async normalizeParamValue(value: any, param: ParamMetadata): Promise { if (value === null || value === undefined) return value; + // map @QueryParams object properties from string to basic types (normalize) + if (param.type === "queries" && typeof value === "object") { + Object.keys(value).map(key => { + const ParamType = Reflect.getMetadata("design:type", param.targetType.prototype, key); + if (ParamType) { + const typeString = typeof ParamType(); // reflected type is always constructor-like (?) + value[key] = this.normalizeValue(value[key], typeString); + } + }); + } + switch (param.targetName) { - case "number": - if (value === "") return undefined; - return +value; + case "number": case "string": - return value; - case "boolean": - if (value === "true" || value === "1") { - return true; - - } else if (value === "false" || value === "0") { - return false; - } - - return !!value; + return this.normalizeValue(value, param.targetName); case "date": const parsedDate = new Date(value); if (isNaN(parsedDate.getTime())) { - return Promise.reject(new BadRequestError(`${param.name} is invalid! It can't be parsed to date.`)); + throw new BadRequestError(`${param.name} is invalid! It can't be parsed to date.`); } return parsedDate; @@ -138,8 +138,41 @@ export class ActionParameterHandler { value = this.transformValue(value, param); value = this.validateValue(value, param); // note this one can return promise } + return value; + } + } + + /** + * Normalizes string value to number or boolean. + */ + protected normalizeValue(value: any, type: string) { + switch (type) { + case "number": + if (value === "") + return undefined; + const valueNumber = Number(value); + // tslint:disable-next-line:triple-equals + if (valueNumber == value) + return valueNumber; + else + throw new BadRequestError(`${value} can't be parsed to number.`); + + case "string": + return value; + + case "boolean": + if (value === "true" || value === "1") { + return true; + + } else if (value === "false" || value === "0") { + return false; + } + + return Boolean(value); + + default: + return value; } - return value; } /** diff --git a/src/decorator/QueryParams.ts b/src/decorator/QueryParams.ts index f24e1ea5..bfc481ad 100644 --- a/src/decorator/QueryParams.ts +++ b/src/decorator/QueryParams.ts @@ -1,18 +1,22 @@ +import {ParamOptions} from "../decorator-options/ParamOptions"; import {getMetadataArgsStorage} from "../index"; /** * Injects all request's query parameters to the controller action parameter. * Must be applied on a controller action parameter. */ -export function QueryParams(): Function { +export function QueryParams(options?: ParamOptions): Function { return function (object: Object, methodName: string, index: number) { getMetadataArgsStorage().params.push({ type: "queries", object: object, method: methodName, index: index, - parse: false, - required: false + parse: options ? options.parse : false, + required: options ? options.required : undefined, + classTransform: options ? options.transform : undefined, + explicitType: options ? options.type : undefined, + validate: options ? options.validate : undefined, }); }; } \ No newline at end of file diff --git a/test/functional/action-params.spec.ts b/test/functional/action-params.spec.ts index e7a79fea..fa20cf77 100644 --- a/test/functional/action-params.spec.ts +++ b/test/functional/action-params.spec.ts @@ -1,7 +1,7 @@ import "reflect-metadata"; -import {createExpressServer, createKoaServer, getMetadataArgsStorage} from "../../src/index"; - +import {IsString, IsBoolean, Min, MaxLength} from "class-validator"; +import {getMetadataArgsStorage, createExpressServer, createKoaServer} from "../../src/index"; import {assertRequest} from "./test-utils"; import {User} from "../fakes/global-options/User"; import {Controller} from "../../src/decorator/Controller"; @@ -14,6 +14,7 @@ import {UseBefore} from "../../src/decorator/UseBefore"; import {Session} from "../../src/decorator/Session"; import {State} from "../../src/decorator/State"; import {QueryParam} from "../../src/decorator/QueryParam"; +import {QueryParams} from "../../src/decorator/QueryParams"; import {HeaderParam} from "../../src/decorator/HeaderParam"; import {CookieParam} from "../../src/decorator/CookieParam"; import {Body} from "../../src/decorator/Body"; @@ -31,6 +32,7 @@ describe("action parameters", () => { let paramUserId: number, paramFirstId: number, paramSecondId: number; let sessionTestElement: string; let queryParamSortBy: string, queryParamCount: string, queryParamLimit: number, queryParamShowAll: boolean, queryParamFilter: any; + let queryParams1: {[key: string]: any}, queryParams2: {[key: string]: any}, queryParams3: {[key: string]: any}; let headerParamToken: string, headerParamCount: number, headerParamLimit: number, headerParamShowAll: boolean, headerParamFilter: any; let cookieParamToken: string, cookieParamCount: number, cookieParamLimit: number, cookieParamShowAll: boolean, cookieParamFilter: any; let body: string; @@ -50,6 +52,9 @@ describe("action parameters", () => { queryParamLimit = undefined; queryParamShowAll = undefined; queryParamFilter = undefined; + queryParams1 = undefined; + queryParams2 = undefined; + queryParams3 = undefined; headerParamToken = undefined; headerParamCount = undefined; headerParamShowAll = undefined; @@ -78,6 +83,20 @@ describe("action parameters", () => { const {SetStateMiddleware} = require("../fakes/global-options/koa-middlewares/SetStateMiddleware"); const {SessionMiddleware} = require("../fakes/global-options/SessionMiddleware"); + class QueryClass { + @MaxLength(5) + sortBy?: string; + + @IsString() + count?: string; + + @Min(5) + limit?: number; + + @IsBoolean() + showAll: boolean = true; + } + @Controller() class UserActionParamsController { @@ -166,6 +185,24 @@ describe("action parameters", () => { return `hello`; } + @Get("/photos-params") + getPhotosWithQuery(@QueryParams() query: QueryClass) { + queryParams1 = query; + return `hello`; + } + + @Get("/photos-params-no-validate") + getPhotosWithQueryAndNoValidation(@QueryParams({ validate: false }) query: QueryClass) { + queryParams2 = query; + return `hello`; + } + + @Get("/photos-params-optional") + getPhotosWithOptionalQuery(@QueryParams({ validate: { skipMissingProperties: true } }) query: QueryClass) { + queryParams3 = query; + return `hello`; + } + @Get("/photos-with-required") getPhotosWithIdRequired(@QueryParam("limit", { required: true }) limit: number) { queryParamLimit = limit; @@ -422,6 +459,40 @@ describe("action parameters", () => { }); }); + // todo: enable koa test when #227 fixed + describe("@QueryParams should give a proper values from request query parameters", () => { + assertRequest([3001, /*3002*/], "get", "photos-params?sortBy=name&count=2&limit=10&showAll=true", response => { + expect(response).to.be.status(200); + expect(response).to.have.header("content-type", "text/html; charset=utf-8"); + expect(queryParams1.sortBy).to.be.equal("name"); + expect(queryParams1.count).to.be.equal("2"); + expect(queryParams1.limit).to.be.equal(10); + expect(queryParams1.showAll).to.be.equal(true); + }); + }); + + describe("@QueryParams should not validate request query parameters when it's turned off in validator options", () => { + assertRequest([3001, 3002], "get", "photos-params-no-validate?sortBy=verylongtext&count=2&limit=1&showAll=true", response => { + expect(response).to.be.status(200); + expect(response).to.have.header("content-type", "text/html; charset=utf-8"); + expect(queryParams2.sortBy).to.be.equal("verylongtext"); + expect(queryParams2.count).to.be.equal("2"); + expect(queryParams2.limit).to.be.equal(1); + expect(queryParams2.showAll).to.be.equal(true); + }); + }); + + // todo: enable koa test when #227 fixed + describe("@QueryParams should give a proper values from request query parameters", () => { + assertRequest([3001, /*3002*/], "get", "photos-params-optional?sortBy=name&limit=10", response => { + expect(queryParams3.sortBy).to.be.equal("name"); + expect(queryParams3.count).to.be.equal(undefined); + expect(queryParams3.limit).to.be.equal(10); + expect(queryParams3.showAll).to.be.equal(true); + expect(response).to.be.status(200); + expect(response).to.have.header("content-type", "text/html; charset=utf-8"); + }); + }); describe("@QueryParam should give a proper values from request query parameters", () => { assertRequest([3001, 3002], "get", "photos?sortBy=name&count=2&limit=10&showAll=true", response => {