Skip to content

Commit

Permalink
feat(swagger): Support Model mapping on QueryParams
Browse files Browse the repository at this point in the history
  • Loading branch information
Travis committed May 5, 2020
1 parent 7518d8d commit dfbd7cd
Show file tree
Hide file tree
Showing 14 changed files with 415 additions and 90 deletions.
25 changes: 24 additions & 1 deletion docs/docs/controllers.md
Expand Up @@ -98,14 +98,37 @@ Getting parameters from Express Request can be done by using the following decor

- @@BodyParams@@: `Express.request.body`
- @@PathParams@@: `Express.request.params`
- @@RawPathParams@@: `Express.request.params` without transformation and validation,
- @@QueryParams@@: `Express.request.query`
- @@RawQueryParams@@: `Express.request.query` without transformation and validation,

<<< @/docs/docs/snippets/controllers/params-decorator.ts

Finally, @@BodyParams@@ accepts to give a @@ParamOptions@@ object as parameter to change the decorator behavior:
Finally, @@BodyParams@@ accepts to give a @@IParamOptions@@ object as parameter to change the decorator behavior:

<<< @/docs/docs/snippets/controllers/params-advanced-usage.ts

::: tip
Since v5.51.0+, @@QueryParams@@ decorator accept a model to transform `Express.request.query` plain object to a Class.

```typescript
class QueryParamsModel {
@Required()
@MinLength(1)
name: string;

@Property()
duration: number;
}

@Controller("/")
class QueryController {
@Get("/")
get(@QueryParams() params: QueryParamsModel, @QueryParams("locale") locale: string) {}
}
```
:::

### Headers

@@HeaderParams@@ decorator provides you a quick access to the `Express.request.get()`
Expand Down
1 change: 1 addition & 0 deletions docs/docs/pipes.md
@@ -1,4 +1,5 @@
# Pipes
<Badge text="5.50.0+"/>

A pipe is a class annotated with the @@Injectable@@ decorator.
Pipes should implement the @@IPipe@@ interface.
Expand Down
1 change: 1 addition & 0 deletions docs/docs/validation.md
@@ -1,4 +1,5 @@
# Validation
<Badge text="5.50.0+"/>

Ts.ED provide by default a [AJV](/tutorials/ajv.md) package `@tsed/ajv` to perform a validation on a [Model](/docs/models.html).

