diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f7492c5..f94e6f9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,7 +18,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] php: [8.3, 8.2, 8.1] - laravel: [10.*] + laravel: [10.*, 11.*, 12.*] stability: [prefer-lowest, prefer-stable] include: - laravel: 10.* diff --git a/composer.json b/composer.json index 1867e42..0ce1ba8 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "^8.1", - "illuminate/contracts": "^10.0||^11", + "illuminate/contracts": "^10.0||^11||^12", "spatie/laravel-package-tools": "^1.14.0" }, "require-dev": { diff --git a/src/Commands/LaravelApiProblemCommand.php b/src/Commands/LaravelApiProblemCommand.php index 202444a..b126421 100644 --- a/src/Commands/LaravelApiProblemCommand.php +++ b/src/Commands/LaravelApiProblemCommand.php @@ -23,7 +23,7 @@ protected function resolveStubPath(string $stub): string { return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) ? $customPath - : __DIR__ . $stub; + : __DIR__.$stub; } protected function getDefaultNamespace($rootNamespace): string diff --git a/src/Commands/LaravelApiProblemExceptionCommand.php b/src/Commands/LaravelApiProblemExceptionCommand.php index 5e88235..9834187 100644 --- a/src/Commands/LaravelApiProblemExceptionCommand.php +++ b/src/Commands/LaravelApiProblemExceptionCommand.php @@ -24,7 +24,7 @@ protected function resolveStubPath(string $stub): string { return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) ? $customPath - : __DIR__ . $stub; + : __DIR__.$stub; } protected function getDefaultNamespace($rootNamespace): string @@ -34,7 +34,7 @@ protected function getDefaultNamespace($rootNamespace): string protected function replaceClass($stub, $name) { - $class = str_replace($this->getNamespace($name) . '\\', '', $name); + $class = str_replace($this->getNamespace($name).'\\', '', $name); // Do string replacement return str_replace('{{ class }}', $class, $stub); diff --git a/src/Http/LaravelHttpApiProblem.php b/src/Http/LaravelHttpApiProblem.php index fcf6956..d98bb19 100644 --- a/src/Http/LaravelHttpApiProblem.php +++ b/src/Http/LaravelHttpApiProblem.php @@ -72,7 +72,7 @@ public function __construct( if ($this->statusCode < 400 || $this->statusCode > 599) { $this->statusCode = 400; } - if (!filter_var($this->type, FILTER_VALIDATE_URL) || empty($this->title)) { + if (! filter_var($this->type, FILTER_VALIDATE_URL) || empty($this->title)) { $this->title = $this->getTitleForStatusCode($this->statusCode); $this->type = self::TYPE_ABOUT_BLANK; } @@ -99,6 +99,11 @@ public function getStatusCode(): int return $this->statusCode; } + public function getInstance(): string + { + return $this->instance; + } + public function getExtensions(): array { return $this->extensions; @@ -113,7 +118,7 @@ public function toArray(): array 'title' => $this->title, 'detail' => $this->detail, 'instance' => $this->instance, - 'timestamp' => $this->timestamp->toJSON() + 'timestamp' => $this->timestamp->toJSON(), ], $this->extensions ); diff --git a/src/LaravelApiProblem.php b/src/LaravelApiProblem.php index a1e81d4..b940fc8 100755 --- a/src/LaravelApiProblem.php +++ b/src/LaravelApiProblem.php @@ -4,6 +4,7 @@ namespace Pedrosalpr\LaravelApiProblem; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -26,20 +27,20 @@ public function __construct( if ($exception instanceof LaravelApiProblemException) { $this->apiProblemException = $exception; $this->apiProblemException(); + return; } match (get_class($exception)) { ValidationException::class => $this->validation(), \UnhandledMatchError::class,\Exception::class => $this->default(), HttpException::class => $this->default(419), + AuthorizationException::class => $this->default(403), default => $this->default() }; } /** * Render the exception as an HTTP response. - * - * @return JsonResponse */ public function render(): JsonResponse { @@ -54,8 +55,6 @@ public function render(): JsonResponse /** * Debug the class in array to view more details, such as: api problem and exception - * - * @return array */ public function toDebuggableArray(): array { @@ -80,8 +79,6 @@ public function toDebuggableArray(): array /** * Transform any exception into an http api problem with status code - * - * @param null|int $statusCode */ protected function default(?int $statusCode = null): void { @@ -103,7 +100,7 @@ protected function validation(): void $extensions = [ 'errors' => ($this->exception instanceof ValidationException) ? $this->exception->errors() - : null + : null, ]; $this->apiProblem = new LaravelHttpApiProblem( Response::HTTP_UNPROCESSABLE_ENTITY, @@ -130,8 +127,6 @@ protected function apiProblemException(): void /** * Get uri as instance - * - * @return string */ protected function getUriInstance(): string { @@ -140,30 +135,25 @@ protected function getUriInstance(): string /** * Get the context if it exists within the exception and return it as an extension - * - * @return array */ protected function getContextExceptionAsExtensions(): array { $extensions = []; - if (!method_exists($this->exception, 'context')) { + if (! method_exists($this->exception, 'context')) { return $extensions; } $context = $this->exception->context(); if (is_array($context)) { $extensions = $context; - } elseif (!empty($context)) { + } elseif (! empty($context)) { $extensions = [$context]; } + return $extensions; } /** * Gets the status code from the exception code, or from the HttpException Interface, otherwise it returns an Internal Server Error - * - * @param null|int $code - * - * @return int */ protected function getStatusCode(?int $code): int { @@ -175,6 +165,7 @@ protected function getStatusCode(?int $code): int ? $this->exception->getStatusCode() : Response::HTTP_INTERNAL_SERVER_ERROR; } + return ($this->isStatusCodeInternalOrServerError($this->exception->getCode())) ? $this->exception->getCode() : Response::HTTP_INTERNAL_SERVER_ERROR; @@ -182,10 +173,6 @@ protected function getStatusCode(?int $code): int /** * Checks if the status code is of the integer type and is in the range of Client and Server Errors - * - * @param null|int $statusCode - * - * @return bool */ protected function isStatusCodeInternalOrServerError(?int $statusCode): bool { @@ -194,10 +181,6 @@ protected function isStatusCodeInternalOrServerError(?int $statusCode): bool /** * Serialize the exception into an array - * - * @param \Throwable $throwable - * - * @return array */ private function serializeException(\Throwable $throwable): array { diff --git a/src/LaravelApiProblemInterface.php b/src/LaravelApiProblemInterface.php index d8ae6d1..f62c11a 100644 --- a/src/LaravelApiProblemInterface.php +++ b/src/LaravelApiProblemInterface.php @@ -19,4 +19,6 @@ public function getDetail(): string; public function getExtensions(): array; public function getHeaderProblemJson(): string; + + public function getInstance(): string; } diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index 87fb64c..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,5 +0,0 @@ -expect(['dd', 'dump', 'ray']) - ->each->not->toBeUsed(); diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 5d36321..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/ApiProblemTest.php b/tests/Feature/ApiProblemTest.php new file mode 100644 index 0000000..613bb32 --- /dev/null +++ b/tests/Feature/ApiProblemTest.php @@ -0,0 +1,130 @@ +app->singleton( + \Illuminate\Contracts\Debug\ExceptionHandler::class, + TestExceptionHandler::class // Use sua classe de handler de teste aqui + ); +}); + +test('authentication exception returns 401 problem json', function () { + // Definir uma rota que lança a exceção diretamente. + Route::get('/api/protected', fn () => throw new AuthenticationException('Unauthenticated.')); + + getJson('/api/protected') + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => Response::HTTP_UNAUTHORIZED, + 'title' => 'Unauthorized', + 'detail' => 'Unauthenticated.', + ]); +}); + +test('authorization exception returns 403 problem json', function () { + // Definir uma rota que lança a exceção diretamente. + Route::get('/api/forbidden', fn () => throw new AuthorizationException('This action is unauthorized.')); + + getJson('/api/forbidden') + ->assertStatus(Response::HTTP_FORBIDDEN) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => Response::HTTP_FORBIDDEN, + 'title' => 'Forbidden', + 'detail' => 'This action is unauthorized.', + ]); +}); + +test('validation exception returns 422 problem json', function () { + Route::post('/api/validate', function () { + request()->validate(['email' => 'required|email']); + }); + + postJson('/api/validate', ['email' => 'invalid-email']) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => Response::HTTP_UNPROCESSABLE_ENTITY, + 'title' => 'Unprocessable Entity', + 'detail' => 'The given data was invalid.', + ]) + ->assertJsonStructure([ + 'status', + 'title', + 'detail', + 'errors' => [ + 'email', + ], + ]); +}); + +test('not found exception returns 404 problem json', function () { + // A rota não é definida, o Laravel lança a exceção automaticamente + getJson('/api/non-existent-route') + ->assertStatus(404) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => 404, + 'title' => 'Not Found', + 'detail' => 'The route api/non-existent-route could not be found.', + ]); +}); + +test('method not allowed exception returns 405 problem json', function () { + // Definir uma rota que só aceita POST + Route::post('/api/only-post', fn () => ['message' => 'success']); + + // Tentar acessar com o método GET + getJson('/api/only-post') + ->assertStatus(Response::HTTP_METHOD_NOT_ALLOWED) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => Response::HTTP_METHOD_NOT_ALLOWED, + 'title' => 'Method Not Allowed', + 'detail' => 'The GET method is not supported for route api/only-post. Supported methods: POST.', + ]); +}); + +test('generic exception returns 500 problem json', function () { + Route::get('/api/internal-error', function () { + throw new Exception('An internal server error occurred.'); + }); + + getJson('/api/internal-error') + ->assertStatus(500) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => 500, + 'title' => 'Internal Server Error', + 'detail' => 'An internal server error occurred.', + ]); +}); + +test('http exception returns correct problem json', function () { + Route::get('/api/custom-error', function () { + throw new HttpException(Response::HTTP_UNAUTHORIZED, 'You do not have permission.'); + }); + + getJson('/api/custom-error') + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJson([ + 'status' => Response::HTTP_UNAUTHORIZED, + 'title' => 'Unauthorized', + 'detail' => 'You do not have permission.', + ]); +}); diff --git a/tests/Handlers/TestExceptionHandler.php b/tests/Handlers/TestExceptionHandler.php new file mode 100644 index 0000000..2cc6cfc --- /dev/null +++ b/tests/Handlers/TestExceptionHandler.php @@ -0,0 +1,67 @@ +fullUrl(); + + if ($e instanceof AuthenticationException) { + $problem = new LaravelHttpApiProblem(Response::HTTP_UNAUTHORIZED, $e->getMessage(), $instance); + + return response()->json($problem->toArray(), $problem->getStatusCode()) + ->withHeaders(['Content-Type' => $problem->getHeaderProblemJson()]); + } + + if ($e instanceof AuthorizationException) { + $problem = new LaravelHttpApiProblem(Response::HTTP_FORBIDDEN, $e->getMessage(), $instance); + + return response()->json($problem->toArray(), $problem->getStatusCode()) + ->withHeaders(['Content-Type' => $problem->getHeaderProblemJson()]); + } + + // As exceções Http (como 404) precisam ser tratadas para que o cabeçalho seja `application/problem+json` + if ($this->isHttpException($e)) { + $statusCode = $e->getStatusCode(); + $title = Response::$statusTexts[$statusCode] ?? 'Unknown Error'; + $problem = new LaravelHttpApiProblem($statusCode, $e->getMessage(), $instance, title: $title); + + return response()->json($problem->toArray(), $problem->getStatusCode()) + ->withHeaders(['Content-Type' => $problem->getHeaderProblemJson()]); + } + + if ($e instanceof ValidationException) { + $problem = new LaravelHttpApiProblem( + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + detail: 'The given data was invalid.', + instance: $instance, + extensions: ['errors' => $e->errors()] + ); + + return response()->json($problem->toArray(), $problem->getStatusCode()) + ->withHeaders(['Content-Type' => $problem->getHeaderProblemJson()]); + } + + $problem = new LaravelHttpApiProblem( + statusCode: Response::HTTP_INTERNAL_SERVER_ERROR, + detail: 'An internal server error occurred.', // Mensagem genérica por segurança + instance: $instance + ); + + return response()->json($problem->toArray(), $problem->getStatusCode()) + ->withHeaders(['Content-Type' => $problem->getHeaderProblemJson()]); + } +} diff --git a/tests/Unit/ApiProblemTest.php b/tests/Unit/ApiProblemTest.php new file mode 100644 index 0000000..05c346d --- /dev/null +++ b/tests/Unit/ApiProblemTest.php @@ -0,0 +1,79 @@ +getStatusCode())->toBe(404); + expect($problem->getDetail())->toBe('The resource was not found.'); + expect($problem->getInstance())->toBe('https://example.com/api/posts/1'); + expect($problem->getTitle())->toBe('Not Found'); + expect($problem->getType())->toBe('about:blank'); +}); + +test('it sets title and type automatically based on status code', function () { + $problem = new LaravelHttpApiProblem( + statusCode: 401, + detail: 'Authentication failed.', + instance: 'https://example.com/api/protected' + ); + + expect($problem->getTitle())->toBe('Unauthorized'); + expect($problem->getType())->toBe('about:blank'); +}); + +test('toArray method returns valid problem json structure', function () { + $problem = new LaravelHttpApiProblem( + statusCode: 422, + detail: 'The request payload is invalid.', + instance: 'https://example.com/api/users', + extensions: ['errors' => ['name' => ['The name field is required.']]] + ); + + $array = $problem->toArray(); + + expect($array) + ->toHaveKeys(['status', 'type', 'title', 'detail', 'instance', 'timestamp', 'errors']) + ->and($array['status'])->toBe(422) + ->and($array['title'])->toBe('Unprocessable Entity') + ->and($array['errors']['name'])->toBe(['The name field is required.']); +}); + +test('it defaults status code to 400 if out of range', function (int $statusCode) { + $problem = new LaravelHttpApiProblem( + statusCode: $statusCode, + detail: 'Invalid status code.', + instance: 'https://example.com/api' + ); + + expect($problem->getStatusCode())->toBe(400); +})->with([ + 100, + 600, +]); + +test('it includes extensions in the problem detail', function () { + $extensions = [ + 'trace_id' => '1a2b3c4d5e', + 'app_code' => 'AUTH-001', + ]; + + $problem = new LaravelHttpApiProblem( + statusCode: 401, + detail: 'Unauthorized access.', + instance: 'https://example.com/api/protected', + extensions: $extensions + ); + + $array = $problem->toArray(); + + expect($array)->toHaveKeys(['trace_id', 'app_code']); + expect($array['trace_id'])->toBe('1a2b3c4d5e'); +});