feat(Production): Complete production deployment infrastructure

- 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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -59,4 +59,4 @@ enum AdminRoutes: string implements RouteNameInterface
{
return false;
}
}
}

View File

@@ -51,4 +51,4 @@ enum ApiRoutes: string implements RouteNameInterface
{
return false;
}
}
}

View File

@@ -37,4 +37,4 @@ enum HealthRoutes: string implements RouteNameInterface
{
return false;
}
}
}

View File

@@ -127,6 +127,7 @@ final readonly class HttpRouter implements Router
);
}
}
return null;
}

View File

@@ -35,4 +35,4 @@ enum MediaRoutes: string implements RouteNameInterface
{
return false;
}
}
}

View File

@@ -121,7 +121,7 @@ final readonly class ParameterProcessor
$rawValue = $queryParams[$parameter->name];
// Try to convert using TypeCaster if type is specified
if ($parameter->type !== null && !$parameter->isBuiltin) {
if ($parameter->type !== null && ! $parameter->isBuiltin) {
$caster = $this->typeCasterRegistry->getCasterForType($parameter->type);
if ($caster !== null && is_string($rawValue)) {
try {

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
use App\Framework\Router\ActionResult;
/**
* API Data Result
*
* Standardized API response with data and optional metadata.
* Supports both paginated and simple data responses.
*
* Response Structure (Paginated):
* {
* "data": [...],
* "meta": {
* "result_count": 10,
* "limit": 10,
* "has_more": true,
* "current_page": 1,
* "total_pages": 5,
* ...
* },
* "timestamp": "2024-01-20T10:30:00Z"
* }
*
* Response Structure (Simple):
* {
* "data": {...},
* "meta": {...},
* "timestamp": "2024-01-20T10:30:00Z"
* }
*/
final readonly class ApiDataResult implements ActionResult
{
/**
* @param PaginationResponse|array<mixed>|mixed $data Response data
* @param array<string, mixed>|null $additionalMeta Additional metadata
*/
private function __construct(
public PaginationResponse|array $data,
public ?array $additionalMeta = null,
public Status $status = Status::OK
) {}
/**
* Create with pagination support (uses framework's PaginationResponse)
*/
public static function paginated(
PaginationResponse $paginationResponse,
?array $additionalMeta = null,
Status $status = Status::OK
): self {
return new self(
data: $paginationResponse,
additionalMeta: $additionalMeta,
status: $status
);
}
/**
* Create simple data response without pagination
*
* @param mixed $data
* @param array<string, mixed>|null $meta
*/
public static function simple(
mixed $data,
?array $meta = null,
Status $status = Status::OK
): self {
return new self(
data: ['data' => $data],
additionalMeta: $meta,
status: $status
);
}
/**
* Create from array data
*
* @param array<mixed> $data
* @param array<string, mixed>|null $meta
*/
public static function fromArray(
array $data,
?array $meta = null,
Status $status = Status::OK
): self {
return new self(
data: ['data' => $data],
additionalMeta: $meta,
status: $status
);
}
/**
* Check if response is paginated
*/
public function isPaginated(): bool
{
return $this->data instanceof PaginationResponse;
}
/**
* Convert to array for JSON serialization
*/
public function toArray(): array
{
if ($this->isPaginated()) {
/** @var PaginationResponse $paginationResponse */
$paginationResponse = $this->data;
$response = $paginationResponse->toArray();
// Merge additional meta if provided
if ($this->additionalMeta !== null) {
$response['meta'] = array_merge(
$response['meta'],
$this->additionalMeta
);
}
} else {
$response = $this->data;
// Add meta section for simple responses
if ($this->additionalMeta !== null || !isset($response['meta'])) {
$response['meta'] = $this->additionalMeta ?? [];
}
}
// Add timestamp to response
$response['timestamp'] = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339);
return $response;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Exception\ErrorCode;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
/**
* API Error Result
*
* Standardized API error response using framework's ErrorCode system.
*
* Response Structure:
* {
* "error": {
* "code": "HTTP002",
* "category": "HTTP",
* "message": "Requested resource not found",
* "severity": "warning",
* "recovery_hint": "Verify resource URL and existence",
* "details": {...}
* },
* "timestamp": "2024-01-20T10:30:00Z",
* "path": "/api/users/123"
* }
*/
final readonly class ApiErrorResult implements ActionResult
{
/**
* @param ErrorCode $errorCode Framework ErrorCode enum
* @param string|null $customMessage Optional custom message (overrides ErrorCode description)
* @param array<string, mixed>|null $details Additional error details
* @param string|null $path Request path
*/
public function __construct(
public ErrorCode $errorCode,
public ?string $customMessage = null,
public ?array $details = null,
public ?string $path = null,
public Status $status = Status::BAD_REQUEST
) {}
/**
* Create from ErrorCode with automatic status mapping
*/
public static function fromErrorCode(
ErrorCode $errorCode,
?string $customMessage = null,
?array $details = null,
?string $path = null
): self {
// Map ErrorCode to HTTP Status
$status = self::mapErrorCodeToStatus($errorCode);
return new self(
errorCode: $errorCode,
customMessage: $customMessage,
details: $details,
path: $path,
status: $status
);
}
/**
* Map ErrorCode to HTTP Status
*/
private static function mapErrorCodeToStatus(ErrorCode $errorCode): Status
{
return match ($errorCode->getCategory()) {
'HTTP' => match ($errorCode->getValue()) {
'HTTP001' => Status::BAD_REQUEST,
'HTTP002' => Status::NOT_FOUND,
'HTTP003' => Status::METHOD_NOT_ALLOWED,
'HTTP004' => Status::TOO_MANY_REQUESTS,
'HTTP005' => Status::INTERNAL_SERVER_ERROR,
default => Status::BAD_REQUEST,
},
'AUTH' => match ($errorCode->getValue()) {
'AUTH001', 'AUTH002', 'AUTH004' => Status::UNAUTHORIZED,
'AUTH003' => Status::FORBIDDEN,
default => Status::UNAUTHORIZED,
},
'VAL' => Status::UNPROCESSABLE_ENTITY,
'DB' => Status::INTERNAL_SERVER_ERROR,
'API' => Status::BAD_GATEWAY,
default => Status::INTERNAL_SERVER_ERROR,
};
}
/**
* Convert to array for JSON serialization
*/
public function toArray(): array
{
$error = [
'code' => $this->errorCode->getValue(),
'category' => $this->errorCode->getCategory(),
'message' => $this->customMessage ?? $this->errorCode->getDescription(),
'severity' => $this->errorCode->getSeverity()->value,
'recovery_hint' => $this->errorCode->getRecoveryHint(),
];
// Add retry information if recoverable
if ($this->errorCode->isRecoverable()) {
$error['recoverable'] = true;
if ($retryAfter = $this->errorCode->getRetryAfterSeconds()) {
$error['retry_after_seconds'] = $retryAfter;
}
}
if ($this->details !== null) {
$error['details'] = $this->details;
}
$response = [
'error' => $error,
'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339),
];
if ($this->path !== null) {
$response['path'] = $this->path;
}
return $response;
}
/**
* Create with path from request
*/
public function withPath(string $path): self
{
return new self(
errorCode: $this->errorCode,
customMessage: $this->customMessage,
details: $this->details,
path: $path,
status: $this->status
);
}
/**
* Create with additional details
*/
public function withDetails(array $details): self
{
return new self(
errorCode: $this->errorCode,
customMessage: $this->customMessage,
details: array_merge($this->details ?? [], $details),
path: $this->path,
status: $this->status
);
}
/**
* Create with custom message
*/
public function withMessage(string $message): self
{
return new self(
errorCode: $this->errorCode,
customMessage: $message,
details: $this->details,
path: $this->path,
status: $this->status
);
}
}

View File

@@ -14,4 +14,4 @@ enum RouteCategory: string
case ADMIN = 'admin';
case AUTH = 'auth';
case MEDIA = 'media';
}
}

View File

@@ -138,4 +138,4 @@ final readonly class RouteHelper
{
return $this->urlGenerator->isCurrentRoute($route);
}
}
}

View File

@@ -45,21 +45,22 @@ final readonly class RouteInspector
$controller = $route->controller ?? null;
$action = $route->action ?? null;
if (!is_string($controller) || $controller === '' || !class_exists($controller)) {
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 === '') {
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)) {
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()) {
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)
@@ -109,11 +110,11 @@ final readonly class RouteInspector
$keys = array_keys($route->parameters);
$expected = array_values(array_filter(
count($keys) !== count($route->parameters) ? $route->parameters : $keys,
fn($v) => is_string($v) && $v !== ''
fn ($v) => is_string($v) && $v !== ''
));
} else {
$expected = array_map(
static fn(\ReflectionParameter $p) => $p->getName(),
static fn (\ReflectionParameter $p) => $p->getName(),
$refMethod->getParameters()
);
}
@@ -124,7 +125,7 @@ final readonly class RouteInspector
// Missing placeholders in path for expected parameters
$missingInPath = array_values(array_diff($expectedSet, $pathSet));
if (!empty($missingInPath)) {
if (! empty($missingInPath)) {
$issues[] = $this->issue(
'param_mismatch',
'warning',
@@ -138,7 +139,7 @@ final readonly class RouteInspector
// Extra placeholders not expected by the action
$extraInPath = array_values(array_diff($pathSet, $expectedSet));
if (!empty($extraInPath)) {
if (! empty($extraInPath)) {
$issues[] = $this->issue(
'param_mismatch',
'warning',
@@ -162,7 +163,7 @@ final readonly class RouteInspector
foreach ($namedRoutes as $name => $route) {
// Minimal: ensure a path exists
$path = $route->path ?? null;
if (!is_string($path) || $path === '') {
if (! is_string($path) || $path === '') {
$issues[] = [
'type' => 'invalid_named_route',
'severity' => 'error',

View File

@@ -35,4 +35,4 @@ interface RouteNameInterface
* Check if this is an auth route
*/
public function isAuthRoute(): bool;
}
}

View File

@@ -17,7 +17,7 @@ final readonly class RouteParametersInitializer
$request = $container->get(Request::class);
// Return existing route parameters if already set (from middleware)
if (!$request->routeParameters->isEmpty()) {
if (! $request->routeParameters->isEmpty()) {
return $request->routeParameters;
}

View File

@@ -14,7 +14,6 @@ use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Responses\RedirectResponse;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\WebSocketResponse;
use App\Framework\Http\Uri;
use App\Framework\Router\Result\FileResult;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
@@ -66,7 +65,7 @@ final readonly class RouteResponder
body : $result->data,
status : $result->status,
),
$result instanceof Redirect => new RedirectResponse(new Uri($result->target)),
$result instanceof Redirect => new RedirectResponse($result->target),
$result instanceof SseResult => new SseResponse($result, $result->callback),
$result instanceof WebSocketResult => $this->createWebSocketResponse($result),
$result instanceof FileResult => new HttpResponse(
@@ -80,59 +79,21 @@ final readonly class RouteResponder
private function createWebSocketResponse(WebSocketResult $result): WebSocketResponse
{
// WebSocket-Key aus Request-Headers abrufen
$websocketKey = $_SERVER['HTTP_SEC_WEBSOCKET_KEY'] ?? '';
$websocketKey = $this->request->server->getWebSocketKey()
?? throw new \RuntimeException('WebSocket key missing in request');
return new WebSocketResponse($result, $websocketKey);
}
private function renderTemplate(RenderContext $context): string
{
// Force log to file to ensure visibility
file_put_contents('/tmp/debug.log', "RouteResponder::renderTemplate STARTED for template: " . $context->template . "\n", FILE_APPEND);
file_put_contents('/tmp/debug.log', "Template data keys: " . implode(', ', array_keys($context->data)) . "\n", FILE_APPEND);
error_log("RouteResponder::renderTemplate STARTED for template: " . $context->template);
error_log("Template renderer class: " . get_class($this->templateRenderer));
// Log the class before calling render
error_log("RouteResponder: About to call render on class: " . get_class($this->templateRenderer));
file_put_contents('/tmp/debug.log', "About to call render on: " . get_class($this->templateRenderer) . "\n", FILE_APPEND);
$result = $this->templateRenderer->render($context);
file_put_contents('/tmp/debug.log', "RouteResponder::renderTemplate COMPLETED, result length: " . strlen($result) . "\n", FILE_APPEND);
file_put_contents('/tmp/debug.log', "Result contains {{: " . (str_contains($result, '{{') ? 'YES' : 'NO') . "\n", FILE_APPEND);
error_log("RouteResponder::renderTemplate COMPLETED, result length: " . strlen($result));
error_log("Result contains {{ model.title }}: " . (str_contains($result, '{{ model.title }}') ? 'YES' : 'NO'));
return $result;
return $this->templateRenderer->render($context);
}
private function isSpaRequest(): bool
{
$xmlHttpRequest = $this->request->headers->getFirst('X-Requested-With');
$hasSpaRequest = $this->request->headers->has('X-SPA-Request');
// Fallback: Direkt aus $_SERVER lesen (für Nginx FastCGI)
if (empty($xmlHttpRequest)) {
$xmlHttpRequest = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
}
if (! $hasSpaRequest) {
$hasSpaRequest = ! empty($_SERVER['HTTP_X_SPA_REQUEST']);
}
// DEBUG: Log SPA detection
error_log("SPA Detection - xmlHttpRequest: '{$xmlHttpRequest}', hasSpaRequest: " . ($hasSpaRequest ? 'true' : 'false'));
error_log("SPA Detection - Headers: " . json_encode($this->request->headers->toArray()));
error_log("SPA Detection - SERVER vars: X-Requested-With='" . ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? 'null') . "', X-SPA-Request='" . ($_SERVER['HTTP_X_SPA_REQUEST'] ?? 'null') . "'");
$isSpa = $xmlHttpRequest === 'XMLHttpRequest' && $hasSpaRequest;
error_log("SPA Detection - Final result: " . ($isSpa ? 'TRUE (returning JSON)' : 'FALSE (returning HTML)'));
return $isSpa;
return $this->request->server->isXmlHttpRequest()
&& $this->request->server->isSpaRequest();
}
private function createSpaResponse(ViewResult $result): JsonResponse

View File

@@ -8,7 +8,6 @@ use App\Framework\Attributes\Singleton;
use App\Framework\Core\Route;
use App\Framework\Http\Request;
use App\Framework\Router\Exception\RouteNotFound;
use App\Framework\Router\RouteNameInterface;
/**
* Service zur Generierung von URLs basierend auf Named Routes

View File

@@ -151,4 +151,4 @@ final readonly class GroupRoute
{
return $this->toString();
}
}
}

View File

@@ -16,7 +16,7 @@ final readonly class Placeholder
throw new \InvalidArgumentException('Placeholder name cannot be empty');
}
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $this->name)) {
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $this->name)) {
throw new \InvalidArgumentException("Invalid placeholder name: {$this->name}");
}
}
@@ -107,4 +107,4 @@ final readonly class Placeholder
default => '([^/]+)'
};
}
}
}

View File

@@ -94,7 +94,7 @@ final readonly class RouteGroup
$prefixedPath = $this->applyPrefix($route->getPath());
$compiledRoute = $route->withPath($prefixedPath);
if (!empty($this->middleware)) {
if (! empty($this->middleware)) {
$combinedMiddleware = array_merge($this->middleware, $route->getMiddleware());
$compiledRoute = $compiledRoute->withMiddleware($combinedMiddleware);
}
@@ -155,4 +155,4 @@ final readonly class RouteGroup
{
return $this->toString();
}
}
}

View File

@@ -16,7 +16,8 @@ final class RouteGroupBuilder
public function __construct(
private readonly RoutePath $prefix
) {}
) {
}
/**
* Add middleware to this group
@@ -26,6 +27,7 @@ final class RouteGroupBuilder
public function middleware(string ...$middleware): self
{
$this->middleware = array_merge($this->middleware, $middleware);
return $this;
}
@@ -79,6 +81,7 @@ final class RouteGroupBuilder
foreach ($methods as $method) {
$this->addRoute($method, $path, $handler, $name);
}
return $this;
}
@@ -94,7 +97,7 @@ final class RouteGroupBuilder
Method::DELETE,
Method::PATCH,
Method::HEAD,
Method::OPTIONS
Method::OPTIONS,
], $path, $handler, $name);
}
@@ -173,6 +176,7 @@ final class RouteGroupBuilder
);
$this->routes[] = $route;
return $this;
}
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\TypeCaster\TypeCasterRegistry;
use InvalidArgumentException;
final readonly class RoutePath
@@ -116,7 +115,7 @@ final readonly class RoutePath
{
return array_filter(
$this->elements,
fn($element) => $element instanceof Placeholder || $element instanceof TypedPlaceholder
fn ($element) => $element instanceof Placeholder || $element instanceof TypedPlaceholder
);
}
@@ -125,7 +124,7 @@ final readonly class RoutePath
*/
public function isDynamic(): bool
{
return !empty($this->getPlaceholders());
return ! empty($this->getPlaceholders());
}
/**
@@ -133,7 +132,7 @@ final readonly class RoutePath
*/
public function isStatic(): bool
{
return !$this->isDynamic();
return ! $this->isDynamic();
}
/**
@@ -144,8 +143,9 @@ final readonly class RoutePath
public function getParameterNames(): array
{
$placeholders = $this->getPlaceholders();
return array_map(
fn(Placeholder|TypedPlaceholder $placeholder) => $placeholder->getName(),
fn (Placeholder|TypedPlaceholder $placeholder) => $placeholder->getName(),
$placeholders
);
}
@@ -176,6 +176,7 @@ final readonly class RoutePath
public function append(string|Placeholder $element): self
{
$newElements = [...$this->elements, $element];
return new self(...$newElements);
}
@@ -185,6 +186,7 @@ final readonly class RoutePath
public function prepend(string|Placeholder $element): self
{
$newElements = [$element, ...$this->elements];
return new self(...$newElements);
}
@@ -206,7 +208,7 @@ final readonly class RoutePath
continue;
}
if (!is_string($element)) {
if (! is_string($element)) {
throw new InvalidArgumentException(
'Route elements must be strings, Placeholder, or TypedPlaceholder objects'
);
@@ -232,4 +234,4 @@ final readonly class RoutePath
{
return $this->toString();
}
}
}

View File

@@ -19,6 +19,7 @@ final class RoutePathBuilder
}
$this->elements[] = $segment;
return $this;
}
@@ -28,6 +29,7 @@ final class RoutePathBuilder
public function parameter(string $name): self
{
$this->elements[] = Placeholder::fromString($name);
return $this;
}
@@ -37,6 +39,7 @@ final class RoutePathBuilder
public function typedParameter(string $name, string $type, ?string $pattern = null): self
{
$this->elements[] = Placeholder::typed($name, $type, $pattern);
return $this;
}
@@ -46,6 +49,7 @@ final class RoutePathBuilder
public function wildcard(string $name): self
{
$this->elements[] = Placeholder::wildcard($name);
return $this;
}
@@ -90,4 +94,4 @@ final class RoutePathBuilder
{
return $this->typedParameter($name, 'slug')->build();
}
}
}

View File

@@ -113,4 +113,4 @@ final readonly class TypedPlaceholder
{
return $this->toString();
}
}
}

View File

@@ -42,4 +42,4 @@ enum WebRoutes: string implements RouteNameInterface
{
return false;
}
}
}