Skip to content

Commit

Permalink
feat(api): list API now support Pagination and Search
Browse files Browse the repository at this point in the history
  • Loading branch information
xmlking committed Mar 18, 2019
1 parent 8dbe9d9 commit 55e3e71
Show file tree
Hide file tree
Showing 33 changed files with 487 additions and 98 deletions.
10 changes: 6 additions & 4 deletions apps/api/src/app/core/crud/crud.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,27 @@ import { Base } from '../entities/base.entity';
import { DeepPartial } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ICrudService } from './icrud.service';
import { IPagination } from './pagination';
import { PaginationParams } from './pagination-params';

@ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized' })
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
export abstract class CrudController<T extends Base> {
protected constructor(private readonly crudService: ICrudService<T>) {}

@ApiOperation({ title: 'find all' })
@ApiResponse({ status: HttpStatus.OK, description: 'All records', /* type: T, */ isArray: true })
@ApiResponse({ status: HttpStatus.OK, description: 'Found records', /* type: IPagination<T> */ })
@Get()
async findAll(options?: any): Promise<[T[], number]> {
return this.crudService.getAll(options);
async findAll(filter?: PaginationParams<T>): Promise<IPagination<T>> {
return this.crudService.findAll(filter);
}

@ApiOperation({ title: 'Find by id' })
@ApiResponse({ status: HttpStatus.OK, description: 'Found one record' /*, type: T*/ })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' })
@Get(':id')
async findById(@Param('id') id: string): Promise<T> {
return this.crudService.getOne(id);
return this.crudService.findOne(id);
}

@ApiOperation({ title: 'Create new record' })
Expand Down
19 changes: 7 additions & 12 deletions apps/api/src/app/core/crud/crud.service.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,22 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
DeepPartial,
DeleteResult,
FindConditions,
FindManyOptions,
FindOneOptions,
Repository,
UpdateResult,
} from 'typeorm';
import { DeepPartial, DeleteResult, FindConditions, FindManyOptions, FindOneOptions, Repository, UpdateResult } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { mergeMap } from 'rxjs/operators';
import { of, throwError } from 'rxjs';
import { Base } from '../entities/base.entity';
import { ICrudService } from './icrud.service';
import { IPagination } from './pagination';

export abstract class CrudService<T extends Base> implements ICrudService<T> {
protected constructor(protected readonly repository: Repository<T>) {}

public async getAll(options?: FindManyOptions<T>): Promise<[T[], number]> {
return await this.repository.findAndCount(options);
public async findAll(filter?: FindManyOptions<T>): Promise<IPagination<T>> {
const total = await this.repository.count(filter);
const items = await this.repository.find(filter);
return { items, total };
}

public async getOne(
public async findOne(
id: string | number | FindOneOptions<T> | FindConditions<T>,
options?: FindOneOptions<T>,
): Promise<T> {
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/app/core/crud/icrud.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { DeepPartial, DeleteResult, FindConditions, FindManyOptions, FindOneOptions, UpdateResult } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { IPagination } from './pagination';

export interface ICrudService<T> {
getAll(filter?: FindManyOptions<T>): Promise<[T[], number]>;
getOne(id: string | number | FindOneOptions<T> | FindConditions<T>, options?: FindOneOptions<T>): Promise<T>;
findAll(filter?: FindManyOptions<T>): Promise<IPagination<T>>;
findOne(id: string | number | FindOneOptions<T> | FindConditions<T>, options?: FindOneOptions<T>): Promise<T>;
create(entity: DeepPartial<T>, ...options: any[]): Promise<T>;
update(id: any, entity: QueryDeepPartialEntity<T>, ...options: any[]): Promise<UpdateResult | T>;
delete(id: any, ...options: any[]): Promise<DeleteResult>;
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app/core/crud/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './pagination';
export * from './pagination-params';
export * from './crud.service';
export * from './crud.controller';
39 changes: 39 additions & 0 deletions apps/api/src/app/core/crud/pagination-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, Max, Min } from 'class-validator';

export enum OrderType {
DESC = 'DESC',
ASC = 'ASC',
}

/**
* Describes generic pagination params
*/
export abstract class PaginationParams<T> {
/**
* Pagination limit
*/
@ApiModelPropertyOptional({ type: Number, minimum: 0, maximum: 50 })
@IsOptional()
@Min(0)
@Max(50)
@Transform((val: string) => parseInt(val, 10))
readonly take = 10;

/**
* Pagination offset
*/
@ApiModelPropertyOptional({ type: Number, minimum: 0 })
@IsOptional()
@Min(0)
@Transform((val: string) => parseInt(val, 10))
readonly skip = 0;

/**
* OrderBy
*/
@ApiModelPropertyOptional()
@IsOptional()
abstract readonly order?: { [P in keyof T]?: OrderType };
}
14 changes: 14 additions & 0 deletions apps/api/src/app/core/crud/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Generic pagination interface
*/
export interface IPagination<T> {
/**
* Items included in the current listing
*/
readonly items: T[];

/**
* Total number of available items
*/
readonly total: number;
}
4 changes: 2 additions & 2 deletions apps/api/src/app/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './crud';
export * from './core.module';
export * from './services/base-remote.service';
export * from './crud/crud.service';
export * from './context/request-context';
export * from './crud/crud.controller';

Empty file.
30 changes: 30 additions & 0 deletions apps/api/src/app/core/pipes/uuid-validation.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ArgumentMetadata, Injectable, NotFoundException, PipeTransform } from '@nestjs/common';
import { Validator } from 'class-validator';

/**
* UUID Validation Pipe
*
* Validates UUID passed in request parameters.
*/
@Injectable()
export class UUIDValidationPipe implements PipeTransform {
/**
* Instance of class-validator
*
* Can not be easily injected, and there's no need to do so as we
* only use it for uuid validation method.
*/
private readonly validator: Validator = new Validator();

/**
* When user requests an entity with invalid UUID we must return 404
* error before reaching into the database.
*/
public transform(value: string, metadata: ArgumentMetadata): string {
if (!this.validator.isUUID(value)) {
throw new NotFoundException();
}

return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { OrderType, PaginationParams } from '../../../core';
import { Notification, TargetType } from '../notification.entity';
import { ApiModelPropertyOptional } from '@nestjs/swagger';
import { IsAscii, IsBoolean, IsEnum, IsNotEmpty, IsOptional, MaxLength, MinLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class FindNotificationsDto extends PaginationParams<Notification> {
@ApiModelPropertyOptional({ type: String, minLength: 3, maxLength: 50 })
@IsOptional()
@IsNotEmpty()
@IsAscii()
@MinLength(3)
@MaxLength(50)
readonly target?: any;

@ApiModelPropertyOptional({ type: String, /*enum: TargetType */ enum: ['all', 'user', 'topic'] })
@IsOptional()
@IsNotEmpty()
@IsEnum(TargetType)
readonly targetType?: string;

@ApiModelPropertyOptional({ type: Boolean, default: false })
@Transform((val: string) => ( val === 'true' ))
@IsOptional()
@IsBoolean()
readonly read = false;

@ApiModelPropertyOptional({ type: Boolean, default: true })
@Transform((val: string) => ( val === 'true' ))
@IsOptional()
@IsBoolean()
readonly isActive = true;

@ApiModelPropertyOptional({ type: String, enum: ['ASC', 'DESC'] })
@IsOptional()
@Transform((val: string) => ({ createdAt: val === OrderType.ASC ? OrderType.ASC : OrderType.DESC }))
readonly order = {
createdAt: OrderType.DESC,
};

constructor(values: Partial<FindNotificationsDto>) {
super();
Object.assign(this, values);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { OrderType, PaginationParams } from '../../../core';
import { Notification } from '../notification.entity';
import { ApiModelPropertyOptional } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';

export class FindOwnNotificationsDto extends PaginationParams<Notification> {
@ApiModelPropertyOptional({ type: String, enum: ['ASC', 'DESC'] })
@IsOptional()
@Transform((val: string) => ({ createdAt: val === OrderType.ASC ? OrderType.ASC : OrderType.DESC }))
readonly order = {
createdAt: OrderType.DESC,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IPagination } from '../../../core';
import { Notification } from '../notification.entity';

export class NotificationList implements IPagination<Notification> {
readonly items: Notification[];
readonly total: number;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger';
import { IsAscii, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { NotificationColor, NotificationIcon, TargetType } from '../notification.entity';
import { Column, Index } from 'typeorm';
import { Index } from 'typeorm';

export class UpdateNotificationDto {
@ApiModelProperty({ type: String, minLength: 10, maxLength: 100 })
Expand Down Expand Up @@ -43,6 +43,7 @@ export class UpdateNotificationDto {

@ApiModelPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
@Index()
readonly read?: boolean;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { CrudController } from '../../core';
import { ApiExcludeEndpoint, ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger';
import { Notification } from './notification.entity';
Expand All @@ -7,38 +7,43 @@ import { NotificationService } from './notification.service';
import { CurrentUser, Roles, RolesEnum, User } from '../../auth';
import { SendNotificationDto } from './dto/send-notification.dto';
import { UpdateNotificationDto } from './dto/update-notification.dto';
import { NotificationList } from './dto/notification-list.model';
import { FindNotificationsDto } from './dto/find-notifications.dto';
import { FindOwnNotificationsDto } from './dto/find-own-notifications.dto';

@ApiOAuth2Auth(['read'])
@ApiUseTags('Notifications')
@Controller('notifications')
@ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized' })
export class NotificationController extends CrudController<Notification> {
constructor(private readonly notificationService: NotificationService) {
super(notificationService);
}

@ApiOperation({ title: 'find all Notifications. Admins only' })
@ApiResponse({ status: HttpStatus.OK, description: 'All Notifications', /* type: Notification, */ isArray: true })
@ApiOperation({ title: 'Find all Notifications. Admins only' })
@ApiResponse({ status: HttpStatus.OK, description: 'Find matching Notifications', type: NotificationList })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No matching records found' })
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Get()
async findAll(): Promise<[Notification[], number]> {
return this.notificationService.getAll();
async findAll(@Query() filter: FindNotificationsDto): Promise<NotificationList> {
// return super.findAll(filter);
return this.notificationService.findAll(filter);
}

@ApiOperation({ title: 'find all user and global Notifications' })
@ApiResponse({
status: HttpStatus.OK,
description: 'All user Notifications',
/* type: Notification, */ isArray: true,
})
@Get('user')
async getUserNotifications(@CurrentUser() user): Promise<[Notification[], number]> {
return this.notificationService.getUserNotifications(user);
@ApiOperation({ title: 'find user\'s and global Notifications' })
@ApiResponse({ status: HttpStatus.OK, description: 'Find matching Notifications', type: NotificationList })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No matching records found' })
@Get('own')
async findOwn(@Query() filter: FindOwnNotificationsDto, @CurrentUser() user): Promise<NotificationList> {
return this.notificationService.findOwn(filter, user);
}

@ApiOperation({ title: 'Find by id. Admins only' })
@ApiResponse({ status: HttpStatus.OK, description: 'Found one record', type: Notification })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' })
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Get(':id')
Expand All @@ -56,6 +61,7 @@ export class NotificationController extends CrudController<Notification> {
status: HttpStatus.BAD_REQUEST,
description: 'Invalid input, The response body may contain clues as to what went wrong',
})
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Post()
Expand All @@ -64,6 +70,7 @@ export class NotificationController extends CrudController<Notification> {
}

@ApiExcludeEndpoint()
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Put(':id')
Expand All @@ -78,6 +85,7 @@ export class NotificationController extends CrudController<Notification> {
@ApiOperation({ title: 'Delete record by admin' })
@ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'The record has been successfully deleted' })
@ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' })
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Delete(':id')
Expand Down Expand Up @@ -105,6 +113,7 @@ export class NotificationController extends CrudController<Notification> {
status: HttpStatus.BAD_REQUEST,
description: 'Invalid input, The response body may contain clues as to what went wrong',
})
@ApiResponse({ status: HttpStatus.FORBIDDEN, description: 'Forbidden' })
@ApiUseTags('Admin')
@Roles(RolesEnum.ADMIN)
@Post('send')
Expand Down

0 comments on commit 55e3e71

Please sign in to comment.