From edfabc2fce91a19f2486701ea005547a7eb3d314 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 3 Jun 2026 16:24:37 +0300 Subject: [PATCH] CONV-293: Add API domain exception mapping Co-Authored-By: Claude Sonnet 4.6 --- app/Support/Api/ApiExceptionMapper.php | 68 ++++++++++++++ app/Support/Api/MappedApiError.php | 15 +++ bootstrap/app.php | 21 +++++ tests/Feature/Api/V1/ApiErrorMappingTest.php | 41 ++++++++ .../Support/Api/ApiExceptionMapperTest.php | 93 +++++++++++++++++++ 5 files changed, 238 insertions(+) create mode 100644 app/Support/Api/ApiExceptionMapper.php create mode 100644 app/Support/Api/MappedApiError.php create mode 100644 tests/Feature/Api/V1/ApiErrorMappingTest.php create mode 100644 tests/Unit/Support/Api/ApiExceptionMapperTest.php diff --git a/app/Support/Api/ApiExceptionMapper.php b/app/Support/Api/ApiExceptionMapper.php new file mode 100644 index 0000000..7ba48b0 --- /dev/null +++ b/app/Support/Api/ApiExceptionMapper.php @@ -0,0 +1,68 @@ + new MappedApiError( + code: 'insufficient_credits', + message: $e->getMessage(), + status: 402, + ), + $e instanceof UnsupportedFormatException, + $e instanceof UnsupportedFileFormatException => new MappedApiError( + code: 'unsupported_format', + message: $e->getMessage(), + status: 422, + ), + $e instanceof UnsupportedConversionException => new MappedApiError( + code: 'unsupported_conversion', + message: $e->getMessage(), + status: 422, + ), + $e instanceof InvalidConverterOptionsException => new MappedApiError( + code: 'invalid_options', + message: $e->getMessage(), + status: 422, + details: $e->fieldErrors(), + ), + $e instanceof StorageLimitExceededException => new MappedApiError( + code: 'storage_limit_exceeded', + message: $e->getMessage(), + status: 413, + ), + $e instanceof AuthenticationException => new MappedApiError( + code: 'unauthorized', + message: 'Unauthenticated.', + status: 401, + ), + $e instanceof AuthorizationException => new MappedApiError( + code: 'forbidden', + message: 'Forbidden.', + status: 403, + ), + $e instanceof ThrottleRequestsException => new MappedApiError( + code: 'rate_limited', + message: 'Too many requests.', + status: 429, + ), + default => null, + }; + } +} diff --git a/app/Support/Api/MappedApiError.php b/app/Support/Api/MappedApiError.php new file mode 100644 index 0000000..94eefa1 --- /dev/null +++ b/app/Support/Api/MappedApiError.php @@ -0,0 +1,15 @@ +shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); + + $exceptions->render(function (Throwable $e, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + $mapped = app(ApiExceptionMapper::class)->map($e); + + if ($mapped === null) { + return null; + } + + return response()->json([ + 'error' => [ + 'code' => $mapped->code, + 'message' => $mapped->message, + 'details' => $mapped->details, + ], + ], $mapped->status); + }); })->create(); diff --git a/tests/Feature/Api/V1/ApiErrorMappingTest.php b/tests/Feature/Api/V1/ApiErrorMappingTest.php new file mode 100644 index 0000000..13541c4 --- /dev/null +++ b/tests/Feature/Api/V1/ApiErrorMappingTest.php @@ -0,0 +1,41 @@ + throw InsufficientCreditsException::make(2, 1)); + Route::get('/api/v1/test-unsupported-conversion', fn () => throw UnsupportedConversionException::forPair('png', 'mp3')); + Route::get('/api/v1/test-invalid-options', fn () => throw InvalidConverterOptionsException::becauseOptionIsRequired('quality')); + Route::get('/api/v1/test-storage-limit', fn () => throw StorageLimitExceededException::make(250, 200 * 1024 * 1024, 100 * 1024 * 1024)); +}); + +it('maps insufficient credits exception to stable api error', function () { + $this->getJson('/api/v1/test-insufficient-credits') + ->assertStatus(402) + ->assertJsonPath('error.code', 'insufficient_credits') + ->assertJsonStructure(['error' => ['code', 'message', 'details']]); +}); + +it('maps unsupported conversion exception to stable api error', function () { + $this->getJson('/api/v1/test-unsupported-conversion') + ->assertStatus(422) + ->assertJsonPath('error.code', 'unsupported_conversion'); +}); + +it('maps invalid options exception to stable api error', function () { + $this->getJson('/api/v1/test-invalid-options') + ->assertStatus(422) + ->assertJsonPath('error.code', 'invalid_options'); +}); + +it('maps storage limit exceeded exception to stable api error', function () { + $this->getJson('/api/v1/test-storage-limit') + ->assertStatus(413) + ->assertJsonPath('error.code', 'storage_limit_exceeded'); +}); diff --git a/tests/Unit/Support/Api/ApiExceptionMapperTest.php b/tests/Unit/Support/Api/ApiExceptionMapperTest.php new file mode 100644 index 0000000..1c75575 --- /dev/null +++ b/tests/Unit/Support/Api/ApiExceptionMapperTest.php @@ -0,0 +1,93 @@ +map(InsufficientCreditsException::make(required: 2, available: 1)); + + expect($mapped->code)->toBe('insufficient_credits'); + expect($mapped->status)->toBe(402); +}); + +it('maps unsupported format exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(UnsupportedFormatException::forInput('xyz')); + + expect($mapped->code)->toBe('unsupported_format'); + expect($mapped->status)->toBe(422); +}); + +it('maps unsupported conversion exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(UnsupportedConversionException::forPair('png', 'mp3')); + + expect($mapped->code)->toBe('unsupported_conversion'); + expect($mapped->status)->toBe(422); +}); + +it('maps invalid converter options exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(InvalidConverterOptionsException::becauseOptionIsRequired('quality')); + + expect($mapped->code)->toBe('invalid_options'); + expect($mapped->status)->toBe(422); +}); + +it('maps unsupported file format exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(UnsupportedFileFormatException::forFile('file.xyz')); + + expect($mapped->code)->toBe('unsupported_format'); + expect($mapped->status)->toBe(422); +}); + +it('maps storage limit exceeded exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(StorageLimitExceededException::make(250, 200 * 1024 * 1024, 100 * 1024 * 1024)); + + expect($mapped->code)->toBe('storage_limit_exceeded'); + expect($mapped->status)->toBe(413); +}); + +it('maps authentication exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(new AuthenticationException); + + expect($mapped->code)->toBe('unauthorized'); + expect($mapped->status)->toBe(401); +}); + +it('maps authorization exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(new AuthorizationException); + + expect($mapped->code)->toBe('forbidden'); + expect($mapped->status)->toBe(403); +}); + +it('maps throttle exception to api error', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(new ThrottleRequestsException('Too Many Attempts.')); + + expect($mapped->code)->toBe('rate_limited'); + expect($mapped->status)->toBe(429); +}); + +it('returns null for unknown exceptions', function () { + $mapper = app(ApiExceptionMapper::class); + $mapped = $mapper->map(new RuntimeException('Some other error')); + + expect($mapped)->toBeNull(); +});