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

@@ -6,8 +6,9 @@ namespace App\Framework\Http\Batch;
use App\Framework\Async\FiberManager;
use App\Framework\Core\Application;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\HttpErrorCode;
use App\Framework\Exception\ValidationErrorCode;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\RequestBody;
@@ -181,14 +182,14 @@ final readonly class BatchProcessor
{
if ($batchRequest->count() > $this->config->maxOperations) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Batch request exceeds maximum operations limit ({$this->config->maxOperations})"
);
}
if (! $this->config->allowContinueOnError && $batchRequest->continueOnError) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Continue on error is not allowed by current configuration'
);
}
@@ -196,7 +197,7 @@ final readonly class BatchProcessor
foreach ($batchRequest->operations as $operation) {
if (! $this->config->isMethodAllowed($operation->method->value)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Method {$operation->method->value} is not allowed in batch operations"
);
}
@@ -211,8 +212,7 @@ final readonly class BatchProcessor
return match(true) {
$e->isCategory('VAL') => Status::BAD_REQUEST,
$e->isCategory('AUTH') => Status::UNAUTHORIZED,
$e->isErrorCode(ErrorCode::ENTITY_NOT_FOUND) => Status::NOT_FOUND,
$e->isErrorCode(ErrorCode::HTTP_RATE_LIMIT_EXCEEDED) => Status::TOO_MANY_REQUESTS,
$e->isCategory('HTTP') && $e->isErrorCode(HttpErrorCode::RATE_LIMIT_EXCEEDED) => Status::TOO_MANY_REQUESTS,
default => Status::INTERNAL_SERVER_ERROR
};
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Http\Batch;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ValidationErrorCode;
/**
* Represents a batch request containing multiple operations
@@ -85,14 +85,14 @@ final readonly class BatchRequest
{
if (empty($operations)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Batch request must contain at least one operation'
);
}
if (count($operations) > 100) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Batch request cannot contain more than 100 operations'
);
}
@@ -101,14 +101,14 @@ final readonly class BatchRequest
foreach ($operations as $operation) {
if (empty($operation->id)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
'Each batch operation must have a unique ID'
);
}
if (in_array($operation->id, $ids)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Duplicate operation ID found: {$operation->id}"
);
}

View File

@@ -15,7 +15,15 @@ final readonly class Cookie
public bool $secure = false,
public bool $httpOnly = false,
public SameSite $sameSite = SameSite::Lax,
public bool $partitioned = false,
) {
// CHIPS (Cookies Having Independent Partitioned State) requirements:
// Partitioned cookies MUST have Secure=true AND SameSite=None
if ($this->partitioned && (!$this->secure || $this->sameSite !== SameSite::None)) {
throw new \InvalidArgumentException(
'Partitioned cookies require Secure=true and SameSite=None'
);
}
}
public function toHeaderString(): string
@@ -44,6 +52,10 @@ final readonly class Cookie
$cookie .= '; SameSite=' . $this->sameSite->value;
if ($this->partitioned) {
$cookie .= '; Partitioned';
}
return $cookie;
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Cookies;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Service for creating cookies with framework-compliant patterns.
*
* Supports CHIPS (Cookies Having Independent Partitioned State) for third-party contexts.
*/
final readonly class CookieService
{
private const MAX_COOKIE_SIZE = 4096;
public function __construct(
private Environment $environment
) {
}
/**
* Create a standard cookie.
*/
public function create(
string $name,
string $value,
?Duration $maxAge = null,
string $path = '/',
?string $domain = null,
?bool $secure = null,
bool $httpOnly = true,
SameSite $sameSite = SameSite::Lax
): Cookie {
// Security: enforce secure in production
$isProduction = $this->environment->get(EnvKey::APP_ENV) === 'production';
$secure = $secure ?? $isProduction;
// Calculate expiration timestamp
$expires = $maxAge ? Timestamp::now()->add($maxAge)->toTimestamp() : null;
return new Cookie(
name: $name,
value: $value,
expires: $expires,
path: $path,
domain: $domain ?? '',
secure: $secure,
httpOnly: $httpOnly,
sameSite: $sameSite,
partitioned: false
);
}
/**
* Create a CHIPS (partitioned) cookie for third-party contexts.
*
* Use cases:
* - Embedded widgets (chat, support, social media)
* - Third-party analytics in iframes
* - Payment provider embeds (Stripe, PayPal)
* - OAuth flows in iframes
*
* Requirements:
* - Must be Secure (HTTPS only)
* - Must have SameSite=None
* - Partitioned attribute set
*
* @throws \InvalidArgumentException if cookie validation fails
*/
public function createPartitioned(
string $name,
string $value,
?Duration $maxAge = null,
string $path = '/',
?string $domain = null,
bool $httpOnly = true
): Cookie {
$expires = $maxAge ? Timestamp::now()->add($maxAge)->toTimestamp() : null;
// CHIPS cookies MUST be Secure and SameSite=None
return new Cookie(
name: $name,
value: $value,
expires: $expires,
path: $path,
domain: $domain ?? '',
secure: true, // Required for Partitioned
httpOnly: $httpOnly,
sameSite: SameSite::None, // Required for Partitioned
partitioned: true // CHIPS attribute
);
}
/**
* Create a session cookie (no expiration).
*/
public function createSessionCookie(
string $name,
string $value,
bool $httpOnly = true,
SameSite $sameSite = SameSite::Lax
): Cookie {
return $this->create(
name: $name,
value: $value,
maxAge: null, // Session cookie
httpOnly: $httpOnly,
sameSite: $sameSite
);
}
/**
* Create a partitioned session cookie for third-party embeds.
*/
public function createPartitionedSession(
string $name,
string $value,
bool $httpOnly = true
): Cookie {
return new Cookie(
name: $name,
value: $value,
expires: null, // Session cookie
path: '/',
domain: '',
secure: true,
httpOnly: $httpOnly,
sameSite: SameSite::None,
partitioned: true
);
}
/**
* Create a remember-me cookie (typically 30 days).
*/
public function createRememberMeCookie(
string $value,
?Duration $maxAge = null
): Cookie {
return $this->create(
name: 'remember_me',
value: $value,
maxAge: $maxAge ?? Duration::fromDays(30),
httpOnly: true,
sameSite: SameSite::Strict
);
}
/**
* Create a third-party analytics cookie (partitioned).
*/
public function createAnalyticsCookie(
string $userId,
?Duration $maxAge = null
): Cookie {
return $this->createPartitioned(
name: '_analytics',
value: $userId,
maxAge: $maxAge ?? Duration::fromDays(365),
httpOnly: false // JavaScript access needed for analytics
);
}
/**
* Create a third-party widget state cookie (partitioned).
*/
public function createWidgetStateCookie(
string $widgetId,
string $state,
?Duration $maxAge = null
): Cookie {
return $this->createPartitioned(
name: "widget_{$widgetId}",
value: $state,
maxAge: $maxAge ?? Duration::fromDays(30),
httpOnly: true
);
}
/**
* Create a cookie deletion instruction.
*
* Sets expiration to past time to delete cookie.
*/
public function delete(string $name, string $path = '/'): Cookie
{
// Set expiration to 1 second ago (past time deletes cookie)
return new Cookie(
name: $name,
value: '',
expires: time() - 1,
path: $path,
domain: '',
secure: false,
httpOnly: false,
sameSite: SameSite::Lax,
partitioned: false
);
}
/**
* Validate cookie size is within browser limits.
*/
public function validateSize(Cookie $cookie): bool
{
return strlen($cookie->toHeaderString()) <= self::MAX_COOKIE_SIZE;
}
/**
* Check if cookie name is valid.
*/
public function isValidName(string $name): bool
{
// Cookie name must not contain: ( ) < > @ , ; : \ " / [ ] ? = { } space tab
return preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
}

View File

@@ -6,13 +6,14 @@ namespace App\Framework\Http\Emitter;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\AdaptiveStreamResponse;
use InvalidArgumentException;
final class AdaptiveStreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (! $response instanceof AdaptiveStreamResponse) {
throw new \InvalidArgumentException('Response must be AdaptiveStreamResponse');
throw new InvalidArgumentException('Response must be AdaptiveStreamResponse');
}
// HTTP Status senden

View File

@@ -9,6 +9,8 @@ use App\Framework\DateTime\Timer;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\SseStream;
use InvalidArgumentException;
use Throwable;
final readonly class SseEmitter implements Emitter
{
@@ -20,7 +22,7 @@ final readonly class SseEmitter implements Emitter
public function emit(Response $response): void
{
if ($response instanceof SseResponse === false) {
throw new \InvalidArgumentException('Response must be an instance of SseResponse');
throw new InvalidArgumentException('Response must be an instance of SseResponse');
}
// Stream-Objekt erstellen
@@ -53,7 +55,7 @@ final readonly class SseEmitter implements Emitter
if ($callback !== null) {
try {
call_user_func($callback, $stream);
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Fehler im Callback abfangen
$stream->sendJson([
'error' => $e->getMessage(),

View File

@@ -8,13 +8,14 @@ use App\Framework\Http\Range;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\StreamResponse;
use App\Framework\Http\Status;
use InvalidArgumentException;
final class StreamEmitter implements Emitter
{
public function emit(Response $response): void
{
if (! $response instanceof StreamResponse) {
throw new \InvalidArgumentException('Response must be StreamResponse');
throw new InvalidArgumentException('Response must be StreamResponse');
}
$chunkSize = 8192;

View File

@@ -24,7 +24,7 @@ final class MiddlewareTimeoutException extends FrameworkException
$timeout
);
$context = ExceptionContext::create()
$context = ExceptionContext::empty()
->withData([
'middleware_name' => $middlewareName,
'timeout' => $timeout,

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\Cookies\Cookies;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
use App\Framework\Router\ValueObjects\RouteParameters;
/**
@@ -54,7 +56,7 @@ final readonly class HttpRequest implements Request
return $this->path;
}
public function getUri(): Uri
public function getUri(): Url
{
$scheme = $this->server->isHttps() ? 'https' : 'http';
$host = $this->server->getHttpHost() ?: 'localhost';
@@ -70,6 +72,6 @@ final readonly class HttpRequest implements Request
$uri .= '?' . http_build_query($this->queryParams);
}
return new Uri($uri);
return UrlFactory::parse($uri);
}
}

View File

@@ -23,22 +23,23 @@ final readonly class IpAddress
return new self($ip);
}
public static function fromRequest(): ?self
public static function fromRequest(ServerEnvironment $server): ?self
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_CLIENT_IP', // Proxy
'HTTP_X_FORWARDED_FOR', // Load balancer/proxy
'HTTP_X_FORWARDED', // Proxy
'HTTP_X_CLUSTER_CLIENT_IP', // Cluster
'HTTP_FORWARDED_FOR', // Proxy
'HTTP_FORWARDED', // Proxy
'REMOTE_ADDR', // Standard
$keys = [
ServerKey::HTTP_CF_CONNECTING_IP, // Cloudflare
ServerKey::HTTP_CLIENT_IP, // Proxy
ServerKey::HTTP_X_FORWARDED_FOR, // Load balancer/proxy
ServerKey::HTTP_X_FORWARDED, // Proxy
ServerKey::HTTP_X_CLUSTER_CLIENT_IP, // Cluster
ServerKey::HTTP_FORWARDED_FOR, // Proxy
ServerKey::HTTP_FORWARDED, // Proxy
ServerKey::REMOTE_ADDR, // Standard
];
foreach ($headers as $header) {
if (! empty($_SERVER[$header])) {
$ip = trim(explode(',', $_SERVER[$header])[0]);
foreach ($keys as $key) {
$value = $server->get($key);
if ($value !== null && $value !== '') {
$ip = trim(explode(',', $value)[0]);
if (self::isValid($ip)) {
return new self($ip);
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\DI\Container;
use App\Framework\Exception\Security\CsrfValidationFailedException;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\Method;
use App\Framework\Http\MiddlewareContext;
@@ -28,9 +29,13 @@ final readonly class CsrfMiddleware implements HttpMiddleware
{
$request = $context->request;
// Skip CSRF validation for API routes temporarily for testing
if (str_starts_with($request->path, '/api/')) {
error_log("CsrfMiddleware: Skipping CSRF validation for API route: " . $request->path);
// Skip CSRF validation for API routes and LiveComponent AJAX endpoints
// LiveComponents use stateless, component-scoped security model instead
if (str_starts_with($request->path, '/api/') ||
str_starts_with($request->path, '/live-component/') ||
str_starts_with($request->path, '/livecomponent/')) {
error_log("CsrfMiddleware: Skipping CSRF validation for: " . $request->path);
return $next($context);
}
@@ -71,19 +76,22 @@ final readonly class CsrfMiddleware implements HttpMiddleware
error_log("CSRF Debug: Validating tokens for form_id='$formId'");
if (! $formId || ! $tokenValue) {
throw new \InvalidArgumentException('CSRF protection requires both form ID and token');
throw CsrfValidationFailedException::missingTokenOrFormId(
missingFormId: !$formId,
missingToken: !$tokenValue
);
}
try {
$token = CsrfToken::fromString($tokenValue);
} catch (\InvalidArgumentException $e) {
throw new \InvalidArgumentException('Invalid CSRF token format: ' . $e->getMessage());
throw CsrfValidationFailedException::invalidTokenFormat($e->getMessage());
}
if (! $session->csrf->validateToken($formId, $token)) {
error_log("CSRF validation failed for form: " . $formId);
throw new \RuntimeException('CSRF token validation failed. This may indicate a security threat.');
throw CsrfValidationFailedException::tokenValidationFailed($formId);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Exception\Security\HoneypotTriggeredException;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\Method;
use App\Framework\Http\MiddlewareContext;
@@ -35,8 +36,10 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
private function validateHoneypot(Request $request): void
{
// Skip honeypot validation for API routes (they use different authentication)
if (str_starts_with($request->path, '/api/')) {
// Skip honeypot validation for API routes and LiveComponent AJAX endpoints
if (str_starts_with($request->path, '/api/') ||
str_starts_with($request->path, '/live-component/') ||
str_starts_with($request->path, '/livecomponent/')) {
return;
}
@@ -45,7 +48,7 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
if (! $honeypotName) {
$this->logSuspiciousActivity('Missing honeypot name', $request);
throw new \Exception('Spam-Schutz ausgelöst');
throw HoneypotTriggeredException::missingHoneypotName();
}
$honeypotValue = $request->parsedBody->get($honeypotName);
@@ -54,7 +57,7 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
if (! empty($honeypotValue)) {
$this->logSuspiciousActivity("Honeypot filled: {$honeypotName} = {$honeypotValue}", $request);
throw new \Exception('Spam-Schutz ausgelöst');
throw HoneypotTriggeredException::honeypotFilled($honeypotName, $honeypotValue);
}
// Zusätzliche Zeit-basierte Validierung (optional)
@@ -66,10 +69,15 @@ final readonly class HoneypotMiddleware implements HttpMiddleware
// Formulare, die zu schnell abgeschickt werden, sind verdächtig
$startTime = $request->parsedBody->get('_form_start_time');
if ($startTime && (time() - (int)$startTime) < 2) {
$this->logSuspiciousActivity('Form submitted too quickly', $request);
if ($startTime) {
$elapsedSeconds = time() - (int)$startTime;
$minimumSeconds = 2;
throw new \Exception('Spam-Schutz ausgelöst');
if ($elapsedSeconds < $minimumSeconds) {
$this->logSuspiciousActivity('Form submitted too quickly', $request);
throw HoneypotTriggeredException::submittedTooQuickly($elapsedSeconds, $minimumSeconds);
}
}
}

View File

@@ -4,12 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Http\Middlewares;
use App\Framework\Auth\Attributes\IpAuth;
use App\Framework\Auth\Auth;
use App\Framework\Auth\ValueObjects\IpAuthPolicy;
use App\Framework\Auth\RouteAuthorizationService;
use App\Framework\Config\TypedConfiguration;
use App\Framework\DI\Container;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\IpAddress;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
@@ -29,7 +27,6 @@ use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\RouteContext;
use App\Framework\Router\RouteDispatcher;
use App\Framework\Router\Router;
use App\Framework\DI\Container;
#[MiddlewarePriorityAttribute(MiddlewarePriority::ROUTING)]
final readonly class RoutingMiddleware implements HttpMiddleware
@@ -40,7 +37,7 @@ final readonly class RoutingMiddleware implements HttpMiddleware
private TypedConfiguration $config,
private PerformanceServiceInterface $performanceService,
private Container $container,
private array $namespaceConfig = []
private RouteAuthorizationService $authService
) {
}
@@ -99,7 +96,7 @@ final readonly class RoutingMiddleware implements HttpMiddleware
// Extract and set route parameters in request (for dynamic routes like /campaign/{slug})
$route = $routeContext->match->route;
if ($route instanceof \App\Framework\Core\DynamicRoute && !empty($route->paramValues)) {
if ($route instanceof \App\Framework\Core\DynamicRoute && ! empty($route->paramValues)) {
error_log("DEBUG: Route param values extracted: " . json_encode($route->paramValues));
$request = new \App\Framework\Http\HttpRequest(
method: $request->method,
@@ -121,8 +118,8 @@ final readonly class RoutingMiddleware implements HttpMiddleware
$this->container->instance(\App\Framework\Http\HttpRequest::class, $request);
}
// Perform IP and namespace-based authentication
$this->performAuthenticationChecks($request, $routeContext);
// Perform route authorization checks
$this->authService->authorize($request, $routeContext);
// Measure controller dispatch
$controllerName = $routeContext->match->route->controller . '::' . $routeContext->match->route->action;
@@ -180,158 +177,4 @@ final readonly class RoutingMiddleware implements HttpMiddleware
// Optional: Fallback, z.B. Fehler- oder Defaultseite
return new JsonResult(['message' => 'Not found']);
}
/**
* Perform IP and namespace-based authentication checks
*/
private function performAuthenticationChecks(Request $request, RouteContext $routeContext): void
{
$clientIp = $this->getClientIp($request);
$controllerClass = $routeContext->match->route->controller;
// Check legacy Auth attribute (backward compatibility)
if (! $this->config->app->debug && in_array(Auth::class, $routeContext->match->route->attributes)) {
$wireguardIp = '172.20.0.1';
if ($clientIp->value !== $wireguardIp) {
throw new RouteNotFound($routeContext->path);
}
}
// Check namespace-based restrictions
$namespacePolicy = $this->getNamespacePolicy($controllerClass);
if ($namespacePolicy && ! $namespacePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($routeContext->path);
}
// Check route-specific IP auth attributes
$ipAuthAttribute = $this->getIpAuthAttribute($routeContext->match->route);
if ($ipAuthAttribute) {
$routePolicy = $ipAuthAttribute->createPolicy();
if (! $routePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($routeContext->path);
}
}
}
/**
* Get client IP address
*/
private function getClientIp(Request $request): IpAddress
{
return $request->server->getClientIp() ?? IpAddress::localhost();
}
/**
* Get namespace-based IP policy
*/
private function getNamespacePolicy(string $controllerClass): ?IpAuthPolicy
{
$namespace = $this->extractNamespace($controllerClass);
foreach ($this->namespaceConfig as $pattern => $config) {
if ($this->namespaceMatches($namespace, $pattern)) {
return $this->createPolicyFromConfig($config);
}
}
return null;
}
/**
* Extract namespace from class name
*/
private function extractNamespace(string $className): string
{
$parts = explode('\\', $className);
array_pop($parts); // Remove class name
return implode('\\', $parts);
}
/**
* Check if namespace matches pattern
*/
private function namespaceMatches(string $namespace, string $pattern): bool
{
// Exact match
if ($namespace === $pattern) {
return true;
}
// Wildcard pattern (e.g., "App\Admin\*")
if (str_ends_with($pattern, '*')) {
$prefix = rtrim($pattern, '*');
return str_starts_with($namespace, $prefix);
}
// Prefix match
return str_starts_with($namespace, $pattern);
}
/**
* Create policy from configuration
*/
private function createPolicyFromConfig(array $config): IpAuthPolicy
{
$visibility = $config['visibility'] ?? 'public';
return match ($visibility) {
'admin' => IpAuthPolicy::adminOnly(),
'local' => IpAuthPolicy::localOnly(),
'development' => IpAuthPolicy::development(),
'private' => IpAuthPolicy::localOnly(),
'custom' => IpAuthPolicy::fromConfig($config),
default => IpAuthPolicy::fromConfig([]) // No restrictions for 'public'
};
}
/**
* Get IpAuth attribute from route
*/
private function getIpAuthAttribute($route): ?IpAuth
{
// Check if route has IpAuth attribute in its attributes array
foreach ($route->attributes ?? [] as $attribute) {
if ($attribute instanceof IpAuth) {
return $attribute;
}
}
// Also check via reflection for method-level attributes
try {
$reflection = new \ReflectionMethod($route->controller, $route->action);
$attributes = $reflection->getAttributes(IpAuth::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance();
}
// Check controller class for IpAuth attribute
$classReflection = new \ReflectionClass($route->controller);
$classAttributes = $classReflection->getAttributes(IpAuth::class);
if (! empty($classAttributes)) {
return $classAttributes[0]->newInstance();
}
} catch (\ReflectionException $e) {
// Ignore reflection errors
}
return null;
}
/**
* Create routing middleware with namespace configuration
*/
public static function withNamespaceConfig(
Router $router,
RouteDispatcher $dispatcher,
TypedConfiguration $config,
PerformanceServiceInterface $performanceService,
Container $container,
array $namespaceConfig
): self {
return new self($router, $dispatcher, $config, $performanceService, $container, $namespaceConfig);
}
}

View File

@@ -14,7 +14,6 @@ use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Http\UploadedFile;
use App\Framework\Http\UploadedFiles;
use App\Framework\Http\Uri;
use InvalidArgumentException;
/**
@@ -175,26 +174,14 @@ final readonly class HttpRequestParser
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
if (str_contains($contentType, 'multipart/form-data')) {
error_log("HttpRequestParser: Detected multipart/form-data");
error_log("HttpRequestParser: rawBody length = " . strlen($rawBody));
error_log("HttpRequestParser: \$_POST count = " . count($_POST));
error_log("HttpRequestParser: \$_FILES count = " . count($_FILES));
// For multipart/form-data, PHP automatically populates $_POST and $_FILES
// and makes php://input empty. Use $_POST and $_FILES directly in this case.
if (strlen($rawBody) === 0 && (! empty($_POST) || ! empty($_FILES))) {
error_log("HttpRequestParser: Using \$_POST fallback for multipart/form-data");
error_log("HttpRequestParser: \$_POST = " . json_encode($_POST));
error_log("HttpRequestParser: \$_FILES = " . json_encode($_FILES));
error_log("HttpRequestParser: \$_FILES empty? " . (empty($_FILES) ? 'YES' : 'NO'));
$parsedBody = $_POST;
// Also handle $_FILES if available
if (! empty($_FILES)) {
error_log("HttpRequestParser: Creating UploadedFiles from \$_FILES");
$uploadedFiles = UploadedFiles::fromFilesArray($_FILES);
} else {
error_log("HttpRequestParser: \$_FILES is empty, using empty UploadedFiles");
}
} else {
// Extract boundary
@@ -239,7 +226,7 @@ final readonly class HttpRequestParser
);
// Generate request ID
$requestId = $this->requestIdGenerator->generate();
$requestId = $this->requestIdGenerator->generate($headers);
// Create the request
return new HttpRequest(

View File

@@ -30,16 +30,6 @@ final readonly class RequestFactory
// Get raw body once
$rawBody = file_get_contents('php://input') ?: '';
// Debug: Track request parsing
error_log("RequestFactory: rawBody length = " . strlen($rawBody));
error_log("RequestFactory: \$_POST count = " . count($_POST));
error_log("RequestFactory: \$_FILES count = " . count($_FILES));
error_log("RequestFactory: Content-Type = " . ($_SERVER['CONTENT_TYPE'] ?? 'not set'));
if (strlen($rawBody) === 0 && ! empty($_POST)) {
error_log("RequestFactory: Detected empty php://input with populated \$_POST");
}
// Use parser with minimal superglobal access
return $this->parser->parseFromGlobals($_SERVER, $rawBody);
}

View File

@@ -31,11 +31,11 @@ final class RequestIdGenerator
/**
* Generiert eine neue Request-ID oder validiert und verwendet eine bestehende
*/
public function generate(): RequestId
public function generate(?Headers $headers = null): RequestId
{
if ($this->requestId === null) {
// Prüfen, ob eine Request-ID im Header vorhanden ist
$headerRequestId = $_SERVER[str_replace('-', '_', 'HTTP_' . self::REQUEST_ID_HEADER)] ?? null;
$headerRequestId = $headers?->getFirst(self::REQUEST_ID_HEADER);
// Neue RequestId erstellen (validiert automatisch die Header-ID, falls vorhanden)
$this->requestId = new RequestId($headerRequestId, $this->secret);

View File

@@ -7,6 +7,8 @@ namespace App\Framework\Http\Responses;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use JsonException;
use RuntimeException;
final readonly class JsonResponse implements Response
{
@@ -19,6 +21,24 @@ final readonly class JsonResponse implements Response
public Status $status = Status::OK,
) {
$this->headers = new Headers()->with('Content-Type', 'application/json');
$this->body = json_encode($body);
try {
$this->body = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (JsonException $e) {
// Log the problematic data for debugging
error_log('JSON encoding failed. Data structure: ' . print_r(array_keys($body), true));
error_log('Error: ' . $e->getMessage());
// Try to identify the problematic key
foreach ($body as $key => $value) {
try {
json_encode([$key => $value], JSON_THROW_ON_ERROR);
} catch (JsonException $keyError) {
error_log("Problematic key: '{$key}' - " . $keyError->getMessage());
}
}
throw new RuntimeException('Failed to encode JSON: ' . $e->getMessage(), 0, $e);
}
}
}

View File

@@ -8,7 +8,8 @@ use App\Framework\Http\HeaderKey;
use App\Framework\Http\Headers;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\Http\Uri;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
final readonly class RedirectResponse implements Response
{
@@ -18,11 +19,17 @@ final readonly class RedirectResponse implements Response
public Status $status;
public Url $location;
public function __construct(
public Uri $location = new Uri('/'),
string|Url $location = '/',
Status $status = Status::FOUND
) {
$this->status = Status::FOUND;
$this->location = is_string($location)
? UrlFactory::forBrowserRedirect($location)
: $location;
$this->status = $status;
$this->body = '';
$this->headers = new Headers()->with(HeaderKey::LOCATION, (string)$this->location);
$this->headers = new Headers()->with(HeaderKey::LOCATION, $this->location->toString());
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
use App\Framework\UserAgent\UserAgent;
/**
@@ -62,11 +64,11 @@ final readonly class ServerEnvironment
return (int) $this->get(ServerKey::SERVER_PORT, 80);
}
public function getRequestUri(): Uri
public function getRequestUri(): Url
{
$uriString = $this->get(ServerKey::REQUEST_URI, '/');
return new Uri($uriString);
return UrlFactory::parse($uriString);
}
public function getScriptName(): string
@@ -147,11 +149,11 @@ final readonly class ServerEnvironment
return $this->get(ServerKey::HTTP_REFERER, '');
}
public function getRefererUri(): Uri
public function getRefererUri(): Url
{
$referer = $this->getReferer();
return new Uri($referer);
return UrlFactory::parse($referer);
}
/**
@@ -198,4 +200,28 @@ final readonly class ServerEnvironment
return $referer;
}
/**
* Get WebSocket key for WebSocket handshake
*/
public function getWebSocketKey(): ?string
{
return $this->get(ServerKey::HTTP_SEC_WEBSOCKET_KEY);
}
/**
* Check if request is XMLHttpRequest (AJAX)
*/
public function isXmlHttpRequest(): bool
{
return $this->get(ServerKey::HTTP_X_REQUESTED_WITH) === 'XMLHttpRequest';
}
/**
* Check if request has SPA header
*/
public function isSpaRequest(): bool
{
return $this->has(ServerKey::HTTP_X_SPA_REQUEST);
}
}

View File

@@ -40,7 +40,16 @@ enum ServerKey: string
case HTTP_AUTHORIZATION = 'HTTP_AUTHORIZATION';
case HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR';
case HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO';
case HTTP_X_FORWARDED = 'HTTP_X_FORWARDED';
case HTTP_X_REAL_IP = 'HTTP_X_REAL_IP';
case HTTP_CF_CONNECTING_IP = 'HTTP_CF_CONNECTING_IP';
case HTTP_CLIENT_IP = 'HTTP_CLIENT_IP';
case HTTP_X_CLUSTER_CLIENT_IP = 'HTTP_X_CLUSTER_CLIENT_IP';
case HTTP_FORWARDED_FOR = 'HTTP_FORWARDED_FOR';
case HTTP_FORWARDED = 'HTTP_FORWARDED';
case HTTP_SEC_WEBSOCKET_KEY = 'HTTP_SEC_WEBSOCKET_KEY';
case HTTP_X_REQUESTED_WITH = 'HTTP_X_REQUESTED_WITH';
case HTTP_X_SPA_REQUEST = 'HTTP_X_SPA_REQUEST';
// HTTPS und Sicherheit
case HTTPS = 'HTTPS';

View File

@@ -9,7 +9,7 @@ use App\Framework\Filesystem\Exceptions\DirectoryCreateException;
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
use App\Framework\Filesystem\Exceptions\FileReadException;
use App\Framework\Filesystem\Exceptions\FileWriteException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\Serializers\JsonSerializer;

View File

@@ -9,7 +9,7 @@ use App\Framework\Router\Result\SseEvent;
/**
* Verwaltet den SSE-Stream für Server-Sent Events
*/
final class SseStream
final class SseStream implements SseStreamInterface
{
private bool $isStreaming = false;

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
use App\Framework\Router\Result\SseEvent;
/**
* SSE Stream Interface
*
* Defines the contract for Server-Sent Events stream implementations.
*/
interface SseStreamInterface
{
/**
* Send an SSE event to the client
*
* @throws \RuntimeException if connection is not active
*/
public function sendEvent(SseEvent $event): void;
/**
* Send heartbeat to keep connection alive
*
* @throws \RuntimeException if connection is not active
*/
public function sendHeartbeat(): void;
/**
* Check if stream is active
*/
public function isActive(): bool;
/**
* Check if underlying connection is active
*/
public function isConnectionActive(): bool;
}

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http;
/**
* Value Object für URLs/URIs
*/
final readonly class Uri
{
public function __construct(
public string $value
) {
}
public function getPath(): string
{
return parse_url($this->value, PHP_URL_PATH) ?? '/';
}
public function getQuery(): string
{
return parse_url($this->value, PHP_URL_QUERY) ?? '';
}
public function __toString(): string
{
return $this->value;
}
public function toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,373 @@
# Native PHP 8.5 URL System
**Zero-dependency URL parsing and manipulation** using native PHP 8.5 `Uri\Rfc3986\Uri` and `Uri\WhatWg\Url`.
## Features
**Native PHP 8.5 API** - No external dependencies
**Dual Spec Support** - RFC 3986 and WHATWG URL Standard
**Smart Factory** - Automatic spec selection based on use case
**Immutable Withers** - Framework-compliant readonly pattern
**Type Safe** - Full type safety with enums and value objects
**IDNA/Punycode** - Native international domain name support
## Installation
**Requirement**: PHP 8.5+
No composer packages needed - uses native PHP URL API!
## Quick Start
### Basic Usage
```php
use App\Framework\Http\Url\UrlFactory;
// Automatic spec selection
$url = UrlFactory::parse('https://example.com/path');
// API Client (RFC 3986)
$apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
// Browser Redirect (WHATWG)
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/dashboard');
```
### URL Manipulation (Immutable)
```php
$url = UrlFactory::forApiClient('https://api.example.com/users');
// Immutable withers return new instances
$withAuth = $url->withUserInfo('api_key', 'secret');
$withQuery = $url->withQuery('filter=active&sort=name');
$withPath = $url->withPath('/v2/users');
// Component access
echo $url->getScheme(); // 'https'
echo $url->getHost(); // 'api.example.com'
echo $url->getPath(); // '/users'
```
### Use Case Factory Methods
```php
// API & Server-Side (RFC 3986)
UrlFactory::forApiClient($url); // REST, GraphQL, SOAP
UrlFactory::forCurlRequest($url); // cURL operations
UrlFactory::forSignature($url); // OAuth, AWS signing
UrlFactory::forCanonical($url); // SEO, duplicate detection
// Browser & Client-Side (WHATWG)
UrlFactory::forBrowserRedirect($url); // HTTP redirects
UrlFactory::forDeepLink($url); // Universal links
UrlFactory::forFormAction($url); // HTML form actions
UrlFactory::forClientSide($url); // JavaScript fetch()
```
## Architecture
### URL Specifications
**RFC 3986** - Server-side URL handling:
- Strict parsing rules
- No automatic encoding
- Preserves exact structure
- Best for: API clients, signatures, cURL
**WHATWG** - Browser-compatible URL handling:
- Living standard (matches browsers)
- Automatic percent-encoding
- URL normalization
- Best for: Redirects, deep links, forms
### Components
```
src/Framework/Http/Url/
├── Url.php # Unified URL interface
├── UrlSpec.php # RFC3986 vs WHATWG enum
├── UrlUseCase.php # Use case categories
├── Rfc3986Url.php # RFC 3986 implementation
├── WhatwgUrl.php # WHATWG implementation
└── UrlFactory.php # Smart factory
```
## Examples
### API Client with Authentication
```php
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
$withAuth = $apiUrl->withUserInfo('api_key', 'secret_token');
echo $withAuth->toString();
// https://api_key:secret_token@api.example.com/resource
```
### URL Signature Generation
```php
$url = UrlFactory::forSignature('https://api.example.com/resource');
$withParams = $url->withQuery('timestamp=1234567890&user_id=42');
// Generate signature from canonical URL string
$canonical = $withParams->toString();
$signature = hash_hmac('sha256', $canonical, $secretKey);
$signed = $withParams->withQuery(
$withParams->getQuery() . "&signature={$signature}"
);
```
### Browser Redirect with Parameters
```php
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/login');
$withReturn = $redirect->withQuery('return_url=/dashboard&status=success');
// Browser-compatible encoding
header('Location: ' . $withReturn->toString());
```
### Deep Link with Fallback
```php
$deepLink = UrlFactory::forDeepLink('myapp://open/article/123');
$fallback = UrlFactory::forBrowserRedirect('https://example.com/article/123');
// Try deep link first, fallback to web
$targetUrl = $isNativeApp ? $deepLink : $fallback;
```
### IDNA/Punycode Support
```php
$unicode = UrlFactory::parse('https://例え.jp/path');
$ascii = $unicode->toAsciiString();
echo $ascii; // https://xn--r8jz45g.jp/path
```
### URL Comparison
```php
$url1 = UrlFactory::parse('https://example.com/path#frag1');
$url2 = UrlFactory::parse('https://example.com/path#frag2');
// Ignore fragment by default
$url1->equals($url2); // true
// Include fragment in comparison
$url1->equals($url2, includeFragment: true); // false
```
### Relative URL Resolution
```php
$base = UrlFactory::parse('https://example.com/base/path');
$resolved = $base->resolve('../other/resource');
echo $resolved->toString();
// https://example.com/other/resource
```
### Spec Conversion
```php
$rfc = UrlFactory::forApiClient('https://example.com/path');
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
// Now browser-compatible with normalization
```
## Use Case Guide
### When to use RFC 3986
✅ REST API requests
✅ URL signature generation (OAuth, AWS)
✅ cURL operations
✅ Canonical URL generation (SEO)
✅ Webhook URLs
✅ FTP/SFTP URLs
### When to use WHATWG
✅ HTTP redirects
✅ Deep links / universal links
✅ HTML form actions
✅ JavaScript fetch() compatibility
✅ Browser-side URL generation
✅ Mobile app URLs
## Testing
Comprehensive Pest tests included:
```bash
./vendor/bin/pest tests/Unit/Framework/Http/Url/
```
Test coverage:
- RFC 3986 parsing and manipulation
- WHATWG parsing and normalization
- Factory method selection
- URL comparison and resolution
- IDNA/Punycode handling
- Immutability guarantees
- Edge cases and error handling
## Framework Integration
### Readonly Pattern
All URL classes are `final readonly` with immutable withers:
```php
final readonly class Rfc3986Url implements Url
{
private function __construct(
private NativeRfc3986Uri $uri
) {}
// Withers return new instances
public function withPath(string $path): self
{
return new self($this->uri->withPath($path));
}
}
```
### Value Object Pattern
URLs are value objects with value semantics:
```php
$url1 = UrlFactory::parse('https://example.com/path');
$url2 = UrlFactory::parse('https://example.com/path');
$url1->equals($url2); // true
```
### DI Container Integration
Register in container initializer:
```php
final readonly class UrlServiceInitializer implements Initializer
{
#[Initializer]
public function __invoke(): UrlService
{
return new UrlService(UrlFactory::class);
}
}
```
## Performance
Native PHP 8.5 implementation = **C-level performance**:
- ✅ Zero external dependencies
- ✅ No reflection overhead
- ✅ Optimized memory usage
- ✅ Fast parsing and manipulation
- ✅ Native IDNA conversion
## Migration from Legacy Code
### Before (primitive strings)
```php
function generateApiUrl(string $baseUrl, array $params): string
{
$query = http_build_query($params);
return $baseUrl . '?' . $query;
}
```
### After (type-safe URLs)
```php
function generateApiUrl(Url $baseUrl, array $params): Url
{
$query = http_build_query($params);
return $baseUrl->withQuery($query);
}
// Usage
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
$withParams = generateApiUrl($apiUrl, ['filter' => 'active']);
```
## Best Practices
1. **Use factory methods** for automatic spec selection
2. **Prefer specific use case methods** over generic parse()
3. **Type hint with Url** for flexibility
4. **Use equals() for comparison** instead of string comparison
5. **Leverage immutability** - withers are safe for concurrent use
6. **Choose correct spec** - RFC 3986 for server, WHATWG for browser
## Advanced Features
### Native URL Access
Access underlying native PHP URL objects when needed:
```php
$url = UrlFactory::forApiClient('https://example.com');
$nativeUri = $url->getNativeUrl(); // \Uri\Rfc3986\Uri
```
### Custom URL Schemes
Both specs support custom schemes:
```php
$custom = UrlFactory::parse('myscheme://resource/path');
echo $custom->getScheme(); // 'myscheme'
```
## Troubleshooting
### Invalid URL Errors
```php
try {
$url = UrlFactory::parse('invalid://url');
} catch (\InvalidArgumentException $e) {
// Handle parse error
}
```
### Spec Mismatch
Convert between specs when needed:
```php
$rfc = UrlFactory::forApiClient('https://example.com');
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
```
### IDNA Issues
Native PHP 8.5 handles IDNA automatically:
```php
$url = UrlFactory::parse('https://例え.jp');
$ascii = $url->toAsciiString(); // Automatic Punycode
```
## Resources
- [RFC 3986 Specification](https://www.rfc-editor.org/rfc/rfc3986)
- [WHATWG URL Standard](https://url.spec.whatwg.org/)
- [PHP 8.5 URL API Documentation](https://www.php.net/manual/en/book.uri.php)
- [IDNA/Punycode Reference](https://www.rfc-editor.org/rfc/rfc5891)
## License
Part of Custom PHP Framework - Internal Use

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
use InvalidArgumentException;
use Throwable;
use Uri\Rfc3986\Uri as NativeRfc3986Uri;
/**
* RFC 3986 compliant URL implementation
*
* Wraps native PHP 8.5+ Uri\Rfc3986\Uri for server-side URL handling.
*
* Use Cases:
* - API clients (REST, GraphQL, SOAP)
* - URL signatures (OAuth, AWS, etc.)
* - cURL requests
* - Server-side canonicalization
*
* Characteristics:
* - Strict RFC 3986 compliance
* - No automatic encoding
* - Preserves exact URL structure
* - Deterministic formatting for signatures
*/
final readonly class Rfc3986Url implements Url
{
/**
* @param NativeRfc3986Uri $uri Native PHP RFC 3986 URI instance
*/
private function __construct(
private NativeRfc3986Uri $uri
) {
}
/**
* Parse RFC 3986 URI from string
*
* @param string $input URI string to parse
* @param Url|null $base Optional base URI for relative resolution
* @return self New RFC 3986 URL instance
* @throws InvalidArgumentException If URI is invalid
*/
public static function parse(string $input, ?Url $base = null): self
{
try {
if ($base instanceof self) {
// RFC 3986 reference resolution with base URI
$uri = $base->uri->resolve($input);
} else {
$uri = NativeRfc3986Uri::parse($input);
}
return new self($uri);
} catch (Throwable $e) {
throw new InvalidArgumentException(
"Failed to parse RFC 3986 URI: {$input}",
previous: $e
);
}
}
public function getSpec(): UrlSpec
{
return UrlSpec::RFC3986;
}
// Component Getters
public function getScheme(): string
{
return $this->uri->getScheme() ?? '';
}
public function getHost(): string
{
return $this->uri->getHost() ?? '';
}
public function getPort(): ?int
{
return $this->uri->getPort();
}
public function getPath(): string
{
return $this->uri->getPath() ?? '';
}
public function getQuery(): string
{
return $this->uri->getQuery() ?? '';
}
public function getFragment(): string
{
return $this->uri->getFragment() ?? '';
}
public function getUserInfo(): string
{
return $this->uri->getUserInfo() ?? '';
}
// Immutable Withers (delegate to native withers)
public function withScheme(string $scheme): self
{
return new self($this->uri->withScheme($scheme));
}
public function withHost(string $host): self
{
return new self($this->uri->withHost($host));
}
public function withPort(?int $port): self
{
return new self($this->uri->withPort($port));
}
public function withPath(string $path): self
{
return new self($this->uri->withPath($path));
}
public function withQuery(string $query): self
{
return new self($this->uri->withQuery($query));
}
public function withFragment(string $fragment): self
{
return new self($this->uri->withFragment($fragment));
}
public function withUserInfo(string $user, ?string $password = null): self
{
$userInfo = $password !== null ? "{$user}:{$password}" : $user;
return new self($this->uri->withUserInfo($userInfo));
}
// Serialization
public function toString(): string
{
return $this->uri->toString();
}
public function toAsciiString(): string
{
// Native PHP 8.5 handles IDNA/Punycode conversion
return $this->uri->toRawString();
}
// Utilities
public function resolve(string $relative): self
{
$resolved = $this->uri->resolve($relative);
return new self($resolved);
}
public function equals(Url $other, bool $includeFragment = false): bool
{
if (! $other instanceof self) {
return false;
}
if ($includeFragment) {
return $this->uri->equals($other->uri);
}
// Compare without fragments
$thisWithoutFragment = $this->uri->withFragment(null);
$otherWithoutFragment = $other->uri->withFragment(null);
return $thisWithoutFragment->equals($otherWithoutFragment);
}
public function getNativeUrl(): NativeRfc3986Uri
{
return $this->uri;
}
/**
* String representation (allows string casting)
*/
public function __toString(): string
{
return $this->uri->toString();
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* Unified URL interface for native PHP 8.5 URL API
*
* Abstracts RFC 3986 and WHATWG URL implementations
* providing a unified API surface for URL manipulation.
*
* Replaces old Uri class with full-featured URL handling.
*
* All implementations must be immutable - modification methods
* return new instances (wither pattern).
*/
interface Url
{
/**
* Parse URL from string with optional base URL
*
* @param string $input URL string to parse
* @param self|null $base Optional base URL for relative resolution
* @return self New URL instance
* @throws \InvalidArgumentException If URL is invalid
*/
public static function parse(string $input, ?self $base = null): self;
/**
* Get URL specification this instance conforms to
*
* @return UrlSpec Either RFC3986 or WHATWG
*/
public function getSpec(): UrlSpec;
// Component Getters
/**
* Get URL scheme (e.g., 'https', 'ftp')
*
* @return string Scheme without trailing colon, empty string if absent
*/
public function getScheme(): string;
/**
* Get host component (domain or IP address)
*
* @return string Host, empty string if absent
*/
public function getHost(): string;
/**
* Get port number
*
* @return int|null Port number, null if default or absent
*/
public function getPort(): ?int;
/**
* Get path component
*
* @return string Path, empty string if absent
*/
public function getPath(): string;
/**
* Get query string
*
* @return string Query without leading '?', empty string if absent
*/
public function getQuery(): string;
/**
* Get fragment identifier
*
* @return string Fragment without leading '#', empty string if absent
*/
public function getFragment(): string;
/**
* Get user info (username:password)
*
* @return string User info, empty string if absent
*/
public function getUserInfo(): string;
// Immutable Withers (Framework Pattern)
/**
* Return instance with specified scheme
*
* @param string $scheme New scheme
* @return self New instance with updated scheme
*/
public function withScheme(string $scheme): self;
/**
* Return instance with specified host
*
* @param string $host New host
* @return self New instance with updated host
*/
public function withHost(string $host): self;
/**
* Return instance with specified port
*
* @param int|null $port New port, null for default
* @return self New instance with updated port
*/
public function withPort(?int $port): self;
/**
* Return instance with specified path
*
* @param string $path New path
* @return self New instance with updated path
*/
public function withPath(string $path): self;
/**
* Return instance with specified query
*
* @param string $query New query string (without leading '?')
* @return self New instance with updated query
*/
public function withQuery(string $query): self;
/**
* Return instance with specified fragment
*
* @param string $fragment New fragment (without leading '#')
* @return self New instance with updated fragment
*/
public function withFragment(string $fragment): self;
/**
* Return instance with specified user info
*
* @param string $user Username
* @param string|null $password Optional password
* @return self New instance with updated user info
*/
public function withUserInfo(string $user, ?string $password = null): self;
// Serialization
/**
* Convert URL to string representation
*
* @return string Complete URL string
*/
public function toString(): string;
/**
* Convert URL to ASCII-compatible string (Punycode/IDNA)
*
* @return string ASCII-encoded URL for international domain names
*/
public function toAsciiString(): string;
// Utilities
/**
* Resolve relative URL against this URL as base
*
* @param string $relative Relative URL to resolve
* @return self New URL instance with resolved URL
*/
public function resolve(string $relative): self;
/**
* Check equality with another URL
*
* @param self $other URL to compare
* @param bool $includeFragment Whether to include fragment in comparison
* @return bool True if URLs are equal
*/
public function equals(self $other, bool $includeFragment = false): bool;
/**
* Get underlying native PHP URL object
*
* Provides access to native \Uri\Rfc3986\Uri or \Uri\WhatWg\Url
* for advanced use cases requiring direct native API access.
*
* @return object Native PHP URL object
*/
public function getNativeUrl(): object;
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* Smart URL factory with automatic spec selection
*
* Provides convenient factory methods that automatically select
* the appropriate URL specification (RFC 3986 vs WHATWG) based
* on the intended use case.
*
* Usage:
* ```php
* // API Client (RFC 3986)
* $apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
*
* // Browser Redirect (WHATWG)
* $redirect = UrlFactory::forBrowserRedirect('https://example.com/dashboard');
*
* // Automatic selection
* $url = UrlFactory::forUseCase(UrlUseCase::DEEP_LINK, 'myapp://open');
* ```
*/
final readonly class UrlFactory
{
/**
* Parse URL with automatic spec detection
*
* Attempts to select appropriate spec based on scheme and structure.
* Defaults to RFC 3986 for ambiguous cases.
*
* @param string $input URL string to parse
* @param Url|null $base Optional base URL
* @return Url Parsed URL with selected spec
*/
public static function parse(string $input, ?Url $base = null): Url
{
// Heuristic: Use WHATWG for http/https URLs, RFC 3986 for others
$scheme = parse_url($input, PHP_URL_SCHEME);
if (in_array($scheme, ['http', 'https', 'file', 'ws', 'wss'], true)) {
return WhatwgUrl::parse($input, $base);
}
return Rfc3986Url::parse($input, $base);
}
/**
* Parse URL for specific use case with automatic spec selection
*
* @param UrlUseCase $useCase Intended use case
* @param string $input URL string to parse
* @param Url|null $base Optional base URL
* @return Url Parsed URL with spec matching use case
*/
public static function forUseCase(UrlUseCase $useCase, string $input, ?Url $base = null): Url
{
$spec = UrlSpec::forUseCase($useCase);
return match ($spec) {
UrlSpec::RFC3986 => Rfc3986Url::parse($input, $base),
UrlSpec::WHATWG => WhatwgUrl::parse($input, $base),
};
}
/**
* Create URL for API client use (RFC 3986)
*
* Best for:
* - REST API calls
* - GraphQL endpoints
* - SOAP services
* - Webhook URLs
*
* @param string $input API endpoint URL
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forApiClient(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for cURL request (RFC 3986)
*
* Best for:
* - cURL operations
* - HTTP client requests
* - File transfers
*
* @param string $input Request URL
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forCurlRequest(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for signature generation (RFC 3986)
*
* Best for:
* - OAuth signatures
* - AWS request signing
* - HMAC-based authentication
* - Webhook signature verification
*
* @param string $input URL to sign
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forSignature(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create canonical URL (RFC 3986)
*
* Best for:
* - SEO canonical URLs
* - Duplicate content detection
* - URL normalization
* - Sitemap generation
*
* @param string $input URL to canonicalize
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forCanonical(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for browser redirect (WHATWG)
*
* Best for:
* - HTTP redirects (302, 301, etc.)
* - Location headers
* - User-facing redirects
*
* @param string $input Redirect target URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forBrowserRedirect(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for deep link (WHATWG)
*
* Best for:
* - Universal links
* - App deep links
* - Mobile-to-web links
* - Cross-platform navigation
*
* @param string $input Deep link URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forDeepLink(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for HTML form action (WHATWG)
*
* Best for:
* - Form submission targets
* - HTML5 form actions
* - Browser form handling
*
* @param string $input Form action URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forFormAction(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for client-side JavaScript (WHATWG)
*
* Best for:
* - JavaScript fetch() API
* - XMLHttpRequest URLs
* - Browser URL API compatibility
* - Client-side routing
*
* @param string $input JavaScript URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forClientSide(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Convert between URL specs
*
* @param Url $url URL to convert
* @param UrlSpec $targetSpec Target specification
* @return Url Converted URL
*/
public static function convert(Url $url, UrlSpec $targetSpec): Url
{
if ($url->getSpec() === $targetSpec) {
return $url;
}
$urlString = $url->toString();
return match ($targetSpec) {
UrlSpec::RFC3986 => Rfc3986Url::parse($urlString),
UrlSpec::WHATWG => WhatwgUrl::parse($urlString),
};
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* URL Specification identifier
*
* Distinguishes between RFC 3986 and WHATWG URL Standard
* for different parsing and handling semantics.
*/
enum UrlSpec: string
{
/**
* RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
*
* Use for:
* - Server-side URL canonicalization
* - API clients (REST, GraphQL, SOAP)
* - URL signatures and validation
* - cURL compatibility
* - File system paths
*
* Characteristics:
* - Strict parsing rules
* - No automatic encoding
* - No URL normalization
* - Preserves original structure
*
* Example:
* ```php
* $uri = Rfc3986Url::parse('https://api.example.com/users?id=123');
* ```
*/
case RFC3986 = 'rfc3986';
/**
* WHATWG URL Standard (Living Standard)
*
* Use for:
* - Browser-like URL handling
* - Deep links and redirects
* - Client-side generated URLs
* - HTML form actions
* - JavaScript fetch() API compatibility
*
* Characteristics:
* - Living standard (matches modern browsers)
* - Automatic percent-encoding
* - URL normalization
* - Special scheme handling (http, https, file, etc.)
*
* Example:
* ```php
* $url = WhatwgUrl::parse('https://example.com/redirect');
* ```
*/
case WHATWG = 'whatwg';
/**
* Get recommended spec for specific use case
*
* Automatically selects the appropriate URL specification
* based on the intended usage pattern.
*/
public static function forUseCase(UrlUseCase $useCase): self
{
return match ($useCase) {
UrlUseCase::API_CLIENT,
UrlUseCase::CURL_REQUEST,
UrlUseCase::SIGNATURE_GENERATION,
UrlUseCase::CANONICAL_URL => self::RFC3986,
UrlUseCase::BROWSER_REDIRECT,
UrlUseCase::DEEP_LINK,
UrlUseCase::HTML_FORM_ACTION,
UrlUseCase::CLIENT_SIDE_URL => self::WHATWG,
};
}
/**
* Check if this spec is RFC 3986
*/
public function isRfc3986(): bool
{
return $this === self::RFC3986;
}
/**
* Check if this spec is WHATWG
*/
public function isWhatwg(): bool
{
return $this === self::WHATWG;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* URL Use Case categories for automatic spec selection
*
* Helps determine whether RFC 3986 or WHATWG URL Standard
* should be used based on the intended usage pattern.
*/
enum UrlUseCase
{
/**
* API Client requests (REST, GraphQL, SOAP)
*
* Recommended Spec: RFC 3986
* - Strict parsing for API endpoints
* - URL signature compatibility
* - Predictable canonicalization
*/
case API_CLIENT;
/**
* cURL requests and HTTP client operations
*
* Recommended Spec: RFC 3986
* - Compatible with cURL expectations
* - No automatic normalization
* - Preserves exact URL structure
*/
case CURL_REQUEST;
/**
* URL signature generation (OAuth, AWS, etc.)
*
* Recommended Spec: RFC 3986
* - Deterministic URL formatting
* - No automatic encoding changes
* - Critical for signature validation
*/
case SIGNATURE_GENERATION;
/**
* Canonical URL generation (SEO, duplicate detection)
*
* Recommended Spec: RFC 3986
* - Consistent URL representation
* - Reliable comparison
* - SEO-friendly formatting
*/
case CANONICAL_URL;
/**
* Browser redirect URLs
*
* Recommended Spec: WHATWG
* - Browser-compatible behavior
* - Automatic encoding
* - Matches browser expectations
*/
case BROWSER_REDIRECT;
/**
* Deep links (app-to-web, universal links)
*
* Recommended Spec: WHATWG
* - Mobile browser compatibility
* - Modern URL handling
* - Cross-platform consistency
*/
case DEEP_LINK;
/**
* HTML form action URLs
*
* Recommended Spec: WHATWG
* - HTML5 specification compliance
* - Browser form submission compatibility
* - Automatic encoding of form data
*/
case HTML_FORM_ACTION;
/**
* Client-side generated URLs (JavaScript compatibility)
*
* Recommended Spec: WHATWG
* - Matches JavaScript URL API
* - Compatible with fetch()
* - Consistent with browser behavior
*/
case CLIENT_SIDE_URL;
/**
* Get human-readable description of this use case
*/
public function description(): string
{
return match ($this) {
self::API_CLIENT => 'API client requests (REST, GraphQL, SOAP)',
self::CURL_REQUEST => 'cURL requests and HTTP client operations',
self::SIGNATURE_GENERATION => 'URL signature generation (OAuth, AWS)',
self::CANONICAL_URL => 'Canonical URL generation (SEO)',
self::BROWSER_REDIRECT => 'Browser redirect URLs',
self::DEEP_LINK => 'Deep links and universal links',
self::HTML_FORM_ACTION => 'HTML form action URLs',
self::CLIENT_SIDE_URL => 'Client-side JavaScript URLs',
};
}
/**
* Get recommended URL spec for this use case
*/
public function recommendedSpec(): UrlSpec
{
return UrlSpec::forUseCase($this);
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
use InvalidArgumentException;
use Uri\WhatWg\Url as NativeWhatwgUrl;
/**
* WHATWG URL Standard implementation
*
* Wraps native PHP 8.5+ Uri\WhatWg\Url for browser-compatible URL handling.
*
* Use Cases:
* - Browser redirects
* - Deep links and universal links
* - HTML form actions
* - Client-side JavaScript compatibility
*
* Characteristics:
* - Living Standard (matches modern browsers)
* - Automatic percent-encoding
* - URL normalization
* - Special scheme handling (http, https, file, etc.)
*/
final readonly class WhatwgUrl implements Url
{
/**
* @param NativeWhatwgUrl $url Native PHP WHATWG URL instance
*/
private function __construct(
private NativeWhatwgUrl $url
) {
}
/**
* Parse WHATWG URL from string
*
* @param string $input URL string to parse
* @param Url|null $base Optional base URL for relative resolution
* @return self New WHATWG URL instance
* @throws InvalidArgumentException If URL is invalid
*/
public static function parse(string $input, ?Url $base = null): self
{
try {
if ($base instanceof self) {
// WHATWG URL resolution with base
$url = $base->url->resolve($input);
} else {
$url = new NativeWhatwgUrl($input);
}
return new self($url);
} catch (\Throwable $e) {
throw new InvalidArgumentException(
"Failed to parse WHATWG URL: {$input}",
previous: $e
);
}
}
public function getSpec(): UrlSpec
{
return UrlSpec::WHATWG;
}
// Component Getters (WHATWG methods)
public function getScheme(): string
{
return $this->url->getScheme() ?? '';
}
public function getHost(): string
{
// Prefer Unicode host for display
return $this->url->getUnicodeHost() ?? '';
}
public function getPort(): ?int
{
return $this->url->getPort();
}
public function getPath(): string
{
return $this->url->getPath() ?? '';
}
public function getQuery(): string
{
return $this->url->getQuery() ?? '';
}
public function getFragment(): string
{
return $this->url->getFragment() ?? '';
}
public function getUserInfo(): string
{
$user = $this->url->getUsername() ?? '';
$pass = $this->url->getPassword() ?? '';
if ($user === '') {
return '';
}
return $pass !== '' ? "{$user}:{$pass}" : $user;
}
// Immutable Withers (delegate to native withers)
public function withScheme(string $scheme): self
{
return new self($this->url->withScheme($scheme));
}
public function withHost(string $host): self
{
return new self($this->url->withHost($host));
}
public function withPort(?int $port): self
{
return new self($this->url->withPort($port));
}
public function withPath(string $path): self
{
return new self($this->url->withPath($path));
}
public function withQuery(string $query): self
{
return new self($this->url->withQuery($query !== '' ? $query : null));
}
public function withFragment(string $fragment): self
{
return new self($this->url->withFragment($fragment !== '' ? $fragment : null));
}
public function withUserInfo(string $user, ?string $password = null): self
{
$withUser = $this->url->withUsername($user);
return new self($withUser->withPassword($password ?? ''));
}
// Serialization
public function toString(): string
{
return $this->url->toUnicodeString();
}
public function toAsciiString(): string
{
// WHATWG URLs with Punycode encoding
return $this->url->toAsciiString();
}
// Utilities
public function resolve(string $relative): self
{
$resolved = $this->url->resolve($relative);
return new self($resolved);
}
public function equals(Url $other, bool $includeFragment = false): bool
{
if (! $other instanceof self) {
return false;
}
if ($includeFragment) {
return $this->url->equals($other->url);
}
// Compare without fragments
$thisWithoutFragment = $this->url->withFragment(null);
$otherWithoutFragment = $other->url->withFragment(null);
return $thisWithoutFragment->equals($otherWithoutFragment);
}
public function getNativeUrl(): NativeWhatwgUrl
{
return $this->url;
}
/**
* String representation (allows string casting)
*/
public function __toString(): string
{
return $this->url->toUnicodeString();
}
}

View File

@@ -41,6 +41,7 @@ final readonly class IpPatternCollection
return true;
}
}
return false;
}
@@ -61,6 +62,7 @@ final readonly class IpPatternCollection
{
$patterns = $this->patterns;
$patterns[] = IpPattern::fromString($pattern);
return new self($patterns);
}