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

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