Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
339f1ce
Make routing coroutine-safe by removing Route mutations
loks0n May 5, 2026
a6e1372
Move matched route + matched path into request context
loks0n May 5, 2026
7996318
Inline context key strings
loks0n May 5, 2026
9d2e861
Rename Router fallback slot to wildcard
loks0n May 5, 2026
03c51c2
Introduce Router\Result DTO for match results
loks0n May 5, 2026
4313dfa
Drop redundant preparePath in Http::execute
loks0n May 5, 2026
291326e
Rename Router\Result to RouteMatch, drop matchInternal indirection
loks0n May 5, 2026
2ba2fa4
Make execute(Request, Response) the public dispatch entry point
loks0n May 5, 2026
0fe0807
RouteMatch carries resolved params, not the matched template
loks0n May 5, 2026
08545da
Drop Http::setRoute()
loks0n May 5, 2026
94371de
Drop Http::getRoute()
loks0n May 5, 2026
9e697cb
Tighten doc comments to user-facing intent
loks0n May 5, 2026
14d9e79
Document run vs execute as distinct entry points
loks0n May 5, 2026
1befdef
Inline \$match->params in execute, drop \$pathValues alias
loks0n May 5, 2026
88d7713
Drop intermediate variables in run() telemetry
loks0n May 5, 2026
df82079
Apply rector and pint to RouteMatch.php
loks0n May 5, 2026
30e1405
Save/restore context['route'] across execute() dispatch
loks0n May 5, 2026
b58bfab
Make 'route' injection frame-local instead of stateful
loks0n May 5, 2026
3df9e7e
Apply rector to HttpTest
loks0n May 15, 2026
3c0e9c0
Coerce empty wildcard path to null in telemetry
loks0n May 15, 2026
e89d58f
Wildcard is a Hook, not a Route
loks0n May 15, 2026
607abe2
Revert "Wildcard is a Hook, not a Route"
loks0n May 15, 2026
01ee74a
Add 'params' frame-local injection
loks0n May 18, 2026
93930f9
Rename frameLocals to locals
loks0n May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 105 additions & 150 deletions src/Http/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,6 @@ class Http
*/
protected static array $requestHooks = [];

/**
* Route
*
* Memory cached result for chosen route
*/
protected ?Route $route = null;

/**
* Wildcard route
* If set, this get's executed if no other route is matched
*/
protected static ?Route $wildcardRoute = null;

/**
* Compression
*/
Expand Down Expand Up @@ -242,9 +229,10 @@ public static function delete(string $url): Route
*/
public static function wildcard(): Route
{
self::$wildcardRoute = new Route('', '');
$route = new Route('', '');
Router::setWildcard($route);

Comment thread
greptile-apps[bot] marked this conversation as resolved.
return self::$wildcardRoute;
return $route;
}

/**
Expand Down Expand Up @@ -416,24 +404,6 @@ public static function getRoutes(): array
return Router::getRoutes();
}

/**
* Get the current route
*/
public function getRoute(): ?Route
{
return $this->route ?? null;
}

/**
* Set the current route
*/
public function setRoute(Route $route): self
{
$this->route = $route;

return $this;
}

/**
* Add Route
*
Expand Down Expand Up @@ -538,44 +508,94 @@ public function start(): void
}

/**
* Match
*
* Find matching route given current user request
*
* @param bool $fresh If true, will not match any cached route
* Find the route registered for the given request, or null if none match.
*/
public function match(Request $request, bool $fresh = true): ?Route
public function match(Request $request): ?RouteMatch
{
if (null !== $this->route && !$fresh) {
return $this->route;
}

$url = parse_url($request->getURI(), PHP_URL_PATH);
$url = \is_string($url) ? ($url === '' ? '/' : $url) : '/';
$method = $request->getMethod();
$method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method;

$this->route = Router::match($method, $url);

return $this->route;
return Router::match($method, $url);
}

