-
-
Save Romakita/2dd599b6905a08ab0301414599c3858a to your computer and use it in GitHub Desktop.
import {Controller, Get, PlatformTest, QueryParams, PlatformTest} from "@tsed/common"; | |
import { | |
getSpec, | |
Returns, | |
SpecTypes | |
} from "@tsed/schema"; | |
import {expect} from "chai"; | |
import * as qs from "querystring"; | |
import * as SuperTest from "supertest"; | |
import {PlatformExpress} from "@tsed/platform-express"; | |
import {Server} from "./app/Server"; | |
import {Pagination} from "./app/models/Pagination"; | |
import {Product} from "./app/models/Product"; | |
import {PaginationFilter} from "./app/filters/PaginationFilter"; | |
@Controller("/pageable") | |
class TestPageableCtrl { | |
@Get("/") | |
@Returns(206, Pagination).Of(Product).Title("PaginatedProduct") | |
@Returns(200, Pagination).Of(Product).Title("PaginatedProduct") | |
async get(@QueryParams() pageableOptions: Pageable, @QueryParams("all") all: boolean) { | |
return new Pagination<Product>({ | |
data: [new Product({ | |
id: "100", | |
title: "CANON D3000" | |
})], | |
totalCount: all ? 1 : 100, // just for test, | |
pageable: pageableOptions | |
}); | |
} | |
} | |
describe("Pageable", () => { | |
let request: SuperTest.SuperTest<SuperTest.Test>; | |
before( | |
PlatformTest.bootstrap(Server, { | |
mount: { | |
"/rest": [TestPageableCtrl] | |
}, | |
responseFilters: [ | |
PaginationFilter | |
] | |
}) | |
); | |
after(utils.reset); | |
before(() => { | |
request = SuperTest(PlatformTest.callback()); | |
}); | |
it("should generate spec", () => { | |
const spec = getSpec(TestPageableCtrl, {specType: SpecTypes.OPENAPI}); | |
expect(spec).to.deep.eq({ | |
"paths": { | |
"/pageable": { | |
"get": { | |
"operationId": "testPageableCtrlGet", | |
"parameters": [{ | |
"in": "query", | |
"required": false, | |
"name": "page", | |
"schema": {"type": "integer", "description": "Page number.", "default": 0, "minimum": 0, "multipleOf": 1} | |
}, { | |
"in": "query", | |
"required": false, | |
"name": "size", | |
"schema": { | |
"type": "integer", | |
"description": "Number of objects per page.", | |
"default": 20, | |
"minimum": 1, | |
"multipleOf": 1 | |
} | |
}, { | |
"in": "query", | |
"required": false, | |
"name": "sort", | |
"schema": { | |
"type": "array", | |
"description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", | |
"maxItems": 2, | |
"items": {"type": "string"} | |
} | |
}, {"in": "query", "name": "all", "required": false, "schema": {"type": "boolean"}}], | |
"responses": { | |
"200": { | |
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaginatedProduct"}}}, | |
"description": "Success" | |
}, | |
"206": { | |
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaginatedProduct"}}}, | |
"description": "Partial Content" | |
} | |
}, | |
"tags": ["TestPageableCtrl"] | |
} | |
} | |
}, | |
"tags": [{"name": "TestPageableCtrl"}], | |
"components": { | |
"schemas": { | |
"Product": { | |
"type": "object", | |
"properties": {"id": {"type": "string"}, "title": {"type": "string"}} | |
}, | |
"PaginatedProduct": { | |
"type": "object", | |
"properties": { | |
"page": { | |
"type": "integer", | |
"description": "Page number.", | |
"default": 0, | |
"minimum": 0, | |
"multipleOf": 1 | |
}, | |
"size": { | |
"type": "integer", | |
"description": "Number of objects per page.", | |
"default": 20, | |
"minimum": 1, | |
"multipleOf": 1 | |
}, | |
"sort": { | |
"type": "array", | |
"description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", | |
"maxItems": 2, | |
"items": {"type": "string"} | |
}, | |
"data": {"type": "array", "items": {"$ref": "#/components/schemas/Product"}}, | |
"totalCount": {"type": "integer", "minLength": 0, "multipleOf": 1} | |
} | |
} | |
} | |
} | |
}); | |
}); | |
it("should get paginated products with a status 206 Partial Content", async () => { | |
const options = { | |
page: 1, | |
size: 10, | |
sort: ["field", "asc"] | |
}; | |
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(206); | |
expect(body).to.deep.eq({ | |
"data": [ | |
{ | |
"id": "100", | |
"title": "CANON D3000" | |
} | |
], | |
"page": 1, | |
"size": 10, | |
"sort": [ | |
"asc", | |
"field" | |
], | |
"totalCount": 100 | |
}); | |
}); | |
it("should get all products with a status 216", async () => { | |
const options = { | |
all: true | |
}; | |
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(200); | |
expect(body).to.deep.eq({ | |
data: [ { id: '100', title: 'CANON D3000' } ], | |
totalCount: 1, | |
page: 0, | |
size: 20 | |
}); | |
}); | |
it("should not return a bad request when sort is not given", async () => { | |
const options = { | |
page: 1, | |
size: 10 | |
}; | |
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(216); | |
expect(body).to.deep.eq({ | |
"data": [ | |
{ | |
"id": "100", | |
"title": "CANON D3000" | |
} | |
], | |
"page": 1, | |
"size": 10, | |
"totalCount": 100 | |
}); | |
}); | |
it("should apply default pagination parameters", async () => { | |
const options = {}; | |
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(216); | |
expect(body).to.deep.eq({ | |
data: [{id: "100", title: "CANON D3000"}], | |
totalCount: 100, | |
page: 0, | |
size: 20 | |
}); | |
}); | |
it("should throw bad request when options isn\'t correct", async () => { | |
const options = { | |
page: -1 | |
}; | |
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(400); | |
expect(body).to.deep.eq({ | |
"errors": [ | |
{ | |
"data": -1, | |
"dataPath": ".page", | |
"keyword": "minimum", | |
"message": "should be >= 0", | |
"modelName": "Pageable", | |
"params": { | |
"comparison": ">=", | |
"exclusive": false, | |
"limit": 0 | |
}, | |
"schemaPath": "#/properties/page/minimum" | |
} | |
], | |
"message": "Bad request on parameter \"request.query\".\nPageable.page should be >= 0. Given value: -1", | |
"name": "AJV_VALIDATION_ERROR", | |
"status": 400 | |
}); | |
}); | |
}); |
import { | |
CollectionOf, | |
Default, | |
Description, | |
Integer, | |
MaxItems, | |
Min, | |
MinLength, | |
Property, | |
Required, | |
For, | |
SpecTypes, | |
oneOf, | |
string, | |
array | |
} from "@tsed/schema"; | |
import {OnDeserialize} from "@tsed/json-mapper"; | |
import {isArray} from "@tsed/core"; | |
class Pageable { | |
@Integer() | |
@Min(0) | |
@Default(0) | |
@Description("Page number.") | |
page: number = 0; | |
@Integer() | |
@Min(1) | |
@Default(20) | |
@Description("Number of objects per page.") | |
size: number = 20; | |
@For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2))) // v6.11.0 | |
@For(SpecTypes.OPENAPI, array().items(string()).maxItems(2)) | |
@For(SpecTypes.SWAGGER, array().items(string()).maxItems(2)) | |
@OnDeserialize((value: string | string[]) => isString(value) ? value.split(",") : value) | |
@Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.") | |
sort: string | string[]; | |
constructor(options: Partial<Pageable>) { | |
options.page && (this.page = options.page); | |
options.size && (this.size = options.size); | |
options.sort && (this.sort = options.sort); | |
} | |
get offset() { | |
return this.page ? this.page * this.limit : 0; | |
} | |
get limit() { | |
return this.size; | |
} | |
} |
import { | |
CollectionOf, | |
Default, | |
Generics, | |
Integer, | |
MaxItems, | |
Min, | |
MinLength, | |
Property, | |
Required | |
} from "@tsed/schema"; | |
import { Pageable } from "./Pageable"; | |
@Generics("T") | |
class Pagination<T> extends Pageable { | |
@CollectionOf("T") | |
data: T[]; | |
@Integer() | |
@MinLength(0) | |
@Default(0) | |
totalCount: number = 0; | |
constructor({data, totalCount, pageable}: Partial<Pagination<T>> & { pageable: Pageable }) { | |
super(pageable); | |
data && (this.data = data); | |
totalCount && (this.totalCount = totalCount); | |
} | |
} |
import { ResponseFilter, ResponseFilterMethods, PlatformContext } from "@tsed/common"; | |
@ResponseFilter("application/json") | |
class PaginationFilter implements ResponseFilterMethods { | |
transform(data: unknown, ctx: PlatformContext): any { | |
if (ctx.data instanceof Pagination) {// get the unserialized data | |
if (ctx.data.isPaginated) { | |
ctx.response.status(206); | |
} | |
} | |
return data; | |
} | |
} |
import { Property } from "@tsed/schema"; | |
class Product { | |
@Property() | |
id: string; | |
@Property() | |
title: string; | |
constructor({id, title}: Partial<Product> = {}) { | |
id && (this.id = id); | |
title && (this.title = title); | |
} | |
} |
cool, thx. Here is my Sort class. I've specific constructor, because _sort has string[] but in DAO good choice and easy to use obejct with key/value, and don't need to do additional logic there. My method in service here
async findAllWithPagination(query: string, pageable: Pageable): Promise<Page<Product> | null> {
let options: FindManyOptions = {};
options.where = [
{
"name": Like("%" + query + "%")
},
{
"description": Like("%" + query + "%")
}
];
const totalCount = await this.productRepo.count(options);
options.take = pageable.limit;
options.skip = pageable.offset;
options.order = pageable.sort;
const data = await this.productRepo.find(options);
return new Page<Product>({data, totalCount, pageable});
}
export class Sort {
[key: string]: string
constructor(options: string[]) {
options.forEach((sort) => {
const [key, direction] = sort.split(',')
this[key] = direction?.toUpperCase();
});
}
}
Thx, for clarification.
The PR is merged and released.
👍
Ok understand, isn't easy to have good solution for that :)
yep, agree :)
gonna use your suggestion with @OnDeserialize, in this case i can remove my:
@Middleware()
export class ParseSortMiddleware implements IMiddleware {
use(@Req() req: Req, @EndpointInfo() endpoint: EndpointInfo) {
let sort = req.query.sort;
if (sort && !isArray<string>(sort)) {
req.query.sort = new Array(`${sort}`)
}
}
}
keep you posted :)
can you help why i don't see description on UI? It's block me to use Pageable as a model, like you did in your example, and then i don't need it to declare page, size and sort in each controller.
Is strange, I have the description in the swagger. Your are on os2 or os3?
so :)
NO pipes! NO middlewares
@Get("/")
@Summary("Get list of products")
@Returns(200, Page).Of(Product).Description("Returns list of Product objects by query and pagination")
@Returns(500)
async list(
@Res() res: Res,
@Description("Query string to search objects by name and description property.")
@QueryParams('query') query: string = "",
@QueryParams() pageable: Pageable
): Promise<Page<Product> | null> {
return await this.productService.findAllWithPagination(query, pageable);
}
import {Sort} from "./Sort";
import {array, Default, Description, For, Integer, Min, oneOf, SpecTypes, string} from "@tsed/schema";
import {OnDeserialize} from "@tsed/json-mapper";
import {isString} from "@tsed/core";
export class Pageable {
@Integer()
@Min(0)
@Default(0)
@Description("Result page to retrieve (0..N).")
page: number = 0;
@Integer()
@Min(1)
@Default(20)
@Description("Number of objects per page.")
size: number = 20;
@For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2)))
@For(SpecTypes.OPENAPI, array().items(string()).maxItems(2))
@For(SpecTypes.SWAGGER, array().items(string()).maxItems(2))
@OnDeserialize((value: string | string[]) => isString(value) ? [value] : value)
@Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
sort: string | string[];
constructor(options: Partial<Pageable>) {
options.page && (this.page = options.page);
options.size && (this.size = options.size);
options.sort && (this.sort = options.sort);
}
get offset() {
return this.page ? this.page * this.limit : 0;
}
get order(): any {
return new Sort(this.sort);
}
get limit() {
return this.size;
}
}
import {CollectionOf, Generics, Property} from "@tsed/schema";
import {Pageable} from "./Pageable";
@Generics("T")
export class Page<T> extends Pageable {
@CollectionOf("T")
data: T[];
@Property()
totalCount: number = 0;
@Property()
totalPages: number;
constructor({data, totalCount = 0, pageable}: Partial<Page<T>> & { pageable: Pageable }) {
super(pageable);
data && (this.data = data);
totalCount && (this.totalCount = totalCount);
this.totalPages = totalCount == 0 ? 0 : Math.ceil(totalCount / pageable.limit);
}
}
export class Sort {
[key: string]: string
constructor(options: any) {
options.forEach((sort: string) => {
const [key, direction] = sort.split(',')
this[key] = direction?.toUpperCase();
});
}
}
async findAllWithPagination(query: string, pageable: Pageable): Promise<Page<Product> | null> {
let options: FindManyOptions = {};
options.where = [
{
"name": Like("%" + query + "%")
},
{
"description": Like("%" + query + "%")
}
];
const totalCount = await this.productRepo.count(options);
options.take = pageable.limit;
options.skip = pageable.offset;
options.order = pageable.order;
const data = await this.productRepo.find(options);
return new Page<Product>({data, totalCount, pageable});
}
DONE :)
Ok the description doesn’t appear on the right level. I’ll fix it tommorrow ;). My bad...
yep, saw the same. Many thx
Great :)
v6.11.0 will introduce the jsonschema functional declaration (like Joi) and the
@For
decorator.By using
@For
decorator when can instrument correctly the Ajv validation and Swagger :)Release coming soon
Romain