Skip to content

Commit cdc1563

Browse files
committed
🌱 Stable version initial release
0 parents  commit cdc1563

File tree

8 files changed

+920
-0
lines changed

8 files changed

+920
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Foundation\Api\Console\Commands;
6+
7+
use Dedoc\Scramble\Scramble;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Routing\Route as IlluminateRoute;
10+
use Illuminate\Support\Facades\Route;
11+
12+
/**
13+
* Class ScrambleTestCommand
14+
*
15+
* Console command to verify that API routes are correctly registered
16+
* and that Scramble (OpenAPI generator) is available for documentation.
17+
*
18+
* - Lists discovered API routes (method, URI, name, middleware).
19+
* - Gives quick pointers to the documentation endpoints.
20+
*/
21+
class ScrambleTestCommand extends Command
22+
{
23+
private const API_PREFIX = 'api/';
24+
25+
/**
26+
* The name and signature of the console command.
27+
*
28+
* @var string
29+
*/
30+
protected $signature = 'api:scramble-test';
31+
32+
/**
33+
* The console command description.
34+
*
35+
* @var string
36+
*/
37+
protected $description = 'Test Scramble integration with modular API routes';
38+
39+
/**
40+
* Execute the console command.
41+
*
42+
* @return int 0 on success, non-zero on failure.
43+
*/
44+
public function handle(): int
45+
{
46+
if (! $this->scrambleIsLoaded())
47+
{
48+
$this->warn('Scramble does not appear to be installed or loaded in this project.');
49+
$this->line('Tip: composer require dedoc/scramble && register the provider if not auto-discovered.');
50+
$this->newLine();
51+
// We don’t hard-fail here; listing routes can still be useful.
52+
}
53+
54+
$this->info('Scanning for modular API routes...');
55+
$this->newLine();
56+
57+
$apiRoutes = $this->collectApiRoutes();
58+
59+
if ([] === $apiRoutes)
60+
{
61+
$this->warn('No API routes found! Make sure your modules are loaded correctly.');
62+
return 1;
63+
}
64+
65+
$this->info('Found '.count($apiRoutes).' API routes:');
66+
$this->table(['Method', 'URI', 'Name', 'Middleware'], $apiRoutes);
67+
68+
$this->newLine();
69+
$this->info('✅ API routes are properly loaded!');
70+
$this->info('📚 If Scramble is enabled, visit /docs/api for your API docs.');
71+
$this->info('📄 OpenAPI specification: /docs/api.json');
72+
73+
$this->newLine();
74+
$this->comment('Note: Add concise PHPDoc to your controller actions to improve the generated docs.');
75+
76+
return 0;
77+
}
78+
79+
/**
80+
* Determine whether Scramble is available and loaded.
81+
*
82+
* Uses a conservative check that does not rely solely on providerIsLoaded.
83+
*
84+
* @return bool
85+
*/
86+
protected function scrambleIsLoaded(): bool
87+
{
88+
return ! (! class_exists(Scramble::class))
89+
90+
91+
92+
93+
;
94+
}
95+
96+
/**
97+
* Collects API routes registered in the application.
98+
*
99+
* @return array<int, array{method:string,uri:string,name:string,middleware:string}>
100+
*/
101+
protected function collectApiRoutes(): array
102+
{
103+
$apiRoutes = [];
104+
105+
/** @var IlluminateRoute $route */
106+
foreach (Route::getRoutes() as $route)
107+
{
108+
$uri = $route->uri();
109+
110+
if (! is_string($uri) || ! str_starts_with($uri, self::API_PREFIX))
111+
{
112+
continue;
113+
}
114+
115+
$apiRoutes[] = [
116+
'method' => implode('|', $route->methods()),
117+
'uri' => $uri,
118+
'name' => $route->getName() ?? 'N/A',
119+
'middleware' => implode(', ', $route->gatherMiddleware()),
120+
];
121+
}
122+
123+
// Sort for stable output (by URI then method)
124+
usort($apiRoutes, static fn (array $a, array $b): int => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]);
125+
126+
return $apiRoutes;
127+
}
128+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class () extends Migration
10+
{
11+
/**
12+
* Run the migrations.
13+
*/
14+
public function up(): void
15+
{
16+
Schema::create('personal_access_tokens', function (Blueprint $table): void
17+
{
18+
$table->id();
19+
$table->morphs('tokenable');
20+
$table->text('name');
21+
$table->string('token', 64)->unique();
22+
$table->text('abilities')->nullable();
23+
$table->timestamp('last_used_at')->nullable();
24+
$table->timestamp('expires_at')->nullable()->index();
25+
$table->timestamps();
26+
});
27+
}
28+
29+
/**
30+
* Reverse the migrations.
31+
*/
32+
public function down(): void
33+
{
34+
Schema::dropIfExists('personal_access_tokens');
35+
}
36+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Foundation\Api\Exceptions\V1\BaseExceptionHandler;
6+
7+
use Foundation\Api\Services\V1\Api\Api;
8+
use Illuminate\Auth\AuthenticationException;
9+
use Illuminate\Database\Eloquent\ModelNotFoundException;
10+
use Illuminate\Database\QueryException;
11+
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
12+
use Illuminate\Http\Exceptions\ThrottleRequestsException;
13+
use Illuminate\Http\JsonResponse;
14+
use Illuminate\Http\RedirectResponse;
15+
use Illuminate\Http\Request;
16+
use Illuminate\Validation\ValidationException;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19+
use Throwable;
20+
21+
/**
22+
* BaseExceptionHandler
23+
*
24+
* Centralizes exception reporting and rendering for API + web.
25+
* - Sends exceptions to Sentry if bound.
26+
* - Returns consistent JSON for API requests using the Api response builder.
27+
* - Avoids leaking sensitive error messages in production.
28+
*/
29+
class BaseExceptionHandler extends ExceptionHandler
30+
{
31+
/**
32+
* Register the exception handler.
33+
*
34+
* - Keeps Laravel defaults via parent::register().
35+
* - Reports to Sentry if container has a 'sentry' binding.
36+
*
37+
* @return void
38+
*/
39+
public function register(): void
40+
{
41+
parent::register();
42+
43+
$this->reportable(function (Throwable $e): void
44+
{
45+
if (app()->bound('sentry'))
46+
{
47+
app('sentry')->captureException($e);
48+
}
49+
});
50+
}
51+
52+
/**
53+
* Render an exception into an HTTP response.
54+
*
55+
* For API requests (`expectsJson()` or `api/*`), return a normalized JSON payload.
56+
* Otherwise, fall back to the default web rendering.
57+
*
58+
* @param Request $request
59+
* @param Throwable $e
60+
* @return \Illuminate\Http\Response|JsonResponse|RedirectResponse|Response
61+
* @throws Throwable
62+
*/
63+
public function render($request, Throwable $e): \Illuminate\Http\Response|JsonResponse|RedirectResponse|Response
64+
{
65+
if ($this->isApiRequest($request))
66+
{
67+
return $this->handleApiExceptions($request, $e);
68+
}
69+
70+
return parent::render($request, $e);
71+
}
72+
73+
/**
74+
* Determine if the incoming request targets the API.
75+
*
76+
* @param Request $request
77+
* @return bool
78+
*/
79+
protected function isApiRequest(Request $request): bool
80+
{
81+
return $request->expectsJson() || $request->is('api/*');
82+
}
83+
84+
/**
85+
* Handle exceptions for API requests in a safe, consistent way.
86+
*
87+
* @param Request $request
88+
* @param Throwable $exception
89+
* @return \Illuminate\Http\Response|JsonResponse|RedirectResponse|Response
90+
* @noinspection PhpUnusedParameterInspection
91+
*/
92+
private function handleApiExceptions(Request $request, Throwable $exception): \Illuminate\Http\Response|JsonResponse|RedirectResponse|Response
93+
{
94+
$debug = (bool) config('app.debug');
95+
96+
return match (true)
97+
{
98+
$exception instanceof AuthenticationException => Api::response()->unauthorized()->send(),
99+
100+
$exception instanceof ThrottleRequestsException => $this->throttledResponse($exception),
101+
102+
$exception instanceof ModelNotFoundException,
103+
$exception instanceof NotFoundHttpException => Api::response()->notFound()->send(),
104+
105+
$exception instanceof ValidationException => Api::response()
106+
->message($exception->getMessage() ?: null)
107+
->errors($exception->errors())
108+
->send(),
109+
110+
// Database/Query errors: generic message in production
111+
$exception instanceof QueryException => Api::response()
112+
->internalError($debug ? $exception->getMessage() : null)
113+
->send(),
114+
115+
// Fallback: generic failed response (400) in prod, include message in debug
116+
default => Api::response()
117+
->failed()
118+
->message($debug ? $exception->getMessage() : null)
119+
->send(),
120+
};
121+
}
122+
123+
/**
124+
* Build a throttled (429) response and attach Retry-After metadata when available.
125+
*
126+
* @param ThrottleRequestsException $exception
127+
* @return JsonResponse
128+
*/
129+
protected function throttledResponse(ThrottleRequestsException $exception): JsonResponse
130+
{
131+
$retryAfter = null;
132+
133+
// ThrottleRequestsException usually carries headers with Retry-After
134+
$headers = method_exists($exception, 'getHeaders') ? $exception->getHeaders() : [];
135+
if (isset($headers['Retry-After']))
136+
{
137+
$retryAfter = (int) $headers['Retry-After'];
138+
}
139+
140+
$api = Api::response()->throttled();
141+
142+
if (null !== $retryAfter)
143+
{
144+
$api->addMeta('retry_after', $retryAfter);
145+
}
146+
147+
return $api->send();
148+
}
149+
}

‎Lang/V1/en/response.php‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
return [
6+
'not_initialized' => 'Value not set',
7+
'success' => 'Operation completed successfully',
8+
'error' => 'An error occurred',
9+
'unauthorized' => 'Unauthorized access',
10+
'forbidden' => 'Access forbidden',
11+
'not_found' => 'Requested resource not found',
12+
'server_error' => 'Internal server error',
13+
'created' => 'Resource created successfully',
14+
'validation_error' => 'Validation failed',
15+
'throttled' => 'Too many requests',
16+
'internal_error' => 'Internal server error',
17+
'external_error' => 'External service error',
18+
];

0 commit comments

Comments
 (0)