fix(routes): support composed prefixes (#18)

also refactors and centralizes prefixes cleanup
This commit is contained in:
Mazen Touati
2025-11-11 20:05:15 +01:00
committed by GitHub
parent 328c13d6a7
commit 23bf3b7691
5 changed files with 83 additions and 40 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri\Concerns;
trait CleansUriPrefix
{
/**
* @return string[]
*/
protected function parseUriPartsAfterPrefix(string $prefix, string $uri): array
{
$parts = explode('/', $uri);
$cleanParts = array_values(array_filter($parts, fn ($part): bool => $part !== ''));
// Convert the prefix string (e.g. "app/api" or "api") into an array of parts: ["app", "api"] or ["api"].
$prefixParts = explode('/', trim($prefix, '/'));
// Iterate through each prefix part and remove matching items from the start of $parts.
foreach ($prefixParts as $index => $prefixPart) {
// Stop checking once we hit a mismatch.
if (! isset($cleanParts[$index]) || $cleanParts[$index] !== $prefixPart) {
break;
}
unset($cleanParts[$index]);
}
return array_values($cleanParts);
}
}

View File

@@ -2,17 +2,16 @@
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\Concerns\CleansUriPrefix;
class NonVersionedUri implements UriContract
{
/** @var string[] */
private array $parts;
use CleansUriPrefix;
public function __construct(
public string $value,
public string $routesPrefix,
) {
$this->parts = explode('/', $value);
}
) {}
public function getVersion(): string
{
@@ -21,29 +20,23 @@ class NonVersionedUri implements UriContract
public function getResource(): string
{
// Remove empty parts from the URI and reindex array
$cleanParts = array_values(array_filter($this->parts, fn (string $part): bool => $part !== ''));
$cleanParts = $this->removePrefixIfPresent($cleanParts);
// Extract the resource (first remaining part)
return array_shift($cleanParts) ?? '';
return $this->parseUriPartsAfterPrefixIfItExists()[0] ?? '';
}
/**
* @param string[] $parts
* @return string[]
*/
private function removePrefixIfPresent(array $parts): array
private function parseUriPartsAfterPrefixIfItExists(): array
{
if (! filled($this->routesPrefix)) {
return $parts;
return array_values(
array_filter(explode('/', $this->value), fn ($part): bool => $part !== ''),
);
}
if ($parts !== [] && $parts[0] === $this->routesPrefix) {
array_shift($parts);
}
return $parts;
return $this->parseUriPartsAfterPrefix(
prefix: $this->routesPrefix,
uri: $this->value,
);
}
}

View File

@@ -2,16 +2,24 @@
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\Concerns\CleansUriPrefix;
class VersionedUri implements UriContract
{
/** @var string[] */
use CleansUriPrefix;
/**
* Clean parts are everything after the route prefix without empty strings.
*
* @var string[]
*/
private array $cleanParts;
public function __construct(
public string $value,
public string $routesPrefix,
) {
$this->cleanParts = $this->parseAndCleanUri();
$this->cleanParts = $this->parseUriPartsAfterPrefixIfItExists();
}
public function getVersion(): string
@@ -25,36 +33,35 @@ class VersionedUri implements UriContract
public function getResource(): string
{
// If there's a version part, skip it to get the resource
// If there's a version part, skip it to get the resource.
if ($this->cleanParts !== [] && $this->isVersionPart($this->cleanParts[0])) {
return $this->cleanParts[1] ?? '';
}
// If there's no version part, the first part is the resource
// If there's no version part, the first part is the resource.
return $this->cleanParts[0] ?? '';
}
private function isVersionPart(string $part): bool
{
// Check if the part looks like a version (e.g., v1, v2, 1.0, etc.).
return preg_match('/^v\d+$/', $part) || preg_match('/^\d+\.\d+$/', $part);
}
/**
* @return string[]
*/
private function parseAndCleanUri(): array
private function parseUriPartsAfterPrefixIfItExists(): array
{
$parts = explode('/', $this->value);
// Remove empty parts from the URI and reindex array
$cleanParts = array_values(array_filter($parts, fn ($part): bool => $part !== ''));
// Remove the routes prefix if present
if ($this->routesPrefix !== '' && $cleanParts !== [] && $cleanParts[0] === $this->routesPrefix) {
array_shift($cleanParts);
if (! filled($this->routesPrefix)) {
return array_values(
array_filter(explode('/', $this->value), fn ($part): bool => $part !== ''),
);
}
return $cleanParts;
}
private function isVersionPart(string $part): bool
{
// Check if the part looks like a version (e.g., v1, v2, 1.0, etc.)
return preg_match('/^v\d+$/', $part) || preg_match('/^\d+\.\d+$/', $part);
return $this->parseUriPartsAfterPrefix(
prefix: $this->routesPrefix,
uri: $this->value,
);
}
}

View File

@@ -52,6 +52,12 @@ class NonVersionedUriUnitTest extends TestCase
'expectedResource' => 'users',
];
yield 'uri with composed routesPrefix' => [
'value' => '/web/api/users',
'routesPrefix' => 'web/api',
'expectedResource' => 'users',
];
yield 'uri with routesPrefix and multiple segments' => [
'value' => '/api/users/123',
'routesPrefix' => 'api',

View File

@@ -201,6 +201,12 @@ class VersionedUriUnitTest extends TestCase
'expectedResource' => 'users',
];
yield 'versioned uri with composed prefix extracts resource' => [
'value' => '/cms/api/v1/users',
'routesPrefix' => 'cms/api',
'expectedResource' => 'users',
];
yield 'versioned uri with multiple segments extracts first resource' => [
'value' => '/v1/users/123/profile',
'routesPrefix' => '',