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
25 changes: 25 additions & 0 deletions backend/app/Http/Controllers/Api/PingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;

/**
* Confirma que a API está no ar, sem tocar banco ou fila.
*/
class PingController extends Controller
{
/**
* @response array{status: string, request_id: string|null}
*/
public function __invoke(): JsonResponse
{
return response()->json([
'status' => 'ok',
'request_id' => request()->attributes->get('request_id'),
]);
}
}
32 changes: 32 additions & 0 deletions backend/app/Http/Middleware/AssignRequestId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AssignRequestId
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$requestId = is_string($header = $request->header('X-Request-Id')) && $header !== ''
? $header
: (string) Str::uuid();

$request->attributes->set('request_id', $requestId);
Log::withContext(['request_id' => $requestId]);

$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);

return $response;
}
}
51 changes: 51 additions & 0 deletions backend/app/Http/Middleware/EnsureIdempotencyKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotencyKey
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');

if (!is_string($key) || $key === '') {
return response()->json([
'code' => 'idempotency_key_required',
'message' => 'O header Idempotency-Key é obrigatório para esta operação.',
'errors' => null,
'request_id' => $request->attributes->get('request_id'),
], 400);
}

$cacheKey = "idempotency:{$key}";

/** @var array{status: int, body: array<string, mixed>}|null $cached */
$cached = Cache::get($cacheKey);

if ($cached !== null) {
return response()->json($cached['body'], $cached['status']);
}

$response = $next($request);

if ($response instanceof JsonResponse && $response->getStatusCode() < 500) {
Cache::put($cacheKey, [
'status' => $response->getStatusCode(),
'body' => $response->getData(true),
], now()->addDay());
}

return $response;
}
}
28 changes: 28 additions & 0 deletions backend/app/Http/Resources/ApiResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;

abstract class ApiResource extends JsonResource
{
/**
* @return array{amount: int, currency: string}|null
*/
protected function money(?int $cents, string $currency = 'BRL'): ?array
{
if ($cents === null) {
return null;
}

return ['amount' => $cents, 'currency' => $currency];
}

protected function isoDate(?Carbon $date): ?string
{
return $date?->clone()->utc()->toIso8601ZuluString();
}
}
41 changes: 40 additions & 1 deletion backend/app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@

namespace App\Providers;

use App\Models\User;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\RouteInfo;
use Illuminate\Routing\RouteRegistrar;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;

class AppServiceProvider extends ServiceProvider
{
Expand All @@ -16,5 +23,37 @@ public function register(): void {}
/**
* Bootstrap any application services.
*/
public function boot(): void {}
public function boot(): void
{
$this->registerApiRouteMacro();
$this->registerApiDocsGroupingByModule();

Gate::define('viewApiDocs', fn (?User $user = null): bool => $user instanceof User);
}

private function registerApiRouteMacro(): void
{
Route::macro('apiV1', function (?string $prefix = null): RouteRegistrar {
$path = 'api/v1'.($prefix ? "/{$prefix}" : '');

return Route::middleware('api')->prefix($path);
});
}

private function registerApiDocsGroupingByModule(): void
{
Scramble::resolveTagsUsing(function (RouteInfo $routeInfo): array {
$className = $routeInfo->className();

if ($className !== null && preg_match('/^PHPeste\\\\([^\\\\]+)\\\\/', $className, $matches) === 1) {
return [$matches[1]];
}

$default = $className !== null
? Str::of(class_basename($className))->replace('Controller', '')->toString()
: null;

return array_filter([$default]);
});
}
}
73 changes: 73 additions & 0 deletions backend/app/Support/Api/ApiError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace App\Support\Api;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;

final class ApiError
{
public static function render(Throwable $e, Request $request): JsonResponse
{
if ($e instanceof ValidationException) {
return self::json(422, 'validation_error', 'Os dados enviados são inválidos.', $e->errors(), $request);
}

[$status, $code, $message] = self::resolve($e);

return self::json($status, $code, $message, null, $request);
}

/**
* @param array<array-key, mixed>|null $errors
*/
private static function json(int $status, string $code, string $message, ?array $errors, Request $request): JsonResponse
{
return response()->json([
'code' => $code,
'message' => $message,
'errors' => $errors,
'request_id' => $request->attributes->get('request_id'),
], $status);
}

/**
* @return array{0: int, 1: string, 2: string}
*/
private static function resolve(Throwable $e): array
{
return match (true) {
$e instanceof AuthenticationException => [
401, 'unauthenticated', 'É preciso autenticar para acessar este recurso.',
],
$e instanceof AccessDeniedHttpException => [
403, 'forbidden', 'Você não tem permissão para esta ação.',
],
$e instanceof NotFoundHttpException => [
404, 'not_found', 'Recurso não encontrado.',
],
$e instanceof MethodNotAllowedHttpException => [
405, 'method_not_allowed', 'Método HTTP não permitido para esta rota.',
],
$e instanceof TooManyRequestsHttpException => [
429, 'too_many_requests', 'Muitas tentativas. Tente novamente mais tarde.',
],
$e instanceof HttpExceptionInterface => [
$e->getStatusCode(), 'http_error', $e->getMessage() ?: 'Erro ao processar a requisição.',
],
default => [
500, 'server_error', config('app.debug') ? $e->getMessage() : 'Erro interno. Tente novamente mais tarde.',
],
};
}
}
21 changes: 20 additions & 1 deletion backend/bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,39 @@

declare(strict_types=1);

use App\Http\Middleware\AssignRequestId;
use App\Http\Middleware\EnsureIdempotencyKey;
use App\Support\Api\ApiError;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
require __DIR__.'/../routes/api.php';
},
)
->withMiddleware(function (Middleware $middleware): void {})
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(AssignRequestId::class);

$middleware->alias([
'idempotent' => EnsureIdempotencyKey::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request): bool => $request->is('api/*') || $request->expectsJson(),
);

$exceptions->render(
fn (Throwable $e, Request $request): ?JsonResponse => $request->is('api/*')
? ApiError::render($e, $request)
: null,
);
})->create();
Loading
Loading