Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
140 changes: 119 additions & 21 deletions src/Phaseolies/Support/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,13 @@ protected function getCacheableRoutes(): array
$cacheableRoutes = [];

foreach (self::$routes as $method => $routes) {
foreach ($routes as $path => $callback) {
if ($this->isCacheableRoute($callback)) {
$cacheableRoutes[$method][$path] = $callback;
foreach ($routes as $path => $entry) {
['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry);

if ($this->isCacheableRoute($entry)) {
$cacheableRoutes[$method][$path] = $domain
? ['__callback' => $callback, '__domain' => $domain]
: $callback;
}
}
}
Expand All @@ -159,6 +163,10 @@ protected function getCacheableRoutes(): array
*/
protected function isCacheableRoute($callback): bool
{
if (is_array($callback) && isset($callback['__callback'], $callback['__domain'])) {
$callback = $callback['__callback'];
}

if (
is_array($callback) &&
count($callback) === 2 &&
Expand Down Expand Up @@ -369,9 +377,10 @@ protected function registerAttributeRoute(string $controllerClass, string $metho
$middleware = $route->middleware ?? [];
$rateLimit = $route->rateLimit ?? null;
$rateLimitDecay = $route->rateLimitDecay ?? 1;
$domain = $route->domain ?? null;

foreach ($httpMethods as $httpMethod) {
$this->addRouteNameToAttributesRouting($httpMethod, $path, [$controllerClass, $method], $name);
$this->addRouteNameToAttributesRouting($httpMethod, $path, [$controllerClass, $method], $name, $domain);
if (!empty($middleware)) {
$this->middleware($middleware);
}
Expand All @@ -389,11 +398,12 @@ protected function registerAttributeRoute(string $controllerClass, string $metho
* @param string $path
* @param callable|array $callback
* @param string|null $name
* @param string|null $domain
* @return self
*/
protected function addRouteNameToAttributesRouting(string $method, string $path, $callback, ?string $name = null): self
protected function addRouteNameToAttributesRouting(string $method, string $path, $callback, ?string $name = null, ?string $domain = null): self
{
$this->addRoute($method, $path, $callback);
$this->addRoute($method, $path, $callback, $domain);

if ($name) {
self::$namedRoutes[$name] = $this->currentRoutePath;
Expand Down Expand Up @@ -542,15 +552,31 @@ public function redirect(string $uri, string $destination, int $status = 302): s
);
}

/**
* Unwrap the route entry, separating the callback from optional domain metadata.
*
* @param mixed $entry
* @return array{callback: mixed, domain: string|null}
*/
protected function unwrapRouteEntry(mixed $entry): array
{
if (is_array($entry) && isset($entry['__callback'], $entry['__domain'])) {
return ['callback' => $entry['__callback'], 'domain' => $entry['__domain']];
}

return ['callback' => $entry, 'domain' => null];
}

/**
* Add a route with group attributes applied.
*
* @param string $method
* @param string $path
* @param callable|array $callback
* @param string|null $domain
* @return self
*/
protected function addRoute(string $method, string $path, $callback): self
protected function addRoute(string $method, string $path, $callback, ?string $domain = null): self
{
$this->failFastOnBadRouteDefinition($callback);

Expand All @@ -570,15 +596,12 @@ protected function addRoute(string $method, string $path, $callback): self
: $fullPath;
}

// Special handling for root within a group
if ($path === '' && $prefix !== '') {
self::$routes[$method][$fullPath] = $callback;
$this->currentRoutePath = $fullPath;
} else {
self::$routes[$method][$fullPath] = $callback;
$this->currentRoutePath = $fullPath;
}
$entry = $domain
? ['__callback' => $callback, '__domain' => $domain]
: $callback;

self::$routes[$method][$fullPath] = $entry;
$this->currentRoutePath = $fullPath;
$this->currentRequestMethod = $method;

if (!static::$cacheLoaded) {
Expand Down Expand Up @@ -640,6 +663,31 @@ public function name(string $name): self
return $this;
}

/**
* Restricts the last registered route to a specific domain.
* Supports exact domains ('api.example.com'), wildcard subdomains
* ('{tenant}.example.com'), and universal wildcard ('*').
*
* @param string $domain The domain pattern to restrict to.
* @return self
*/
public function domain(string $domain): self
{
if ($this->currentRoutePath) {
$method = $this->getCurrentRequestMethod();
$entry = self::$routes[$method][$this->currentRoutePath];

['callback' => $callback] = $this->unwrapRouteEntry($entry);

self::$routes[$method][$this->currentRoutePath] = [
'__callback' => $callback,
'__domain' => $domain,
];
}

return $this;
}

/**
* Generates a URL for a named route.
*
Expand Down Expand Up @@ -709,25 +757,31 @@ protected function getCurrentRequestMethod(): string
public function getCallback($request): mixed
{
$method = $request->getMethod();
$url = $request->getPath();
$url = $request->getPath();

$url = ($url !== '/') ? rtrim($url, '/') : $url;

$routes = self::$routes[$method] ?? [];

if (isset($routes[$url])) {
return $routes[$url];
}
foreach ($routes as $route => $entry) {
['callback' => $callback, 'domain' => $domain] = $this->unwrapRouteEntry($entry);

foreach ($routes as $route => $callback) {
if ($route === $url) {
// Domain guard — skip routes whose domain pattern doesn't match the request host
if ($domain !== null && !$this->matchesDomain($domain, $request)) {
continue;
}

// Exact match
if ($route === $url) {
return $callback;
}

// Catch-all wildcard
if ($route === '(.*)') {
return $callback;
}

// Pattern match with named parameters
$routeRegex = $this->convertRouteToRegex($route);

if (preg_match($routeRegex, $url, $matches)) {
Expand All @@ -742,6 +796,50 @@ public function getCallback($request): mixed
return false;
}

/**
* Match the incoming host against a route domain pattern.
* Supports:
* - Exact domain: 'api.example.com'
* - Port-qualified domain: 'localhost:8000'
* - Wildcard subdomain: '{tenant}.example.com' (injects param into route params)
* - Universal wildcard: '*'
*
* @param string $domain
* @param mixed $request
* @return bool
*/
protected function matchesDomain(string $domain, $request): bool
{
$host = $request->getHost();
$host = strtolower($host);

// Universal wildcard: match any host
if ($domain === '*') {
return true;
}

// Named subdomain wildcard: {subdomain}.example.com
if (preg_match('/^\{(\w+)\}\.(.+)$/', $domain, $m)) {
// Strip port from host before matching the suffix
$bareHost = explode(':', $host)[0];
$domainSuffix = strtolower($m[2]);
$pattern = '/^([^.]+)\.' . preg_quote($domainSuffix, '/') . '$/';

if (preg_match($pattern, $bareHost, $hostMatch)) {
// Inject the captured subdomain segment as a route parameter
$existing = $request->getRouteParams();
$existing[$m[1]] = $hostMatch[1];
$request->setRouteParams($existing);
return true;
}

return false;
}

// Exact match — intentionally port-aware so 'localhost:8000' != 'localhost'
return $host === strtolower($domain);
}

/**
* Convert route to regex
*
Expand Down
3 changes: 2 additions & 1 deletion src/Phaseolies/Utilities/Attributes/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public function __construct(
public ?string $name = null,
public array $middleware = [],
public ?int $rateLimit = null,
public ?int $rateLimitDecay = 1
public ?int $rateLimitDecay = 1,
public ?string $domain = null
) {
if (is_string($this->methods)) {
$this->methods = [$this->methods];
Expand Down
Loading