aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBrett Parson <brett@brett-parson.com>2026-08-21 13:55:37 -0500
committerBrett Parson <brett@brett-parson.com>2026-08-21 14:00:11 -0500
commitd039a5d62a9ac6ababae70d9d96422ca59f2b2d4 (patch)
treef0db4750a571e8040a5da02cc7190079c0d62914 /src
parent0de77a6334cee1f6e15f7e0fd35602514560be33 (diff)
downloadminiroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.tar.gz
miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.zip
Scaffold miniroute routing/middleware kernel
Diffstat (limited to 'src')
-rw-r--r--src/Attribute/Delete.php25
-rw-r--r--src/Attribute/Get.php25
-rw-r--r--src/Attribute/Middleware.php21
-rw-r--r--src/Attribute/Patch.php25
-rw-r--r--src/Attribute/Post.php25
-rw-r--r--src/Attribute/Put.php25
-rw-r--r--src/Attribute/RouteAttribute.php19
-rw-r--r--src/Controller/ControllerResolverInterface.php17
-rw-r--r--src/Http/RequestInterface.php31
-rw-r--r--src/Http/ResponseInterface.php17
-rw-r--r--src/Middleware/MiddlewareInterface.php21
-rw-r--r--src/Middleware/MiddlewarePipeline.php35
-rw-r--r--src/Routing/RouteDef.php24
-rw-r--r--src/Routing/RouteLoader.php98
-rw-r--r--src/Routing/RouteMatcher.php109
-rw-r--r--src/Routing/RouteNotFoundException.php11
-rw-r--r--src/Routing/RouteRegistrationException.php11
-rw-r--r--src/Routing/Router.php220
18 files changed, 759 insertions, 0 deletions
diff --git a/src/Attribute/Delete.php b/src/Attribute/Delete.php
new file mode 100644
index 0000000..4588f76
--- /dev/null
+++ b/src/Attribute/Delete.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+#[Attribute(Attribute::TARGET_METHOD)]
+final class Delete implements RouteAttribute
+{
+ public function __construct(public readonly string $path)
+ {
+ }
+
+ public function method(): string
+ {
+ return 'DELETE';
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/src/Attribute/Get.php b/src/Attribute/Get.php
new file mode 100644
index 0000000..779031a
--- /dev/null
+++ b/src/Attribute/Get.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+#[Attribute(Attribute::TARGET_METHOD)]
+final class Get implements RouteAttribute
+{
+ public function __construct(public readonly string $path)
+ {
+ }
+
+ public function method(): string
+ {
+ return 'GET';
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/src/Attribute/Middleware.php b/src/Attribute/Middleware.php
new file mode 100644
index 0000000..e98c580
--- /dev/null
+++ b/src/Attribute/Middleware.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+/**
+ * Declares middleware to apply to a controller method or class.
+ *
+ * Repeatable, and valid on both classes (applies to every method) and
+ * individual methods (applies only to that method).
+ */
+#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
+final class Middleware
+{
+ public function __construct(public readonly string $middlewareClass)
+ {
+ }
+}
diff --git a/src/Attribute/Patch.php b/src/Attribute/Patch.php
new file mode 100644
index 0000000..8402398
--- /dev/null
+++ b/src/Attribute/Patch.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+#[Attribute(Attribute::TARGET_METHOD)]
+final class Patch implements RouteAttribute
+{
+ public function __construct(public readonly string $path)
+ {
+ }
+
+ public function method(): string
+ {
+ return 'PATCH';
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/src/Attribute/Post.php b/src/Attribute/Post.php
new file mode 100644
index 0000000..a395acf
--- /dev/null
+++ b/src/Attribute/Post.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+#[Attribute(Attribute::TARGET_METHOD)]
+final class Post implements RouteAttribute
+{
+ public function __construct(public readonly string $path)
+ {
+ }
+
+ public function method(): string
+ {
+ return 'POST';
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/src/Attribute/Put.php b/src/Attribute/Put.php
new file mode 100644
index 0000000..fc08399
--- /dev/null
+++ b/src/Attribute/Put.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+use Attribute;
+
+#[Attribute(Attribute::TARGET_METHOD)]
+final class Put implements RouteAttribute
+{
+ public function __construct(public readonly string $path)
+ {
+ }
+
+ public function method(): string
+ {
+ return 'PUT';
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+}
diff --git a/src/Attribute/RouteAttribute.php b/src/Attribute/RouteAttribute.php
new file mode 100644
index 0000000..a7b0458
--- /dev/null
+++ b/src/Attribute/RouteAttribute.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Attribute;
+
+/**
+ * Shared contract implemented by every HTTP-method route attribute
+ * (Get, Post, Put, Patch, Delete).
+ *
+ * The loader discovers route attributes by matching against this interface,
+ * so new HTTP methods can be added as attributes without touching the loader.
+ */
+interface RouteAttribute
+{
+ public function method(): string;
+
+ public function path(): string;
+}
diff --git a/src/Controller/ControllerResolverInterface.php b/src/Controller/ControllerResolverInterface.php
new file mode 100644
index 0000000..3b4ea48
--- /dev/null
+++ b/src/Controller/ControllerResolverInterface.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Controller;
+
+/**
+ * Resolves a class name to an object instance.
+ *
+ * Used for both controllers and middleware. Applications provide an
+ * implementation backed by their own dependency container or plain
+ * construction, keeping the kernel independent of any specific wiring.
+ */
+interface ControllerResolverInterface
+{
+ public function resolve(string $class): object;
+}
diff --git a/src/Http/RequestInterface.php b/src/Http/RequestInterface.php
new file mode 100644
index 0000000..fe54667
--- /dev/null
+++ b/src/Http/RequestInterface.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Http;
+
+/**
+ * Minimal HTTP request contract the router needs.
+ *
+ * Applications keep their own concrete Request class and implement this
+ * interface. The router only reads method/path and injects route parameters
+ * via withParams(); everything else stays application-owned.
+ */
+interface RequestInterface
+{
+ public function method(): string;
+
+ public function path(): string;
+
+ /**
+ * Get a route parameter extracted by the router (e.g. {slug}).
+ */
+ public function param(string $name, string $default = ''): string;
+
+ /**
+ * Return a copy of the request with route parameters attached.
+ *
+ * @param array<string, string> $params
+ */
+ public function withParams(array $params): RequestInterface;
+}
diff --git a/src/Http/ResponseInterface.php b/src/Http/ResponseInterface.php
new file mode 100644
index 0000000..cb74ea0
--- /dev/null
+++ b/src/Http/ResponseInterface.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Http;
+
+interface ResponseInterface
+{
+ public function status(): int;
+
+ public function body(): string;
+
+ /**
+ * Return a copy of the response with an additional (or overridden) header.
+ */
+ public function withHeader(string $name, string $value): ResponseInterface;
+}
diff --git a/src/Middleware/MiddlewareInterface.php b/src/Middleware/MiddlewareInterface.php
new file mode 100644
index 0000000..3d21a3f
--- /dev/null
+++ b/src/Middleware/MiddlewareInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Middleware;
+
+use BrettParson\MiniRoute\Http\RequestInterface;
+use BrettParson\MiniRoute\Http\ResponseInterface;
+
+interface MiddlewareInterface
+{
+ /**
+ * Process the request and either short-circuit with a Response or pass
+ * control to $next.
+ *
+ * Middleware may wrap $next to post-process the response.
+ *
+ * @param callable(RequestInterface): ResponseInterface $next
+ */
+ public function handle(RequestInterface $request, callable $next): ResponseInterface;
+}
diff --git a/src/Middleware/MiddlewarePipeline.php b/src/Middleware/MiddlewarePipeline.php
new file mode 100644
index 0000000..65a300e
--- /dev/null
+++ b/src/Middleware/MiddlewarePipeline.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Middleware;
+
+use BrettParson\MiniRoute\Http\RequestInterface;
+use BrettParson\MiniRoute\Http\ResponseInterface;
+
+final class MiddlewarePipeline
+{
+ /**
+ * Wrap a core handler with an ordered list of middleware instances.
+ *
+ * The first middleware in $middleware becomes the outermost layer, so a
+ * request flows through it first and its response post-processing runs
+ * last.
+ *
+ * @param list<MiddlewareInterface> $middleware
+ * @param callable(RequestInterface): ResponseInterface $core
+ * @return callable(RequestInterface): ResponseInterface
+ */
+ public static function compose(array $middleware, callable $core): callable
+ {
+ foreach (array_reverse($middleware) as $middlewareInstance) {
+ $next = $core;
+
+ $core = static function (RequestInterface $request) use ($middlewareInstance, $next): ResponseInterface {
+ return $middlewareInstance->handle($request, $next);
+ };
+ }
+
+ return $core;
+ }
+}
diff --git a/src/Routing/RouteDef.php b/src/Routing/RouteDef.php
new file mode 100644
index 0000000..9d54ff0
--- /dev/null
+++ b/src/Routing/RouteDef.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+/**
+ * A compiled route: one controller method plus its method, path, and
+ * middleware.
+ */
+final class RouteDef
+{
+ /**
+ * @param list<string> $middleware Middleware class names, outermost first.
+ */
+ public function __construct(
+ public readonly string $method,
+ public readonly string $path,
+ public readonly string $controllerClass,
+ public readonly string $controllerMethod,
+ public readonly array $middleware = [],
+ ) {
+ }
+}
diff --git a/src/Routing/RouteLoader.php b/src/Routing/RouteLoader.php
new file mode 100644
index 0000000..596340f
--- /dev/null
+++ b/src/Routing/RouteLoader.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use BrettParson\MiniRoute\Attribute\Middleware;
+use BrettParson\MiniRoute\Attribute\RouteAttribute;
+use ReflectionAttribute;
+use ReflectionClass;
+use ReflectionException;
+use ReflectionMethod;
+
+final class RouteLoader
+{
+ /**
+ * Scan a controller class and compile the routes declared on its public
+ * methods.
+ *
+ * @param class-string $controllerClass
+ * @return list<RouteDef>
+ */
+ public function load(string $controllerClass): array
+ {
+ if (!class_exists($controllerClass)) {
+ throw new RouteRegistrationException("Controller class not found: {$controllerClass}");
+ }
+
+ try {
+ $reflector = new ReflectionClass($controllerClass);
+ } catch (ReflectionException $e) {
+ throw new RouteRegistrationException("Unable to reflect controller: {$controllerClass}", 0, $e);
+ }
+
+ $classMiddleware = $this->middlewareClasses($reflector->getAttributes(Middleware::class));
+
+ $routes = [];
+
+ foreach ($reflector->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ foreach ($this->loadMethod($controllerClass, $method, $classMiddleware) as $route) {
+ $routes[] = $route;
+ }
+ }
+
+ return $routes;
+ }
+
+ /**
+ * @param list<string> $classMiddleware
+ * @return list<RouteDef>
+ */
+ private function loadMethod(string $controllerClass, ReflectionMethod $method, array $classMiddleware): array
+ {
+ $routeAttributes = $method->getAttributes(RouteAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
+
+ if ($routeAttributes === []) {
+ return [];
+ }
+
+ $methodMiddleware = $this->middlewareClasses($method->getAttributes(Middleware::class));
+
+ $middleware = array_merge($classMiddleware, $methodMiddleware);
+
+ $routes = [];
+
+ foreach ($routeAttributes as $attribute) {
+ /** @var RouteAttribute $route */
+ $route = $attribute->newInstance();
+
+ $routes[] = new RouteDef(
+ strtoupper($route->method()),
+ $route->path(),
+ $controllerClass,
+ $method->getName(),
+ $middleware,
+ );
+ }
+
+ return $routes;
+ }
+
+ /**
+ * @param list<ReflectionAttribute> $attributes
+ * @return list<string>
+ */
+ private function middlewareClasses(array $attributes): array
+ {
+ $classes = [];
+
+ foreach ($attributes as $attribute) {
+ /** @var Middleware $middleware */
+ $middleware = $attribute->newInstance();
+ $classes[] = $middleware->middlewareClass;
+ }
+
+ return $classes;
+ }
+}
diff --git a/src/Routing/RouteMatcher.php b/src/Routing/RouteMatcher.php
new file mode 100644
index 0000000..f5b725f
--- /dev/null
+++ b/src/Routing/RouteMatcher.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+final class RouteMatcher
+{
+ public function matches(RouteDef $route, string $method, string $path): bool
+ {
+ if ($route->method !== strtoupper($method)) {
+ return false;
+ }
+
+ return preg_match($this->compile($route->path), $path) === 1;
+ }
+
+ /**
+ * Extract named route parameters from a matched path.
+ *
+ * @return array<string, string>
+ */
+ public function extractParams(RouteDef $route, string $path): array
+ {
+ if (preg_match($this->compile($route->path), $path, $matches) !== 1) {
+ return [];
+ }
+
+ $params = [];
+
+ foreach ($matches as $key => $value) {
+ if (is_string($key)) {
+ $params[$key] = $value;
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Order two routes so the more specific one sorts first.
+ *
+ * Segments are compared left to right; a literal segment beats a
+ * {parameter} segment at the same position. So /entries/archive sorts
+ * before /entries/{slug} and /admin/entries/preview before
+ * /admin/entries/{id}, independent of registration order.
+ */
+ public function compare(RouteDef $a, RouteDef $b): int
+ {
+ $segmentsA = $this->segments($a->path);
+ $segmentsB = $this->segments($b->path);
+
+ $length = max(count($segmentsA), count($segmentsB));
+
+ for ($i = 0; $i < $length; $i++) {
+ $rankA = $this->rank($segmentsA[$i] ?? null);
+ $rankB = $this->rank($segmentsB[$i] ?? null);
+
+ if ($rankA !== $rankB) {
+ return $rankA <=> $rankB;
+ }
+ }
+
+ return 0;
+ }
+
+ private function compile(string $path): string
+ {
+ $escaped = preg_quote($path, '#');
+
+ $pattern = preg_replace(
+ '#\\\\\{([a-zA-Z_]+)\\\\\}#',
+ '(?P<$1>[^/]+)',
+ $escaped,
+ );
+
+ return '#^' . $pattern . '$#';
+ }
+
+ /**
+ * @return list<string>
+ */
+ private function segments(string $path): array
+ {
+ $trimmed = trim($path, '/');
+
+ if ($trimmed === '') {
+ return [];
+ }
+
+ return explode('/', $trimmed);
+ }
+
+ /**
+ * Lower rank = more specific: literal (0) < parameter (1) < missing (2).
+ */
+ private function rank(?string $segment): int
+ {
+ if ($segment === null) {
+ return 2;
+ }
+
+ if (str_starts_with($segment, '{') && str_ends_with($segment, '}')) {
+ return 1;
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Routing/RouteNotFoundException.php b/src/Routing/RouteNotFoundException.php
new file mode 100644
index 0000000..ae0289d
--- /dev/null
+++ b/src/Routing/RouteNotFoundException.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use RuntimeException;
+
+final class RouteNotFoundException extends RuntimeException
+{
+}
diff --git a/src/Routing/RouteRegistrationException.php b/src/Routing/RouteRegistrationException.php
new file mode 100644
index 0000000..7d01fec
--- /dev/null
+++ b/src/Routing/RouteRegistrationException.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use RuntimeException;
+
+final class RouteRegistrationException extends RuntimeException
+{
+}
diff --git a/src/Routing/Router.php b/src/Routing/Router.php
new file mode 100644
index 0000000..4c2d615
--- /dev/null
+++ b/src/Routing/Router.php
@@ -0,0 +1,220 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use BrettParson\MiniRoute\Controller\ControllerResolverInterface;
+use BrettParson\MiniRoute\Http\RequestInterface;
+use BrettParson\MiniRoute\Http\ResponseInterface;
+use BrettParson\MiniRoute\Middleware\MiddlewareInterface;
+use BrettParson\MiniRoute\Middleware\MiddlewarePipeline;
+use LogicException;
+
+final class Router
+{
+ private RouteLoader $loader;
+
+ private RouteMatcher $matcher;
+
+ /** @var list<RouteDef> */
+ private array $routes = [];
+
+ /** @var list<array{prefix: string, middleware: list<string>, methods: list<string>|null}> */
+ private array $groups = [];
+
+ /** @var callable(RequestInterface): ResponseInterface|null */
+ private $notFoundHandler = null;
+
+ public function __construct(
+ private readonly ControllerResolverInterface $resolver,
+ ?RouteLoader $loader = null,
+ ?RouteMatcher $matcher = null,
+ ) {
+ $this->loader = $loader ?? new RouteLoader();
+ $this->matcher = $matcher ?? new RouteMatcher();
+ }
+
+ /**
+ * Register every route declared on a controller class.
+ *
+ * @param class-string $controllerClass
+ */
+ public function registerController(string $controllerClass): void
+ {
+ foreach ($this->loader->load($controllerClass) as $route) {
+ $this->add($route);
+ }
+ }
+
+ /**
+ * @param list<class-string> $controllerClasses
+ */
+ public function registerControllers(array $controllerClasses): void
+ {
+ foreach ($controllerClasses as $controllerClass) {
+ $this->registerController($controllerClass);
+ }
+ }
+
+ /**
+ * Apply middleware to every route under a path prefix.
+ *
+ * @param list<string> $middleware Middleware class names, outermost first.
+ * @param list<string>|null $methods Restrict to these HTTP methods, or null for all.
+ */
+ public function group(string $prefix, array $middleware, ?array $methods = null): void
+ {
+ $this->groups[] = [
+ 'prefix' => $this->normalizePrefix($prefix),
+ 'middleware' => $middleware,
+ 'methods' => $methods === null ? null : array_map('strtoupper', $methods),
+ ];
+ }
+
+ /**
+ * Set the handler used when no route matches. Defaults to throwing
+ * RouteNotFoundException when unset.
+ *
+ * @param callable(RequestInterface): ResponseInterface $handler
+ */
+ public function setNotFoundHandler(callable $handler): void
+ {
+ $this->notFoundHandler = $handler;
+ }
+
+ public function dispatch(RequestInterface $request): ResponseInterface
+ {
+ $method = strtoupper($request->method());
+ $path = $this->normalizePath($request->path());
+
+ $route = $this->find($method, $path);
+
+ if ($route === null) {
+ if ($this->notFoundHandler !== null) {
+ return ($this->notFoundHandler)($request);
+ }
+
+ throw new RouteNotFoundException("No route matches {$method} {$path}");
+ }
+
+ $params = $this->matcher->extractParams($route, $path);
+
+ if ($params !== []) {
+ $request = $request->withParams($params);
+ }
+
+ $controller = $this->resolver->resolve($route->controllerClass);
+
+ $core = static fn (RequestInterface $r): ResponseInterface => $controller->{$route->controllerMethod}($r);
+
+ $middleware = array_merge(
+ $this->groupMiddleware($route, $method),
+ $route->middleware,
+ );
+
+ return MiddlewarePipeline::compose($this->resolveMiddleware($middleware), $core)($request);
+ }
+
+ private function add(RouteDef $route): void
+ {
+ foreach ($this->routes as $existing) {
+ if ($existing->method === $route->method && $existing->path === $route->path) {
+ throw new RouteRegistrationException(
+ "Duplicate route {$route->method} {$route->path} declared on "
+ . "{$existing->controllerClass}::{$existing->controllerMethod} and "
+ . "{$route->controllerClass}::{$route->controllerMethod}",
+ );
+ }
+ }
+
+ $this->routes[] = $route;
+
+ usort($this->routes, fn (RouteDef $a, RouteDef $b): int => $this->matcher->compare($a, $b));
+ }
+
+ private function find(string $method, string $path): ?RouteDef
+ {
+ foreach ($this->routes as $route) {
+ if ($this->matcher->matches($route, $method, $path)) {
+ return $route;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return list<string>
+ */
+ private function groupMiddleware(RouteDef $route, string $method): array
+ {
+ $middleware = [];
+
+ foreach ($this->groups as $group) {
+ if (!$this->matchesPrefix($group['prefix'], $route->path)) {
+ continue;
+ }
+
+ if ($group['methods'] !== null && !in_array($method, $group['methods'], true)) {
+ continue;
+ }
+
+ $middleware = array_merge($middleware, $group['middleware']);
+ }
+
+ return $middleware;
+ }
+
+ /**
+ * @param list<string> $classes
+ * @return list<MiddlewareInterface>
+ */
+ private function resolveMiddleware(array $classes): array
+ {
+ $instances = [];
+
+ foreach ($classes as $class) {
+ $instance = $this->resolver->resolve($class);
+
+ if (!$instance instanceof MiddlewareInterface) {
+ throw new LogicException("Middleware {$class} must implement " . MiddlewareInterface::class);
+ }
+
+ $instances[] = $instance;
+ }
+
+ return $instances;
+ }
+
+ private function matchesPrefix(string $prefix, string $path): bool
+ {
+ if ($prefix === '') {
+ return true;
+ }
+
+ if ($path === $prefix) {
+ return true;
+ }
+
+ return str_starts_with($path, $prefix . '/');
+ }
+
+ private function normalizePrefix(string $prefix): string
+ {
+ $trimmed = rtrim($prefix, '/');
+
+ return $trimmed === '' ? '' : '/' . ltrim($trimmed, '/');
+ }
+
+ private function normalizePath(string $path): string
+ {
+ $parsed = parse_url($path, PHP_URL_PATH);
+
+ if (!is_string($parsed) || $parsed === '') {
+ return '/';
+ }
+
+ return '/' . trim($parsed, '/');
+ }
+}