Ajout de la pagination sur l'API

This commit is contained in:
Emmy D'Anello 2024-12-07 16:50:26 +01:00
parent 86427bb41b
commit 138ff1df65
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
10 changed files with 186 additions and 23 deletions

View File

@ -17,6 +17,7 @@
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@prisma/client": "^6.0.1", "@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@ -3674,12 +3675,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": { "node_modules/class-transformer": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"optional": true,
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.1", "version": "0.14.1",

View File

@ -28,6 +28,7 @@
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@prisma/client": "^6.0.1", "@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",

View File

@ -0,0 +1,42 @@
import { ApiProperty } from "@nestjs/swagger"
import { IsNumber, IsOptional } from "class-validator"
export const DEFAULT_PAGE_NUMBER = 1
export const DEFAULT_PAGE_SIZE = 20
export class MetaPaginateOutputDto {
@IsNumber()
@ApiProperty()
total: number
@IsNumber()
@ApiProperty()
lastPage: number
@IsNumber()
@ApiProperty({ default: DEFAULT_PAGE_NUMBER })
currentPage: number = DEFAULT_PAGE_NUMBER
@IsNumber()
@ApiProperty({ default: DEFAULT_PAGE_SIZE })
totalPerPage: number = DEFAULT_PAGE_SIZE
@IsOptional()
@IsNumber()
@ApiProperty({ required: false, nullable: true, default: null })
prevPage?: number | null
@IsOptional()
@IsNumber()
@ApiProperty({ required: false, nullable: true, default: null })
nextPage?: number | null
}
export class PaginateOutputDto<T> {
@ApiProperty({ isArray: true })
data: T[]
@ApiProperty()
meta: MetaPaginateOutputDto
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger'
import { Type } from 'class-transformer'
import { IsNumber, IsOptional } from 'class-validator'
export class QueryPaginationDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiProperty({default: 1, required: false, description: "Numéro de page à charger"})
page?: number = 1
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiProperty({default: 20, required: false, description: "Nombre d'éléments à charger par page"})
size?: number = 20
}

View File

