Skip to content

Latest commit

 

History

History
264 lines (178 loc) · 8.15 KB

File metadata and controls

264 lines (178 loc) · 8.15 KB

HTTP Router (Legacy Attribute Adapter)

This project includes a legacy HTTP router (Il4mb\\Routing\\Router) that supports attribute routes (#[Route(...)]) while delegating matching/decision-making to the core engine.

The goal of this adapter is:

  • keep the public API familiar for HTTP controller routing
  • keep routing decisions deterministic and observable
  • keep the core engine protocol-agnostic and dependency-free

Quick Start

See runnable examples:

For a controller-focused guide (binding + reading inputs + writing responses), see:

Router Lifecycle (High-Level)

When you call Router::dispatch(Request $request):

  1. The router builds an engine RoutingContext from the HTTP request.
  2. Attribute routes are compiled (and cached) into an engine RouteTable.
  3. RouterEngine::route() selects the best route(s) deterministically.
  4. The legacy execution pipeline runs:
    • interceptors (onDispatchonBeforeInvokeonInvoke)
    • per-route HTTP middlewares (Il4mb\\Routing\\Middlewares\\MiddlewareExecutor)
    • controller callback execution (Il4mb\\Routing\\Callback)

Production Options

Recommended for production:

  • manageHtaccess=false (avoid filesystem side-effects)
  • decisionPolicy='first' for typical HTTP apps
  • decisionPolicy='error_on_ambiguous' for security-sensitive routing
  • debugTrace=false unless troubleshooting

Example:

$router = new Router(options: [
    'manageHtaccess' => false,
    'decisionPolicy' => 'first',
    'debugTrace' => false,
]);

Mounting / base path

If your app is mounted under a sub-path (e.g. behind a reverse proxy, or served from /app), configure it explicitly:

  • basePath (string|null): Preferred option. A URL path prefix (e.g. /api) that will be stripped before matching routes.
  • pathOffset (string|null): Backward-compatible alias of basePath.
  • autoDetectFolderOffset (bool): When true (default), the router may infer an implicit base path from $_SERVER['SCRIPT_NAME'].

Precedence:

  • If basePath is set, it is used.
  • Else if pathOffset is set, it is used.
  • Else if autoDetectFolderOffset is true, the router uses the folder of SCRIPT_NAME (best-effort).
  • Else, no offset is applied.

Recommended for production:

$router = new Router(options: [
    'basePath' => '/api',
    'autoDetectFolderOffset' => false,
]);

Debug Trace

When debugTrace=true, the router stores engine trace information into the request:

  • Request::get('__route_trace'): list of structured trace events
  • Request::get('__route_decision'): selected route ids + reason + policy

This is intended for troubleshooting and building tooling.

405 Method Not Allowed

When no route matches, the router will additionally check whether the same path/constraints match for a different HTTP method.

If so, it responds with:

  • status code 405 Method Not Allowed
  • an Allow: ... header listing the supported methods

This makes the legacy HTTP adapter behave more like a production HTTP router.

Standardized error responses (optional)

By default, the legacy adapter keeps its historical plain-text error body behavior.

You can opt into a consistent error format via router options:

  • errorFormat='text': plain text/plain body (defaults to the HTTP reason phrase)
  • errorFormat='json': JSON body like {"error": {"code": 404, "message": "Not Found"}}
  • errorExposeDetails=true: include exception message/class in the standardized response (recommended only for development)

Example:

$router = new Router(options: [
    'manageHtaccess' => false,
    'errorFormat' => 'json',
    'errorExposeDetails' => false,
]);

Attribute Route Fields

The attribute #[Route(...)] supports:

  • method (string)
  • path (pattern)
  • priority (higher wins)
  • fallback (only considered if no non-fallback matches)
  • host (exact or *.example.com)
  • protocol (http, https, ...)
  • headers (exact match or presence)
  • metadata (arbitrary array for integrations)

Deterministic selection (when multiple routes match)

If multiple non-fallback routes match the same request, the engine will rank them deterministically:

  1. higher priority
  2. higher specificity (static path beats captures/wildcards)
  3. stable tie-break by route id

Then decisionPolicy controls what happens:

  • first: execute only the best match
  • chain: execute all matches in ranked order
  • error_on_ambiguous: return an error if more than one non-fallback matches

For a full worked example, see docs/routing.md.

Path patterns & captures (HTTP adapter)

The path string is compiled into the engine’s path matcher.

Common patterns:

  • Static: /health
  • Named segment: /users/{id}
  • Regex segment: /users/{id:[0-9]+}
  • Greedy wildcard: /proxy/**
  • Greedy named (legacy): /{path.*}
  • Greedy named (explicit): /{rest:**}

Capture values:

  • Captures are decoded using rawurldecode (path-safe). This means + stays +.
  • Greedy named captures may match an empty remainder. Example: /{path.*} can match / and binds $path = '' (empty string).

If you write a fallback like:

#[Route(Method::GET, '/{path.*}', fallback: true)]
public function notFound(string $path, Response $res): array
{
        $res->setCode(404);
        return ['error' => 'not_found', 'path' => $path];
}

then requesting / will correctly hit the fallback and receive path: "".

Controller Signature Binding (Flexible)

The router executes controller methods via the Callback binder.

What gets injected

A controller method can accept:

  • route captures by parameter name (e.g. {id} binds to $id)
  • Request and Response objects by type-hint
  • $next as callable (or Closure) to continue the chain

Example:

#[Route(Method::GET, '/users/{id}')]
public function show(int $id, Request $req, Response $res, callable $next)
{
    return ['id' => $id];
}

Binding Precedence (Contract)

When building the argument list for your controller method, the binder applies a deterministic precedence:

  1. If a parameter name matches a route capture (e.g. {id}$id) AND the parameter type is scalar-ish (string|int|float|bool|mixed or unions containing them), bind from the capture.
  2. Otherwise, try to inject from runtime arguments by type:
    • Request, Response, or other objects passed into the callback
    • callable / Closure for $next
  3. If nothing matches:
    • use the PHP default value, if present
    • otherwise, if the parameter allows null (?T), use null
    • otherwise, use null (which may trigger a PHP type error if the signature disallows it)

This ensures class-typed parameters (like Request $request) are never accidentally populated from a same-named capture like /{request}.

Scalar typing rules

Captures come from paths and are typically strings. The binder will cast when you type-hint:

  • int: numeric strings like "123"123
  • float: numeric strings like "3.14"3.14
  • bool: on/yes/true/1true, off/no/false/0false
  • string: kept as string

Union types

Union types are supported (PHP 8):

  • int|string $id will prefer int if the capture looks like an integer; otherwise string.

Nullable types

Nullable params are supported:

  • ?string $x can receive null if no capture value is available.

Defaults are respected:

#[Route(Method::GET, '/opt')]
public function opt(?string $x = 'dflt', Request $req, Response $res, callable $next)
{
    return $x; // "dflt" when no capture exists
}

Note: optional path segments depend on the path pattern support in the matcher.

Defaults

If a parameter is not matched and has a default value, the default is used.

Notes on URL Decoding

Captures are decoded using path-safe semantics (rawurldecode).

This means + stays + (unlike urldecode, which treats + as space).

Tests

Run the lightweight test suite:

php -d zend.assertions=1 -d assert.exception=1 tests/run.php