Atlas/src/Router/RouteMatcher.php

237 lines
7.6 KiB
PHP

<?php
namespace Atlas\Router;
use Psr\Http\Message\ServerRequestInterface;
/**
* Handles the logic for matching a request to a route.
*/
class RouteMatcher
{
use PathHelper;
private array $compiledPatterns = [];
/**
* Matches a request against a collection of routes.
*
* @param ServerRequestInterface $request The request to match
* @param RouteCollection $routes The collection of routes to match against
* @return RouteDefinition|null The matched route or null if no match found
*/
public function match(ServerRequestInterface $request, RouteCollection $routes): ?RouteDefinition
{
$method = strtoupper($request->getMethod());
$path = $this->normalizePath($request->getUri()->getPath());
$host = $request->getUri()->getHost();
$routesToMatch = $routes;
foreach ($routesToMatch as $route) {
$attributes = [];
if ($this->isMatch($method, $path, $host, $route, $attributes)) {
$attributes = $this->mergeDefaults($route, $attributes);
return $this->applyAttributes($route, $attributes);
}
// i18n support: check alternative paths
$routeAttributes = $route->getAttributes();
if (isset($routeAttributes['i18n']) && is_array($routeAttributes['i18n'])) {
foreach ($routeAttributes['i18n'] as $lang => $i18nPath) {
$normalizedI18nPath = $this->normalizePath($i18nPath);
if ($this->isMatch($method, $path, $host, $route, $attributes, $normalizedI18nPath)) {
$attributes['lang'] = $lang;
$attributes = $this->mergeDefaults($route, $attributes);
return $this->applyAttributes($route, $attributes);
}
}
}
}
// Try to find a fallback
return $this->matchFallback($path, $routes);
}
/**
* Attempts to match a fallback handler for the given path.
*
* @param string $path
* @param RouteCollection $routes
* @return RouteDefinition|null
*/
private function matchFallback(string $path, RouteCollection $routes): ?RouteDefinition
{
$bestFallback = null;
$longestPrefix = -1;
foreach ($routes as $route) {
$attributes = $route->getAttributes();
if (isset($attributes['_fallback'])) {
$prefix = $attributes['_fallback_prefix'] ?? '';
if (str_starts_with($path, $prefix) && strlen($prefix) > $longestPrefix) {
$longestPrefix = strlen($prefix);
$bestFallback = $route;
}
}
}
if ($bestFallback) {
$attributes = $bestFallback->getAttributes();
return new RouteDefinition(
'FALLBACK',
$path,
$path,
$attributes['_fallback'],
null,
$bestFallback->getMiddleware()
);
}
return null;
}
/**
* Merges default values for missing optional parameters.
*
* @param RouteDefinition $route
* @param array $attributes
* @return array
*/
private function mergeDefaults(RouteDefinition $route, array $attributes): array
{
return array_merge($route->getDefaults(), $attributes);
}
/**
* Determines if a request matches a route definition.
*
* @param string $method The request method
* @param string $path The request path
* @param RouteDefinition $route The route to check
* @param array $attributes Extracted attributes
* @return bool
*/
private function isMatch(string $method, string $path, string $host, RouteDefinition $route, array &$attributes, ?string $overridePath = null): bool
{
$routeMethod = strtoupper($route->getMethod());
if ($routeMethod !== $method && $routeMethod !== 'REDIRECT') {
return false;
}
// Subdomain constraint check
$routeAttributes = $route->getAttributes();
if (isset($routeAttributes['subdomain'])) {
$subdomain = $routeAttributes['subdomain'];
if (!str_starts_with($host, $subdomain . '.')) {
return false;
}
}
$pattern = $overridePath ? $this->compilePatternFromPath($overridePath, $route) : $this->getPatternForRoute($route);
if (preg_match($pattern, $path, $matches)) {
$attributes = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return true;
}
return false;
}
/**
* @internal
*/
public function getPatternForRoute(RouteDefinition $route): string
{
return $this->compilePattern($route);
}
/**
* Compiles a route path into a regex pattern.
*
* @param RouteDefinition $route
* @return string
*/
private function compilePattern(RouteDefinition $route): string
{
$id = spl_object_id($route);
if (isset($this->compiledPatterns[$id])) {
return $this->compiledPatterns[$id];
}
$this->compiledPatterns[$id] = $this->compilePatternFromPath($route->getPath(), $route);
return $this->compiledPatterns[$id];
}
/**
* Compiles a specific path into a regex pattern using route's validation and defaults.
*
* @param string $path
* @param RouteDefinition $route
* @return string
*/
private function compilePatternFromPath(string $path, RouteDefinition $route): string
{
$validation = $route->getValidation();
$defaults = $route->getDefaults();
// Replace {{param?}} and {{param}} with regex
$pattern = preg_replace_callback('#/\{\{([a-zA-Z0-9_]+)(\?)?\}\}#', function ($matches) use ($validation, $defaults) {
$name = $matches[1];
$optional = (isset($matches[2]) && $matches[2] === '?') || array_key_exists($name, $defaults);
$rules = $validation[$name] ?? [];
$regex = '[^/]+';
// Validation rules support
foreach ((array)$rules as $rule) {
if ($rule === 'numeric' || $rule === 'int') {
$regex = '[0-9]+';
} elseif ($rule === 'alpha') {
$regex = '[a-zA-Z]+';
} elseif ($rule === 'alphanumeric') {
$regex = '[a-zA-Z0-9]+';
} elseif (str_starts_with($rule, 'regex:')) {
$regex = substr($rule, 6);
}
}
if ($optional) {
return '(?:/(?P<' . $name . '>' . $regex . '))?';
}
return '/(?P<' . $name . '>' . $regex . ')';
}, $path);
$pattern = str_replace('//', '/', $pattern);
return '#^' . $pattern . '/?$#';
}
/**
* Applies extracted attributes to a new route definition.
*
* @param RouteDefinition $route
* @param array $attributes
* @return RouteDefinition
*/
private function applyAttributes(RouteDefinition $route, array $attributes): RouteDefinition
{
$data = $route->toArray();
$data['attributes'] = array_merge($data['attributes'], $attributes);
return new RouteDefinition(
$data['method'],
$data['pattern'],
$data['path'],
$data['handler'],
$data['name'],
$data['middleware'],
$data['validation'],
$data['defaults'],
$data['module'],
$data['attributes']
);
}
}