diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 20a1646339..0a4f5d5f86 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -381,6 +381,24 @@ public function isScoped(string $abstract): bool return false; } + /** + * Determine if a scoped binding has already been resolved in this coroutine. + * + * Scoped instances live in coroutine-local context, so this is false at the + * start of every request even for a binding resolved many times before. + * Callers that would only inspect an instance can use this to avoid paying + * for a full resolution — and the construction it implies — when nothing in + * the request asked for one. + * + * Only the scoped resolution path writes this context key, so its presence + * already implies the binding is scoped; singletons, auto-singletons and + * transient bindings never produce one. + */ + public function resolvedScoped(string $abstract): bool + { + return CoroutineContext::has(self::SCOPED_CONTEXT_PREFIX . $this->getAlias($abstract)); + } + /** * Determine if a ReflectionClass has scoping attributes applied. * diff --git a/src/context/src/CoroutineContext.php b/src/context/src/CoroutineContext.php index 3de9788582..876856676f 100644 --- a/src/context/src/CoroutineContext.php +++ b/src/context/src/CoroutineContext.php @@ -32,7 +32,7 @@ class CoroutineContext */ public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId = null): mixed { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -54,7 +54,7 @@ public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId */ public static function get(UnitEnum|string $id, mixed $default = null, ?int $coroutineId = null): mixed { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -73,7 +73,7 @@ public static function get(UnitEnum|string $id, mixed $default = null, ?int $cor */ public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { @@ -90,7 +90,7 @@ public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool */ public static function forget(UnitEnum|string $id, ?int $coroutineId = null): void { - $id = enum_value($id); + $id = is_string($id) ? $id : enum_value($id); $context = Coroutine::getContextFor($coroutineId); if ($context !== null) { diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index d3aba0dd31..79d5011c50 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -81,6 +81,34 @@ class Kernel implements KernelContract */ protected array $requestLifecycleDurationHandlers = []; + /** + * Whether each middleware name resolves to a terminable instance. + * + * Keyed by the middleware string as it appears in the stack, parameters + * included. Populated on first termination and reused for the worker + * lifetime, so repeat requests skip parsing and resolving middleware that + * turned out to have no terminate() method. + * + * @var array + */ + protected array $terminableMiddleware = []; + + /** + * The global middleware stack represented by the reusable pipeline. + * + * @var array + */ + protected array $middlewarePipelineStack = []; + + /** + * The reusable global middleware pipeline. + * + * The request is supplied to the compiled onion per invocation, and pipe + * descriptors resolve middleware inside that invocation. The closure can + * therefore be shared by concurrent requests without retaining either one. + */ + protected ?Closure $middlewarePipeline = null; + /** * Context key for the current request's start time. * @@ -127,7 +155,10 @@ public function __construct(Application $app, Router $router) */ public function handle(Request $request): Response { - CoroutineContext::set(self::REQUEST_STARTED_AT_CONTEXT_KEY, CarbonImmutable::now()); + CoroutineContext::set( + self::REQUEST_STARTED_AT_CONTEXT_KEY, + CarbonImmutable::hasTestNow() ? CarbonImmutable::now() : microtime(true) + ); try { $request->enableHttpMethodParameterOverride(); @@ -170,10 +201,18 @@ protected function sendRequestThroughRouter(Request $request): Response return ($this->dispatchToRouter())($request); } - return (new Pipeline($this->app)) - ->send($request) - ->through($middleware) - ->then($this->dispatchToRouter()); + // Compile lazily on first use, then rebuild if middleware configuration + // changes so the cached onion never retains a stale pipe list. + if ($this->middlewarePipeline === null + || $this->middlewarePipelineStack !== $middleware + ) { + $this->middlewarePipelineStack = $middleware; + $this->middlewarePipeline = (new Pipeline($this->app)) + ->through($middleware) + ->toClosure($this->dispatchToRouter()); + } + + return ($this->middlewarePipeline)($request); } /** @@ -232,9 +271,9 @@ public function terminate(Request $request, Response $response): void } try { - $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); - - if ($requestStartedAt !== null && $this->requestLifecycleDurationHandlers !== []) { + if ($this->requestLifecycleDurationHandlers !== [] + && ($requestStartedAt = $this->requestStartedAt()) !== null + ) { $requestStartedAt = $requestStartedAt->setTimezone( $this->app->make('config')->string('app.timezone') ); @@ -291,12 +330,24 @@ protected function terminateMiddleware(Request $request, Response $response): vo continue; } + // Most middleware are not terminable, and resolving each one only to + // find it has no terminate() is the dominant cost of this method. + // Whether a name resolves to something terminable is fixed once + // bindings are registered — those are boot-only — so the answer is + // memoized and non-terminable middleware skip both the name parse + // and the resolution on later requests. + if (($this->terminableMiddleware[$middleware] ?? null) === false) { + continue; + } + try { [$name] = $this->parseMiddleware($middleware); $instance = $this->app->make($name); + $terminable = method_exists($instance, 'terminate'); + $this->terminableMiddleware[$middleware] = $terminable; - if (method_exists($instance, 'terminate')) { + if ($terminable) { $instance->terminate($request, $response); } } catch (Throwable $throwable) { @@ -333,7 +384,17 @@ public function whenRequestLifecycleIsLongerThan(DateTimeInterface|CarbonInterva */ public function requestStartedAt(): ?CarbonImmutable { - return CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + + if (is_float($requestStartedAt)) { + $requestStartedAt = CarbonImmutable::createFromTimestamp( + $requestStartedAt, + date_default_timezone_get() + ); + CoroutineContext::set(self::REQUEST_STARTED_AT_CONTEXT_KEY, $requestStartedAt); + } + + return $requestStartedAt; } /** @@ -739,6 +800,9 @@ public function getApplication(): Application public function setApplication(Application $app): static { $this->app = $app; + $this->terminableMiddleware = []; + $this->middlewarePipelineStack = []; + $this->middlewarePipeline = null; return $this; } diff --git a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php index 2b5202e481..759d7f4d98 100644 --- a/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php +++ b/src/foundation/src/Http/Middleware/Concerns/ExcludesPaths.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Http\Middleware\Concerns; use Hypervel\Http\Request; +use Hypervel\Support\Str; trait ExcludesPaths { @@ -13,12 +14,16 @@ trait ExcludesPaths */ protected function inExceptArray(Request $request): bool { + $fullUrl = null; + $decodedPath = null; + foreach ($this->getExcludedPaths() as $except) { if ($except !== '/') { $except = trim($except, '/'); } - if ($request->fullUrlIs($except) || $request->is($except)) { + if (Str::is($except, $fullUrl ??= $request->fullUrl()) + || Str::is($except, $decodedPath ??= $request->decodedPath())) { return true; } } diff --git a/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php b/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php index 851c225a9a..cb8615885d 100644 --- a/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php +++ b/src/foundation/src/Http/Middleware/InvokeDeferredCallbacks.php @@ -26,8 +26,16 @@ public function handle(Request $request, Closure $next): Response */ public function terminate(Request $request, Response $response): void { - Container::getInstance() - ->make(DeferredCallbackCollection::class) + $container = Container::getInstance(); + + // The collection is scoped, so it only exists here if something in this + // request actually called defer(). Resolving it otherwise would build a + // collection on every request just to iterate nothing. + if (! $container->resolvedScoped(DeferredCallbackCollection::class)) { + return; + } + + $container->make(DeferredCallbackCollection::class) ->invokeWhen(fn (DeferredCallback $callback) => $response->getStatusCode() < 400 || $callback->always); } } diff --git a/src/foundation/src/Http/Middleware/TransformsRequest.php b/src/foundation/src/Http/Middleware/TransformsRequest.php index b7cfe6b65d..e149dcedc7 100644 --- a/src/foundation/src/Http/Middleware/TransformsRequest.php +++ b/src/foundation/src/Http/Middleware/TransformsRequest.php @@ -39,6 +39,10 @@ protected function clean(Request $request): void */ protected function cleanParameterBag(ParameterBag $bag): void { + if ($bag->count() === 0) { + return; + } + $bag->replace($this->cleanArray($bag->all())); } diff --git a/src/foundation/src/Http/Middleware/TrimStrings.php b/src/foundation/src/Http/Middleware/TrimStrings.php index a7c894a125..2a5f05346c 100644 --- a/src/foundation/src/Http/Middleware/TrimStrings.php +++ b/src/foundation/src/Http/Middleware/TrimStrings.php @@ -55,9 +55,13 @@ public function handle(Request $request, Closure $next): mixed */ protected function transform(string $key, mixed $value): mixed { + if (! is_string($value)) { + return $value; + } + $except = array_merge($this->except, static::$neverTrim); - if ($this->shouldSkip($key, $except) || ! is_string($value)) { + if ($this->shouldSkip($key, $except)) { return $value; } diff --git a/src/foundation/src/WorkerCachedMaintenanceMode.php b/src/foundation/src/WorkerCachedMaintenanceMode.php index 2ac229201c..bc22ad205b 100644 --- a/src/foundation/src/WorkerCachedMaintenanceMode.php +++ b/src/foundation/src/WorkerCachedMaintenanceMode.php @@ -21,8 +21,13 @@ class WorkerCachedMaintenanceMode implements MaintenanceModeContract /** * The time when the cached snapshot was last refreshed. + * + * Stored as a Unix timestamp float rather than a Carbon instance: this is + * read on every request through the global middleware stack, and Carbon's + * interval arithmetic costs microseconds per comparison where a float + * subtraction costs nanoseconds. */ - protected static ?CarbonImmutable $refreshedAt = null; + protected static ?float $refreshedAt = null; /** * Create a new worker-cached maintenance mode instance. @@ -101,7 +106,7 @@ protected function loadSnapshot(): array ]; // Set after successful reads so failed refreshes retry on the next request. - static::$refreshedAt = CarbonImmutable::now(); + static::$refreshedAt = static::now(); } return static::$snapshot; @@ -117,6 +122,25 @@ protected function shouldRefreshSnapshot(): bool } return $this->refreshInterval > 0 - && static::$refreshedAt->addSeconds($this->refreshInterval)->lte(CarbonImmutable::now()); + && static::now() - static::$refreshedAt >= $this->refreshInterval; + } + + /** + * Get the current time as a Unix timestamp in seconds. + * + * This runs on every request through the global middleware stack, so the + * common path uses microtime() rather than Carbon interval arithmetic — + * building two Carbon instances and an interval to compare them costs + * microseconds where a float subtraction costs nanoseconds. Carbon is only + * consulted when a test now is set, so time travel still drives refreshes. + */ + protected static function now(): float + { + // Sub-second precision on both sides: getTimestamp() truncates to whole + // seconds, which over-reports the elapsed time between two fractional + // instants and would refresh the snapshot before the interval is up. + return CarbonImmutable::hasTestNow() + ? CarbonImmutable::now()->getPreciseTimestamp(6) / 1_000_000 + : microtime(true); } } diff --git a/src/http-server/src/RequestBridge.php b/src/http-server/src/RequestBridge.php index 675b6140e9..e6222dd336 100644 --- a/src/http-server/src/RequestBridge.php +++ b/src/http-server/src/RequestBridge.php @@ -33,6 +33,7 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request $server = static::normalizeTrailingSlash($server); $content = $swooleRequest->rawContent(); + $headers = static::transformHeaders($swooleRequest->header ?? [], $server); return new Request( query: $swooleRequest->get ?? [], @@ -41,10 +42,67 @@ public static function createFromSwoole(SwooleRequest $swooleRequest): Request cookies: $swooleRequest->cookie ?? [], files: static::transformFiles($swooleRequest->files ?? []), server: $server, - content: $content === false ? null : $content + content: $content === false ? null : $content, + headers: $headers, + pathInfo: static::extractPathInfo($server), ); } + /** + * Build request headers directly while retaining Symfony's authorization aliases. + */ + protected static function transformHeaders(array $headers, array &$server): RequestHeaderBag + { + $headerBag = new RequestHeaderBag($headers); + + foreach (['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'] as $name) { + if (! $headerBag->has($name) && isset($server[$name]) && $server[$name] !== '') { + $headerBag->set($name, $server[$name]); + } + } + + if (isset($server['PHP_AUTH_USER'])) { + $headerBag->set('PHP_AUTH_USER', $server['PHP_AUTH_USER']); + $headerBag->set('PHP_AUTH_PW', $server['PHP_AUTH_PW'] ?? ''); + } else { + $authorization = $server['HTTP_AUTHORIZATION'] + ?? $server['REDIRECT_HTTP_AUTHORIZATION'] + ?? null; + + if ($authorization !== null && stripos($authorization, 'basic ') === 0) { + $credentials = explode(':', (string) base64_decode(substr($authorization, 6)), 2); + + if (count($credentials) === 2) { + [$user, $password] = $credentials; + $headerBag->set('PHP_AUTH_USER', $user); + $headerBag->set('PHP_AUTH_PW', $password); + } + } elseif ($authorization !== null + && empty($server['PHP_AUTH_DIGEST']) + && stripos($authorization, 'digest ') === 0 + ) { + $headerBag->set('PHP_AUTH_DIGEST', $authorization); + $server['PHP_AUTH_DIGEST'] = $authorization; + } elseif ($authorization !== null && stripos($authorization, 'bearer ') === 0) { + $headerBag->set('AUTHORIZATION', $authorization); + } + } + + if ($headerBag->has('AUTHORIZATION')) { + return $headerBag; + } + + if ($headerBag->has('PHP_AUTH_USER')) { + $headerBag->set('AUTHORIZATION', 'Basic ' . base64_encode( + $headerBag->get('PHP_AUTH_USER') . ':' . $headerBag->get('PHP_AUTH_PW') + )); + } elseif ($headerBag->has('PHP_AUTH_DIGEST')) { + $headerBag->set('AUTHORIZATION', $headerBag->get('PHP_AUTH_DIGEST')); + } + + return $headerBag; + } + /** * Transform Swoole's server params to $_SERVER style. * @@ -62,7 +120,24 @@ protected static function transformServerParams(array $server, array $headers): // Swoole headers → HTTP_* format foreach ($headers as $key => $value) { - $httpKey = 'HTTP_' . strtoupper(str_replace('-', '_', $key)); + // Swoole normally supplies lowercase names. Map common headers + // without replace/uppercase allocations; retain the generic path. + $httpKey = match ($key) { + 'accept' => 'HTTP_ACCEPT', + 'accept-encoding' => 'HTTP_ACCEPT_ENCODING', + 'authorization' => 'HTTP_AUTHORIZATION', + 'connection' => 'HTTP_CONNECTION', + 'content-length' => 'HTTP_CONTENT_LENGTH', + 'content-type' => 'HTTP_CONTENT_TYPE', + 'host' => 'HTTP_HOST', + 'user-agent' => 'HTTP_USER_AGENT', + 'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR', + 'x-forwarded-host' => 'HTTP_X_FORWARDED_HOST', + 'x-forwarded-port' => 'HTTP_X_FORWARDED_PORT', + 'x-forwarded-proto' => 'HTTP_X_FORWARDED_PROTO', + 'x-request-id' => 'HTTP_X_REQUEST_ID', + default => 'HTTP_' . strtoupper(str_replace('-', '_', $key)), + }; $result[$httpKey] = $value; } @@ -162,4 +237,34 @@ protected static function normalizeTrailingSlash(array $server): array return $server; } + + /** + * Extract the raw path from the normalized Swoole request URI. + */ + protected static function extractPathInfo(array $server): ?string + { + // Front-controller and IIS metadata affect Symfony's base-path rules; + // defer to its full path derivation whenever those inputs are present. + foreach (['SCRIPT_FILENAME', 'SCRIPT_NAME', 'PHP_SELF', 'ORIG_SCRIPT_NAME', 'UNENCODED_URL', 'ORIG_PATH_INFO'] as $name) { + if (! empty($server[$name])) { + return null; + } + } + + $requestUri = $server['REQUEST_URI'] ?? null; + + if (! is_string($requestUri)) { + return null; + } + + if (($queryPosition = strpos($requestUri, '?')) !== false) { + $requestUri = substr($requestUri, 0, $queryPosition); + } + + if ($requestUri === '') { + return '/'; + } + + return $requestUri[0] === '/' ? $requestUri : '/' . $requestUri; + } } diff --git a/src/http-server/src/RequestHeaderBag.php b/src/http-server/src/RequestHeaderBag.php new file mode 100644 index 0000000000..6e74512d32 --- /dev/null +++ b/src/http-server/src/RequestHeaderBag.php @@ -0,0 +1,32 @@ + $values) { + $key = strtr((string) $key, self::UPPER, self::LOWER); + $this->headers[$key] = is_array($values) + ? array_values($values) + : [$values]; + } + + if (isset($this->headers['cache-control'])) { + $this->cacheControl = $this->parseCacheControl( + implode(', ', $this->headers['cache-control']) + ); + } + } +} diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php index 878e036487..38a79c46b0 100644 --- a/src/http-server/src/Server.php +++ b/src/http-server/src/Server.php @@ -30,6 +30,11 @@ class Server implements OnRequestInterface, BootstrapsForServer protected ?EventDispatcherContract $event = null; + /** + * Whether this worker has observed the completed worker-start boundary. + */ + protected bool $workerStarted = false; + public function __construct( protected Container $container, ) { @@ -73,7 +78,10 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe $exception = null; try { - CoordinatorManager::until(Constants::WORKER_START)->yield(); + if (! $this->workerStarted) { + CoordinatorManager::until(Constants::WORKER_START)->yield(); + $this->workerStarted = true; + } // Capture the raw transport method before any Symfony method-override // processing. This avoids SuspiciousOperationException from malformed diff --git a/src/http/src/Concerns/PreparesResponse.php b/src/http/src/Concerns/PreparesResponse.php new file mode 100644 index 0000000000..f42b86b498 --- /dev/null +++ b/src/http/src/Concerns/PreparesResponse.php @@ -0,0 +1,61 @@ +statusCode < 200 + || $this->statusCode === 204 + || $this->statusCode === 304 + || $request->getMethod() === 'HEAD' + || $request->server->get('SERVER_PROTOCOL') === 'HTTP/1.0' + ) { + return parent::prepare($request); + } + + $contentType = $this->headers->get('Content-Type'); + + // A missing content type requires Symfony's request-format lookup. + if ($contentType === null) { + return parent::prepare($request); + } + + // HTTPS preparation mutates cookie defaults and contains a legacy IE + // download workaround. Fall back only when either can be relevant. + if ($request->isSecure() + && ($this->headers->getCookies() !== [] + || stripos($this->headers->get('Content-Disposition') ?? '', 'attachment') !== false) + ) { + return parent::prepare($request); + } + + if (stripos($contentType, 'text/') === 0 && stripos($contentType, 'charset') === false) { + $this->headers->set('Content-Type', $contentType . '; charset=' . ($this->charset ?: 'utf-8')); + } + + if ($this->headers->has('Transfer-Encoding')) { + $this->headers->remove('Content-Length'); + } + + $this->version = '1.1'; + + return $this; + } +} diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 6b8ee3ddc2..bba3eb18a6 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -4,6 +4,7 @@ namespace Hypervel\Http; +use ArrayObject; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Json; @@ -11,14 +12,28 @@ use InvalidArgumentException; use JsonSerializable; use Override; +use Stringable; use Symfony\Component\HttpFoundation\JsonResponse as BaseJsonResponse; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use TypeError; class JsonResponse extends BaseJsonResponse { + use Concerns\PreparesResponse; use ResponseTrait, Macroable { Macroable::__call as macroCall; } + /** + * The pristine header bag cloned for responses that add no headers of their own. + */ + protected static ?ResponseHeaderBag $headerPrototype = null; + + /** + * Unix second represented by the prototype's Date header. + */ + protected static int $headerPrototypeTimestamp = 0; + /** * Create a new JSON response instance. */ @@ -26,7 +41,48 @@ public function __construct(mixed $data = null, int $status = 200, array $header { $this->encodingOptions = $options; - parent::__construct($data, $status, $headers, $json); + if ($json + && ! is_string($data) + && ! is_numeric($data) + && ! $data instanceof Stringable + ) { + throw new TypeError(sprintf( + '"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', + BaseJsonResponse::class . '::__construct', + get_debug_type($data), + )); + } + + // Symfony builds a ResponseHeaderBag per response, and its constructor + // sets Cache-Control to an empty string only to parse that value back + // out through a regex — roughly four fifths of the cost of building a + // JSON response, for a result identical every time. Cloning a prototype + // skips it. SymfonyResponse::__construct is called rather than + // parent::__construct because JsonResponse's signature only accepts an + // array of headers, while Response's also accepts a prepared bag. + // HTTP dates have one-second precision, so all responses created in the + // same second can clone one value. Caller headers are applied afterward. + $timestamp = time(); + + if (static::$headerPrototype === null) { + static::$headerPrototype = new ResponseHeaderBag; + static::$headerPrototypeTimestamp = $timestamp; + } elseif (static::$headerPrototypeTimestamp !== $timestamp) { + static::$headerPrototype->set('Date', gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'); + static::$headerPrototypeTimestamp = $timestamp; + } + + $bag = clone static::$headerPrototype; + + if ($headers !== []) { + $bag->add($headers); + } + + SymfonyResponse::__construct('', $status, $bag); + + $data ??= new ArrayObject; + + $json ? $this->setJson((string) $data) : $this->setData($data); } /** @@ -123,5 +179,8 @@ public function hasEncodingOption(int $option): bool public static function flushState(): void { static::flushMacros(); + + static::$headerPrototype = null; + static::$headerPrototypeTimestamp = 0; } } diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index 4ec26e2efc..c796823f5a 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -8,6 +8,7 @@ use Fruitcake\Cors\CorsService; use Hypervel\Contracts\Container\Container; use Hypervel\Http\Request; +use Hypervel\Support\Str; use Symfony\Component\HttpFoundation\Response; class HandleCors @@ -84,13 +85,16 @@ public function handle(Request $request, Closure $next): Response protected function hasMatchingPath(Request $request, array $paths): bool { $paths = $this->getPathsByHost($request->getHost(), $paths); + $fullUrl = null; + $decodedPath = null; foreach ($paths as $path) { if ($path !== '/') { $path = trim($path, '/'); } - if ($request->fullUrlIs($path) || $request->is($path)) { + if (Str::is($path, $fullUrl ??= $request->fullUrl()) + || Str::is($path, $decodedPath ??= $request->decodedPath())) { return true; } } diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 2c086b4606..b41eabf18c 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -17,14 +17,19 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Uri; use Override; +use ReflectionProperty; use RuntimeException; use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; +use Symfony\Component\HttpFoundation\FileBag; +use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; +use Symfony\Component\HttpFoundation\ServerBag; use Symfony\Component\HttpFoundation\Session\SessionInterface; /** @@ -145,8 +150,53 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess */ protected bool $isForwardedValidValue = true; + /** + * Transport URI associated with a precomputed path. + */ + protected ?string $transportRequestUri = null; + + /** + * Raw accessors for Symfony's hooked request bag properties. + * + * @var array + */ + private static array $baseRequestProperties = []; + // Request::capture() is omitted because the Swoole bridge creates each request. + /** + * Create a request, optionally reusing headers and path data prepared by the transport. + */ + public function __construct( + array $query = [], + array $request = [], + array $attributes = [], + array $cookies = [], + array $files = [], + array $server = [], + mixed $content = null, + ?HeaderBag $headers = null, + ?string $pathInfo = null, + ) { + if ($headers === null) { + parent::__construct($query, $request, $attributes, $cookies, $files, $server, $content); + + return; + } + + $this->initializeWithHeaderBag( + $query, + $request, + $attributes, + $cookies, + $files, + $server, + $content, + $headers, + $pathInfo, + ); + } + /** * Initialize the request data. */ @@ -160,6 +210,8 @@ public function initialize(array $query = [], array $request = [], array $attrib parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content); + $this->transportRequestUri = null; + $this->trustedProxiesValue = []; $this->trustedHeaderSetValue = -1; $this->trustedHostPatternsValue = []; @@ -167,6 +219,81 @@ public function initialize(array $query = [], array $request = [], array $attrib $this->resetTrustedRequestCaches(); } + /** + * Initialize request bags while retaining headers already normalized by the transport. + */ + protected function initializeWithHeaderBag( + array $query, + array $request, + array $attributes, + array $cookies, + array $files, + array $server, + mixed $content, + HeaderBag $headers, + ?string $pathInfo, + ): void { + $this->startedAtTimestamp = (float) ($server['REQUEST_TIME_FLOAT'] ?? microtime(true)); + + $server['REQUEST_TIME_FLOAT'] ??= $this->startedAtTimestamp; + $server['REQUEST_TIME'] ??= (int) $this->startedAtTimestamp; + + $this->setBaseRequestProperty('request', new InputBag($request)); + $this->setBaseRequestProperty('query', new InputBag($query)); + $this->setBaseRequestProperty('attributes', new ParameterBag($attributes)); + $this->setBaseRequestProperty('cookies', new InputBag($cookies)); + $this->setBaseRequestProperty('files', new FileBag($files)); + $this->setBaseRequestProperty('server', new ServerBag($server)); + $this->setBaseRequestProperty('headers', $headers); + + $this->content = $content; + $this->languages = null; + $this->charsets = null; + $this->encodings = null; + $this->acceptableContentTypes = null; + $this->pathInfo = $pathInfo; + $this->transportRequestUri = $pathInfo === null ? null : (string) $server['REQUEST_URI']; + $this->requestUri = null; + $this->baseUrl = null; + $this->basePath = null; + $this->method = null; + $this->format = null; + + $this->trustedProxiesValue = []; + $this->trustedHeaderSetValue = -1; + $this->trustedHostPatternsValue = []; + $this->trustedHostsValue = []; + $this->resetTrustedRequestCaches(); + } + + /** + * Set a Symfony Request bag without triggering its public-property deprecation hook. + */ + private function setBaseRequestProperty(string $name, object $value): void + { + $property = self::$baseRequestProperties[$name] + ??= new ReflectionProperty(SymfonyRequest::class, $name); + + $property->setRawValue($this, $value); + } + + /** + * Return the precomputed transport path unless middleware rewrote its URI. + */ + #[Override] + public function getPathInfo(): string + { + if ($this->transportRequestUri !== null) { + if ($this->server->get('REQUEST_URI') !== $this->transportRequestUri) { + $this->pathInfo = null; + } + + $this->transportRequestUri = null; + } + + return parent::getPathInfo(); + } + /** * Create a new HTTP request from PHP superglobals. * @@ -394,8 +521,24 @@ public function segments(): array */ public function is(mixed ...$patterns): bool { - return (new Collection($patterns)) - ->contains(fn ($pattern) => Str::is($pattern, $this->decodedPath())); + // Global middleware runs this per request, so the path is decoded once + // for the whole list rather than per pattern, and matched without + // wrapping the patterns in a Collection. Resolving it lazily also keeps + // Collection::contains()'s behavior of never touching the subject when + // there is nothing to match against. + if ($patterns === []) { + return false; + } + + $path = $this->decodedPath(); + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $path)) { + return true; + } + } + + return false; } /** @@ -411,8 +554,22 @@ public function routeIs(mixed ...$patterns): bool */ public function fullUrlIs(mixed ...$patterns): bool { - return (new Collection($patterns)) - ->contains(fn ($pattern) => Str::is($pattern, $this->fullUrl())); + // See is(). The empty check matters more here: rebuilding the URL + // resolves the host, which validates it and can throw, so an empty + // pattern list has to stay the no-op Collection::contains() made it. + if ($patterns === []) { + return false; + } + + $url = $this->fullUrl(); + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $url)) { + return true; + } + } + + return false; } /** diff --git a/src/http/src/Response.php b/src/http/src/Response.php index e6ba9670a8..822bf72e8f 100755 --- a/src/http/src/Response.php +++ b/src/http/src/Response.php @@ -21,8 +21,19 @@ class Response extends SymfonyResponse use Macroable { Macroable::__call as macroCall; } + use Concerns\PreparesResponse; use ResponseTrait; + /** + * The pristine header bag cloned for each response. + */ + protected static ?ResponseHeaderBag $headerPrototype = null; + + /** + * Unix second represented by the prototype's Date header. + */ + protected static int $headerPrototypeTimestamp = 0; + /** * Create a new HTTP response. * @@ -30,9 +41,25 @@ class Response extends SymfonyResponse */ public function __construct(mixed $content = '', int $status = 200, array $headers = []) { - // The parent constructor accepts the headers since Symfony 8.1; assigning - // the property directly would hit the deprecated property setter. - parent::__construct('', $status, $headers); + // HTTP dates have one-second precision, so all responses created in the + // same second can clone one value. Caller headers are applied afterward. + $timestamp = time(); + + if (static::$headerPrototype === null) { + static::$headerPrototype = new ResponseHeaderBag; + static::$headerPrototypeTimestamp = $timestamp; + } elseif (static::$headerPrototypeTimestamp !== $timestamp) { + static::$headerPrototype->set('Date', gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'); + static::$headerPrototypeTimestamp = $timestamp; + } + + $bag = clone static::$headerPrototype; + + if ($headers !== []) { + $bag->add($headers); + } + + SymfonyResponse::__construct('', $status, $bag); $this->setContent($content); } @@ -43,7 +70,9 @@ public function __construct(mixed $content = '', int $status = 200, array $heade #[Override] public function getContent(): string|false { - return transform(parent::getContent(), fn ($content) => $content, ''); + $content = parent::getContent(); + + return $content === false ? '' : $content; } /** @@ -153,5 +182,8 @@ public function send(bool $flush = true): static public static function flushState(): void { static::flushMacros(); + + static::$headerPrototype = null; + static::$headerPrototypeTimestamp = 0; } } diff --git a/src/http/src/ResponseHeaderBag.php b/src/http/src/ResponseHeaderBag.php new file mode 100644 index 0000000000..450d01b2c6 --- /dev/null +++ b/src/http/src/ResponseHeaderBag.php @@ -0,0 +1,62 @@ +getCookies()) + : $this->headers[$key] ?? []; + } + + /** + * Determine whether a response header is present. + */ + #[Override] + public function has(string $key): bool + { + $key = strtr($key, self::UPPER, self::LOWER); + + return $key === 'set-cookie' + ? $this->cookies !== [] + : array_key_exists($key, $this->headers); + } + + /** + * Return headers with their original capitalization, excluding cookies. + */ + #[Override] + public function allPreserveCaseWithoutCookies(): array + { + $headers = []; + + // Symfony's implementation first materializes Set-Cookie strings and + // then removes them. Swoole sends cookies separately, so iterate the + // already cookie-free header storage directly. + foreach ($this->headers as $name => $values) { + $headers[$this->headerNames[$name] ?? $name] = $values; + } + + return $headers; + } +} diff --git a/src/pipeline/src/PipeDescriptor.php b/src/pipeline/src/PipeDescriptor.php new file mode 100644 index 0000000000..d7433d3634 --- /dev/null +++ b/src/pipeline/src/PipeDescriptor.php @@ -0,0 +1,35 @@ + $parameters + */ + public function __construct( + public string $name, + public array $parameters = [], + public ?string $method = null, + ) { + } + + /** + * Create a descriptor from a pipeline string. + */ + public static function fromString(string $pipe, ?string $method = null): self + { + if (! str_contains($pipe, ':')) { + return new self($pipe, method: $method); + } + + [$name, $parameters] = explode(':', $pipe, 2); + + return new self($name, explode(',', $parameters), $method); + } +} diff --git a/src/pipeline/src/Pipeline.php b/src/pipeline/src/Pipeline.php index a47c9a8ad1..434664be3b 100644 --- a/src/pipeline/src/Pipeline.php +++ b/src/pipeline/src/Pipeline.php @@ -101,11 +101,7 @@ public function via(string $method): static */ public function then(Closure $destination): mixed { - $pipeline = array_reduce( - array_reverse($this->pipes()), - $this->carry(), - $this->prepareDestination($destination) - ); + $pipeline = $this->toClosure($destination); try { return $this->withinTransaction !== false @@ -118,6 +114,24 @@ public function then(Closure $destination): mixed } } + /** + * Compile the pipeline structure without binding it to a passable value. + * + * The returned closure receives its passable per invocation. Immutable pipe + * descriptors stay in the onion while their middleware instances are resolved + * inside each call, allowing independent requests to reuse the same structure. + * + * @internal + */ + public function toClosure(Closure $destination): Closure + { + return array_reduce( + array_reverse($this->pipes()), + $this->carry(), + $this->prepareDestination($destination) + ); + } + /** * Run the pipeline and return the result. */ @@ -160,21 +174,34 @@ protected function carry(): Closure return function ($stack, $pipe) { return function ($passable) use ($stack, $pipe) { try { - if (is_callable($pipe)) { + $method = $this->method; + + if ($pipe instanceof PipeDescriptor) { + $parameters = $pipe->parameters === [] + ? [$passable, $stack] + : array_merge([$passable, $stack], $pipe->parameters); + $method = $pipe->method ?? $method; + $pipe = $this->getContainer()->make($pipe->name); + } elseif (is_callable($pipe)) { // If the pipe is a callable, then we will call it directly, but otherwise we // will resolve the pipes out of the dependency container and call it with // the appropriate method and arguments, returning the results back out. return $pipe($passable, $stack); - } - if (! is_object($pipe)) { - [$name, $parameters] = $this->parsePipeString($pipe); + } elseif (! is_object($pipe)) { + // Only pipes written as 'name:arg,arg' carry parameters, so the + // parse is skipped for the common parameterless case. + if (str_contains($pipe, ':')) { + [$name, $parameters] = $this->parsePipeString($pipe); + $parameters = array_merge([$passable, $stack], $parameters); + } else { + $name = $pipe; + $parameters = [$passable, $stack]; + } // If the pipe is a string we will parse the string and resolve the class out // of the dependency injection container. We can then build a callable and // execute the pipe function giving in the parameters that are required. $pipe = $this->getContainer()->make($name); - - $parameters = array_merge([$passable, $stack], $parameters); } else { // If the pipe is already an object we'll just make a callable and pass it to // the pipe as-is. There is no need to do any extra parsing and formatting @@ -182,8 +209,8 @@ protected function carry(): Closure $parameters = [$passable, $stack]; } - $carry = method_exists($pipe, $this->method) - ? $pipe->{$this->method}(...$parameters) + $carry = method_exists($pipe, $method) + ? $pipe->{$method}(...$parameters) : $pipe(...$parameters); return $this->handleCarry($carry); diff --git a/src/routing/src/CallableDispatcher.php b/src/routing/src/CallableDispatcher.php index 59c99b2aef..826b13f073 100644 --- a/src/routing/src/CallableDispatcher.php +++ b/src/routing/src/CallableDispatcher.php @@ -59,7 +59,11 @@ public function dispatch(Route $route, callable $callable): mixed ->dispatch($route, $callable); } - return $callable(...array_values($this->resolveParameters($route, $callable))); + $parameters = $this->resolveParameters($route, $callable); + + return $parameters === [] + ? $callable() + : $callable(...array_values($parameters)); } /** diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index 0a9eedec42..93175fec84 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -32,6 +32,11 @@ class CompiledRouteCollection extends AbstractRouteCollection */ protected ?RouteCollection $routes = null; + /** + * Whether routes have been added after this collection was compiled. + */ + protected bool $hasDynamicRoutes = false; + /** * The router instance used by the route. */ @@ -49,6 +54,41 @@ class CompiledRouteCollection extends AbstractRouteCollection */ protected array $nameCache = []; + /** + * An immutable request context template cloned for each match. + */ + protected RequestContext $requestContextPrototype; + + /** + * Whether any compiled route has a scheme constraint. + */ + protected bool $requiresScheme; + + /** + * Whether compiled route conditions require the complete request context. + */ + protected bool $requiresFullRequestContext; + + /** + * Whether any compiled route has a port constraint. + */ + protected bool $hasPortConstraints; + + /** + * Whether exact static routes need only path, method, and slash checks. + * + * Host, scheme, condition, and port constraints retain Symfony matching. + */ + protected bool $supportsDirectStaticMatching; + + /** + * The compiled matcher state used after an exact static path miss. + * + * Removing the already-missed static table lets Symfony continue with dynamic + * routes without repeating the same static lookup. + */ + protected array $compiledWithoutStaticRoutes = []; + /** * A cache of route names grouped by the HTTP method they respond to, built from the route attributes. * @@ -81,6 +121,55 @@ public function __construct(array $compiled, array $attributes) $this->compiled = $compiled; $this->attributes = $attributes; $this->routes = new RouteCollection; + $this->requestContextPrototype = new RequestContext; + + // Symfony stores static/dynamic routes at top-level indexes 1/3, + // conditions at 4, and required schemes at index 3 of each route tuple. + $this->requiresScheme = $this->compiledRoutesRequireScheme($compiled); + $this->requiresFullRequestContext = ($compiled[4] ?? null) !== null; + $this->hasPortConstraints = $this->compiledRoutesHavePortConstraints($attributes); + $this->supportsDirectStaticMatching = ! empty($compiled[1]) + && ! ($compiled[0] ?? false) + && ! $this->requiresScheme + && ! $this->requiresFullRequestContext + && ! $this->hasPortConstraints; + + if ($this->supportsDirectStaticMatching) { + $this->compiledWithoutStaticRoutes = $compiled; + $this->compiledWithoutStaticRoutes[1] = []; + } + } + + /** + * Determine whether the compiled matcher contains scheme-constrained routes. + */ + protected function compiledRoutesRequireScheme(array $compiled): bool + { + foreach ([$compiled[1] ?? [], $compiled[3] ?? []] as $routeGroups) { + foreach ($routeGroups as $routes) { + foreach ($routes as $route) { + if (! empty($route[3])) { + return true; + } + } + } + } + + return false; + } + + /** + * Determine whether any compiled route requires a specific server port. + */ + protected function compiledRoutesHavePortConstraints(array $attributes): bool + { + foreach ($attributes as $route) { + if (($route['action']['port'] ?? null) !== null) { + return true; + } + } + + return false; } /** @@ -92,7 +181,10 @@ public function add(Route $route): Route { $this->ensureNoCrossPortConflictWithCompiledRoutes($route); - return $this->routes->add($route); + $route = $this->routes->add($route); + $this->hasDynamicRoutes = true; + + return $route; } /** @@ -161,9 +253,10 @@ public function refreshActionLookups(): void /** * Find the first route matching a given request. * - * Fresh RequestContext per request for coroutine safety — a shared mutable - * RequestContext would race under coroutine interleaving. The allocation - * cost of one small object per request is negligible. + * Clone an immutable RequestContext template per request for coroutine safety. + * A shared mutable context would race under coroutine interleaving. Ordinary + * routes populate only method and validated host; scheme and complete request + * metadata are resolved only when the compiled route table requires them. * * No request duplication needed — trailing slashes are trimmed via rtrim() * on the path string and passed to match() directly, avoiding the overhead @@ -175,43 +268,106 @@ public function refreshActionLookups(): void */ public function match(Request $request): Route { - $context = new RequestContext( - method: $request->getMethod(), - host: $request->getHost(), - scheme: $request->getScheme(), - httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), - httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, - path: $request->getPathInfo(), - queryString: $request->server->get('QUERY_STRING', ''), - ); - - $matcher = new CompiledUrlMatcher($this->compiled, $context); - $path = rtrim($request->getPathInfo(), '/') ?: '/'; - + $method = $request->getMethod(); + $pathInfo = $request->getPathInfo(); + $path = rtrim($pathInfo, '/') ?: '/'; + $skipStaticRoutes = false; + $result = null; $route = null; - try { - if ($result = $matcher->match($path)) { - $route = $this->getByName($result['_route']); + if ($this->supportsDirectStaticMatching) { + $staticRoutes = $this->compiled[1][$path] ?? (str_contains($path, '%') ? null : false); + + if (is_array($staticRoutes)) { + $result = $this->matchDirectStaticRoute($method, $path, $staticRoutes); + + if ($result !== null) { + $route = $this->getByName($result['_route']); + + // Preserve the host validation performed by Symfony's request context path. + $request->getHost(); + } + } else { + $skipStaticRoutes = $staticRoutes === false; + } + } + + if ($result === null) { + $host = $request->getHost(); + + if ($this->requiresFullRequestContext) { + $context = new RequestContext( + method: $method, + host: $host, + scheme: $request->getScheme(), + httpPort: $request->isSecure() ? 443 : (int) $request->getPort(), + httpsPort: $request->isSecure() ? (int) $request->getPort() : 443, + path: $pathInfo, + queryString: $request->server->get('QUERY_STRING', ''), + ); + } else { + $context = clone $this->requestContextPrototype; + + if ($method !== 'GET') { + $context->setMethod($method); + } + + $context->setHost($host); + + if ($this->requiresScheme) { + $context->setScheme($request->getScheme()); + } } - } catch (ResourceNotFoundException|MethodNotAllowedException) { + + $matcher = new CompiledUrlMatcher( + $skipStaticRoutes ? $this->compiledWithoutStaticRoutes : $this->compiled, + $context + ); + try { - return $this->routes->match($request); - } catch (NotFoundHttpException) { + if ($result = $matcher->match($path)) { + $route = $this->getByName($result['_route']); + } + } catch (MethodNotAllowedException $exception) { + if (! $this->hasDynamicRoutes && ! $this->hasPortConstraints) { + return $this->getRouteForMethods($request, $exception->getAllowedMethods()); + } + + try { + return $this->routes->match($request); + } catch (NotFoundHttpException) { + } + } catch (ResourceNotFoundException) { + if (! $this->hasDynamicRoutes) { + throw new NotFoundHttpException(sprintf( + 'The route %s could not be found.', + $request->path() + )); + } + + try { + return $this->routes->match($request); + } catch (NotFoundHttpException) { + } } } + $compiledRoute = $route; + $routePort = $route?->getPort(); if ($routePort !== null && $routePort !== (int) $request->getPort()) { - try { - return $this->routes->match($request); - } catch (NotFoundHttpException|MethodNotAllowedHttpException) { - $route = null; + $route = null; + + if ($this->hasDynamicRoutes) { + try { + return $this->routes->match($request); + } catch (NotFoundHttpException|MethodNotAllowedHttpException) { + } } } - if ($route && $route->isFallback) { + if ($route && $route->isFallback && $this->hasDynamicRoutes) { try { $dynamicRoute = $this->routes->match($request); @@ -222,9 +378,45 @@ public function match(Request $request): Route } } + if ($route !== null && $route === $compiledRoute && $result !== null) { + return $route->bindFromCompiledMatch($result, $request); + } + return $this->handleMatchedRoute($request, $route); } + /** + * Match an unconstrained exact path without constructing Symfony matcher state. + * + * Null means a static path existed but Symfony must resolve method or slash + * semantics. Encoded static paths use that fallback as well so dynamic + * requests pay only one preliminary hash lookup. + * + * @param array> $routes + * @return null|array + */ + protected function matchDirectStaticRoute(string $method, string $path, array $routes): ?array + { + $canonicalMethod = $method === 'HEAD' ? 'GET' : $method; + + foreach ($routes as [$result, , $requiredMethods, , $hasTrailingSlash]) { + if ($path !== '/' && $hasTrailingSlash) { + continue; + } + + if ($requiredMethods + && ! isset($requiredMethods[$canonicalMethod]) + && ! isset($requiredMethods[$method]) + ) { + continue; + } + + return $result; + } + + return null; + } + /** * Get routes from the collection by method. * diff --git a/src/routing/src/ImplicitRouteBinding.php b/src/routing/src/ImplicitRouteBinding.php index 918ccc1dc4..c63edb996f 100644 --- a/src/routing/src/ImplicitRouteBinding.php +++ b/src/routing/src/ImplicitRouteBinding.php @@ -54,6 +54,10 @@ public static function resolveForRoute(Container $container, Route $route): void { $parameters = $route->parameters(); + if ($parameters === []) { + return; + } + $action = $route->getAction('uses'); if (is_string($action)) { diff --git a/src/routing/src/ResolvesRouteDependencies.php b/src/routing/src/ResolvesRouteDependencies.php index 1da6c0b037..3b1756dbdd 100644 --- a/src/routing/src/ResolvesRouteDependencies.php +++ b/src/routing/src/ResolvesRouteDependencies.php @@ -57,6 +57,10 @@ protected function resolveClassMethodDependencies(array $parameters, object $ins */ public function resolveMethodDependencies(array $parameters, array $reflectedParameters): array { + if ($reflectedParameters === []) { + return $parameters; + } + $instanceCount = 0; $values = array_values($parameters); diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index b903f55faf..72e408ff27 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -138,6 +138,28 @@ class Route */ public ?array $resolvedMiddleware = null; + /** + * The cached pipeline descriptors for resolved class-string middleware. + * + * @var null|array + */ + public ?array $middlewareDescriptors = null; + + /** + * The middleware descriptors represented by the compiled pipeline. + * + * @var null|array + */ + public ?array $middlewarePipelinePipes = null; + + /** + * The reusable route middleware pipeline. + * + * It captures only the onion structure; each invocation supplies its own + * request and resolves descriptor middleware without caching the instance. + */ + public ?Closure $middlewarePipeline = null; + /** * The compiled version of the route. * @@ -398,6 +420,9 @@ public function flushController(): void { $this->computedMiddleware = null; $this->controller = null; + $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->resolvedMiddleware = null; if ($this->isControllerAction()) { @@ -482,8 +507,46 @@ public function bind(Request $request): static $parameters = (new RouteParameterBinder($this))->parameters($request); - CoroutineContext::set($this->parametersContextKey(), $parameters); - CoroutineContext::set($this->originalParametersContextKey(), $parameters); + return $this->storeParameters($parameters); + } + + /** + * Bind parameters returned by the compiled route matcher. + * + * @internal + * + * @param array $parameters + */ + public function bindFromCompiledMatch(array $parameters, Request $request): static + { + // A custom Route may override bind() semantics, so only the base class + // can safely reuse parameters produced by Symfony's compiled matcher. + if ($this::class !== self::class) { + return $this->bind($request); + } + + $this->compileRoute(); + + // Keep the bound-empty context state, but avoid allocating a binder + // when the compiled matcher cannot have produced route parameters. + $parameters = $this->parameterNames() === [] && $this->defaults === [] + ? [] + : (new RouteParameterBinder($this))->parametersFromCompiledMatch($parameters); + + return $this->storeParameters($parameters); + } + + /** + * Store the route's current and original parameters for this coroutine. + */ + private function storeParameters(array $parameters): static + { + $routeId = spl_object_id($this); + + CoroutineContext::setMany([ + self::PARAMS_CONTEXT_KEY_PREFIX . $routeId => $parameters, + self::ORIGINAL_PARAMS_CONTEXT_KEY_PREFIX . $routeId => $parameters, + ]); return $this; } @@ -555,8 +618,10 @@ public function forgetParameter(string $name): void */ public function parameters(): array { - if (CoroutineContext::has($this->parametersContextKey())) { - return CoroutineContext::get($this->parametersContextKey()); + $parameters = CoroutineContext::get($this->parametersContextKey()); + + if ($parameters !== null) { + return $parameters; } throw new LogicException('Route is not bound.'); @@ -569,8 +634,10 @@ public function parameters(): array */ public function originalParameters(): array { - if (CoroutineContext::has($this->originalParametersContextKey())) { - return CoroutineContext::get($this->originalParametersContextKey()); + $parameters = CoroutineContext::get($this->originalParametersContextKey()); + + if ($parameters !== null) { + return $parameters; } throw new LogicException('Route is not bound.'); @@ -581,7 +648,11 @@ public function originalParameters(): array */ public function parametersWithoutNulls(): array { - return array_filter($this->parameters(), fn ($parameter) => ! is_null($parameter)); + $parameters = $this->parameters(); + + return $parameters === [] + ? [] + : array_filter($parameters, fn ($parameter) => ! is_null($parameter)); } /** @@ -1455,7 +1526,7 @@ public function toSymfonyRoute(): SymfonyRoute $this->wheres, ['utf8' => true], $this->getDomain() ?: '', - [], + $this->httpOnly() ? ['http'] : ($this->httpsOnly() ? ['https'] : []), $this->methods ); } @@ -1507,6 +1578,9 @@ public function setContainer(Container $container): static $this->computedMiddleware = null; $this->controller = null; $this->controllerDispatcher = null; + $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->resolvedMiddleware = null; $this->shouldCacheControllerOnRoute = null; @@ -1541,6 +1615,9 @@ public function prepareForSerialization(): void $this->container = null; $this->controller = null; $this->controllerDispatcher = null; + $this->middlewareDescriptors = null; + $this->middlewarePipeline = null; + $this->middlewarePipelinePipes = null; $this->missing = null; $this->resolvedMiddleware = null; $this->router = null; diff --git a/src/routing/src/RouteParameterBinder.php b/src/routing/src/RouteParameterBinder.php index 7e8bd2ab55..357914e3c9 100644 --- a/src/routing/src/RouteParameterBinder.php +++ b/src/routing/src/RouteParameterBinder.php @@ -37,6 +37,20 @@ public function parameters(Request $request): array return $this->replaceDefaults($parameters); } + /** + * Get route parameters from a compiled matcher result. + * + * @param array $parameters + */ + public function parametersFromCompiledMatch(array $parameters): array + { + if ($this->route->parameterNames() === [] && $this->route->defaults === []) { + return []; + } + + return $this->replaceDefaults($this->matchToKeys($parameters)); + } + /** * Get the parameter matches for the path portion of the URI. */ @@ -68,11 +82,19 @@ protected function matchToKeys(array $matches): array return []; } - $parameters = array_intersect_key($matches, array_flip($parameterNames)); + $parameters = []; + + // Preserve URI order without allocating array_flip() and + // array_intersect_key() intermediates for every route match. + foreach ($parameterNames as $parameterName) { + $value = $matches[$parameterName] ?? null; + + if (is_string($value) && $value !== '') { + $parameters[$parameterName] = $value; + } + } - return array_filter($parameters, function ($value) { - return is_string($value) && strlen($value) > 0; - }); + return $parameters; } /** diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 58c2bc86d4..d6cbfcd1a9 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -18,6 +18,7 @@ use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Pipeline\Pipeline as BasePipeline; use Hypervel\Routing\Events\PreparingResponse; use Hypervel\Routing\Events\ResponsePrepared; @@ -687,12 +688,20 @@ protected function runRouteWithinStack(Route $route, Request $request): mixed return $route->run(); } - return $this->newPipeline() - ->send($request) - ->through($middleware) - ->then(function ($request) use ($route) { - return $this->prepareResponse($request, $route->run()); - }); + // Compile lazily for each route, then rebuild if middleware resolution + // changes so the cached onion never retains a stale pipe list. + if ($route->middlewarePipeline === null + || $route->middlewarePipelinePipes !== $middleware + ) { + $route->middlewarePipelinePipes = $middleware; + $route->middlewarePipeline = $this->newPipeline() + ->through($middleware) + ->toClosure(function ($request) use ($route) { + return $this->prepareResponse($request, $route->run()); + }); + } + + return ($route->middlewarePipeline)($request); } /** @@ -710,10 +719,40 @@ protected function newPipeline(): BasePipeline */ protected function middlewareFor(Route $route): array { + if ($route->resolvedMiddleware === []) { + return []; + } + $disabled = $this->container->bound('middleware.disable') && $this->container->make('middleware.disable') === true; - return $disabled ? [] : $this->gatherRouteMiddleware($route); + if ($disabled) { + return []; + } + + $middleware = $this->gatherRouteMiddleware($route); + + if ($middleware === []) { + return []; + } + + // Cache only the route's canonical list. Router overrides may return + // request-dependent middleware that must be described per dispatch. + if ($middleware !== $route->resolvedMiddleware) { + return array_map( + static fn (mixed $pipe): mixed => is_string($pipe) && ! is_callable($pipe) + ? PipeDescriptor::fromString($pipe) + : $pipe, + $middleware + ); + } + + return $route->middlewareDescriptors ??= array_map( + static fn (mixed $pipe): mixed => is_string($pipe) && ! is_callable($pipe) + ? PipeDescriptor::fromString($pipe) + : $pipe, + $middleware + ); } /** diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index eb82fcc40b..ac7143f676 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -100,6 +100,7 @@ * @method static \Hypervel\Support\ServiceProvider register(\Hypervel\Support\ServiceProvider|string $provider, bool $force = false) * @method static void registerConfiguredProviders() * @method static void registered(callable $callback) + * @method static bool resolvedScoped(string $abstract) * @method static void resolveEnvironmentUsing(null|callable $callback) * @method static mixed resolveFromAttribute(\ReflectionAttribute $attribute, \ReflectionParameter $parameter) * @method static \Hypervel\Support\ServiceProvider resolveProvider(string $provider) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 91f720f6c5..17bc851867 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1325,13 +1325,28 @@ public static function snake(string $value, string $delimiter = '_'): string */ public static function trim(string $value, ?string $charlist = null): string { - if ($charlist === null) { - $trimDefaultCharacters = " \n\r\t\v\0"; + if ($charlist !== null) { + return trim($value, $charlist); + } - return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? trim($value); + $trimmed = trim($value); + + if ($trimmed === '') { + return ''; } - return trim($value, $charlist); + // Native trim() already consumed its ASCII charlist. Only a multibyte + // boundary or form feed can still match the Unicode whitespace set. + if (ord($trimmed[0]) < 0x80 + && ord($trimmed[-1]) < 0x80 + && $trimmed[0] !== "\f" + && $trimmed[-1] !== "\f") { + return $trimmed; + } + + $trimDefaultCharacters = " \n\r\t\v\0"; + + return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? $trimmed; } /** diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index b71e34a2d4..470b829648 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -24,6 +24,8 @@ use stdClass; use TypeError; +use function Hypervel\Coroutine\parallel; + class ContainerTest extends TestCase { public function testContainerSingleton() @@ -678,6 +680,68 @@ public function testForgettingTemporaryInstanceRestoresScopedLifecycle(): void $this->assertSame($restored, $container->make(ContainerConcreteStub::class)); } + public function testResolvedScopedReportsWhetherAScopedBindingWasResolved(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + $this->assertFalse($container->resolvedScoped(ContainerConcreteStub::class)); + + $container->make(ContainerConcreteStub::class); + + $this->assertTrue($container->resolvedScoped(ContainerConcreteStub::class)); + } + + public function testResolvedScopedIsFalseForBindingsThatAreNotScoped(): void + { + $container = new Container; + $container->singleton('singleton', fn (): stdClass => new stdClass); + $container->bind('transient', fn (): stdClass => new stdClass); + + $container->make('singleton'); + $container->make('transient'); + + $this->assertFalse($container->resolvedScoped('singleton')); + $this->assertFalse($container->resolvedScoped('transient')); + $this->assertFalse($container->resolvedScoped('unbound')); + } + + public function testResolvedScopedResolvesAliases(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + $container->alias(ContainerConcreteStub::class, 'stub'); + + $this->assertFalse($container->resolvedScoped('stub')); + + $container->make(ContainerConcreteStub::class); + + $this->assertTrue($container->resolvedScoped('stub')); + } + + public function testResolvedScopedIsIsolatedBetweenCoroutines(): void + { + $container = new Container; + $container->scoped(ContainerConcreteStub::class); + + [$resolver, $observer] = parallel([ + function () use ($container) { + $container->make(ContainerConcreteStub::class); + usleep(5000); + + return $container->resolvedScoped(ContainerConcreteStub::class); + }, + function () use ($container) { + usleep(1000); + + return $container->resolvedScoped(ContainerConcreteStub::class); + }, + ]); + + $this->assertTrue($resolver); + $this->assertFalse($observer, 'A scoped instance resolved in one coroutine leaked into another.'); + } + public function testExplicitTransientBindingOverridesScopedAttribute(): void { $container = new Container; diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 6f2ebfb261..319f73f8c0 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Foundation\Http; +use Closure; use Hypervel\Config\Repository; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Events\Dispatcher; use Hypervel\Foundation\Application; @@ -262,6 +264,49 @@ public function terminate($request, $response): void ], $called); } + public function testItTerminatesTerminableMiddlewareOnEveryRequest(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $kernel = new Kernel($app, new Router($events, $app)); + + $terminable = new class { + public int $terminated = 0; + + public function terminate(Request $request, Response $response): void + { + ++$this->terminated; + } + }; + + $nonTerminable = new class { + public int $resolved = 0; + }; + + $app->instance('terminable-middleware', $terminable); + $app->bind('non-terminable-middleware', function () use ($nonTerminable) { + ++$nonTerminable->resolved; + + return $nonTerminable; + }); + + $kernel->setGlobalMiddleware([ + 'terminable-middleware', + 'non-terminable-middleware', + ]); + + $kernel->terminate(new Request, new Response); + $kernel->terminate(new Request, new Response); + $kernel->terminate(new Request, new Response); + + $this->assertSame(3, $terminable->terminated); + + // The terminability answer is memoized for the worker lifetime, so a + // middleware without terminate() is resolved once and skipped after. + $this->assertSame(1, $nonTerminable->resolved); + } + public function testHandleReportsAndRendersRouterFailures(): void { $app = new Application; @@ -424,6 +469,104 @@ public function testDurationHandlerReceivesConvertedImmutableStartTimeFromContex $this->assertTrue($transportStartedAt->equalTo($request->startedAt())); } + public function testProductionRequestStartTimeIsMaterializedLazily(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->instance('config', new Repository(['app' => ['timezone' => 'UTC']])); + $app->bootstrapWith([]); + + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->once()->andReturn(new Response); + + $kernel = new class($app, $router) extends Kernel { + public function rawRequestStartedAt(): mixed + { + return CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + } + }; + $request = Request::create('/'); + $response = $kernel->handle($request); + + $this->assertIsFloat($kernel->rawRequestStartedAt()); + + $startedAt = $kernel->requestStartedAt(); + + $this->assertInstanceOf(CarbonImmutable::class, $startedAt); + $this->assertSame($startedAt, $kernel->rawRequestStartedAt()); + $this->assertSame($startedAt, $kernel->requestStartedAt()); + + $kernel->terminate($request, $response); + + $this->assertNull($kernel->rawRequestStartedAt()); + } + + public function testGlobalMiddlewarePipelineIsReusedWithoutCachingMiddlewareInstances(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->bootstrapWith([]); + $resolutions = 0; + $app->bind('test-middleware', function () use (&$resolutions): object { + ++$resolutions; + + return new class { + public function handle(Request $request, Closure $next): Response + { + return $next($request); + } + }; + }); + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->twice()->andReturn(new Response); + $kernel = new class($app, $router) extends Kernel { + public function reusablePipeline(): ?Closure + { + return $this->middlewarePipeline; + } + }; + $kernel->setGlobalMiddleware(['test-middleware']); + + $kernel->handle(Request::create('/first')); + $pipeline = $kernel->reusablePipeline(); + + $this->assertNotNull($pipeline); + + $kernel->handle(Request::create('/second')); + + $this->assertSame($pipeline, $kernel->reusablePipeline()); + $this->assertSame(2, $resolutions); + } + + public function testSetApplicationClearsApplicationSpecificMiddlewareCaches(): void + { + $kernel = new class(new Application, m::mock(Router::class)) extends Kernel { + public function primeMiddlewareCaches(): void + { + $this->terminableMiddleware = ['test-middleware' => false]; + $this->middlewarePipelineStack = ['test-middleware']; + $this->middlewarePipeline = static fn (): null => null; + } + + public function middlewareCaches(): array + { + return [ + $this->terminableMiddleware, + $this->middlewarePipelineStack, + $this->middlewarePipeline, + ]; + } + }; + $application = new Application; + $kernel->primeMiddlewareCaches(); + + $this->assertSame($kernel, $kernel->setApplication($application)); + $this->assertSame($application, $kernel->getApplication()); + $this->assertSame([[], [], null], $kernel->middlewareCaches()); + } + public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines(): void { $app = new Application; diff --git a/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php new file mode 100644 index 0000000000..76c6dd98f4 --- /dev/null +++ b/tests/Foundation/Http/Middleware/Concerns/ExcludesPathsTest.php @@ -0,0 +1,147 @@ +assertTrue($excluder->check(Request::create('http://example.com/up'))); + $this->assertTrue($excluder->check(Request::create('http://example.com/api/users'))); + $this->assertFalse($excluder->check(Request::create('http://example.com/dashboard'))); + } + + public function testExcludesAbsoluteUrlPatterns(): void + { + // The full URL is only rebuilt for patterns that could match one, so a + // pattern written as an absolute URL still has to work. + $excluder = new ExcludesPathsTestExcluder(['http://example.com/admin/*']); + + $this->assertTrue($excluder->check(Request::create('http://example.com/admin/users'))); + $this->assertFalse($excluder->check(Request::create('http://other.com/admin/users'))); + } + + public function testEmptyExclusionListMatchesNothing(): void + { + $excluder = new ExcludesPathsTestExcluder([]); + + $this->assertFalse($excluder->check(Request::create('http://example.com/up'))); + } + + public function testUrlAndPathAreResolvedOnceAcrossMultiplePatterns(): void + { + $request = CountingRequest::create('http://example.com/api/users'); + $excluder = new ExcludesPathsTestExcluder(['missing', 'also-missing', 'api/*']); + + $this->assertTrue($excluder->check($request)); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(1, $request->decodedPathCalls); + } + + public function testFullUrlMatchDoesNotResolveTheDecodedPath(): void + { + $request = CountingRequest::create('http://example.com/admin/users'); + $excluder = new ExcludesPathsTestExcluder(['http://example.com/admin/*']); + + $this->assertTrue($excluder->check($request)); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(0, $request->decodedPathCalls); + } + + public function testUntrustedHostIsRejectedEvenWhenThePathIsExcluded(): void + { + $request = Request::create('http://evil.com/up'); + RequestContext::set($request); + Request::setTrustedHosts(['^allowed\.com$']); + + $excluder = new ExcludesPathsTestExcluder(['up']); + + $this->expectException(SuspiciousOperationException::class); + + $excluder->check($request); + } + + public function testConflictingForwardedHeadersAreRejectedEvenWhenThePathIsExcluded(): void + { + foreach (['host', 'proto', 'port'] as $axis) { + $request = $this->conflictingRequest($axis); + $excluder = new ExcludesPathsTestExcluder(['up']); + + try { + $excluder->check($request); + + $this->fail("A conflicting {$axis} header was not rejected."); + } catch (ConflictingHeadersException $exception) { + $this->assertInstanceOf(ConflictingHeadersException::class, $exception); + } + } + } + + /** + * Build a request whose Forwarded and X-Forwarded-* headers disagree on one axis. + */ + protected function conflictingRequest(string $axis): Request + { + $request = Request::create('http://allowed.com/up', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_FORWARDED' => 'for=1.2.3.4;host=allowed.com;proto=https;port=443', + 'HTTP_X_FORWARDED_HOST' => $axis === 'host' ? 'evil.com' : 'allowed.com', + 'HTTP_X_FORWARDED_PROTO' => $axis === 'proto' ? 'http' : 'https', + 'HTTP_X_FORWARDED_PORT' => $axis === 'port' ? '8080' : '443', + ]); + + RequestContext::set($request); + + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_FORWARDED + | Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT); + + return $request; + } +} + +class ExcludesPathsTestExcluder +{ + use ExcludesPaths; + + public function __construct(protected array $except = []) + { + } + + public function check(Request $request): bool + { + return $this->inExceptArray($request); + } +} + +class CountingRequest extends Request +{ + public int $fullUrlCalls = 0; + + public int $decodedPathCalls = 0; + + public function fullUrl(): string + { + ++$this->fullUrlCalls; + + return parent::fullUrl(); + } + + public function decodedPath(): string + { + ++$this->decodedPathCalls; + + return parent::decodedPath(); + } +} diff --git a/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php b/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php new file mode 100644 index 0000000000..5de7dc5ba9 --- /dev/null +++ b/tests/Foundation/Http/Middleware/InvokeDeferredCallbacksTest.php @@ -0,0 +1,66 @@ +scoped(DeferredCallbackCollection::class); + + $ran = false; + defer(function () use (&$ran) { + $ran = true; + }); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 200)); + + $this->assertTrue($ran, 'defer() callback did not run through the middleware.'); + } + + public function testItSkipsDeferredCallbacksOnFailedResponses(): void + { + $container = Container::setInstance(new Container); + $container->scoped(DeferredCallbackCollection::class); + + $ran = false; + $always = false; + + defer(function () use (&$ran) { + $ran = true; + }); + defer(function () use (&$always) { + $always = true; + }, always: true); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 500)); + + $this->assertFalse($ran, 'A deferred callback ran despite the response failing.'); + $this->assertTrue($always, 'An always-deferred callback did not run on a failed response.'); + } + + public function testItDoesNotConstructTheCollectionWhenNothingDeferred(): void + { + $container = Container::setInstance(new Container); + $container->scoped(DeferredCallbackCollection::class); + + (new InvokeDeferredCallbacks)->terminate(new Request, new Response('', 200)); + + $this->assertFalse( + $container->resolvedScoped(DeferredCallbackCollection::class), + 'The collection was constructed even though nothing deferred.' + ); + } +} diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index 99024b8f9d..482ca0e4e6 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -7,10 +7,32 @@ use Hypervel\Foundation\Http\Middleware\TransformsRequest; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; class TransformsRequestTest extends TestCase { + public function testEmptyParameterBagIsNotReadOrReplaced(): void + { + $bag = new TrackingParameterBag; + + (new ExposedTransformsRequest)->cleanBag($bag); + + $this->assertSame(0, $bag->allCalls); + $this->assertSame(0, $bag->replaceCalls); + } + + public function testNonEmptyParameterBagIsStillReadAndReplaced(): void + { + $bag = new TrackingParameterBag(['name' => 'Taylor']); + + (new ExposedTransformsRequest)->cleanBag($bag); + + $this->assertSame(1, $bag->allCalls); + $this->assertSame(1, $bag->replaceCalls); + $this->assertSame(['name' => 'Taylor'], $bag->all()); + } + public function testTransformOncePerKeyWhenMethodIsGet() { $middleware = new TruncateInput; @@ -134,3 +156,32 @@ protected function transform(string $key, mixed $value): mixed return substr($value, 0, -1); } } + +class ExposedTransformsRequest extends TransformsRequest +{ + public function cleanBag(ParameterBag $bag): void + { + $this->cleanParameterBag($bag); + } +} + +class TrackingParameterBag extends ParameterBag +{ + public int $allCalls = 0; + + public int $replaceCalls = 0; + + public function all(?string $key = null): array + { + ++$this->allCalls; + + return parent::all($key); + } + + public function replace(array $parameters = []): void + { + ++$this->replaceCalls; + + parent::replace($parameters); + } +} diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index 9e3f21399b..be3c600e8a 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -11,6 +11,28 @@ class TrimStringsTest extends TestCase { + public function testNonStringValuesDoNotPerformExclusionMatching() + { + $middleware = new TrimStringsTrackingExclusionMatches; + $symfonyRequest = new SymfonyRequest([ + 'integer' => 123, + 'boolean' => true, + 'null' => null, + 'string' => ' value ', + ]); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, function (Request $request) { + $this->assertSame(123, $request->input('integer')); + $this->assertTrue($request->input('boolean')); + $this->assertNull($request->input('null')); + $this->assertSame('value', $request->input('string')); + }); + + $this->assertSame(1, $middleware->exclusionMatchCount); + } + public function testTrimStringsIgnoringExceptAttribute() { $middleware = new TrimStringsWithExceptAttribute; @@ -31,6 +53,65 @@ public function testTrimStringsIgnoringExceptAttribute() }); } + public function testTrimStringsSupportsExactAndWildcardExceptAttributes() + { + $middleware = new TrimStringsWithExactAndWildcardExceptAttributes; + $symfonyRequest = new SymfonyRequest([ + 'exact' => ' exact ', + 'other' => ' other ', + 'users' => [ + ['secret' => ' first ', 'name' => ' Taylor '], + ['secret' => ' second ', 'name' => ' Abigail '], + ], + ]); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, function (Request $request) { + $this->assertSame(' exact ', $request->input('exact')); + $this->assertSame('other', $request->input('other')); + $this->assertSame(' first ', $request->input('users.0.secret')); + $this->assertSame('Taylor', $request->input('users.0.name')); + $this->assertSame(' second ', $request->input('users.1.secret')); + $this->assertSame('Abigail', $request->input('users.1.name')); + }); + } + + public function testGlobalExceptAppliesToAnExistingMiddlewareInstance() + { + $middleware = new TrimStrings; + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + + TrimStrings::except('token'); + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + + public function testFlushStateAppliesToAnExistingMiddlewareInstance() + { + TrimStrings::except('token'); + + $middleware = new TrimStrings; + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + + TrimStrings::flushState(); + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + + public function testInstanceExceptChangesAreUsedBySubsequentRequests(): void + { + $middleware = new MutableExceptTrimStrings; + + $this->assertSame('value', $this->handle($middleware, ['token' => ' value '])->input('token')); + + $middleware->setExcept(['token']); + + $this->assertSame(' value ', $this->handle($middleware, ['token' => ' value '])->input('token')); + } + public function testTrimStringsNBSP() { $middleware = new TrimStrings; @@ -58,6 +139,17 @@ public function testTrimStringsNBSP() $this->assertSame("\xE9", $request->input('binary')); }); } + + private function handle(TrimStrings $middleware, array $input): Request + { + $symfonyRequest = new SymfonyRequest($input); + $symfonyRequest->server->set('REQUEST_METHOD', 'GET'); + $request = Request::createFromBase($symfonyRequest); + + $middleware->handle($request, fn (Request $request) => $request); + + return $request; + } } class TrimStringsWithExceptAttribute extends TrimStrings @@ -67,3 +159,31 @@ class TrimStringsWithExceptAttribute extends TrimStrings 'bar', ]; } + +class TrimStringsWithExactAndWildcardExceptAttributes extends TrimStrings +{ + protected array $except = [ + 'exact', + 'users.*.secret', + ]; +} + +class TrimStringsTrackingExclusionMatches extends TrimStrings +{ + public int $exclusionMatchCount = 0; + + protected function shouldSkip(string $key, array $except): bool + { + ++$this->exclusionMatchCount; + + return parent::shouldSkip($key, $except); + } +} + +class MutableExceptTrimStrings extends TrimStrings +{ + public function setExcept(array $except): void + { + $this->except = $except; + } +} diff --git a/tests/Foundation/WorkerCachedMaintenanceModeTest.php b/tests/Foundation/WorkerCachedMaintenanceModeTest.php index b1810cb52f..1ab1685e96 100644 --- a/tests/Foundation/WorkerCachedMaintenanceModeTest.php +++ b/tests/Foundation/WorkerCachedMaintenanceModeTest.php @@ -103,6 +103,24 @@ public function testSnapshotRefreshesAtExactIntervalBoundary(): void $this->assertTrue($cached->active()); } + public function testSnapshotDoesNotRefreshBeforeIntervalAcrossFractionalSeconds(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::parse('2026-01-01 00:00:00.900000')); + + $driver = m::mock(MaintenanceModeContract::class); + $driver->shouldReceive('active')->once()->andReturn(false); + + $cached = new WorkerCachedMaintenanceMode($driver, refreshInterval: 5); + + $this->assertFalse($cached->active()); + + // 4.2 seconds have passed, not 5: the whole-second parts differ by 5, + // so a truncated timestamp would report the interval as elapsed. + CarbonImmutable::setTestNow($now->addSeconds(4.2)); + + $this->assertFalse($cached->active()); + } + public function testActivePayloadRefreshesAfterInterval(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::parse('2026-01-01 00:00:00')); diff --git a/tests/Http/HttpJsonResponseTest.php b/tests/Http/HttpJsonResponseTest.php index 0f38a1282a..0deccebb41 100644 --- a/tests/Http/HttpJsonResponseTest.php +++ b/tests/Http/HttpJsonResponseTest.php @@ -13,6 +13,9 @@ use JsonSerializable; use PHPUnit\Framework\Attributes\DataProvider; use stdClass; +use Stringable; +use Symfony\Component\HttpFoundation\JsonResponse as SymfonyJsonResponse; +use TypeError; class HttpJsonResponseTest extends TestCase { @@ -112,6 +115,45 @@ public function testFromJsonString(): void $this->assertSame('bar', $response->getData()->foo); } + #[DataProvider('rawJsonDataProvider')] + public function testRawJsonRetainsSymfonyConstructorCompatibility(mixed $data): void + { + $expected = new SymfonyJsonResponse($data, 201, ['X-Test' => 'value'], true); + $response = new JsonResponse($data, 201, ['X-Test' => 'value'], json: true); + + $this->assertSame($expected->getContent(), $response->getContent()); + $this->assertSame($expected->getStatusCode(), $response->getStatusCode()); + $this->assertSame('value', $response->headers->get('X-Test')); + } + + public static function rawJsonDataProvider(): array + { + return [ + 'string' => ['{"foo":"bar"}'], + 'integer' => [123], + 'float' => [12.5], + 'Stringable' => [new JsonResponseTestStringableObject], + ]; + } + + #[DataProvider('invalidRawJsonDataProvider')] + public function testRawJsonRejectsValuesSymfonyDoesNotAccept(mixed $data): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('If $json is set to true'); + + new JsonResponse($data, json: true); + } + + public static function invalidRawJsonDataProvider(): array + { + return [ + 'null' => [null], + 'array' => [[]], + 'ordinary object' => [new stdClass], + ]; + } + public function testDataRoundTripsAtTheMaximumSupportedNestingDepth(): void { $value = 'leaf'; @@ -167,3 +209,11 @@ public function toArray(): array return ['foo' => 'bar']; } } + +class JsonResponseTestStringableObject implements Stringable +{ + public function __toString(): string + { + return '{"foo":"bar"}'; + } +} diff --git a/tests/Http/Middleware/HandleCorsTest.php b/tests/Http/Middleware/HandleCorsTest.php index 30dcc327c0..75a5d33dfb 100644 --- a/tests/Http/Middleware/HandleCorsTest.php +++ b/tests/Http/Middleware/HandleCorsTest.php @@ -325,6 +325,58 @@ protected function makeContainer(array $corsConfig): Container return $container; } + public function testPathsWrittenAsAbsoluteUrlsStillMatch(): void + { + // The full URL is only rebuilt for patterns that could match one, so a + // CORS path written as an absolute URL still has to be honored. + $response = $this->dispatchPreflight('admin/ping', [ + 'Origin' => 'http://localhost', + 'Access-Control-Request-Method' => 'POST', + ], ['paths' => ['http://localhost/admin/*']]); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testPathsWrittenAsAbsoluteUrlsDoNotMatchOtherPaths(): void + { + $response = $this->dispatchPreflight('public/ping', [ + 'Origin' => 'http://localhost', + 'Access-Control-Request-Method' => 'POST', + ], ['paths' => ['http://localhost/admin/*']]); + + $this->assertNull($response->headers->get('Access-Control-Allow-Origin')); + } + + public function testPathSubjectsAreResolvedOnceAcrossMultipleCorsPatterns(): void + { + $request = CountingCorsRequest::create('http://localhost/api/ping', 'OPTIONS'); + $request->headers->set('Origin', 'http://localhost'); + $request->headers->set('Access-Control-Request-Method', 'POST'); + + $response = $this->makeMiddleware([ + 'paths' => ['missing', 'also-missing', 'api/*'], + ])->handle($request, fn () => new Response('', 200)); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(1, $request->decodedPathCalls); + } + + public function testAbsoluteCorsUrlMatchDoesNotResolveTheDecodedPath(): void + { + $request = CountingCorsRequest::create('http://localhost/admin/ping', 'OPTIONS'); + $request->headers->set('Origin', 'http://localhost'); + $request->headers->set('Access-Control-Request-Method', 'POST'); + + $response = $this->makeMiddleware([ + 'paths' => ['http://localhost/admin/*'], + ])->handle($request, fn () => new Response('', 200)); + + $this->assertSame('http://localhost', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(1, $request->fullUrlCalls); + $this->assertSame(0, $request->decodedPathCalls); + } + protected function makeRequest(string $method, string $path, array $headers = []): Request { $request = Request::create('http://localhost/' . ltrim($path, '/'), $method); @@ -352,3 +404,24 @@ protected function dispatchRequest(string $method, string $path, array $headers, ); } } + +class CountingCorsRequest extends Request +{ + public int $fullUrlCalls = 0; + + public int $decodedPathCalls = 0; + + public function fullUrl(): string + { + ++$this->fullUrlCalls; + + return parent::fullUrl(); + } + + public function decodedPath(): string + { + ++$this->decodedPathCalls; + + return parent::decodedPath(); + } +} diff --git a/tests/Http/ResponsePerformanceOptimizationTest.php b/tests/Http/ResponsePerformanceOptimizationTest.php new file mode 100644 index 0000000000..eb4c01bd39 --- /dev/null +++ b/tests/Http/ResponsePerformanceOptimizationTest.php @@ -0,0 +1,77 @@ + 'HTTP/1.1']); + $expected = new SymfonyResponse('content', 200, $headers); + $actual = new Response('content', 200, $headers); + + $expected->prepare($request); + $actual->prepare($request); + + $expectedHeaders = $expected->headers->all(); + $actualHeaders = $actual->headers->all(); + ksort($expectedHeaders); + ksort($actualHeaders); + + $this->assertSame($expected->getContent(), $actual->getContent()); + $this->assertSame($expected->getProtocolVersion(), $actual->getProtocolVersion()); + $this->assertSame($expectedHeaders, $actualHeaders); + } + + public static function commonResponsePreparationProvider(): array + { + return [ + 'text content type' => [['Content-Type' => 'text/plain']], + 'text content type with charset' => [['Content-Type' => 'text/html; charset=UTF-16']], + 'non-text content type' => [['Content-Type' => 'application/json']], + 'transfer encoding removes length' => [[ + 'Content-Type' => 'text/plain', + 'Content-Length' => '7', + 'Transfer-Encoding' => 'chunked', + ]], + 'content disposition' => [[ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="file.txt"', + ]], + ]; + } + + public function testPrototypeDateIsRefreshedAndAnExplicitDateStillWins(): void + { + Response::flushState(); + new Response; + + $prototype = (new ReflectionProperty(Response::class, 'headerPrototype'))->getValue(); + $this->assertInstanceOf(ResponseHeaderBag::class, $prototype); + $prototype->set('Date', 'Thu, 01 Jan 1970 00:00:00 GMT'); + (new ReflectionProperty(Response::class, 'headerPrototypeTimestamp'))->setValue(null, 0); + + $response = new Response; + $date = $response->headers->get('Date'); + $this->assertNotNull($date); + $timestamp = strtotime($date); + $this->assertNotFalse($timestamp); + + $this->assertLessThanOrEqual(1, abs(time() - $timestamp)); + $this->assertSame( + 'Thu, 01 Jan 1970 00:00:00 GMT', + (new Response(headers: ['Date' => 'Thu, 01 Jan 1970 00:00:00 GMT']))->headers->get('Date'), + ); + } +} diff --git a/tests/HttpServer/RequestBridgeTest.php b/tests/HttpServer/RequestBridgeTest.php index 9b3ba15c20..dcc93cba96 100644 --- a/tests/HttpServer/RequestBridgeTest.php +++ b/tests/HttpServer/RequestBridgeTest.php @@ -12,7 +12,9 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use Swoole\Http\Request as SwooleRequest; +use Symfony\Component\HttpFoundation\Request as SymfonyRequest; class RequestBridgeTest extends TestCase { @@ -45,6 +47,8 @@ public function testCreateFromSwooleWithGetRequest(): void $request = RequestBridge::createFromSwoole($swooleRequest); $this->assertInstanceOf(Request::class, $request); + $this->assertSame('/users', (new ReflectionProperty(SymfonyRequest::class, 'pathInfo'))->getValue($request)); + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'method'))->getValue($request)); $this->assertSame('GET', $request->getMethod()); $this->assertSame('/users', $request->getPathInfo()); $this->assertSame('1', $request->query->get('page')); @@ -61,11 +65,40 @@ public function testCreateFromSwooleWithPostRequest(): void $request = RequestBridge::createFromSwoole($swooleRequest); + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'method'))->getValue($request)); $this->assertSame('POST', $request->getMethod()); $this->assertSame('Taylor', $request->request->get('name')); $this->assertSame('taylor@example.com', $request->request->get('email')); } + public function testPathInfoFallsBackWhenMiddlewareRewritesRequestUri(): void + { + $request = RequestBridge::createFromSwoole($this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/original'], + header: ['host' => 'example.com'], + )); + + $request->server->set('REQUEST_URI', '/rewritten'); + + $this->assertSame('/rewritten', $request->getPathInfo()); + } + + public function testPathInfoFallsBackForFrontControllerServerParams(): void + { + $request = RequestBridge::createFromSwoole($this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/index.php/users', + 'script_name' => '/index.php', + 'script_filename' => '/var/www/index.php', + ], + header: ['host' => 'example.com'], + )); + + $this->assertNull((new ReflectionProperty(SymfonyRequest::class, 'pathInfo'))->getValue($request)); + $this->assertSame('/users', $request->getPathInfo()); + } + public function testCreateFromSwooleWithCookies(): void { $swooleRequest = $this->createSwooleRequest( @@ -151,10 +184,98 @@ public function testHeadersGetHttpPrefix(): void $this->assertSame('Bearer token123', $request->headers->get('authorization')); } + #[DataProvider('authorizationHeaderProvider')] + public function testAuthorizationHeadersMatchSymfonyNormalization(string $authorization): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: ['host' => 'example.com', 'authorization' => $authorization], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'HTTP_AUTHORIZATION' => $authorization, + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + } + + public static function authorizationHeaderProvider(): iterable + { + yield 'basic' => ['Basic ' . base64_encode('user:password')]; + yield 'digest' => ['Digest username="user"']; + yield 'bearer' => ['Bearer token']; + } + + public function testServerBasicAuthorizationMatchesSymfonyNormalization(): void + { + $swooleRequest = $this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/', + 'php_auth_user' => 'server-user', + 'php_auth_pw' => 'server-password', + ], + header: ['host' => 'example.com'], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'PHP_AUTH_USER' => 'server-user', + 'PHP_AUTH_PW' => 'server-password', + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + } + + public function testRedirectDigestAuthorizationMatchesSymfonyNormalization(): void + { + $authorization = 'Digest username="redirect-user"'; + $swooleRequest = $this->createSwooleRequest( + server: [ + 'request_method' => 'get', + 'request_uri' => '/', + 'redirect_http_authorization' => $authorization, + ], + header: ['host' => 'example.com'], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + $expected = new Request(server: [ + 'HTTP_HOST' => 'example.com', + 'REDIRECT_HTTP_AUTHORIZATION' => $authorization, + ]); + + $this->assertSame($expected->headers->all(), $request->headers->all()); + $this->assertSame($authorization, $request->server->get('PHP_AUTH_DIGEST')); + } + + public function testMixedCaseAndUnknownHeadersUseGenericNormalization(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: [ + 'User-Agent' => 'custom-client', + 'X-Custom-Mixed-Header' => 'custom-value', + ], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertSame('custom-client', $request->headers->get('user-agent')); + $this->assertSame('custom-value', $request->headers->get('x-custom-mixed-header')); + } + public function testContentTypeAndContentLengthGetSpecialTreatment(): void { $swooleRequest = $this->createSwooleRequest( - server: ['request_method' => 'post', 'request_uri' => '/'], + server: [ + 'request_method' => 'post', + 'request_uri' => '/', + 'content_md5' => 'checksum', + ], header: [ 'host' => 'example.com', 'content-type' => 'application/json', @@ -171,6 +292,23 @@ public function testContentTypeAndContentLengthGetSpecialTreatment(): void // They should also be accessible via headers (HttpFoundation normalizes from server) $this->assertSame('application/json', $request->headers->get('content-type')); + $this->assertSame('checksum', $request->headers->get('content-md5')); + } + + public function testCacheControlHeaderRetainsParsedDirectives(): void + { + $swooleRequest = $this->createSwooleRequest( + server: ['request_method' => 'get', 'request_uri' => '/'], + header: [ + 'host' => 'example.com', + 'cache-control' => 'no-cache, max-age=60', + ], + ); + + $request = RequestBridge::createFromSwoole($swooleRequest); + + $this->assertTrue($request->headers->hasCacheControlDirective('no-cache')); + $this->assertSame('60', $request->headers->getCacheControlDirective('max-age')); } #[DataProvider('requestUriProvider')] diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 7425562004..6ebcbc1791 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -97,6 +97,38 @@ public function testOnRequestDelegatestoKernelAndSendsResponse(): void $server->onRequest($swooleRequest, $swooleResponse); } + public function testOnRequestWaitsForWorkerStartOnlyOnce(): void + { + CoordinatorManager::until(Constants::WORKER_START)->resume(); + + $kernel = m::mock(KernelContract::class); + $kernel->shouldReceive('handle') + ->twice() + ->with(m::type(Request::class)) + ->andReturn(new Response('OK')); + $kernel->shouldReceive('terminate')->twice(); + + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(false); + + $server = new Server($container); + $this->setKernel($server, $kernel); + + $swooleResponse = m::mock(SwooleResponse::class); + $swooleResponse->shouldReceive('status')->twice()->with(200)->andReturnTrue(); + $swooleResponse->shouldReceive('header')->withAnyArgs()->andReturnTrue(); + $swooleResponse->shouldReceive('end')->twice()->with('OK')->andReturnTrue(); + + wait(fn () => $server->onRequest($this->createSwooleRequest(), $swooleResponse)); + + CoordinatorManager::clear(Constants::WORKER_START); + + wait( + fn () => $server->onRequest($this->createSwooleRequest(), $swooleResponse), + timeout: 0.1 + ); + } + public function testOnRequestSetsRequestInContext(): void { CoordinatorManager::until(Constants::WORKER_START)->resume(); diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 120dbfb94d..d0d05866d5 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -11,8 +11,10 @@ use Hypervel\Routing\RouteCollection; use Hypervel\Routing\Router; use Hypervel\Support\Arr; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\RequestContext as SymfonyRequestContext; class CompiledRouteCollectionTest extends RoutingTestCase { @@ -300,9 +302,21 @@ public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAl { $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); - $this->expectException(MethodNotAllowedHttpException::class); + try { + $this->collection()->match(Request::create('/foo', 'POST')); + $this->fail('Expected the compiled route to reject the POST method.'); + } catch (MethodNotAllowedHttpException $exception) { + $this->assertSame('GET, HEAD', $exception->getHeaders()['Allow']); + } + } + + public function testOptionsRequestUsesCompiledAllowedMethods(): void + { + $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); - $this->collection()->match(Request::create('/foo', 'POST')); + $route = $this->collection()->match(Request::create('/foo', 'OPTIONS')); + + $this->assertSame(['OPTIONS'], $route->methods()); } public function testMatchingThrowsExceptionWhenMethodIsNotAllowedWhileSameRouteIsAddedDynamically() @@ -359,6 +373,257 @@ public function testMatchingWildcardFromCompiledRoutesAlwaysTakesPrecedent() $this->assertSame('foo', $routes->match(Request::create('/foo', 'GET'))->getName()); } + public function testCompiledMatchBindsPathParametersAndOriginalValues(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/users/{user}/posts/{post}', [ + 'uses' => 'FooController@index', + 'as' => 'posts.show', + ]) + ); + + $route = $this->collection()->match(Request::create('/users/12/posts/34', 'GET')); + + $this->assertSame(['user' => '12', 'post' => '34'], $route->parameters()); + $this->assertSame(['user' => '12', 'post' => '34'], $route->originalParameters()); + + $route->setParameter('user', 'changed'); + + $this->assertSame(['user' => 'changed', 'post' => '34'], $route->parameters()); + $this->assertSame(['user' => '12', 'post' => '34'], $route->originalParameters()); + } + + public function testCompiledMatchBindsEmptyParameterState(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + + $route = $this->collection()->match(Request::create('/status', 'GET')); + + $this->assertTrue($route->hasParameters()); + $this->assertSame([], $route->parameters()); + $this->assertSame([], $route->originalParameters()); + + $route->setParameter('runtime', 'value'); + + $this->assertSame(['runtime' => 'value'], $route->parameters()); + $this->assertSame([], $route->originalParameters()); + + $route->forgetParameter('runtime'); + + $this->assertSame([], $route->parameters()); + } + + public function testUnconstrainedStaticMatchPreservesHostValidation(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + + $request = StaticMatchTrackingRequest::create('/status', 'GET'); + $route = $this->collection()->match($request); + + $this->assertSame('status', $route->getName()); + $this->assertSame(1, $request->hostReads); + } + + public function testUnconstrainedStaticMatchRejectsAnInvalidHost(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'status', + ]) + ); + $request = Request::create('/status', 'GET'); + $request->headers->set('HOST', 'invalid host'); + + $this->expectException(SuspiciousOperationException::class); + + $this->collection()->match($request); + } + + public function testStaticCompiledFallbackYieldsToDynamicNonFallbackRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'compiled-fallback', + ])->fallback() + ); + $routes = $this->collection(); + $routes->add( + $this->newRoute('GET', '/status', [ + 'uses' => 'FooController@index', + 'as' => 'dynamic', + ]) + ); + + $this->assertSame( + 'dynamic', + $routes->match(Request::create('/status', 'GET'))->getName() + ); + } + + public function testCompiledMatchUsesAnOverriddenRouteBindMethod(): void + { + $router = new BindTrackingRouter($this->app->make('events'), $this->app); + $routes = new RouteCollection; + $routes->add($router->newRoute('GET', '/users/{user}', [ + 'uses' => 'FooController@index', + 'as' => 'users.show', + ])); + + $route = $routes + ->toCompiledRouteCollection($router, $this->app) + ->match(Request::create('/users/42', 'GET')); + + $this->assertInstanceOf(BindTrackingRoute::class, $route); + $this->assertSame(1, $route->bindCalls); + $this->assertSame(['user' => '42'], $route->parameters()); + } + + public function testCompiledMatchBindsDomainAndPathParameters(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/users/{user}', [ + 'uses' => 'FooController@index', + 'as' => 'tenant.users.show', + 'domain' => '{tenant}.example.com', + ]) + ); + + $route = $this->collection()->match( + Request::create('https://hypervel.example.com/users/42', 'GET') + ); + + $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->parameters()); + $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->originalParameters()); + } + + public function testCompiledMatchHonorsHttpsOnlyRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/secure/{resource}', [ + 'uses' => 'FooController@index', + 'as' => 'secure', + 'https', + ]) + ); + + $this->assertSame( + 'secure', + $this->collection()->match(Request::create('https://example.com/secure/report'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $this->collection()->match(Request::create('http://example.com/secure/report')); + } + + public function testCompiledMatchHonorsHttpOnlyRoute(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/insecure', [ + 'uses' => 'FooController@index', + 'as' => 'insecure', + 'http', + ]) + ); + + $this->assertSame( + 'insecure', + $this->collection()->match(Request::create('http://example.com/insecure'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $this->collection()->match(Request::create('https://example.com/insecure')); + } + + public function testCompiledConditionReceivesCompleteRequestContext(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/conditional', [ + 'uses' => 'FooController@index', + 'as' => 'conditional', + ]) + ); + + $compiled = $this->routeCollection->compile(); + $compiled['compiled'][1]['/conditional'][0][6] = -1; + $compiled['compiled'][4] = static fn ( + int $condition, + SymfonyRequestContext $context + ): bool => $condition === -1 && $context->getQueryString() === 'token=1'; + $collection = (new CompiledRouteCollection($compiled['compiled'], $compiled['attributes'])) + ->setRouter($this->router) + ->setContainer($this->app); + + $this->assertSame( + 'conditional', + $collection->match(Request::create('/conditional?token=1'))->getName() + ); + + $this->expectException(NotFoundHttpException::class); + + $collection->match(Request::create('/conditional?token=2')); + } + + public function testCompiledMatchAppliesOptionalParameterDefaults(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports/{period?}', [ + 'uses' => 'FooController@index', + 'as' => 'reports.show', + ])->defaults('period', 'current') + ); + + $route = $this->collection()->match(Request::create('/reports', 'GET')); + + $this->assertSame(['period' => 'current'], $route->parameters()); + $this->assertSame(['period' => 'current'], $route->originalParameters()); + } + + public function testCompiledMatchKeepsUriParameterOrderWhenDefaultsArePresent(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports/{category}/{period?}', [ + 'uses' => 'FooController@index', + 'as' => 'reports.category', + ])->defaults('period', 'current') + ); + + $route = $this->collection()->match(Request::create('/reports/sales', 'GET')); + + $this->assertSame( + ['category' => 'sales', 'period' => 'current'], + $route->parameters() + ); + } + + public function testCompiledMatchAppliesDefaultsWithoutUriParameters(): void + { + $this->routeCollection->add( + $this->newRoute('GET', '/reports', [ + 'uses' => 'FooController@index', + 'as' => 'reports.index', + ])->defaults('format', 'summary') + ); + + $route = $this->collection()->match(Request::create('/reports', 'GET')); + + $this->assertSame(['format' => 'summary'], $route->parameters()); + $this->assertSame(['format' => 'summary'], $route->originalParameters()); + } + public function testMatchingDynamicallyAddedRoutesTakePrecedenceOverFallbackRoutes() { $this->routeCollection->add($this->fallbackRoute(['uses' => 'FooController@index'])); @@ -384,7 +649,10 @@ public function testMatchingFallbackRouteCatchesAll() $routes->add($this->newRoute('GET', '/bar/{id}', ['uses' => 'FooController@index', 'as' => 'bar'])); - $this->assertSame('fallback', $routes->match(Request::create('/baz/1', 'GET'))->getName()); + $route = $routes->match(Request::create('/baz/1', 'GET')); + + $this->assertSame('fallback', $route->getName()); + $this->assertSame(['fallbackPlaceholder' => 'baz/1'], $route->parameters()); } public function testMatchingCachedFallbackTakesPrecedenceOverDynamicFallback() @@ -592,3 +860,37 @@ protected function fallbackRoute(mixed $action): Route )->where($placeholder, '.*')->fallback(); } } + +class BindTrackingRouter extends Router +{ + public function newRoute(array|string $methods, string $uri, mixed $action): Route + { + return (new BindTrackingRoute($methods, $uri, $action)) + ->setRouter($this) + ->setContainer($this->container); + } +} + +class BindTrackingRoute extends Route +{ + public int $bindCalls = 0; + + public function bind(Request $request): static + { + ++$this->bindCalls; + + return parent::bind($request); + } +} + +class StaticMatchTrackingRequest extends Request +{ + public int $hostReads = 0; + + public function getHost(): string + { + ++$this->hostReads; + + return parent::getHost(); + } +} diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index 58b3a98608..61541acdca 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Pipeline; +use Closure; use Exception; use Hypervel\Container\Container; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Pipeline\Pipeline; use Hypervel\Tests\Pipeline\Fixtures\FooPipeline; use Hypervel\Tests\TestCase; @@ -236,6 +238,57 @@ public function testPipelineUsageWithParameters(): void unset($_SERVER['__test.pipe.parameters']); } + public function testPipelineUsageWithAnImmutablePipeDescriptor(): void + { + $descriptor = PipeDescriptor::fromString(PipelineTestParameterPipe::class . ':one,two'); + + $result = (new Pipeline(new Container)) + ->send('foo') + ->through($descriptor) + ->then(fn ($piped) => $piped); + + $this->assertSame('foo', $result); + $this->assertSame(['one', 'two'], $_SERVER['__test.pipe.parameters']); + + unset($_SERVER['__test.pipe.parameters']); + } + + public function testPipeDescriptorUsesThePipelineMethodWhenNoneIsSpecified(): void + { + $result = (new Pipeline(new Container))->send('data') + ->through(PipeDescriptor::fromString(PipelineTestPartialMethodPipe::class)) + ->via('missingMethod') + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('data:invoked', $result); + } + + public function testPipeDescriptorPreservesLazyContainerResolutionAndCallableFallback(): void + { + $container = new Container; + $container->bind(PipelineTestUnreachablePipe::class); + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestPipeTwo); + + $result = (new Pipeline($container))->send('data') + ->through([ + new PipeDescriptor(PipelineTestShortCircuitPipe::class), + new PipeDescriptor(PipelineTestUnreachablePipe::class), + ]) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('short-circuited', $result); + $this->assertArrayNotHasKey('__test.pipe.unreachable', $_SERVER); + + $result = (new Pipeline($container))->send('foo') + ->through(new PipeDescriptor(PipelineTestPipeOne::class)) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void { $pipelineInstance = new Pipeline(new Container); @@ -248,6 +301,64 @@ public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void $this->assertSame('data', $result); } + public function testCompiledClosureCanProcessIndependentValuesAndBindings(): void + { + $container = new Container; + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestAppendPipe(':first')); + + $pipeline = (new Pipeline($container)) + ->through(PipeDescriptor::fromString(PipelineTestPipeOne::class)) + ->toClosure(fn (mixed $value): mixed => $value); + + $this->assertSame('foo:first', $pipeline('foo')); + + $container->bind(PipelineTestPipeOne::class, fn () => new PipelineTestAppendPipe(':second')); + + $this->assertSame('foo:second', $pipeline('foo')); + } + + public function testPipelineViaDispatchesPerMethodForTheSamePipeClass(): void + { + $container = new Container; + + // The pipe defines handle() but not missingMethod(), so piping it + // through the latter has to fall back to __invoke rather than reuse + // whatever the previous pipeline resolved for the same class. + $handled = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->then(fn (mixed $piped): mixed => $piped); + + $invoked = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->via('missingMethod') + ->then(fn (mixed $piped): mixed => $piped); + + $handledAgain = (new Pipeline($container))->send('data') + ->through(PipelineTestPartialMethodPipe::class) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('data:handled', $handled); + $this->assertSame('data:invoked', $invoked, 'A missing pipe method did not fall back to __invoke.'); + $this->assertSame('data:handled', $handledAgain); + } + + public function testPipelineResolvesPipesOnlyWhenReached(): void + { + $container = new Container; + $container->bind(PipelineTestUnreachablePipe::class); + + $result = (new Pipeline($container))->send('data') + ->through([PipelineTestShortCircuitPipe::class, PipelineTestUnreachablePipe::class]) + ->then(fn (mixed $piped): mixed => $piped); + + $this->assertSame('short-circuited', $result); + $this->assertArrayNotHasKey( + '__test.pipe.unreachable', + $_SERVER, + 'A pipe was constructed even though an earlier pipe never called next().' + ); + } + public function testPipelineThrowsExceptionOnResolveWithoutContainer(): void { $this->expectException(RuntimeException::class); @@ -555,6 +666,18 @@ public function __invoke($piped, $next) } } +class PipelineTestAppendPipe +{ + public function __construct(private readonly string $suffix) + { + } + + public function handle(mixed $piped, Closure $next): mixed + { + return $next($piped . $this->suffix); + } +} + class PipelineTestParameterPipe { public function handle($piped, $next, $parameter1 = null, $parameter2 = null) @@ -564,3 +687,37 @@ public function handle($piped, $next, $parameter1 = null, $parameter2 = null) return $next($piped); } } + +class PipelineTestShortCircuitPipe +{ + public function handle(mixed $piped, Closure $next): string + { + return 'short-circuited'; + } +} + +class PipelineTestPartialMethodPipe +{ + public function handle(mixed $piped, Closure $next): mixed + { + return $next($piped . ':handled'); + } + + public function __invoke(mixed $piped, Closure $next): mixed + { + return $next($piped . ':invoked'); + } +} + +class PipelineTestUnreachablePipe +{ + public function __construct() + { + $_SERVER['__test.pipe.unreachable'] = true; + } + + public function handle(mixed $piped, Closure $next): mixed + { + return $next($piped); + } +} diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php index 09f07bee13..e1f0184771 100644 --- a/tests/Routing/ImplicitRouteBindingTest.php +++ b/tests/Routing/ImplicitRouteBindingTest.php @@ -19,6 +19,16 @@ class ImplicitRouteBindingTest extends RoutingTestCase { + public function testItDoesNotInspectTheActionWhenTheRouteHasNoParameters(): void + { + $route = new EmptyParameterRoute('GET', '/test', fn () => 'ok'); + $route->bind(Request::create('/test')); + + ImplicitRouteBinding::resolveForRoute(Container::getInstance(), $route); + + $this->assertSame(0, $route->signatureParameterCalls); + } + public function testItCanResolveTheImplicitBackedEnumRouteBindingsForTheGivenRoute(): void { $action = ['uses' => function (CategoryBackedEnum $category) { @@ -207,6 +217,18 @@ class ImplicitRouteBindingUser extends Model { } +class EmptyParameterRoute extends Route +{ + public int $signatureParameterCalls = 0; + + public function signatureParameters(array|string $conditions = []): array + { + ++$this->signatureParameterCalls; + + return parent::signatureParameters($conditions); + } +} + class ImplicitRouteBindingInvoker { public function __invoke(CategoryBackedEnum $category): string diff --git a/tests/Routing/RouteMiddlewareCachingTest.php b/tests/Routing/RouteMiddlewareCachingTest.php index c2cc39564f..b6bf045e1a 100644 --- a/tests/Routing/RouteMiddlewareCachingTest.php +++ b/tests/Routing/RouteMiddlewareCachingTest.php @@ -9,11 +9,13 @@ use Hypervel\Contracts\Routing\Registrar; use Hypervel\Events\Dispatcher; use Hypervel\Http\Request; +use Hypervel\Pipeline\PipeDescriptor; use Hypervel\Routing\CallableDispatcher; use Hypervel\Routing\Contracts\CallableDispatcher as CallableDispatcherContract; use Hypervel\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract; use Hypervel\Routing\Controller; use Hypervel\Routing\ControllerDispatcher; +use Hypervel\Routing\Route; use Hypervel\Routing\Router; use Hypervel\Tests\Routing\RoutingTestCase; @@ -36,6 +38,89 @@ public function testResolvedMiddlewareIsCachedOnRoute(): void $this->assertNotNull($route->resolvedMiddleware); } + public function testRouteDispatchCachesDescriptorsWithoutChangingGatheredMiddleware(): void + { + $router = $this->getRouter(); + $route = $router->get('foo', [ + 'middleware' => TestMiddleware::class, + 'uses' => fn () => 'ok', + ]); + + $response = $router->dispatch(Request::create('foo', 'GET')); + + $this->assertSame('ok', $response->getContent()); + $this->assertSame([TestMiddleware::class], $router->gatherRouteMiddleware($route)); + $this->assertContainsOnlyInstancesOf(PipeDescriptor::class, $route->middlewareDescriptors); + $this->assertSame(TestMiddleware::class, $route->middlewareDescriptors[0]->name); + } + + public function testRouteDispatchReusesPipelineWithoutCachingMiddlewareInstances(): void + { + $container = new Container; + $resolutions = 0; + $router = $this->getRouter($container); + $container->bind(TestMiddleware::class, function () use (&$resolutions): TestMiddleware { + ++$resolutions; + + return new TestMiddleware; + }); + $route = $router->get('foo', [ + 'middleware' => TestMiddleware::class, + 'uses' => fn () => 'ok', + ]); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $pipeline = $route->middlewarePipeline; + $this->assertNotNull($pipeline); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $this->assertSame($pipeline, $route->middlewarePipeline); + $this->assertSame(2, $resolutions); + } + + public function testRouteWithoutMiddlewareDoesNotBuildDescriptors(): void + { + $router = $this->getRouter(); + $route = $router->get('foo', fn () => 'ok'); + + $response = $router->dispatch(Request::create('foo', 'GET')); + + $this->assertSame('ok', $response->getContent()); + $this->assertNull($route->middlewareDescriptors); + } + + public function testDynamicGatheredMiddlewareIsNotReplacedByCachedDescriptors(): void + { + $container = new Container; + $router = new DynamicMiddlewareRouter(new Dispatcher($container), $container); + $route = $router->get('foo', fn () => 'ok'); + + $first = $router->middlewareForRoute($route); + $second = $router->middlewareForRoute($route); + + $this->assertSame(TestMiddleware::class, $first[0]->name); + $this->assertSame(SecondTestMiddleware::class, $second[0]->name); + $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); + } + + public function testRouterSubclassRebuildsReusablePipelineWhenMiddlewareChanges(): void + { + $container = new Container; + $router = new DynamicMiddlewareRouter(new Dispatcher($container), $container); + $container->bind(ControllerDispatcherContract::class, fn ($app) => new ControllerDispatcher($app)); + $container->bind(CallableDispatcherContract::class, fn ($app) => new CallableDispatcher($app)); + $route = $router->get('foo', fn () => 'ok'); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $firstPipeline = $route->middlewarePipeline; + $this->assertNotNull($firstPipeline); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $secondPipeline = $route->middlewarePipeline; + $this->assertNotSame($firstPipeline, $secondPipeline); + } + public function testResolvedMiddlewareIsNullBeforeGathering(): void { $router = $this->getRouter(); @@ -56,10 +141,13 @@ public function testFlushControllerClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->resolvedMiddleware); $route->flushController(); + $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } @@ -72,10 +160,13 @@ public function testPrepareForSerializationClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->resolvedMiddleware); $route->prepareForSerialization(); + $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } @@ -113,9 +204,12 @@ public function testSettingTheSameContainerPreservesResolvedMiddleware(): void $router->gatherRouteMiddleware($route); $resolvedMiddleware = $route->resolvedMiddleware; + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; + $middlewareDescriptors = $route->middlewareDescriptors; $route->setContainer($container); + $this->assertSame($middlewareDescriptors, $route->middlewareDescriptors); $this->assertSame($resolvedMiddleware, $route->resolvedMiddleware); $this->assertSame($resolvedMiddleware, $router->gatherRouteMiddleware($route)); $this->assertSame(1, $dispatcherResolutions); @@ -130,12 +224,15 @@ public function testSettingADifferentContainerClearsResolvedMiddleware(): void }]); $router->gatherRouteMiddleware($route); + $route->middlewareDescriptors = [new PipeDescriptor(TestMiddleware::class)]; $this->assertNotNull($route->computedMiddleware); $this->assertNotNull($route->resolvedMiddleware); $route->setContainer(new Container); $this->assertNull($route->computedMiddleware); + $this->assertNull($route->middlewareDescriptors); + $this->assertNull($route->middlewarePipeline); $this->assertNull($route->resolvedMiddleware); } @@ -162,6 +259,25 @@ public function handle(mixed $request, Closure $next): mixed } } +class SecondTestMiddleware extends TestMiddleware +{ +} + +class DynamicMiddlewareRouter extends Router +{ + protected int $gatherCalls = 0; + + public function gatherRouteMiddleware(Route $route): array + { + return [++$this->gatherCalls === 1 ? TestMiddleware::class : SecondTestMiddleware::class]; + } + + public function middlewareForRoute(Route $route): array + { + return $this->middlewareFor($route); + } +} + class MiddlewareController extends Controller { public function __construct() diff --git a/tests/Routing/RouterExtensionTest.php b/tests/Routing/RouterExtensionTest.php index 39d9d3b79a..2b3de05482 100644 --- a/tests/Routing/RouterExtensionTest.php +++ b/tests/Routing/RouterExtensionTest.php @@ -50,6 +50,23 @@ public function testBothDispatchPathsUseThePipelineAndMiddlewareHooks(): void $this->assertSame(2, $middleware->runs); } + public function testSubclassRouteDispatchReusesItsPipeline(): void + { + $container = new Container; + $middleware = new RouterTestMiddleware; + $container->instance(RouterTestMiddleware::class, $middleware); + $router = $this->router($container); + $router->get('hooked', static fn (): string => 'route') + ->middleware(RouterTestMiddleware::class); + + $this->assertSame('route', $router->dispatch(Request::create('/hooked', 'GET'))->getContent()); + $this->assertSame('route', $router->dispatch(Request::create('/hooked', 'GET'))->getContent()); + + $this->assertSame(1, $router->pipelineCreations); + $this->assertSame(2, $router->middlewareResolutions); + $this->assertSame(2, $middleware->runs); + } + public function testMiddlewareOverrideCanRetainRequiredMiddlewareWhenUserMiddlewareIsDisabled(): void { $container = new Container; diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index 9fc40eb3b7..226213bf78 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -504,6 +504,21 @@ public function testClassesCanBeInjectedIntoRoutes() unset($_SERVER['__test.route_inject']); } + public function testZeroArgumentCallableRetainsExtraRouteArguments(): void + { + $router = $this->getRouter(); + $arguments = null; + + $router->get('foo/{value}', function () use (&$arguments) { + $arguments = func_get_args(); + + return 'hello'; + }); + + $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent()); + $this->assertSame(['bar'], $arguments); + } + public function testNullValuesCanBeInjectedIntoRoutes() { $container = new Container; @@ -1792,6 +1807,26 @@ public function testImplicitBindingsWithClosure() $this->assertSame('otwell', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); } + public function testCustomImplicitBindingCallbackRunsForRouteWithoutParameters() + { + $router = $this->getRouter(); + $calls = 0; + + $router->substituteImplicitBindingsUsing(function ($container, $route, $default) use (&$calls) { + ++$calls; + + return $default(); + }); + + $router->get('foo', [ + 'middleware' => SubstituteBindings::class, + 'uses' => fn () => 'ok', + ]); + + $this->assertSame('ok', $router->dispatch(Request::create('foo', 'GET'))->getContent()); + $this->assertSame(1, $calls); + } + public function testImplicitBindingsWhereScopedBindingsArePrevented() { $router = $this->getRouter(); diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index d364d85ff6..82f155dd61 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1227,6 +1227,9 @@ public function testTrim(): void $this->assertSame('ム', Str::trim('ム')); $this->assertSame('だ', Str::trim('  だ   ')); $this->assertSame('ム', Str::trim('  ム   ')); + $this->assertSame('foo', Str::trim("\f foo \f")); + $this->assertSame('foo', Str::trim("\u{200B}foo\u{200B}")); + $this->assertSame('foo', Str::trim("\u{3000}foo\u{3000}")); $this->assertSame( 'foo bar', @@ -1244,6 +1247,9 @@ public function testTrim(): void ); $this->assertSame("\xE9", Str::trim(" \xE9 ")); + $this->assertSame("foo\xE9bar", Str::trim(" foo\xE9bar ")); + $this->assertSame("\xE9foo", Str::trim(" \xE9foo ")); + $this->assertSame("foo\xE9", Str::trim(" foo\xE9 ")); $trimDefaultChars = [' ', "\n", "\r", "\t", "\v", "\0"];