@ -0,0 +1,86 @@
import { applyDecorators, NotFoundException, Type } from '@nestjs/common'
import { QueryPaginationDto } from '../dto/pagination-query.dto'
import { ApiExtraModels, ApiOkResponse, ApiResponseNoStatusOptions, getSchemaPath } from '@nestjs/swagger'
import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE, PaginateOutputDto } from '../dto/pagination-output.dto'
export interface PrismaPaginationParams {
skip: number
take: number
}
export const paginate = (
query: QueryPaginationDto,
): PrismaPaginationParams => {
const size = query.size || DEFAULT_PAGE_SIZE
const page = query.page || DEFAULT_PAGE_NUMBER
return {
skip: size * (page - 1),
take: size,
}
}
export const paginateOutput = <T>(
data: T[],
total: number,
query: QueryPaginationDto,
): PaginateOutputDto<T> => {
const page = query.page || DEFAULT_PAGE_NUMBER
const size = query.size || DEFAULT_PAGE_SIZE
const lastPage = Math.ceil(total / size)
// if data is empty, return empty array
if (!data.length) {
return {
data,
meta: {
total,
lastPage,
currentPage: page,
totalPerPage: size,
prevPage: null,
nextPage: null,
},
}
}
// if page is greater than last page, throw an error
if (page > lastPage) {
throw new NotFoundException(
`Page ${page} not found. Last page is ${lastPage}`,
)
}
return {
data,
meta: {
total,
lastPage,
currentPage: page,
totalPerPage: size,
prevPage: page > 1 ? page - 1 : null,
nextPage: page < lastPage ? page + 1 : null,
},
}
}
export const ApiOkResponsePaginated = <DataDto extends Type<unknown>>(dataDto: DataDto, options?: ApiResponseNoStatusOptions) =>
applyDecorators(
ApiExtraModels(PaginateOutputDto, dataDto),
ApiOkResponse({
...options,
schema: {
allOf: [
{ $ref: getSchemaPath(PaginateOutputDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
}),
)

View File

@ -1,9 +1,12 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseGuards, HttpCode, Req, NotFoundException } from '@nestjs/common' import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseGuards, HttpCode, Req, NotFoundException, Query } from '@nestjs/common'
import { GeolocationsService } from './geolocations.service' import { GeolocationsService } from './geolocations.service'
import { CreateGeolocationDto } from './dto/create-geolocation.dto' import { CreateGeolocationDto } from './dto/create-geolocation.dto'
import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger' import { ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { GeolocationEntity } from './entities/geolocation.entity' import { GeolocationEntity } from './entities/geolocation.entity'
import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto'
@Controller('geolocations') @Controller('geolocations')
export class GeolocationsController { export class GeolocationsController {
@ -26,13 +29,13 @@ export class GeolocationsController {
@Get() @Get()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ type: GeolocationEntity, isArray: true }) @ApiOkResponsePaginated(GeolocationEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" }) @ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" }) @ApiNotFoundResponse({ description: "Objet non trouvé" })
async findAll(): Promise<GeolocationEntity[]> { async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<GeolocationEntity>> {
const geolocations = await this.geolocationsService.findAll() const [geolocations, total] = await this.geolocationsService.findAll(queryPagination)
return geolocations.map(geolocation => new GeolocationEntity(geolocation)) return paginateOutput<GeolocationEntity>(geolocations.map(geolocation => new GeolocationEntity(geolocation)), total, queryPagination)
} }
@Get(':id') @Get(':id')

View File

@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'
import { CreateGeolocationDto } from './dto/create-geolocation.dto' import { CreateGeolocationDto } from './dto/create-geolocation.dto'
import { PrismaService } from 'src/prisma/prisma.service' import { PrismaService } from 'src/prisma/prisma.service'
import { Geolocation, User } from '@prisma/client' import { Geolocation, User } from '@prisma/client'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { paginate } from 'src/common/utils/pagination.utils'
@Injectable() @Injectable()
export class GeolocationsService { export class GeolocationsService {
@ -12,8 +14,13 @@ export class GeolocationsService {
return await this.prisma.geolocation.create({ data: data }) return await this.prisma.geolocation.create({ data: data })
} }
async findAll(): Promise<Geolocation[]> { async findAll(queryPagination?: QueryPaginationDto): Promise<[Geolocation[], number]> {
return await this.prisma.geolocation.findMany() return [
await this.prisma.geolocation.findMany({
...paginate(queryPagination),
}),
await this.prisma.geolocation.count(),
]
} }
async findOne(id: number): Promise<Geolocation> { async findOne(id: number): Promise<Geolocation> {

View File

@ -6,7 +6,7 @@ import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule) const app = await NestFactory.create(AppModule)
app.useGlobalPipes(new ValidationPipe()) app.useGlobalPipes(new ValidationPipe({ transform: true }))
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
const config = new DocumentBuilder() const config = new DocumentBuilder()

View File

@ -1,9 +1,12 @@
import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Req, UseGuards } from '@nestjs/common' import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Query, Req, UseGuards } from '@nestjs/common'
import { UsersService } from './users.service' import { UsersService } from './users.service'
import { ApiBadRequestResponse, ApiBearerAuth, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger' import { ApiBadRequestResponse, ApiBearerAuth, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { UserEntity } from './entities/user.entity' import { UserEntity } from './entities/user.entity'
import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { UpdatePasswordDto } from './dto/user_password.dto' import { UpdatePasswordDto } from './dto/user_password.dto'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils'
import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto'
@Controller('users') @Controller('users')
export class UsersController { export class UsersController {
@ -12,12 +15,12 @@ export class UsersController {
@Get() @Get()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ type: UserEntity, isArray: true }) @ApiOkResponsePaginated(UserEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" }) @ApiForbiddenResponse({ description: "Permission refusée" })
async findAll() { async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<UserEntity>> {
const users = await this.usersService.findAll() const [users, total] = await this.usersService.findAll(queryPagination)
return users.map(user => new UserEntity(user)) return paginateOutput<UserEntity>(users.map(user => new UserEntity(user)), total, queryPagination)
} }
@Get(':id') @Get(':id')

View File

@ -3,20 +3,25 @@ import { User } from '@prisma/client'
import { PrismaService } from 'src/prisma/prisma.service' import { PrismaService } from 'src/prisma/prisma.service'
import * as bcrypt from 'bcrypt' import * as bcrypt from 'bcrypt'
import { UpdatePasswordDto } from './dto/user_password.dto' import { UpdatePasswordDto } from './dto/user_password.dto'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
import { paginate } from 'src/common/utils/pagination.utils'
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async findAll() { async findAll(queryPagination?: QueryPaginationDto): Promise<[User[], number]> {
return await this.prisma.user.findMany() return [
await this.prisma.user.findMany({ ...paginate(queryPagination) }),
await this.prisma.user.count()
]
} }
async findOne(id: number) { async findOne(id: number): Promise<User> {
return await this.prisma.user.findUnique({ where: { id } }) return await this.prisma.user.findUnique({ where: { id } })
} }
async updatePassword(user: User, { password }: UpdatePasswordDto) { async updatePassword(user: User, { password }: UpdatePasswordDto): Promise<void> {
const hashedPassword = await bcrypt.hash(password, 10) const hashedPassword = await bcrypt.hash(password, 10)
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },