- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
213 lines
7.7 KiB
PHP
213 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Router;
|
|
|
|
use ReflectionClass;
|
|
use ReflectionMethod;
|
|
|
|
/**
|
|
* Performs sanity checks on compiled routes (controller/action presence, parameter consistency, etc.)
|
|
*/
|
|
final readonly class RouteInspector
|
|
{
|
|
public function __construct(
|
|
private CompiledRoutes $compiledRoutes
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Analyze compiled routes and return structured diagnostics
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function analyze(): array
|
|
{
|
|
$issues = [];
|
|
$staticRoutes = $this->compiledRoutes->getStaticRoutes();
|
|
$namedRoutes = $this->compiledRoutes->getAllNamedRoutes();
|
|
|
|
$totalStatic = 0;
|
|
|
|
// Track seen routes for potential duplicates per method+subdomain+path
|
|
$seen = [];
|
|
|
|
foreach ($staticRoutes as $method => $subdomains) {
|
|
foreach ($subdomains as $subdomain => $paths) {
|
|
foreach ($paths as $path => $route) {
|
|
$totalStatic++;
|
|
$key = "{$method}|{$subdomain}|{$path}";
|
|
$seen[$key] = ($seen[$key] ?? 0) + 1;
|
|
|
|
$routeName = $route->name ?? null;
|
|
|
|
// Controller existence
|
|
$controller = $route->controller ?? null;
|
|
$action = $route->action ?? null;
|
|
|
|
if (! is_string($controller) || $controller === '' || ! class_exists($controller)) {
|
|
$issues[] = $this->issue('controller_missing', 'error', $method, $subdomain, $path, $routeName, "Controller class not found or invalid: " . var_export($controller, true));
|
|
|
|
continue; // skip further checks for this route
|
|
}
|
|
|
|
// Action existence and visibility
|
|
if (! is_string($action) || $action === '') {
|
|
$issues[] = $this->issue('action_missing', 'error', $method, $subdomain, $path, $routeName, 'Action method not defined or invalid');
|
|
} else {
|
|
$refClass = new ReflectionClass($controller);
|
|
if (! $refClass->hasMethod($action)) {
|
|
$issues[] = $this->issue('action_missing', 'error', $method, $subdomain, $path, $routeName, "Action method '{$action}' not found in {$controller}");
|
|
} else {
|
|
$refMethod = $refClass->getMethod($action);
|
|
if (! $refMethod->isPublic()) {
|
|
$issues[] = $this->issue('action_not_public', 'warning', $method, $subdomain, $path, $routeName, "Action method '{$action}' is not public");
|
|
}
|
|
// Parameter consistency check (placeholders vs method signature)
|
|
$this->checkParameterConsistency($issues, $method, $subdomain, $path, $routeName, $route, $refMethod);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Duplicate path checks (should normally be prevented by map keys, but guard anyway)
|
|
foreach ($seen as $k => $count) {
|
|
if ($count > 1) {
|
|
[$m, $sub, $p] = explode('|', $k, 3);
|
|
$issues[] = $this->issue('duplicate_route', 'error', $m, $sub, $p, null, "Duplicate route detected for {$m} {$sub} {$p}");
|
|
}
|
|
}
|
|
|
|
// Named routes basic validation: ensure name -> route is consistent
|
|
$namedIssues = $this->validateNamedRoutes($namedRoutes);
|
|
array_push($issues, ...$namedIssues);
|
|
|
|
$summary = [
|
|
'total_static_routes' => $totalStatic,
|
|
'total_named_routes' => count($namedRoutes),
|
|
'issue_count' => count($issues),
|
|
];
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'issues' => $issues,
|
|
'stats' => $this->compiledRoutes->getStats(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check that route parameters in path are consistent with action method signature
|
|
*/
|
|
private function checkParameterConsistency(array &$issues, string $method, string $subdomain, string $path, ?string $routeName, object $route, ReflectionMethod $refMethod): void
|
|
{
|
|
$pathParams = $this->extractPathParams($path);
|
|
|
|
// Try to read expected parameters from route definition; otherwise from reflection
|
|
$expected = [];
|
|
if (isset($route->parameters) && is_array($route->parameters)) {
|
|
// If associative, use keys; if list, use values
|
|
$keys = array_keys($route->parameters);
|
|
$expected = array_values(array_filter(
|
|
count($keys) !== count($route->parameters) ? $route->parameters : $keys,
|
|
fn ($v) => is_string($v) && $v !== ''
|
|
));
|
|
} else {
|
|
$expected = array_map(
|
|
static fn (\ReflectionParameter $p) => $p->getName(),
|
|
$refMethod->getParameters()
|
|
);
|
|
}
|
|
|
|
// Normalize unique sets
|
|
$pathSet = array_values(array_unique($pathParams));
|
|
$expectedSet = array_values(array_unique($expected));
|
|
|
|
// Missing placeholders in path for expected parameters
|
|
$missingInPath = array_values(array_diff($expectedSet, $pathSet));
|
|
if (! empty($missingInPath)) {
|
|
$issues[] = $this->issue(
|
|
'param_mismatch',
|
|
'warning',
|
|
$method,
|
|
$subdomain,
|
|
$path,
|
|
$routeName,
|
|
'Expected parameters not present in path: ' . implode(', ', $missingInPath)
|
|
);
|
|
}
|
|
|
|
// Extra placeholders not expected by the action
|
|
$extraInPath = array_values(array_diff($pathSet, $expectedSet));
|
|
if (! empty($extraInPath)) {
|
|
$issues[] = $this->issue(
|
|
'param_mismatch',
|
|
'warning',
|
|
$method,
|
|
$subdomain,
|
|
$path,
|
|
$routeName,
|
|
'Path has placeholders not expected by action: ' . implode(', ', $extraInPath)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate named routes (basic structural checks)
|
|
* @param array<string, object> $namedRoutes
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function validateNamedRoutes(array $namedRoutes): array
|
|
{
|
|
$issues = [];
|
|
foreach ($namedRoutes as $name => $route) {
|
|
// Minimal: ensure a path exists
|
|
$path = $route->path ?? null;
|
|
if (! is_string($path) || $path === '') {
|
|
$issues[] = [
|
|
'type' => 'invalid_named_route',
|
|
'severity' => 'error',
|
|
'route_name' => $name,
|
|
'message' => 'Named route has no valid path',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $issues;
|
|
}
|
|
|
|
/**
|
|
* Extract placeholders from route path like /users/{id}/posts/{slug}
|
|
* @return array<int, string>
|
|
*/
|
|
private function extractPathParams(string $path): array
|
|
{
|
|
$matches = [];
|
|
preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $path, $matches);
|
|
|
|
/** @var array<int, string> $params */
|
|
$params = $matches[1] ?? [];
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* Build a standardized issue array
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function issue(string $type, string $severity, string $method, string $subdomain, string $path, ?string $name, string $message): array
|
|
{
|
|
return [
|
|
'type' => $type,
|
|
'severity' => $severity,
|
|
'route' => [
|
|
'method' => $method,
|
|
'subdomain' => $subdomain,
|
|
'path' => $path,
|
|
'name' => $name,
|
|
],
|
|
'message' => $message,
|
|
];
|
|
}
|
|
}
|