From 3a4f2c855154cab3c7c5d2f73af59b648de07f8e Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Wed, 13 May 2026 22:47:04 +0200 Subject: [PATCH] refactor(controller): improve api responses of controller methods This should drastically improve how accurate our api document is. Signed-off-by: Philip Molares --- .../src/api/private/alias/alias.controller.ts | 2 +- .../api-tokens/api-tokens.controller.ts | 2 +- .../private/auth/guest/guest.controller.ts | 4 +- .../private/auth/local/local.controller.ts | 4 +- .../api/private/auth/oidc/oidc.controller.ts | 2 +- .../api/private/explore/explore.controller.ts | 2 +- .../api/private/groups/groups.controller.ts | 2 +- backend/src/api/private/me/me.controller.ts | 2 +- .../src/api/private/media/media.controller.ts | 2 +- .../src/api/private/notes/notes.controller.ts | 13 ++++--- .../src/api/private/users/users.controller.ts | 1 + .../src/api/public/alias/alias.controller.ts | 5 +-- backend/src/api/public/me/me.controller.ts | 2 +- .../src/api/public/media/media.controller.ts | 5 +-- .../src/api/public/notes/notes.controller.ts | 20 ++-------- .../api/utils/decorators/openapi.decorator.ts | 37 +++++++++++++++++-- backend/src/api/utils/descriptions.ts | 3 +- 17 files changed, 62 insertions(+), 46 deletions(-) diff --git a/backend/src/api/private/alias/alias.controller.ts b/backend/src/api/private/alias/alias.controller.ts index 361f6ea9b..360733254 100644 --- a/backend/src/api/private/alias/alias.controller.ts +++ b/backend/src/api/private/alias/alias.controller.ts @@ -30,7 +30,7 @@ import { PermissionLevel } from '@hedgedoc/commons'; import { NoteAliasesDto } from '../../../dtos/note-aliases.dto'; @UseGuards(SessionGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('alias') @Controller('alias') export class AliasController { diff --git a/backend/src/api/private/api-tokens/api-tokens.controller.ts b/backend/src/api/private/api-tokens/api-tokens.controller.ts index 1bcc07a89..2c0fd0a1c 100644 --- a/backend/src/api/private/api-tokens/api-tokens.controller.ts +++ b/backend/src/api/private/api-tokens/api-tokens.controller.ts @@ -19,7 +19,7 @@ import { OpenApi } from '../../utils/decorators/openapi.decorator'; import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; @UseGuards(SessionGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('tokens') @Controller('tokens') export class ApiTokensController { diff --git a/backend/src/api/private/auth/guest/guest.controller.ts b/backend/src/api/private/auth/guest/guest.controller.ts index c0fa289d6..684e426d0 100644 --- a/backend/src/api/private/auth/guest/guest.controller.ts +++ b/backend/src/api/private/auth/guest/guest.controller.ts @@ -27,7 +27,7 @@ export class GuestController { @UseGuards(GuestsEnabledGuard) @Post('register') - @OpenApi(201, 403) + @OpenApi(201, 403, 429) async registerGuestUser( @Req() request: RequestWithSession, ): Promise { @@ -42,7 +42,7 @@ export class GuestController { @UseGuards(GuestsEnabledGuard) @Post('login') - @OpenApi(204, 400) + @OpenApi(204, 400, 429) async loginGuestUser( @Req() request: RequestWithSession, @Body() loginDto: GuestLoginDto, diff --git a/backend/src/api/private/auth/local/local.controller.ts b/backend/src/api/private/auth/local/local.controller.ts index 899d3ea41..db4a46f91 100644 --- a/backend/src/api/private/auth/local/local.controller.ts +++ b/backend/src/api/private/auth/local/local.controller.ts @@ -54,7 +54,7 @@ export class LocalController { @UseGuards(LoginEnabledGuard, SessionGuard) @Put() - @OpenApi(200, 400, 401) + @OpenApi(200, 400, 401, 403, 429) async updatePassword( @RequestUserId() userId: number, @Body() changePasswordDto: UpdatePasswordDto, @@ -70,7 +70,7 @@ export class LocalController { @UseGuards(LoginEnabledGuard) @Post('login') - @OpenApi(201, 400, 401) + @OpenApi(201, 400, 401, 429) async login( @Req() request: RequestWithSession, diff --git a/backend/src/api/private/auth/oidc/oidc.controller.ts b/backend/src/api/private/auth/oidc/oidc.controller.ts index ad2b6669a..8b40a88f0 100644 --- a/backend/src/api/private/auth/oidc/oidc.controller.ts +++ b/backend/src/api/private/auth/oidc/oidc.controller.ts @@ -44,7 +44,7 @@ export class OidcController { @Get(':oidcIdentifier') @Redirect() - @OpenApi(201, 400, 401) + @OpenApi(201, 400, 401, 429) loginWithOpenIdConnect( @Req() request: RequestWithSession, @Param('oidcIdentifier') oidcIdentifier: string, diff --git a/backend/src/api/private/explore/explore.controller.ts b/backend/src/api/private/explore/explore.controller.ts index b2a84d306..580c3f357 100644 --- a/backend/src/api/private/explore/explore.controller.ts +++ b/backend/src/api/private/explore/explore.controller.ts @@ -30,7 +30,7 @@ import { RequestUserId } from '../../utils/decorators/request-user-id.decorator' type OptionalNoteType = NoteType | ''; @UseGuards(SessionGuard) -@OpenApi(401, 403) +@OpenApi(401, 403, 429) @ApiTags('explore') @Controller('explore') export class ExploreController { diff --git a/backend/src/api/private/groups/groups.controller.ts b/backend/src/api/private/groups/groups.controller.ts index 53a684a96..b28665b4e 100644 --- a/backend/src/api/private/groups/groups.controller.ts +++ b/backend/src/api/private/groups/groups.controller.ts @@ -13,7 +13,7 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { OpenApi } from '../../utils/decorators/openapi.decorator'; @UseGuards(SessionGuard) -@OpenApi(401, 403) +@OpenApi(401, 403, 429) @ApiTags('groups') @Controller('groups') export class GroupsController { diff --git a/backend/src/api/private/me/me.controller.ts b/backend/src/api/private/me/me.controller.ts index 0103b752c..0ac0ecb46 100644 --- a/backend/src/api/private/me/me.controller.ts +++ b/backend/src/api/private/me/me.controller.ts @@ -29,7 +29,7 @@ import { promisify } from 'node:util'; import { RequestWithSession } from '../../utils/request.type'; @UseGuards(SessionGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('me') @Controller('me') export class MeController { diff --git a/backend/src/api/private/media/media.controller.ts b/backend/src/api/private/media/media.controller.ts index 3d30425fb..685aca441 100644 --- a/backend/src/api/private/media/media.controller.ts +++ b/backend/src/api/private/media/media.controller.ts @@ -33,7 +33,7 @@ import { RequestUserId } from '../../utils/decorators/request-user-id.decorator' import { NoteHeaderInterceptor } from '../../utils/interceptors/note-header.interceptor'; @UseGuards(SessionGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('media') @Controller('media') export class MediaController { diff --git a/backend/src/api/private/notes/notes.controller.ts b/backend/src/api/private/notes/notes.controller.ts index 0983157de..3a60105c9 100644 --- a/backend/src/api/private/notes/notes.controller.ts +++ b/backend/src/api/private/notes/notes.controller.ts @@ -53,7 +53,7 @@ import { RequestUserId } from '../../utils/decorators/request-user-id.decorator' import { GetNoteIdInterceptor } from '../../utils/interceptors/get-note-id.interceptor'; @UseGuards(SessionGuard, PermissionsGuard) -@OpenApi(401, 403) +@OpenApi(401, 403, 429) @ApiTags('notes') @Controller('notes') export class NotesController { @@ -181,7 +181,7 @@ export class NotesController { } @Put(':noteAlias/metadata/permissions/users/:username') - @OpenApi(200, 403, 404) + @OpenApi(200, 404) @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(PermissionLevel.FULL) async setUserPermission( @@ -194,9 +194,10 @@ export class NotesController { return await this.permissionService.getPermissionsDtoForNote(noteId); } + @Delete(':noteAlias/metadata/permissions/users/:username') + @OpenApi(200, 400) @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(PermissionLevel.FULL) - @Delete(':noteAlias/metadata/permissions/users/:username') async removeUserPermission( @RequestNoteId() noteId: number, @Param('username') username: NoteUserPermissionEntryDto['username'], @@ -213,9 +214,10 @@ export class NotesController { } } + @Put(':noteAlias/metadata/permissions/groups/:groupName') + @OpenApi(200, 400, 404) @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(PermissionLevel.FULL) - @Put(':noteAlias/metadata/permissions/groups/:groupName') async setGroupPermission( @RequestNoteId() noteId: number, @Param('groupName') groupName: NoteGroupPermissionUpdateDto['groupName'], @@ -235,10 +237,11 @@ export class NotesController { return await this.permissionService.getPermissionsDtoForNote(noteId); } + @Delete(':noteAlias/metadata/permissions/groups/:groupName') + @OpenApi(200, 404) @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(PermissionLevel.FULL) @UseGuards(PermissionsGuard) - @Delete(':noteAlias/metadata/permissions/groups/:groupName') async removeGroupPermission( @RequestNoteId() noteId: number, @Param('groupName') groupName: NoteGroupPermissionEntryDto['groupName'], diff --git a/backend/src/api/private/users/users.controller.ts b/backend/src/api/private/users/users.controller.ts index afcb7cb08..bf7a7c8f3 100644 --- a/backend/src/api/private/users/users.controller.ts +++ b/backend/src/api/private/users/users.controller.ts @@ -14,6 +14,7 @@ import { UsersService } from '../../../users/users.service'; import { OpenApi } from '../../utils/decorators/openapi.decorator'; @ApiTags('users') +@OpenApi(403, 429) @Controller('users') export class UsersController { constructor( diff --git a/backend/src/api/public/alias/alias.controller.ts b/backend/src/api/public/alias/alias.controller.ts index 603f5e5b8..0a79c6c49 100644 --- a/backend/src/api/public/alias/alias.controller.ts +++ b/backend/src/api/public/alias/alias.controller.ts @@ -29,7 +29,7 @@ import { RequestUserId } from '../../utils/decorators/request-user-id.decorator' import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; @UseGuards(ApiTokenGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('alias') @ApiSecurity('token') @Controller('alias') @@ -50,7 +50,6 @@ export class AliasController { description: 'The new alias', schema: AliasSchema, }, - 403, 404, ) async addAlias( @@ -74,7 +73,6 @@ export class AliasController { description: 'The updated aliases', schema: AliasSchema, }, - 403, 404, ) async makeAliasPrimary( @@ -102,7 +100,6 @@ export class AliasController { description: 'The aliases was deleted', }, 400, - 403, 404, ) async removeAlias(@RequestUserId() user: number, @Param('alias') alias: string): Promise { diff --git a/backend/src/api/public/me/me.controller.ts b/backend/src/api/public/me/me.controller.ts index a7a9f8d2f..5841901fc 100644 --- a/backend/src/api/public/me/me.controller.ts +++ b/backend/src/api/public/me/me.controller.ts @@ -25,7 +25,7 @@ import { RequestUserId } from '../../utils/decorators/request-user-id.decorator' import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; @UseGuards(ApiTokenGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('me') @ApiSecurity('token') @Controller('me') diff --git a/backend/src/api/public/media/media.controller.ts b/backend/src/api/public/media/media.controller.ts index 7624e0374..8c213c253 100644 --- a/backend/src/api/public/media/media.controller.ts +++ b/backend/src/api/public/media/media.controller.ts @@ -33,7 +33,7 @@ import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; import { NoteHeaderInterceptor } from '../../utils/interceptors/note-header.interceptor'; @UseGuards(ApiTokenGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('media') @ApiSecurity('token') @Controller('media') @@ -70,7 +70,6 @@ export class MediaController { schema: MediaUploadSchema, }, 400, - 403, 404, 500, ) @@ -105,7 +104,7 @@ export class MediaController { } @Delete(':uuid') - @OpenApi(204, 403, 404, 500) + @OpenApi(204, 404, 500) async deleteMedia(@RequestUserId() userId: number, @Param('uuid') uuid: string): Promise { const mediaUpload = await this.mediaService.findUploadByUuid(uuid); if (await this.permissionsService.checkMediaDeletePermission(userId, uuid)) { diff --git a/backend/src/api/public/notes/notes.controller.ts b/backend/src/api/public/notes/notes.controller.ts index 01c0996f7..a84294bb4 100644 --- a/backend/src/api/public/notes/notes.controller.ts +++ b/backend/src/api/public/notes/notes.controller.ts @@ -49,7 +49,7 @@ import { ApiTokenGuard } from '../../utils/guards/api-token.guard'; import { GetNoteIdInterceptor } from '../../utils/interceptors/get-note-id.interceptor'; @UseGuards(ApiTokenGuard, PermissionsGuard) -@OpenApi(401) +@OpenApi(401, 403, 429) @ApiTags('notes') @ApiSecurity('token') @Controller('notes') @@ -68,7 +68,7 @@ export class NotesController { @RequirePermission(PermissionLevel.FULL) @Post() - @OpenApi(201, 403, 409, 413) + @OpenApi(201, 409, 413) async createNote( @RequestUserId() userId: number, @MarkdownBody() text: string, @@ -86,7 +86,6 @@ export class NotesController { description: 'Get information about the newly created note', schema: NoteSchema, }, - 403, 404, ) async getNote( @@ -105,7 +104,6 @@ export class NotesController { schema: NoteSchema, }, 400, - 403, 409, 413, ) @@ -122,7 +120,7 @@ export class NotesController { @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(PermissionLevel.FULL) @Delete(':noteAlias') - @OpenApi(204, 403, 404, 500) + @OpenApi(204, 404, 500) async deleteNote( @RequestUserId() userId: number, @RequestNoteId() noteId: number, @@ -151,7 +149,6 @@ export class NotesController { description: 'The new, changed note', schema: NoteSchema, }, - 403, 404, ) async updateNote( @@ -173,7 +170,6 @@ export class NotesController { description: 'The raw markdown content of the note', mimeType: 'text/markdown', }, - 403, 404, ) async getNoteContent( @@ -192,7 +188,6 @@ export class NotesController { description: 'The metadata of the note', schema: NoteMetadataSchema, }, - 403, 404, ) async getNoteMetadata(@RequestNoteId() noteId: number): Promise { @@ -208,7 +203,6 @@ export class NotesController { description: 'Get the permissions for a note', schema: NotePermissionsSchema, }, - 403, 404, ) async getPermissions(@RequestNoteId() noteId: number): Promise { @@ -224,7 +218,6 @@ export class NotesController { description: 'Set the permissions for a user on a note', schema: NotePermissionsSchema, }, - 403, 404, ) async setUserPermission( @@ -247,7 +240,6 @@ export class NotesController { description: 'Remove the permission for a user on a note', schema: NotePermissionsSchema, }, - 403, 404, ) async removeUserPermission( @@ -269,7 +261,6 @@ export class NotesController { description: 'Set the permissions for a group on a note', schema: NotePermissionsSchema, }, - 403, 404, ) async setGroupPermission( @@ -292,7 +283,6 @@ export class NotesController { description: 'Remove the permission for a group on a note', schema: NotePermissionsSchema, }, - 403, 404, ) async removeGroupPermission( @@ -313,7 +303,6 @@ export class NotesController { description: 'Changes the owner of the note', schema: NoteSchema, }, - 403, 404, ) async changeOwner( @@ -335,7 +324,6 @@ export class NotesController { description: 'Changes the owner of the note', schema: NoteSchema, }, - 403, 404, ) async change( @@ -357,7 +345,6 @@ export class NotesController { isArray: true, schema: RevisionMetadataSchema, }, - 403, 404, ) async getNoteRevisions(@RequestNoteId() noteId: number): Promise { @@ -373,7 +360,6 @@ export class NotesController { description: 'Revision of the note for the given id or aliases', schema: RevisionSchema, }, - 403, 404, ) async getNoteRevision(@Param('revisionUuid') revisionUuid: string): Promise { diff --git a/backend/src/api/utils/decorators/openapi.decorator.ts b/backend/src/api/utils/decorators/openapi.decorator.ts index 14d38276a..8a43cdffc 100644 --- a/backend/src/api/utils/decorators/openapi.decorator.ts +++ b/backend/src/api/utils/decorators/openapi.decorator.ts @@ -4,36 +4,53 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { applyDecorators, Header, HttpCode } from '@nestjs/common'; +import type { ApiResponseNoStatusOptions } from '@nestjs/swagger'; import { ApiBadRequestResponse, ApiConflictResponse, ApiCreatedResponse, + ApiForbiddenResponse, ApiFoundResponse, ApiInternalServerErrorResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiProduces, - ApiResponseNoStatusOptions, + ApiTooManyRequestsResponse, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { zodToOpenAPI } from 'nestjs-zod'; -import { ZodSchema } from 'zod'; +import type { ZodSchema } from 'zod'; import { badRequestDescription, conflictDescription, createdDescription, + forbiddenDescription, foundDescription, internalServerErrorDescription, noContentDescription, notFoundDescription, okDescription, payloadTooLargeDescription, + tooManyRequestsDescription, unauthorizedDescription, } from '../descriptions'; -export type HttpStatusCodes = 200 | 201 | 204 | 302 | 400 | 401 | 403 | 404 | 409 | 413 | 500 | 503; +export type HttpStatusCodes = + | 200 + | 201 + | 204 + | 302 + | 400 + | 401 + | 403 + | 404 + | 409 + | 413 + | 429 + | 500 + | 503; /** * Defines what the open api route should document. @@ -154,6 +171,13 @@ export const OpenApi = ( }), ); break; + case 403: + decoratorsToApply.push( + ApiForbiddenResponse({ + description: description ?? forbiddenDescription, + }), + ); + break; case 404: decoratorsToApply.push( ApiNotFoundResponse({ @@ -175,6 +199,13 @@ export const OpenApi = ( }), ); break; + case 429: + decoratorsToApply.push( + ApiTooManyRequestsResponse({ + description: description ?? tooManyRequestsDescription, + }), + ); + break; case 500: decoratorsToApply.push( ApiInternalServerErrorResponse({ diff --git a/backend/src/api/utils/descriptions.ts b/backend/src/api/utils/descriptions.ts index c480af290..b6c7bf803 100644 --- a/backend/src/api/utils/descriptions.ts +++ b/backend/src/api/utils/descriptions.ts @@ -12,10 +12,9 @@ export const badRequestDescription = "The request is malformed and can't be proc export const unauthorizedDescription = 'Authorization information is missing or invalid'; export const forbiddenDescription = 'Access to the requested resource is not permitted'; export const notFoundDescription = 'The requested resource was not found'; -export const successfullyDeletedDescription = 'The requested resource was successfully deleted'; -export const unprocessableEntityDescription = "The request change can't be processed"; export const conflictDescription = 'The request conflicts with the current state of the application'; export const payloadTooLargeDescription = 'The note is longer than the maximal allowed length of a note'; +export const tooManyRequestsDescription = 'The request triggered a rate limit.'; export const internalServerErrorDescription = 'The request triggered an internal server error.';