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
3 changes: 2 additions & 1 deletion src/Routing/Loader/AttributeDirectoryLoaderDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Stixx\OpenApiCommandBundle\Routing\Loader;

use Stixx\OpenApiCommandBundle\Routing\RouteSpecificitySorter;
use Symfony\Component\Config\FileLocatorInterface;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Routing\Loader\AttributeDirectoryLoader;
Expand Down Expand Up @@ -49,7 +50,7 @@ public function load(mixed $resource, ?string $type = null): RouteCollection

$commands = $commandDirLoader->load($projectDirectory, 'attribute');
if ($commands instanceof RouteCollection) {
$collection->addCollection($commands);
$collection->addCollection((new RouteSpecificitySorter())->sort($commands));
}

return $collection;
Expand Down
72 changes: 72 additions & 0 deletions src/Routing/RouteSpecificitySorter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

/*
* This file is part of the StixxOpenApiCommandBundle package.
*
* (c) Stixx
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Stixx\OpenApiCommandBundle\Routing;

use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

/**
* Reorders command routes so concrete paths are matched before templated ones, following the OpenAPI path-precedence rule.
*
* Symfony matches routes in registration order, and a placeholder segment such as `{id}` compiles to `[^/]++`, which also
* matches a sibling literal segment such as `current`. Ordering more-specific paths first ensures the literal route wins.
*
* @internal
*/
final class RouteSpecificitySorter
{
public function sort(RouteCollection $routes): RouteCollection
{
$all = $routes->all();

$positions = [];
$position = 0;
foreach ($all as $name => $route) {
$positions[$name] = $position;
++$position;
}

$names = array_keys($all);
usort($names, function (string $left, string $right) use ($all, $positions): int {
$byPlaceholders = $this->placeholderCount($all[$left]) <=> $this->placeholderCount($all[$right]);
if ($byPlaceholders !== 0) {
return $byPlaceholders;
}

$byStaticLength = $this->staticLength($all[$right]) <=> $this->staticLength($all[$left]);
if ($byStaticLength !== 0) {
return $byStaticLength;
}

return $positions[$left] <=> $positions[$right];
});

$sorted = new RouteCollection();
foreach ($names as $name) {
$sorted->add($name, $all[$name], $routes->getPriority($name) ?? 0);
}

return $sorted;
}

private function placeholderCount(Route $route): int
{
return substr_count($route->getPath(), '{');
}

private function staticLength(Route $route): int
{
return strlen((string) preg_replace('/\{[^}]*\}/', '', $route->getPath()));
}
}
21 changes: 21 additions & 0 deletions tests/Mock/Routing/src/CollectionItemCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

/*
* This file is part of the StixxOpenApiCommandBundle package.
*
* (c) Stixx
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Stixx\OpenApiCommandBundle\Tests\Mock\Routing\src;

use OpenApi\Attributes as OA;

#[OA\Get(path: '/api/items/{id}', operationId: 'items_item')]
final class CollectionItemCommand
{
}
21 changes: 21 additions & 0 deletions tests/Mock/Routing/src/CollectionLiteralCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

/*
* This file is part of the StixxOpenApiCommandBundle package.
*
* (c) Stixx
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Stixx\OpenApiCommandBundle\Tests\Mock\Routing\src;

use OpenApi\Attributes as OA;

#[OA\Get(path: '/api/items/featured', operationId: 'items_featured')]
final class CollectionLiteralCommand
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ public function testLoadAugmentsOnlyOnceByScanningSrc(): void
self::assertCount(0, $second->all(), 'Second load returns inner collection without augmentation');
}

public function testCommandRoutesAreOrderedMostSpecificFirst(): void
{
// Arrange — the fixture filenames scan as CollectionItemCommand (/api/items/{id}) before
// CollectionLiteralCommand (/api/items/featured), so without specificity ordering the
// placeholder route would be registered first and swallow the literal one.
$this->inner->method('load')->willReturn(new RouteCollection());

$locator = new FileLocator([$this->projectDir]);
$decorator = new AttributeDirectoryLoaderDecorator($this->inner, $locator, new CommandRouteClassLoader(), $this->projectDir);

// Act
$names = array_keys($decorator->load('ignored')->all());
$itemRoutes = array_values(array_filter($names, static fn (string $name): bool => str_starts_with($name, 'items_')));

// Assert
self::assertSame(['items_featured', 'items_item'], $itemRoutes);
}

public function testSupportsDelegatesToInner(): void
{
// Arrange
Expand Down
94 changes: 94 additions & 0 deletions tests/Unit/Routing/RouteSpecificitySorterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

/*
* This file is part of the StixxOpenApiCommandBundle package.
*
* (c) Stixx
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing;

use PHPUnit\Framework\TestCase;
use Stixx\OpenApiCommandBundle\Routing\RouteSpecificitySorter;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

final class RouteSpecificitySorterTest extends TestCase
{
public function testConcreteRouteIsOrderedBeforeCollidingPlaceholderRoute(): void
{
// Arrange — added in the order a filename scan yields (placeholder first), which is the latent bug.
$routes = new RouteCollection();
$routes->add('books_get_one', new Route('/api/books/{id}'));
$routes->add('books_featured', new Route('/api/books/featured'));

// Act
$sorted = array_keys((new RouteSpecificitySorter())->sort($routes)->all());

// Assert
self::assertSame(['books_featured', 'books_get_one'], $sorted);
}

public function testFewerPlaceholdersAreOrderedFirstAcrossDepth(): void
{
// Arrange
$routes = new RouteCollection();
$routes->add('two', new Route('/api/books/{id}/reviews/{reviewId}'));
$routes->add('one', new Route('/api/books/{id}/reviews'));
$routes->add('zero', new Route('/api/books/reviews'));

// Act
$sorted = array_keys((new RouteSpecificitySorter())->sort($routes)->all());

// Assert
self::assertSame(['zero', 'one', 'two'], $sorted);
}

public function testLongerStaticPrefixWinsAmongEqualPlaceholderCounts(): void
{
// Arrange — both carry one placeholder; the longer literal prefix is more specific.
$routes = new RouteCollection();
$routes->add('short', new Route('/api/{id}'));
$routes->add('long', new Route('/api/books/{id}'));

// Act
$sorted = array_keys((new RouteSpecificitySorter())->sort($routes)->all());

// Assert
self::assertSame(['long', 'short'], $sorted);
}

public function testEquallySpecificRoutesKeepTheirOriginalOrder(): void
{
// Arrange — same placeholder count and static length, so the original order must be preserved.
$routes = new RouteCollection();
$routes->add('alpha', new Route('/api/aaa/{id}'));
$routes->add('beta', new Route('/api/bbb/{id}'));

// Act
$sorted = array_keys((new RouteSpecificitySorter())->sort($routes)->all());

// Assert
self::assertSame(['alpha', 'beta'], $sorted);
}

public function testItPreservesExistingRoutePriorities(): void
{
// Arrange — a non-default priority must survive the rebuild into the sorted collection.
$routes = new RouteCollection();
$routes->add('low', new Route('/api/books/{id}'));
$routes->add('high', new Route('/api/books/{id}/reviews'), 10);

// Act
$sorted = (new RouteSpecificitySorter())->sort($routes);

// Assert
self::assertSame(10, $sorted->getPriority('high'));
self::assertNull($sorted->getPriority('low'));
}
}
Loading