Skip to content

Instantly share code, notes, and snippets.

@Romakita
Last active December 4, 2020 07:51
Show Gist options
  • Save Romakita/2dd599b6905a08ab0301414599c3858a to your computer and use it in GitHub Desktop.
Save Romakita/2dd599b6905a08ab0301414599c3858a to your computer and use it in GitHub Desktop.
Pagination example with Ts.ED
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);
}
}
@arturrozputnii
Copy link

arturrozputnii commented Nov 22, 2020

sorry still fighting with sorting, and it doesn't work for me when i send one parameter, see what i did

@Get("/")
    @Summary("Get list of products")
    @Returns(200, Page).Of(Product).Description("Returns list of Product objects by query and pagination")
    @Returns(500)
    // @UseBefore(ParseSortMiddleware)
    async list(
        @Res() res: Res,
        @Description("Query string to search objects by name and description property.")
        @QueryParams('query') query: string = "",
        // @Description("Number of objects per page.")
        // @QueryParams('size') size: number = 20,
        // @Description("Result page to retrieve (0..N).")
        // @QueryParams('page') page: number = 0,
        // @Description("Sorting criteria: property(,asc | desc). Default sort order is ascending. Multiple sort criteria are supported.")
        // @QueryParams('sort', String) sort: Sort[],
        // @QueryParams('sort') sort: Sort[],
        // @PageableParam() pageable: Pageable,
        @QueryParams() pageable: Pageable
    ): Promise<Page<Product> | null> {
        return await this.productService.findAllWithPagination(query, pageable);
    }
import {Sort} from "./Sort";
import {CollectionOf, Description, Property} from "@tsed/schema";
import {QueryParams} from "@tsed/common";

export class Pageable {

    @Property()
    page: number = 0;

    @Property()
    size: number = 20;

    @CollectionOf(String, Array)
    @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
    sort: string[];

    // private _sort: any;

    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;
    }

    // @CollectionOf(String, Array)
    // @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
    // get sort(): any {
    //     return new Sort(this._sort);
        // return this.sort
    // }


    // set sort(value: any) {
    //     this.sort = value;
    // }

    get limit() {
        return this.size;
    }
}

and error is the same

  "name": "AJV_VALIDATION_ERROR",
  "message": "Bad request on parameter \"request.query\".\nAt Pageable.sort, value 'id,asc' should be array",
  "status": 400,
  "errors": [
    {
      "keyword": "type",
      "dataPath": ".sort",
      "schemaPath": "#/properties/sort/type",
      "params": {
        "type": "array"
      },
      "message": "should be array",
      "schema": "array",
      "parentSchema": {
        "type": "array",
        "items": {
          "type": "string"
        },
        "description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported."
      },
      "data": "id,asc",
      "modelName": "Pageable"
    }
  ],
  "stack": "PARAM_VALIDATION_ERROR: Bad request on parameter \"request.query\".\nAt Pageable.sort, value 'id,asc' should be array\n    at Function.from (/Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/errors/ParamValidationError.ts:19:21)\n    at handleError (/Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:336:36)\n    at /Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:343:14\n    at processTicksAndRejections (internal/process/task_queues.js:97:5)\n    at /Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:341:15"
}

maybe i miss something. Just to want inform you. Still used my tricky fix for that.

@arturrozputnii
Copy link

arturrozputnii commented Nov 22, 2020

Screenshot 2020-11-22 at 23 43 58

BTW, can you please tell why description doesn't work in my Pageable model

    @Min(0)
    @Default(0)
    @Description("Result page to retrieve (0..N).")
    page: number = 0;

    @Description("Number of objects per page.")
    size: number = 20;```

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

Why have you removed the getter / setter /constructor? I really important to keep it. Without, the serialization/deserialization won't work as expected.

See the integration test here:
https://github.com/TypedProject/tsed/blob/production/packages/platform-express/test/pageable.spec.ts

The spec is correctly generated (with description) and the validation doesn't fail. I tested your scenario :) But if you change the class implementation, the result change also! (works as expected with v6.10.3)

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

I see you don't won't the sort class. Let me change the example.

Ajv complaining because you don't send an array of string, it's normal. We have to tell Ajv about the possibility to receive a string and string[].

@Romakita
Copy link
Author

v6.11.0 will introduce the jsonschema functional declaration (like Joi) and the @For decorator.

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)))
  @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;
  }
}

By using @For decorator when can instrument correctly the Ajv validation and Swagger :)

Release coming soon
Romain

@Romakita
Copy link
Author

@arturrozputnii
Copy link

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.

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

The PR is merged and released.

@arturrozputnii
Copy link

👍

@Romakita
Copy link
Author

Ok understand, isn't easy to have good solution for that :)

@arturrozputnii
Copy link

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 :)

Screenshot 2020-11-23 at 22 50 56

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.

@Romakita
Copy link
Author

Is strange, I have the description in the swagger. Your are on os2 or os3?

@arturrozputnii
Copy link

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 :)

@arturrozputnii
Copy link

My swagger
Screenshot 2020-11-23 at 23 29 43

Screenshot 2020-11-23 at 23 30 41

    swagger: [
        {
            path: "/swagger",
            specVersion: "3.0.1",
            specPath: `${rootDir}/swagger.json`,
            options: {
                oauth2RedirectUrl: "",
                oauth: {
                    clientId: "",
                    clientSecret: "",
                    scopes: []
                }
            }
        }
    ],

am i miss something?

@Romakita
Copy link
Author

Ok the description doesn’t appear on the right level. I’ll fix it tommorrow ;). My bad...

@arturrozputnii
Copy link

yep, saw the same. Many thx

@arturrozputnii
Copy link

Screenshot 2020-11-24 at 10 08 08

AWESOME, thx. I am done with pagination/sorting now. I hope :)

@Romakita
Copy link
Author

Great :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment