Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/Support/Api/ApiExceptionMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\Support\Api;

use App\Exceptions\Billing\InsufficientCreditsException;
use App\Exceptions\Files\UnsupportedFileFormatException;
use App\Exceptions\Storage\StorageLimitExceededException;
use App\Support\Conversions\Exceptions\UnsupportedConversionException;
use App\Support\Converters\Exceptions\InvalidConverterOptionsException;
use App\Support\Converters\Exceptions\UnsupportedFormatException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Throwable;

final class ApiExceptionMapper
{
public function map(Throwable $e): ?MappedApiError
{
return match (true) {
$e instanceof InsufficientCreditsException => 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,
};
}
}
15 changes: 15 additions & 0 deletions app/Support/Api/MappedApiError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Support\Api;

final readonly class MappedApiError
{
public function __construct(
public string $code,
public string $message,
public int $status,
public array $details = [],
) {}
}
21 changes: 21 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Support\Api\ApiExceptionMapper;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
Expand All @@ -21,4 +22,24 @@
$exceptions->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();
41 changes: 41 additions & 0 deletions tests/Feature/Api/V1/ApiErrorMappingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

use App\Exceptions\Billing\InsufficientCreditsException;
use App\Exceptions\Storage\StorageLimitExceededException;
use App\Support\Conversions\Exceptions\UnsupportedConversionException;
use App\Support\Converters\Exceptions\InvalidConverterOptionsException;
use Illuminate\Support\Facades\Route;

beforeEach(function () {
Route::get('/api/v1/test-insufficient-credits', fn () => 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');
});
93 changes: 93 additions & 0 deletions tests/Unit/Support/Api/ApiExceptionMapperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

use App\Exceptions\Billing\InsufficientCreditsException;
use App\Exceptions\Files\UnsupportedFileFormatException;
use App\Exceptions\Storage\StorageLimitExceededException;
use App\Support\Api\ApiExceptionMapper;
use App\Support\Conversions\Exceptions\UnsupportedConversionException;
use App\Support\Converters\Exceptions\InvalidConverterOptionsException;
use App\Support\Converters\Exceptions\UnsupportedFormatException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;

it('maps insufficient credits exception to api error', function () {
$mapper = app(ApiExceptionMapper::class);
$mapped = $mapper->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();
});