feat(server): better api error messages (for unhandled exceptions) (#4817)

* feat(server): better error messages

* chore: open api

* chore: remove debug log

* fix: syntax error

* fix: e2e test
This commit is contained in:
Jason Rasmussen
2023-11-03 21:33:15 -04:00
committed by GitHub
parent d4ef6f52bb
commit 2e424fe249
72 changed files with 1974 additions and 1952 deletions
@@ -17,8 +17,8 @@ import {
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { AuthUser, Authenticated, SharedLinkRoute } from '../../app.guard';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../app.interceptor';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@@ -204,7 +204,7 @@ export class AssetController {
*/
@Post('/bulk-upload-check')
@HttpCode(HttpStatus.OK)
bulkUploadCheck(
checkBulkUpload(
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
+4 -5
View File
@@ -2,14 +2,13 @@ import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import { AssetEntity } from '@app/infra/entities';
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
import { AssetService } from './api-v1/asset/asset.service';
import { AppGuard } from './app.guard';
import { FileUploadInterceptor } from './app.interceptor';
import { AppService } from './app.service';
import {
APIKeyController,
@@ -31,6 +30,7 @@ import {
TagController,
UserController,
} from './controllers';
import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
@Module({
imports: [
@@ -61,10 +61,9 @@ import {
PersonController,
],
providers: [
//
{ provide: APP_GUARD, useExisting: AppGuard },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AppGuard },
{ provide: IAssetRepository, useClass: AssetRepository },
AppGuard,
AppService,
AssetService,
FileUploadInterceptor,
+7
View File
@@ -47,6 +47,9 @@ function sortKeys<T>(obj: T): T {
return result as T;
}
export const routeToErrorMessage = (methodName: string) =>
'Failed to ' + methodName.replace(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
const patchOpenAPI = (document: OpenAPIObject) => {
document.paths = sortKeys(document.paths);
if (document.components?.schemas) {
@@ -78,6 +81,10 @@ const patchOpenAPI = (document: OpenAPIObject) => {
delete operation.summary;
}
if (operation.operationId) {
// console.log(`${routeToErrorMessage(operation.operationId).padEnd(40)} (${operation.operationId})`);
}
if (operation.description === '') {
delete operation.description;
}
@@ -20,22 +20,22 @@ export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
createKey(@AuthUser() authUser: AuthUserDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
createApiKey(@AuthUser() authUser: AuthUserDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(authUser, dto);
}
@Get()
getKeys(@AuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
getApiKeys(@AuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
getApiKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(authUser, id);
}
@Put(':id')
updateKey(
updateApiKey(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
@@ -44,7 +44,7 @@ export class APIKeyController {
}
@Delete(':id')
deleteKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
deleteApiKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(authUser, id);
}
}
@@ -39,10 +39,11 @@ import {
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
import { UseValidation, asStreamableFile } from '../app.utils';
import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Asset')
@Controller('asset')
@Controller(Route.ASSET)
@Authenticated()
@UseValidation()
export class AssetController {
@@ -86,7 +87,7 @@ export class AssetController {
}
@Get('statistics')
getAssetStats(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
getAssetStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(authUser, dto);
}
@@ -98,8 +99,8 @@ export class AssetController {
@Authenticated({ isShared: true })
@Get('time-bucket')
getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getByTimeBucket(authUser, dto) as Promise<AssetResponseDto[]>;
getTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getTimeBucket(authUser, dto) as Promise<AssetResponseDto[]>;
}
@Post('jobs')
@@ -43,7 +43,7 @@ export class AuthController {
@PublicRoute()
@Post('admin-sign-up')
@ApiBadRequestResponse({ description: 'The server already has an admin' })
adminSignUp(@Body() signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
signUpAdmin(@Body() signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
return this.service.adminSignUp(signUpCredential);
}
@@ -21,7 +21,7 @@ export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
getAllForUser(@AuthUser() authUser: AuthUserDto): Promise<ResponseDto[]> {
getLibraries(@AuthUser() authUser: AuthUserDto): Promise<ResponseDto[]> {
return this.service.getAllForUser(authUser);
}
@@ -25,7 +25,7 @@ export class OAuthController {
@PublicRoute()
@Get('mobile-redirect')
@Redirect()
mobileRedirect(@Req() req: Request) {
redirectOAuthToMobile(@Req() req: Request) {
return {
url: this.service.getMobileRedirect(req.url),
statusCode: HttpStatus.TEMPORARY_REDIRECT,
@@ -35,19 +35,19 @@ export class OAuthController {
/** @deprecated use feature flags and /oauth/authorize */
@PublicRoute()
@Post('config')
generateConfig(@Body() dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
generateOAuthConfig(@Body() dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
return this.service.generateConfig(dto);
}
@PublicRoute()
@Post('authorize')
authorizeOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
startOAuth(@Body() dto: OAuthConfigDto): Promise<OAuthAuthorizeResponseDto> {
return this.service.authorize(dto);
}
@PublicRoute()
@Post('callback')
async callback(
async finishOAuth(
@Res({ passthrough: true }) res: Response,
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
@@ -58,12 +58,12 @@ export class OAuthController {
}
@Post('link')
link(@AuthUser() authUser: AuthUserDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
linkOAuthAccount(@AuthUser() authUser: AuthUserDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
return this.service.link(authUser, dto);
}
@Post('unlink')
unlink(@AuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
unlinkOAuthAccount(@AuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
return this.service.unlink(authUser);
}
}
@@ -57,9 +57,9 @@ export class ServerInfoController {
}
@AdminRoute()
@Get('stats')
getStats(): Promise<ServerStatsResponseDto> {
return this.service.getStats();
@Get('statistics')
getServerStatistics(): Promise<ServerStatsResponseDto> {
return this.service.getStatistics();
}
@PublicRoute()
@@ -17,7 +17,7 @@ export class SystemConfigController {
}
@Get('defaults')
getDefaults(): SystemConfigDto {
getConfigDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@@ -22,8 +22,8 @@ import {
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
import { FileUploadInterceptor, Route } from '../app.interceptor';
import { UseValidation, asStreamableFile } from '../app.utils';
import { FileUploadInterceptor, Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('User')
@@ -0,0 +1,32 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs';
import { routeToErrorMessage } from '../app.utils';
@Injectable()
export class ErrorInterceptor implements NestInterceptor {
private logger = new Logger(ErrorInterceptor.name);
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
return next.handle().pipe(
catchError((error) =>
throwError(() => {
if (error instanceof HttpException === false) {
const errorMessage = routeToErrorMessage(context.getHandler().name);
this.logger.error(errorMessage, error, error?.errors);
return new InternalServerErrorException(errorMessage);
} else {
return error;
}
}),
),
);
}
}
@@ -7,7 +7,7 @@ import { createHash } from 'crypto';
import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer';
import { Observable } from 'rxjs';
import { AuthRequest } from './app.guard';
import { AuthRequest } from '../app.guard';
export enum Route {
ASSET = 'asset',
+2
View File
@@ -0,0 +1,2 @@
export * from './error.interceptor';
export * from './file.interceptor';