diff --git a/nest-cli.json b/nest-cli.json index 7285ea52b..7cd4db7dd 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -9,6 +9,23 @@ "include": "src/playerTasks/playerTasks.json", "outDir": "dist" } + ], + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "dtoFileNameSuffix": [".dto.ts", ".schema.ts", "APIError.ts"], + "controllerFileNameSuffix": ".controller.ts", + + "dtoKeyOfComment": "description", + "controllerKeyOfComment": "summary", + + "classValidatorShim": true, + "introspectComments": true, + "skipAutoHttpCode": true, + "esmCompatible": false + } + } ] } } diff --git a/src/__tests__/clanShop/clanShopService/buyItem.test.ts b/src/__tests__/clanShop/clanShopService/buyItem.test.ts index 7ef28591b..6102aad37 100644 --- a/src/__tests__/clanShop/clanShopService/buyItem.test.ts +++ b/src/__tests__/clanShop/clanShopService/buyItem.test.ts @@ -105,7 +105,6 @@ describe('ClanShopService.buyItem() test suite', () => { additional: null, field: 'gameCoins', message: 'Clan does not have enough coins to buy the item', - objectType: 'ServiceError', reason: 'LESS_THAN_MIN', value: null, }, diff --git a/src/clan/clan.controller.ts b/src/clan/clan.controller.ts index 6f693bf9a..85a335e5d 100644 --- a/src/clan/clan.controller.ts +++ b/src/clan/clan.controller.ts @@ -37,12 +37,17 @@ import { publicReferences } from './clan.schema'; import { RoomService } from '../clanInventory/room/room.service'; import { ItemService } from '../clanInventory/item/item.service'; import { PlayerService } from '../player/player.service'; -import { ClanItemsResponseDto } from './join/dto/clanItemsResponse.dto'; import HasClanRights from './role/decorator/guard/HasClanRights'; import { ClanBasicRight } from './role/enum/clanBasicRight.enum'; import DetermineClanId from '../common/guard/clanId.guard'; import { APIError } from '../common/controller/APIError'; import { APIErrorReason } from '../common/controller/APIErrorReason'; +import { ApiStandardErrors } from '../common/swagger/response/errors/ApiStandardErrors.decorator'; +import { ApiSuccessResponse } from '../common/swagger/response/success/ApiSuccessResponse.decorator'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import ClanItemsDto from './dto/clanItems.dto'; +import { ApiExtraModels } from '@nestjs/swagger'; +import { ItemDto } from '../clanInventory/item/dto/item.dto'; @Controller('clan') export class ClanController { @@ -54,6 +59,25 @@ export class ClanController { private readonly playerService: PlayerService, ) {} + /** + * Create a new Clan. + * + * @remarks The creator of the Clan becomes its admin. + * Notice that if Player is creating a new Clan, he/she becomes a member of it, + * that means that if Player is member of some Clan it can not create a new one, before leaving the old one. + * + * Also, endpoint creates Clan's Stock, as well as Clan's SoulHome and Rooms. + * + * For the created Clan a set of default Items will be added to Stock and to one of the SoulHome Rooms. + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + status: 201, + modelName: ModelName.CLAN, + }, + errors: [400, 401, 403, 409], + }) @Post() @Authorize({ action: Action.create, subject: ClanDto }) @UniformResponse(ModelName.CLAN) @@ -61,8 +85,23 @@ export class ClanController { return this.service.createOne(body, user.player_id); } + /** + * Get items of the logged-in user clan. + * + * @remarks Get items of the logged-in user clan. + * + * Notice that it will return 403 if the logged-in player is not in any clan. + */ + @ApiResponseDescription({ + success: { + dto: ClanItemsDto, + modelName: ModelName.ITEM, + }, + errors: [401, 403], + }) + @ApiExtraModels(ItemDto) @Get('items') - @UniformResponse(ModelName.ITEM, ClanItemsResponseDto) + @UniformResponse(ModelName.ITEM, ClanItemsDto) async getClanItems(@LoggedUser() user: User) { const clanId = await this.playerService.getPlayerClanId(user.player_id); const [clan, clanErrors] = await this.service.readOneById(clanId, { @@ -84,6 +123,19 @@ export class ClanController { return { stockItems, soulHomeItems }; } + /** + * Get Clan by _id. + * + * @remarks Read Clan data by its _id field + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + modelName: ModelName.CLAN, + }, + errors: [400, 404], + hasAuth: false, + }) @Get('/:_id') @NoAuth() @UniformResponse(ModelName.CLAN) @@ -94,7 +146,26 @@ export class ClanController { return this.service.readOneById(param._id, { includeRefs }); } + /** + * Read all clans. + * + * @remarks Read all created Clans + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + modelName: ModelName.CLAN, + returnsArray: true, + }, + errors: [404], + hasAuth: false, + }) @Get() + @ApiSuccessResponse(ClanDto, { + modelName: ModelName.CLAN, + returnsArray: true, + }) + @ApiStandardErrors(404) @NoAuth() @UniformResponse(ModelName.CLAN, ClanDto) @OffsetPaginate(ModelName.CLAN) @@ -104,6 +175,19 @@ export class ClanController { return this.service.readAll(query); } + /** + * Update a clan + * + * @remarks Update the Clan, which _id is specified in the body. + * + * Notice that the player must be in the same clan and it must have a basic right "Edit clan data" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404, 409], + }) @Put() @DetermineClanId() @HasClanRights([ClanBasicRight.EDIT_CLAN_DATA]) @@ -121,11 +205,25 @@ export class ClanController { }), ], ]; - const [, errors] = await this.service.updateOneById(body); if (errors) return [null, errors]; } + /** + * Delete a clan + * + * @remarks Delete Clan its _id field. + * + * Notice that only Clan admins can delete the Clan. + * + * Notice that the player must be in the same clan and it must have a basic right "Edit clan data" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Delete('/:_id') @Authorize({ action: Action.delete, subject: UpdateClanDto }) @UniformResponse() @@ -134,6 +232,23 @@ export class ClanController { if (errors) return [null, errors]; } + /** + * Player requests join to clan + * + * @remarks Request to join a Clan. + * + * Notice that if the Clan is open the Player will be joined automatically without admin approval. + * + * Notice that if the Player was in another Clan then he/she will be removed from the old one and if in this Clan was no other Players, it will be removed. + */ + @ApiResponseDescription({ + success: { + dto: JoinDto, + modelName: ModelName.JOIN, + status: 201, + }, + errors: [400, 401, 403, 404], + }) @Post('join') @Authorize({ action: Action.create, subject: JoinDto }) @BasicPOST(JoinDto) @@ -141,6 +256,19 @@ export class ClanController { return this.joinService.handleJoinRequest(body); } + /** + * Player requests leave a clan + * + * @remarks Request to leave a Clan. + * + * Notice that Player can leave any Clan without admin approval. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [401, 404], + }) @Post('leave') @HttpCode(204) @Authorize({ action: Action.create, subject: PlayerLeaveClanDto }) @@ -148,6 +276,19 @@ export class ClanController { return this.joinService.leaveClan(user.player_id); } + /** + * Exclude the player from clan. + * + * @remarks Request exclude the player from clan. + * + * Notice that only Clan admin can remove the Player + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Post('exclude') @HttpCode(204) @DetermineClanId() diff --git a/src/clan/dto/clan.dto.ts b/src/clan/dto/clan.dto.ts index dcab8d19d..a765486c0 100644 --- a/src/clan/dto/clan.dto.ts +++ b/src/clan/dto/clan.dto.ts @@ -1,7 +1,6 @@ import { Expose, Type } from 'class-transformer'; import { PlayerDto } from '../../player/dto/player.dto'; import { ExtractField } from '../../common/decorator/response/ExtractField'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { Language } from '../../common/enum/language.enum'; import { AgeRange } from '../enum/ageRange.enum'; import { Goal } from '../enum/goal.enum'; @@ -9,73 +8,165 @@ import { StockDto } from '../../clanInventory/stock/dto/stock.dto'; import { SoulHomeDto } from '../../clanInventory/soulhome/dto/soulhome.dto'; import { ClanLogoDto } from './clanLogo.dto'; import ClanRoleDto from '../role/dto/clanRole.dto'; +import { ClanLabel } from '../enum/clanLabel.enum'; -@AddType('ClanDto') +/** + * DTO for reading clan data. + */ export class ClanDto { + /** + * Unique identifier of the clan + * + * @example "67fe4e2d8a54d4cc39266a43" + */ @ExtractField() @Expose() _id: string; + /** + * Name of the clan + * + * @example "Warriors Of Light" + */ @Expose() name: string; + /** + * Short tag representing the clan + * + * @example "WoL" + */ @Expose() tag: string; + /** + * Clan logo + */ @Type(() => ClanLogoDto) @Expose() clanLogo: ClanLogoDto; + /** + * List of labels describing the clan. + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @Expose() - labels: string[]; + labels: ClanLabel[]; + /** + * Amount of game coins the clan owns + * + * @example 1500 + */ @Expose() gameCoins: number; + /** + * Total points accumulated by the clan + * + * @example 320 + */ @Expose() points: number; + /** + * List of user IDs that are administrators of the clan + * + * @example ["67fe4e2d8a54d4cc39266a41", "67fe4e2d8a54d4cc39266a42"] + */ @ExtractField() @Expose() admin_ids: string[]; + /** + * Number of players currently in the clan + * + * @example 12 + */ @Expose() playerCount: number; + /** + * Number of items stored by the clan + * + * @example 45 + */ @Expose() itemCount: number; + /** + * Number of stock units the clan owns + * + * @example 9 + */ @Expose() stockCount: number; + /** + * Allowed age range for clan members + * + * @example "All" + */ @Expose() ageRange: AgeRange; + /** + * Goal or purpose of the clan + * + * @example "Competitive" + */ @Expose() goal: Goal; + /** + * Clan's motto or phrase + * + * @example "Victory through unity" + */ @Expose() phrase: string; + /** + * Preferred language used in the clan + * + * @example "English" + */ @Expose() language: Language; + /** + * Indicates if members can join the clan without admin approvals + * + * @example true + */ @Expose() isOpen: boolean; + /** + * Clan roles + */ @Type(() => ClanRoleDto) @Expose() roles: ClanRoleDto[]; + /** + * Clan members, optional, upon request + */ @Type(() => PlayerDto) @Expose() - Player: PlayerDto[]; + Player?: PlayerDto[]; + /** + * Clan stock, optional, upon request + */ @Type(() => StockDto) @Expose() - Stock: StockDto; + Stock?: StockDto; + /** + * Clan soul home, upon request + */ @Type(() => SoulHomeDto) @Expose() - SoulHome: SoulHomeDto; + SoulHome?: SoulHomeDto; } diff --git a/src/clan/dto/clanItems.dto.ts b/src/clan/dto/clanItems.dto.ts new file mode 100644 index 000000000..11a1df7dd --- /dev/null +++ b/src/clan/dto/clanItems.dto.ts @@ -0,0 +1,13 @@ +import { ItemDto } from '../../clanInventory/item/dto/item.dto'; + +export default class ClanItemsDto { + /** + * Clan items in its stock + */ + stockItems: ItemDto[]; + + /** + * Clan items in its soul home rooms + */ + soulHomeItems: ItemDto[]; +} diff --git a/src/clan/dto/clanLogo.dto.ts b/src/clan/dto/clanLogo.dto.ts index 35573299e..fd67fe601 100644 --- a/src/clan/dto/clanLogo.dto.ts +++ b/src/clan/dto/clanLogo.dto.ts @@ -1,14 +1,22 @@ import { IsArray, IsEnum, IsHexColor } from 'class-validator'; import { LogoType } from '../enum/logoType.enum'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { Expose } from 'class-transformer'; -@AddType('ClanLogoDto') export class ClanLogoDto { + /** + * Type of the logo used by the clan + * + * @example "Heart" + */ @Expose() @IsEnum(LogoType) logoType: LogoType; + /** + * Colors used in the logo, defined as hex codes + * + * @example ["#FFFFFF", "#000000"] + */ @Expose() @IsArray() @IsHexColor({ each: true }) diff --git a/src/clan/dto/createClan.dto.ts b/src/clan/dto/createClan.dto.ts index 301dcadac..28972166f 100644 --- a/src/clan/dto/createClan.dto.ts +++ b/src/clan/dto/createClan.dto.ts @@ -8,48 +8,89 @@ import { ValidateNested, MaxLength, } from 'class-validator'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { ClanLabel } from '../enum/clanLabel.enum'; import { AgeRange } from '../enum/ageRange.enum'; import { Language } from '../../common/enum/language.enum'; import { Goal } from '../enum/goal.enum'; import { Type } from 'class-transformer'; import { ClanLogoDto } from './clanLogo.dto'; +import { ApiProperty } from '@nestjs/swagger'; -@AddType('CreateClanDto') +/** + * DTO for creating a clan. + */ export class CreateClanDto { + /** + * Unique name of the clan (max 20 characters). + * @example "my_clan" + */ + @ApiProperty({ + uniqueItems: true, + }) @IsString() @MaxLength(20) name: string; + /** + * Short tag used to identify the clan. + * @example "CLN123" + */ @IsString() tag: string; + /** + * Optional logo of the clan. + * @example { logoType: "Heart", pieceColors: [#FFFFFF] } + */ @Type(() => ClanLogoDto) @IsOptional() @ValidateNested() clanLogo?: ClanLogoDto; + /** + * List of labels describing the clan (max 5). + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @IsArray() @ArrayMaxSize(5) @IsEnum(ClanLabel, { each: true }) labels: ClanLabel[]; + /** + * Whether the clan is open to new members. + * @example true + */ @IsBoolean() @IsOptional() isOpen?: boolean; + /** + * Optional age range preference for clan members. + * @example "ALL" + */ @IsEnum(AgeRange) @IsOptional() ageRange?: AgeRange; + /** + * Optional goal or focus of the clan. + * @example "Fiilistely" + */ @IsEnum(Goal) @IsOptional() goal?: Goal; + /** + * Clan motto or phrase. + * @example "Victory or nothing!" + */ @IsString() phrase: string; + /** + * Preferred language used in the clan. + * @example "English" + */ @IsEnum(Language) @IsOptional() language?: Language; diff --git a/src/clan/dto/updateClan.dto.ts b/src/clan/dto/updateClan.dto.ts index b963a4aaf..6cf4d0453 100644 --- a/src/clan/dto/updateClan.dto.ts +++ b/src/clan/dto/updateClan.dto.ts @@ -23,58 +23,115 @@ import { Type } from 'class-transformer'; @AddType('UpdateClanDto') export class UpdateClanDto { + /** + * ID of the clan to update + * + * @example "67fe4e2d8a54d4cc39266a43" + */ @IsClanExists() @IsMongoId() _id: string; + /** + * New name of the clan (optional) + * + * @example "Warriors Of Light" + */ @IsString() @IsOptional() @MaxLength(20) name?: string; + /** + * New tag for the clan (optional) + * + * @example "WOL" + */ @IsString() @IsOptional() tag?: string; + /** + * New logo configuration for the clan (optional) + */ @ValidateNested() @Type(() => ClanLogoDto) @IsOptional() clanLogo?: ClanLogoDto; + /** + * Updated labels for the clan (max 5, optional) + * + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @IsArray() @ArrayMaxSize(5) @IsEnum(ClanLabel, { each: true }) @IsOptional() labels?: ClanLabel[]; - //TODO: validate is player exists does not work + /** + * Player IDs to be added as clan administrators (optional) + * + * @example ["67fe4e2d8a54d4cc39266a42", "67fe4e2d8a54d4cc39266a41"] + */ @IsArray() @ArrayNotEmpty() @Validate(IsPlayerExists) @IsOptional() admin_idsToAdd?: string[]; + /** + * Player IDs to be removed from clan administrators (optional) + * + * @example ["67fe4e2d8a54d4cc39266a43"] + */ @IsArray() @ArrayNotEmpty() @IsOptional() admin_idsToDelete?: string[]; + /** + * Whether the clan is open to join without admin approval (optional) + * + * @example true + */ @IsBoolean() @IsOptional() isOpen?: boolean; + /** + * Age range restriction for clan members (optional) + * + * @example "All" + */ @IsEnum(AgeRange) @IsOptional() ageRange?: AgeRange; + /** + * Goal or focus of the clan (optional) + * + * @example "Competitive" + */ @IsEnum(Goal) @IsOptional() goal?: Goal; + /** + * Clan's motto or slogan (optional) + * + * @example "Together we rise" + */ @IsString() @IsOptional() phrase?: string; + /** + * Preferred language of the clan (optional) + * + * @example "English" + */ @IsEnum(Language) @IsOptional() language?: Language; diff --git a/src/clan/join/dto/clanItemsResponse.dto.ts b/src/clan/join/dto/clanItemsResponse.dto.ts deleted file mode 100644 index 977562d2f..000000000 --- a/src/clan/join/dto/clanItemsResponse.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Expose, Type } from 'class-transformer'; -import { ItemDto } from '../../../clanInventory/item/dto/item.dto'; - -export class ClanItemsResponseDto { - @Expose() - @Type(() => ItemDto) - stockItems: ItemDto[]; - - @Expose() - @Type(() => ItemDto) - soulHomeItems: ItemDto[]; -} diff --git a/src/clan/join/dto/join.dto.ts b/src/clan/join/dto/join.dto.ts index 0315d8a67..1817dd0a1 100644 --- a/src/clan/join/dto/join.dto.ts +++ b/src/clan/join/dto/join.dto.ts @@ -4,19 +4,39 @@ import { ExtractField } from '../../../common/decorator/response/ExtractField'; @AddType('JoinDto') export class JoinDto { + /** + * Unique identifier of the join request + * @example "665f9e42b4b74d098aac4d22" + */ @ExtractField() @Expose() _id: string; + /** + * The ID of the clan to join + * @example "6643ec9cbeddb7e88fc76ae1" + */ @Expose() - clan_id: string; // the clan id we are trying to join + clan_id: string; + /** + * The ID of the player requesting to join + * @example "6643ec9cbeddb7e88fc76ae3" + */ @Expose() - player_id: string; // the player who is trying to join + player_id: string; + /** + * Optional message sent by the player when joining a private clan + * @example "Hey! I'd love to join your clan and help out!" + */ @Expose() - join_message: string; // join message if clan is private + join_message: string; + /** + * Indicates whether the join request was accepted + * @example true + */ @Expose() - accepted: boolean; // whether you are accepted or you arent + accepted: boolean; } diff --git a/src/clan/join/dto/joinRequest.dto.ts b/src/clan/join/dto/joinRequest.dto.ts index 3f160fe35..cffa7f137 100644 --- a/src/clan/join/dto/joinRequest.dto.ts +++ b/src/clan/join/dto/joinRequest.dto.ts @@ -5,15 +5,27 @@ import { IsPlayerExists } from '../../../player/decorator/validation/IsPlayerExi @AddType('JoinRequestDto') export class JoinRequestDto { + /** + * The ID of the clan the player is attempting to join + * @example "6643ec9cbeddb7e88fc76ae1" + */ @IsClanExists() @IsMongoId() - clan_id: string; // the clan id we are trying to join + clan_id: string; + /** + * The ID of the player submitting the join request + * @example "6643ec9cbeddb7e88fc76ae3" + */ @IsPlayerExists() @IsMongoId() - player_id: string; // the player who is trying to join + player_id: string; + /** + * Optional message provided with the join request + * @example "Looking forward to playing with you all!" + */ @IsString() @IsOptional() - join_message: string; // join message if clan is private + join_message: string; } diff --git a/src/clan/join/dto/joinResult.dto.ts b/src/clan/join/dto/joinResult.dto.ts index 4b03e6b20..f73fecb47 100644 --- a/src/clan/join/dto/joinResult.dto.ts +++ b/src/clan/join/dto/joinResult.dto.ts @@ -3,9 +3,17 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('JoinResultDto') export class JoinResultDto { + /** + * ID of the join request + * @example "665fa038b4b74d098aac4e77" + */ @IsMongoId() _id: string; + /** + * Indicates whether the join request has been accepted + * @example true + */ @IsOptional() @IsBoolean() accepted: boolean; diff --git a/src/clan/join/dto/removePlayer.dto.ts b/src/clan/join/dto/removePlayer.dto.ts index edff1af57..465c2bbc3 100644 --- a/src/clan/join/dto/removePlayer.dto.ts +++ b/src/clan/join/dto/removePlayer.dto.ts @@ -1,9 +1,12 @@ import { IsMongoId } from 'class-validator'; -import AddType from '../../../common/base/decorator/AddType.decorator'; import { IsPlayerExists } from '../../../player/decorator/validation/IsPlayerExists.decorator'; export class RemovePlayerDTO { + /** + * The ID of the player to be removed from the clan + * @example "6643ec9cbeddb7e88fc76ae3" + */ @IsPlayerExists() @IsMongoId() - player_id: string; // whom to remove from Clan + player_id: string; } diff --git a/src/common/base/decorator/AddType.decorator.ts b/src/common/base/decorator/AddType.decorator.ts index 2db9e6c11..4ef96fbe7 100644 --- a/src/common/base/decorator/AddType.decorator.ts +++ b/src/common/base/decorator/AddType.decorator.ts @@ -15,18 +15,27 @@ export interface ObjectType { * * @param type class objectType field to be added */ -export default function AddType(type: string) { - return function (constructor: T) { - return class extends constructor { - objectType = type; - - constructor(...args: any[]) { - super(...args); - Object.assign(this, { objectType: type }); - } - }; +export default function AddType(type: string): ClassDecorator { + return (target: any) => { + Reflect.defineMetadata('objectType', type, target); + Object.defineProperty(target.prototype, 'objectType', { + value: type, + writable: true, + }); }; } +// export default function AddType(type: string) { +// return function (constructor: T) { +// return class extends constructor { +// objectType = type; +// +// constructor(...args: any[]) { +// super(...args); +// Object.assign(this, { objectType: type }); +// } +// }; +// }; +// } /** * Determines whenever the object is of specified type or not diff --git a/src/common/controller/APIError.ts b/src/common/controller/APIError.ts index 731d00ba1..d46f0d702 100644 --- a/src/common/controller/APIError.ts +++ b/src/common/controller/APIError.ts @@ -47,7 +47,7 @@ type APIErrorArgs = { /** * The class represents an error occurred on controller level * - * The class is used to sent an error to the client side + * The class is used to send an error to the client side * * It extends the Nest HttpException and can be handled by built-in filters (not recommended) * @@ -78,13 +78,51 @@ export class APIError extends HttpException { this.statusCode = validStatus; } + /** + * Name of the error + */ name: string; + + /** + * Error stack + */ stack?: string; + + /** + * Why the error is happened + * @example "NOT_STRING" + */ reason: APIErrorReason; + + /** + * HTTP status code of the error + * @example 400 + */ statusCode: number | null; + + /** + * Message specifies why error happened, for developers "FYI" + * @example "name must be a string" + */ message: string | null; + + /** + * On what field the error happen (if the field is possible to define), mostly found in validation errors + * @example name + */ field: string | null; + + /** + * Value of the field (only if the field is specified), mostly found in validation errors + * @example 1 + */ value: string | null; + + /** + * Any additional data provided. + * For example if the error is thrown by some method and is UNEXPECTED, when this field should contain the thrown error + * @example isString + */ additional: any | null; } diff --git a/src/swagger/documentSetup/introSection.ts b/src/common/swagger/documentSetup/introSection.ts similarity index 100% rename from src/swagger/documentSetup/introSection.ts rename to src/common/swagger/documentSetup/introSection.ts diff --git a/src/swagger/documentSetup/swaggerDocumentOptions.ts b/src/common/swagger/documentSetup/swaggerDocumentOptions.ts similarity index 100% rename from src/swagger/documentSetup/swaggerDocumentOptions.ts rename to src/common/swagger/documentSetup/swaggerDocumentOptions.ts diff --git a/src/swagger/documentSetup/swaggerUIOptions.ts b/src/common/swagger/documentSetup/swaggerUIOptions.ts similarity index 100% rename from src/swagger/documentSetup/swaggerUIOptions.ts rename to src/common/swagger/documentSetup/swaggerUIOptions.ts diff --git a/src/common/swagger/response/ApiResponseDescription.ts b/src/common/swagger/response/ApiResponseDescription.ts new file mode 100644 index 000000000..9c3cf5629 --- /dev/null +++ b/src/common/swagger/response/ApiResponseDescription.ts @@ -0,0 +1,57 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiStandardErrors, + SupportedErrorCode, +} from './errors/ApiStandardErrors.decorator'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiSuccessResponse, + ApiSuccessResponseOptions, +} from './success/ApiSuccessResponse.decorator'; +import { Type } from '@nestjs/common/interfaces'; + +/** + * Options for describing an endpoint. + */ +type ApiDescriptionOptions = { + /** + * description information of successful response + */ + success: { + /** + * The class of the DTO returned in the `data` field + */ + dto?: Type; + } & ApiSuccessResponseOptions; + /** + * possible errors that may occur on the endpoint + * @default [] + */ + errors?: SupportedErrorCode[]; + /** + * does endpoint have authentication requirement or not + * @default true + */ + hasAuth?: boolean; +}; + +/** + * Adds swagger description for an endpoint. + * + * Notice that it is a helper decorator combining 3: `ApiSuccessResponse`, `ApiStandardErrors` and `ApiBearerAuth`. + */ +export default function ApiResponseDescription({ + success, + errors = [], + hasAuth = true, +}: ApiDescriptionOptions) { + const { dto, ...successOptions } = success; + + const decoratorsToApply = [ + ApiSuccessResponse(dto, successOptions), + ApiStandardErrors(...errors), + ]; + if (hasAuth) decoratorsToApply.push(ApiBearerAuth()); + + return applyDecorators(...decoratorsToApply); +} diff --git a/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts b/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts new file mode 100644 index 000000000..b69f13243 --- /dev/null +++ b/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts @@ -0,0 +1,162 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse, ApiResponseOptions } from '@nestjs/swagger'; +import { APIError } from '../../../controller/APIError'; + +/** + * Supported HTTP error status codes for standard API responses. + */ +export type SupportedErrorCode = 400 | 401 | 403 | 404 | 409; + +/** + * Central record with error status code definitions + */ +const errorDefinitions: Record = { + 400: { + status: 400, + description: + 'Validation error. Some fields are missing, of the wrong type, or failed validation.', + type: APIError, + example: { + statusCode: 400, + errors: [ + { + response: 'NOT_STRING', + status: 400, + message: 'name must be a string', + name: '', + reason: 'NOT_STRING', + field: 'name', + value: '1', + additional: 'isString', + statusCode: 400, + objectType: 'APIError', + }, + ], + }, + }, + 401: { + status: 401, + description: + 'Not authenticated. The Authorization header is missing or the token is expired. ' + + '[More info](https://github.com/Alt-Org/Altzone-Server/wiki/2.-Authentication-and-authorization)', + type: APIError, + example: { + statusCode: 401, + errors: [ + { + response: 'AUTHENTICATION_FAILED', + status: 401, + message: 'Could not authenticate the user', + name: '', + reason: 'AUTHENTICATION_FAILED', + field: 'string', + value: 'string', + additional: 'string', + statusCode: 401, + objectType: 'APIError', + }, + ], + }, + }, + 403: { + status: 403, + description: + 'No permission. The user lacks authorization to perform the requested action. ' + + '[More info](https://github.com/Alt-Org/Altzone-Server/wiki/2.-Authentication-and-authorization)', + type: APIError, + example: { + statusCode: 403, + errors: [ + { + response: 'NOT_AUTHORIZED', + status: 403, + message: + 'The logged-in user has no permission to execute create_request action', + name: '', + reason: 'NOT_AUTHORIZED', + field: 'string', + value: 'string', + additional: 'create_request', + statusCode: 403, + objectType: 'APIError', + }, + ], + }, + }, + 404: { + status: 404, + description: + 'Not found. No matching object was found for the given identifier.', + type: APIError, + example: { + statusCode: 404, + errors: [ + { + response: 'NOT_FOUND', + status: 404, + message: 'Could not find any objects with specified id', + name: '', + reason: 'NOT_FOUND', + field: '_id', + value: '64df3aad42cbaf850a3f891f', + additional: 'string', + statusCode: 404, + objectType: 'APIError', + }, + ], + }, + }, + 409: { + status: 409, + description: 'Conflict. A unique constraint was violated.', + type: APIError, + example: { + statusCode: 409, + errors: [ + { + response: 'NOT_UNIQUE', + status: 409, + message: 'Field "name" with value "John" already exists', + name: '', + reason: 'NOT_UNIQUE', + field: 'name', + value: 'John', + additional: 'string', + statusCode: 409, + objectType: 'APIError', + }, + ], + }, + }, +}; + +/** + * Adds standard error responses to swagger definition using Nest's `@ApiResponse()` decorators + * It supports all common error cases. + * + * Notice that the decorator should be used for controller methods only. + * + * @param statusCodes One or more standard error HTTP status codes. + * + * @example + * ```ts + * @Get + * @ApiStandardErrors(400, 401, 403) + * async myControllerMethod(){ + * return true; + * } + * ``` + * + * @returns A composite NestJS decorator applying appropriate standard error responses. + */ +export function ApiStandardErrors(...statusCodes: SupportedErrorCode[]) { + if (!statusCodes || statusCodes.length === 0) applyDecorators(); + + const uniqueCodes = Array.from(new Set(statusCodes)); + + const decorators = uniqueCodes.map((code) => + ApiResponse(errorDefinitions[code]), + ); + + return applyDecorators(...decorators); +} diff --git a/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts b/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts new file mode 100644 index 000000000..6f8d5a128 --- /dev/null +++ b/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts @@ -0,0 +1,123 @@ +import { applyDecorators, Type } from '@nestjs/common'; +import { + ApiExtraModels, + ApiResponse, + ApiResponseOptions, + getSchemaPath, +} from '@nestjs/swagger'; +import { getArrayMetaDataSchema, getObjectMetaDataSchema } from './metaData'; + +/** + * Additional options for configuring response swagger definition + */ +export type ApiSuccessResponseOptions = ApiResponseOptions & { + /** + * Indicates whether the returned `data` is an array of items. + * If true, Swagger will document the response `data` field as an array of the specified DTO type. + * + * @default false + */ + returnsArray?: boolean; + + /** + * Model name to be used in the data and metadata section of the response. + * + * @default "Object" + * @example "Clan" + */ + modelName?: string; + + /** + * Key name under the `data` object to wrap the returned value. + * If not provided, the modelName is used as the default key. + * + * @example "Clan" + */ + dataKey?: string; + + /** + * When true, adds a `paginationData` object to the Swagger schema definition + * + * Notice that if `returnsArray` is set to true, `paginationData` default option will be set to true by default + * + * @default false + */ + hasPagination?: boolean; +}; + +/** + * Adds a Swagger success response with a wrapped `data` object containing the given DTO. + * + * Notice that it will add status code 200 and description "Operation is successful". + * You can overwrite them in options. + * + * @param dto The class of the DTO returned in the `data` field + * @param options additional options and swagger definition options, which can override any other response definitions + */ +export function ApiSuccessResponse( + dto?: Type, + options?: ApiSuccessResponseOptions, +) { + if (!dto) { + return applyDecorators( + ApiResponse({ + description: 'Successful request, response with no body', + ...options, + }), + ); + } + + const { returnsArray, modelName, dataKey, hasPagination } = options; + + const dataKeyField = dataKey ?? modelName ?? 'Object'; + + const metaData = returnsArray + ? getArrayMetaDataSchema(modelName, dataKey) + : getObjectMetaDataSchema(modelName, dataKey); + + const addPaginationData = + (returnsArray && hasPagination !== false) || hasPagination; + + const paginationData = addPaginationData + ? { + type: 'object', + properties: { + currentPage: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 20 }, + offset: { type: 'integer', example: 0 }, + itemCount: { type: 'integer', example: 5 }, + pageCount: { type: 'integer', example: 1 }, + }, + } + : undefined; + + return applyDecorators( + ApiExtraModels(dto), + ApiResponse({ + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + [dataKeyField]: returnsArray + ? { + type: 'array', + items: { $ref: getSchemaPath(dto.name) }, + } + : { + $ref: getSchemaPath(dto.name), + }, + }, + }, + metaData, + paginationData, + }, + }, + + status: 200, + description: 'Operation is successful', + ...options, + }), + ); +} diff --git a/src/common/swagger/response/success/metaData.ts b/src/common/swagger/response/success/metaData.ts new file mode 100644 index 000000000..4963a8631 --- /dev/null +++ b/src/common/swagger/response/success/metaData.ts @@ -0,0 +1,67 @@ +/** + * Generates swagger schema definition for objects metadata. + * @param modelName `modelName` example value, if not specified 'Object' will be set + * @param dataKey `dataKey` example value, if not specified the model name will be set + * + * @returns swagger schema definition that can be set for the `metaData` field + */ +export function getObjectMetaDataSchema( + modelName = 'Object', + dataKey = modelName, +) { + return { + type: 'object', + properties: { + dataKey: { + type: 'string', + example: dataKey, + }, + modelName: { + type: 'string', + example: modelName, + }, + dataType: { + type: 'string', + example: 'Object', + }, + dataCount: { + type: 'integer', + example: 1, + }, + }, + }; +} + +/** + * Generates swagger schema definition for arrays metadata. + * @param modelName `modelName` example value, if not specified 'Object' will be set + * @param dataKey `dataKey` example value, if not specified the model name will be set + * + * @returns swagger schema definition that can be set for the `metaData` field + */ +export function getArrayMetaDataSchema( + modelName = 'Object', + dataKey = modelName, +) { + return { + type: 'object', + properties: { + dataKey: { + type: 'string', + example: dataKey, + }, + modelName: { + type: 'string', + example: modelName, + }, + dataType: { + type: 'string', + example: 'Array', + }, + dataCount: { + type: 'number', + example: 3, + }, + }, + }; +} diff --git a/src/swagger/swaggerDocumentBuilder.ts b/src/common/swagger/swaggerDocumentBuilder.ts similarity index 100% rename from src/swagger/swaggerDocumentBuilder.ts rename to src/common/swagger/swaggerDocumentBuilder.ts diff --git a/src/swagger/swaggerSetuper.ts b/src/common/swagger/swaggerInitializer.ts similarity index 51% rename from src/swagger/swaggerSetuper.ts rename to src/common/swagger/swaggerInitializer.ts index b89b8b8d1..2a0985157 100644 --- a/src/swagger/swaggerSetuper.ts +++ b/src/common/swagger/swaggerInitializer.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; -import { envVars } from '../common/service/envHandler/envVars'; +import { envVars } from '../service/envHandler/envVars'; import { swaggerDocumentOptions } from './documentSetup/swaggerDocumentOptions'; import { swaggerUIOptions } from './documentSetup/swaggerUIOptions'; import { @@ -10,9 +10,10 @@ import { } from './documentSetup/introSection'; import { SwaggerDocumentBuilder } from './swaggerDocumentBuilder'; import { SwaggerTag, swaggerTags } from './tags/tags'; +import { APIError } from '../controller/APIError'; -export default class SwaggerSetuper { - static setupSwaggerFromJSDocs(app: INestApplication) { +export default class SwaggerInitializer { + static initSwaggerFromDecorators(app: INestApplication) { const tagsToAdd: SwaggerTag[] = []; for (const tag in swaggerTags) tagsToAdd.push(swaggerTags[tag]); @@ -22,10 +23,35 @@ export default class SwaggerSetuper { .setVersion(swaggerVersion) .addTags(tagsToAdd) .addBearerAuth() + .addGlobalResponse({ + example: { + statusCode: 500, + errors: [ + { + response: 'UNEXPECTED', + status: 500, + message: 'Unexpected error happen', + name: '', + reason: 'UNEXPECTED', + field: 'string', + value: 'string', + additional: 'string', + statusCode: 500, + objectType: 'APIError', + }, + ], + }, + status: 500, + type: () => APIError, + description: + 'Unexpected error happened. [Please create a new issue here](https://github.com/Alt-Org/Altzone-Server/issues) ' + + 'and specify the endpoint, HTTP method and description if you have any', + }) .build(); const documentFactory = () => SwaggerModule.createDocument(app, config, swaggerDocumentOptions); + SwaggerModule.setup( envVars.SWAGGER_PATH, app, diff --git a/src/swagger/tags/SwaggerTags.decorator.ts b/src/common/swagger/tags/SwaggerTags.decorator.ts similarity index 100% rename from src/swagger/tags/SwaggerTags.decorator.ts rename to src/common/swagger/tags/SwaggerTags.decorator.ts diff --git a/src/swagger/tags/tags.ts b/src/common/swagger/tags/tags.ts similarity index 100% rename from src/swagger/tags/tags.ts rename to src/common/swagger/tags/tags.ts diff --git a/src/main.ts b/src/main.ts index a9dd27787..b7bdb3697 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { APIError } from './common/controller/APIError'; import { validationToAPIErrors } from './common/exceptionFilter/ValidationExceptionFilter'; import EnvHandler from './common/service/envHandler/envHandler'; import cookieParser from 'cookie-parser'; -import SwaggerSetuper from './swagger/swaggerSetuper'; +import SwaggerInitializer from './common/swagger/swaggerInitializer'; async function bootstrap() { // Validate that all environment variables are added to the .env file @@ -27,7 +27,7 @@ async function bootstrap() { //Let the class-validator use DI system of NestJS useContainer(app.select(AppModule), { fallbackOnErrors: true }); - SwaggerSetuper.setupSwaggerFromJSDocs(app); + SwaggerInitializer.initSwaggerFromDecorators(app); await app.listen(8080); }