/**
* Execute a given route with middlewares and error handling
* Match a request and run its route's handler and hooks.
*
* HEAD runs as GET with the response body suppressed. OPTIONS fires
* options hooks and returns without dispatching. An unmatched request
* fires global error hooks with a 404.
*
* This is a re-entrant dispatch primitive — safe to call from inside
* another handler with a synthesized Request/Response (e.g. a GraphQL
* resolver invoking an API route). It does not run request-level setup
* (compression, request hooks, telemetry); those belong to {@see run()},
* which is the entry point for top-level requests from the server.
*/
public function execute(Route $route, Request $request, Response $response): static
public function execute(Request $request, Response $response): static
{
$method = $request->getMethod();

if (self::REQUEST_METHOD_HEAD === $method) {
$method = self::REQUEST_METHOD_GET;
$response->disablePayload();
}

$match = $this->match($request);

if (self::REQUEST_METHOD_OPTIONS === $method) {
$groups = $match?->route->getGroups() ?? [];

try {
foreach ($groups as $group) {
foreach (self::$options as $option) { // Group options hooks
/** @var Hook $option */
if (\in_array($group, $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match->route));
}
}
}

foreach (self::$options as $option) { // Global options hooks
/** @var Hook $option */
if (\in_array('*', $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match?->route));
}
}
} catch (\Throwable $e) {
foreach (self::$errors as $error) { // Global error hooks
/** @var Hook $error */
if (\in_array('*', $error->getGroups())) {
$this->context()->set('error', fn() => $e, []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams(), $match?->route));
}
}
}

return $this;
}