Expand Down
9 changes: 5 additions & 4 deletions packages/ajv/src/pipes/AjvValidationPipe.ts
@@ -1,4 +1,4 @@
import {ConverterService, getJsonSchema, Inject, IPipe, OverrideProvider, ParamMetadata, ValidationPipe} from "@tsed/common";
import {ConverterService, getJsonSchema, Inject, IPipe, OverrideProvider, ParamMetadata, ParamTypes, ValidationPipe} from "@tsed/common";
import {isEmpty} from "@tsed/core";
import {Ajv} from "../services/Ajv";
import {AjvErrorFormatterPipe} from "./AjvErrorFormatterPipe";
Expand Down Expand Up @@ -63,16 +63,17 @@ export class AjvValidationPipe extends ValidationPipe implements IPipe {

const options = {
ignoreCallback: (obj: any, type: any) => type === Date,
checkRequiredValue: false
checkRequiredValue: false,
additionalProperties: metadata.paramType === ParamTypes.QUERY ? "ignore" : undefined
};

if (metadata.isCollection) {
Object.entries(value).forEach(([key, item]) => {
item = this.converterService.deserialize(item, metadata.type, undefined, options); // FIXME REMOVE THIS when @tsed/schema is out
item = this.converterService.deserialize(item, metadata.type, undefined, options as any); // FIXME REMOVE THIS when @tsed/schema is out
this.validate(schema, item, {type: metadata.type, index: key});
});
} else {
value = this.converterService.deserialize(value, metadata.type, undefined, options); // FIXME REMOVE THIS when @tsed/schema is out
value = this.converterService.deserialize(value, metadata.type, undefined, options as any); // FIXME REMOVE THIS when @tsed/schema is out
this.validate(schema, value, {type: metadata.type});
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/converters/interfaces/IConverter.ts
Expand Up @@ -25,6 +25,7 @@ export interface IConverterOptions extends IMetadataType {
ignoreCallback?: IConverterIgnoreCB;
checkRequiredValue?: boolean;
withIgnoredProps?: boolean;
additionalProperties?: "error" | "ignore" | "accept";
}

/**
Expand Down
12 changes: 9 additions & 3 deletions packages/common/src/converters/services/ConverterService.ts
Expand Up @@ -238,7 +238,7 @@ export class ConverterService {
* @param options
*/
private convertProperty(obj: any, instance: any, propertyName: string, propertyMetadata?: PropertyMetadata, options?: any) {
if (this.skipAdditionalProperty(instance, propertyName, propertyMetadata)) {
if (this.skipAdditionalProperty(instance, propertyName, propertyMetadata, options)) {
return;
}

Expand Down Expand Up @@ -280,13 +280,19 @@ export class ConverterService {
* @param instance
* @param {string} propertyKey
* @param {PropertyMetadata | undefined} propertyMetadata
* @param options
*/
private skipAdditionalProperty(instance: any, propertyKey: string | symbol, propertyMetadata: PropertyMetadata | undefined) {
private skipAdditionalProperty(
instance: any,
propertyKey: string | symbol,
propertyMetadata: PropertyMetadata | undefined,
options: any
) {
if (propertyMetadata !== undefined) {
return false;
}

const additionalPropertiesLevel = this.getAdditionalPropertiesLevel(getClass(instance));
const additionalPropertiesLevel = options.additionalProperties || this.getAdditionalPropertiesLevel(getClass(instance));

switch (additionalPropertiesLevel) {
case "error":
Expand Down
26 changes: 25 additions & 1 deletion packages/common/src/mvc/pipes/DeserializerPipe.spec.ts
Expand Up @@ -32,7 +32,31 @@ describe("DeserializerPipe", () => {
expect(pipe.transform({}, param)).to.deep.eq({});

// @ts-ignore
pipe.converterService.deserialize.should.have.been.calledWithExactly({}, Array, String);
pipe.converterService.deserialize.should.have.been.calledWithExactly({}, Array, String, {additionalProperties: undefined});
})
);
it(
"should transform an object to a model (Query)",
TestContext.inject([DeserializerPipe], (pipe: DeserializerPipe) => {
// @ts-ignore
sandbox.stub(pipe.converterService, "deserialize").returns({});

class Test {}

const param = new ParamMetadata({
index: 0,
target: Test,
propertyKey: "test",
paramType: ParamTypes.QUERY
});
// @ts-ignore
param._type = String;
param.collectionType = Array;
// WHEN
expect(pipe.transform({}, param)).to.deep.eq({});

// @ts-ignore
pipe.converterService.deserialize.should.have.been.calledWithExactly({}, Array, String, {additionalProperties: "ignore"});
})
);
});
5 changes: 4 additions & 1 deletion packages/common/src/mvc/pipes/DeserializerPipe.ts
@@ -1,3 +1,4 @@
import {ParamTypes} from "@tsed/common";
import {Injectable} from "@tsed/di";
import {ConverterService} from "../../converters/services/ConverterService";
import {IPipe, ParamMetadata} from "../../mvc/models/ParamMetadata";
Expand All @@ -7,6 +8,8 @@ export class DeserializerPipe implements IPipe {
constructor(private converterService: ConverterService) {}

transform(value: any, param: ParamMetadata) {
return this.converterService.deserialize(value, param.collectionType || param.type, param.type);
return this.converterService.deserialize(value, param.collectionType || param.type, param.type, {
additionalProperties: param.paramType === ParamTypes.QUERY ? "ignore" : undefined
});
}
}
2 changes: 1 addition & 1 deletion packages/swagger/src/class/OpenApiModelSchemaBuilder.ts
Expand Up @@ -73,7 +73,7 @@ export class OpenApiModelSchemaBuilder {
}: {
schema: Partial<Schema>;
type: Type<any>;
collectionType: Type<any>;
collectionType: Type<any> | undefined;
}): Schema {
let builder;
const typeName = nameOf(type);
Expand Down
103 changes: 77 additions & 26 deletions packages/swagger/src/class/OpenApiParamsBuilder.spec.ts
@@ -1,16 +1,18 @@
import {ParamMetadata, ParamRegistry, ParamTypes, Property, Required} from "@tsed/common";
import {MinLength, ParamMetadata, ParamRegistry, ParamTypes, Property, Required} from "@tsed/common";
import {BodyParams} from "@tsed/common/src/mvc/decorators/params/bodyParams";
import {QueryParams} from "@tsed/common/src/mvc/decorators/params/queryParams";
import {MultipartFile} from "@tsed/multipartfiles/src";
import {expect} from "chai";
import * as Sinon from "sinon";
import {Ctrl, SwaFoo2} from "../../test/helpers/class/classes";
import {Consumes, Description} from "../index";
import {OpenApiParamsBuilder} from "./OpenApiParamsBuilder";
import {Ctrl, SwaFoo2} from "../../test/helpers/class/classes";

const param0 = new ParamMetadata({target: Ctrl, propertyKey: "test", index: 0});
param0.paramType = ParamTypes.BODY;
param0.type = SwaFoo2;

const sandbox = Sinon.createSandbox();
describe("OpenApiParamsBuilder", () => {
describe("build()", () => {
describe("when consumes has application/x-www-form-urlencoded", () => {
Expand Down Expand Up @@ -85,7 +87,7 @@ describe("OpenApiParamsBuilder", () => {
});
});

describe("getInQueryParams()", () => {
describe("getInQueryParams()", function test() {
before(() => {
const storeGet = (key: string) => {
if (key === "hidden") {
Expand Down Expand Up @@ -124,7 +126,7 @@ describe("OpenApiParamsBuilder", () => {

this.builder = new OpenApiParamsBuilder(Ctrl, "test");
Sinon.stub(this.builder, "addResponse400");
Sinon.stub(this.builder, "createSchemaFromQueryParam").returns({type: "string"});
Sinon.stub(this.builder, "createSchemaFromQueryParam").returns([{type: "string"}]);

this.result = this.builder.getInQueryParams();
});
Expand Down Expand Up @@ -626,9 +628,11 @@ describe("OpenApiParamsBuilder", () => {
this.getParamsStub.restore();
});
it("should return the right schema", () => {
this.result.should.deep.equal({
type: "string"
});
this.result.should.deep.equal([
{
type: "string"
}
]);
});
});

Expand All @@ -650,40 +654,48 @@ describe("OpenApiParamsBuilder", () => {
this.getParamsStub.restore();
});
it("should return the right schema", () => {
this.result.should.deep.equal({
type: "array",
collectionFormat: "multi",
items: {
type: "string"
this.result.should.deep.equal([
{
type: "array",
collectionFormat: "multi",
items: {
type: "string"
}
}
});
]);
});
});

describe("when there is an object of string", () => {
before(() => {
this.getParamsStub = Sinon.stub(ParamRegistry, "getParams").returns([param0]);
sandbox.stub(ParamRegistry, "getParams");
});
after(() => {
sandbox.restore();
});
it("should return the right schema", () => {
// @ts-ignore
ParamRegistry.getParams.returns([param0]);

this.builder = new OpenApiParamsBuilder(Ctrl, "test");
const builder = new OpenApiParamsBuilder(Ctrl, "test");

this.result = this.builder.createSchemaFromQueryParam({
// @ts-ignore
const result = builder.createSchemaFromQueryParam({
expression: "t1",
type: String,
isClass: false,
isCollection: true,
isArray: false
});
});
after(() => {
this.getParamsStub.restore();
});
it("should return the right schema", () => {
this.result.should.deep.equal({
type: "object",
additionalProperties: {
type: "string"

result.should.deep.equal([
{
type: "object",
additionalProperties: {
type: "string"
}
}
});
]);
});
});
});
Expand Down Expand Up @@ -873,5 +885,44 @@ describe("OpenApiParamsBuilder", () => {
}
});
});
it("should create query params", () => {
class ParameterModel {
@Property()
@Required()
name: string;

@Property()
@MinLength(1)
start: string;
}

class Ctrl {
test(@QueryParams() test: ParameterModel, @QueryParams("id") id: string) {}
}

const builder = new OpenApiParamsBuilder(Ctrl, "test").build();

expect(builder.parameters).to.deep.eq([
{
in: "query",
name: "name",
required: true,
type: "string"
},
{
in: "query",
minLength: 1,
name: "start",
required: false,
type: "string"
},
{
in: "query",
name: "id",
required: false,
type: "string"
}
]);
});
});
});

0 comments on commit dfbd7cd

Please sign in to comment.