if ($match === null) {
foreach (self::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
$this->context()->set('error', fn() => new Exception('Not Found', 404), []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}

return $this;
}

$route = $match->route;
$arguments = [];
$groups = $route->getGroups();

$preparedPath = Router::preparePath($route->getMatchedPath());
$pathValues = $route->getPathValues($request, $preparedPath[0]);

try {
if ($route->getHook()) {
foreach (self::$init as $hook) { // Global init hooks
if (\in_array('*', $hook->getGroups())) {
$arguments = $this->getArguments($hook, $pathValues, $request->getParams());
$arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route);
\call_user_func_array($hook->getAction(), $arguments);
}
}
Expand All @@ -584,21 +604,21 @@ public function execute(Route $route, Request $request, Response $response): sta
foreach ($groups as $group) {
foreach (self::$init as $hook) { // Group init hooks
if (\in_array($group, $hook->getGroups())) {
$arguments = $this->getArguments($hook, $pathValues, $request->getParams());
$arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route);
\call_user_func_array($hook->getAction(), $arguments);
}
}
}

if (!$response->isSent()) {
$arguments = $this->getArguments($route, $pathValues, $request->getParams());
$arguments = $this->getArguments($route, $match->params, $request->getParams(), $route);
\call_user_func_array($route->getAction(), $arguments);
}

foreach ($groups as $group) {
foreach (self::$shutdown as $hook) { // Group shutdown hooks
if (\in_array($group, $hook->getGroups())) {
$arguments = $this->getArguments($hook, $pathValues, $request->getParams());
$arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route);
\call_user_func_array($hook->getAction(), $arguments);
}
}
Expand All @@ -607,7 +627,7 @@ public function execute(Route $route, Request $request, Response $response): sta
if ($route->getHook()) {
foreach (self::$shutdown as $hook) { // Group shutdown hooks
if (\in_array('*', $hook->getGroups())) {
$arguments = $this->getArguments($hook, $pathValues, $request->getParams());
$arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route);
\call_user_func_array($hook->getAction(), $arguments);
}
}
Expand All @@ -619,7 +639,7 @@ public function execute(Route $route, Request $request, Response $response): sta
foreach (self::$errors as $error) { // Group error hooks
if (\in_array($group, $error->getGroups())) {
try {
$arguments = $this->getArguments($error, $pathValues, $request->getParams());
$arguments = $this->getArguments($error, $match->params, $request->getParams(), $route);
\call_user_func_array($error->getAction(), $arguments);
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
Expand All @@ -631,7 +651,7 @@ public function execute(Route $route, Request $request, Response $response): sta
foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
try {
$arguments = $this->getArguments($error, $pathValues, $request->getParams());
$arguments = $this->getArguments($error, $match->params, $request->getParams(), $route);
\call_user_func_array($error->getAction(), $arguments);
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
Expand All @@ -651,7 +671,7 @@ public function execute(Route $route, Request $request, Response $response): sta
* @return array<int, mixed>
* @throws Exception
*/
protected function getArguments(Hook $hook, array $values, array $requestParams): array
protected function getArguments(Hook $hook, array $values, array $requestParams, ?Route $route = null): array
{
$arguments = [];
foreach ($hook->getParams() as $key => $param) { // Get value from route or request object
Expand Down Expand Up @@ -700,15 +720,35 @@ protected function getArguments(Hook $hook, array $values, array $requestParams)
$arguments[$param['order']] = $value;
}

// Locals come from the dispatch frame, not the per-request context.
// Writing them to context would leak across nested execute() calls
// (e.g. sub-request dispatch).
$locals = [
'route' => $route,
'params' => $values,
];

foreach ($hook->getInjections() as $injection) {
$arguments[$injection['order']] = $this->adapter->context()->get($injection['name']);
$arguments[$injection['order']] = \array_key_exists($injection['name'], $locals)
? $locals[$injection['name']]
: $this->adapter->context()->get($injection['name']);
}

return $arguments;
}

/**
* Run: wrapper function to record telemetry. All domain logic should happen in `runInternal`.
* Handle a top-level HTTP request.
*
* This is the entry point wired into the server adapter for each
* incoming request. It runs the full request lifecycle: compression
* setup, request hooks, static-file serving, route match, dispatch,
* and telemetry.
*
* For dispatching a sub-request from inside a handler (e.g. a
* GraphQL resolver invoking another API route with a synthesized
* Request/Response), use {@see execute()} instead — it skips the
* outer-request setup that has already run.
*/
public function run(Request $request, Response $response): static
{
Expand All @@ -724,7 +764,9 @@ public function run(Request $request, Response $response): static
$attributes = [
'url.scheme' => $request->getProtocol(),
'http.request.method' => $request->getMethod(),
'http.route' => $this->route?->getPath(),
// OTel semantics: http.route is the matched route template, or
// unset when no template applies (wildcard / no match).
'http.route' => ($this->match($request)?->route->getPath() ?: null),
'http.response.status_code' => $response->getStatusCode(),
];
$this->requestDuration->record($requestDuration, $attributes);
Expand Down Expand Up @@ -789,93 +831,7 @@ private function runInternal(Request $request, Response $response): static
return $this;
}

$method = $request->getMethod();
$route = $this->match($request);
$groups = ($route instanceof Route) ? $route->getGroups() : [];

$this->context()->set('route', fn() => $route, []);

if (self::REQUEST_METHOD_HEAD === $method) {
$method = self::REQUEST_METHOD_GET;
$response->disablePayload();
}

if (self::REQUEST_METHOD_OPTIONS === $method) {
try {
foreach ($groups as $group) {
foreach (self::$options as $option) { // Group options hooks
/** @var Hook $option */
if (\in_array($group, $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams()));
}
}
}

foreach (self::$options as $option) { // Global options hooks
/** @var Hook $option */
if (\in_array('*', $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams()));
}
}
} catch (\Throwable $e) {
foreach (self::$errors as $error) { // Global error hooks
/** @var Hook $error */
if (\in_array('*', $error->getGroups())) {
$this->context()->set('error', fn() => $e, []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
}

return $this;
}

if (null === $route && null !== self::$wildcardRoute) {
$route = self::$wildcardRoute;
$this->route = $route;
$path = parse_url($request->getURI(), PHP_URL_PATH);
$path = \is_string($path) ? ($path === '' ? '/' : $path) : '/';
$route->path($path);

$this->context()->set('route', fn() => $route, []);
}
if (null !== $route) {
return $this->execute($route, $request, $response);
}

if (self::REQUEST_METHOD_OPTIONS === $method) {
try {
foreach ($groups as $group) {
foreach (self::$options as $option) { // Group options hooks
if (\in_array($group, $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams()));
}
}
}

foreach (self::$options as $option) { // Global options hooks
if (\in_array('*', $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams()));
}
}
} catch (\Throwable $e) {
foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
$this->context()->set('error', fn() => $e, []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
}
} else {
foreach (self::$errors as $error) { // Global error hooks
if (\in_array('*', $error->getGroups())) {
$this->context()->set('error', fn() => new Exception('Not Found', 404), []);
\call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams()));
}
}
}

return $this;
return $this->execute($request, $response);
}


Expand Down Expand Up @@ -923,6 +879,5 @@ public static function reset(): void
self::$options = [];
self::$startHooks = [];
self::$requestHooks = [];
self::$wildcardRoute = null;
}
}
Loading