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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ stixx_openapi_command:
enabled: true
groups: ['Default']
cache_control: 'no-store' # Any valid Cache-Control directives, or null to disable
command_paths: # Directories scanned for command DTOs; [] disables discovery
- '%kernel.project_dir%/src'
openapi:
problem_details: true # Enable RFC 7807 problem details for errors
```
Expand Down
36 changes: 27 additions & 9 deletions config/routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

declare(strict_types=1);

use Stixx\OpenApiCommandBundle\Routing\Loader\AttributeDirectoryLoaderDecorator;
use Stixx\OpenApiCommandBundle\Routing\CommandRouteDiscovery;
use Stixx\OpenApiCommandBundle\Routing\Loader\CommandRouteClassLoader;
use Stixx\OpenApiCommandBundle\Routing\Loader\CommandRouteDirectoryLoader;
use Stixx\OpenApiCommandBundle\Routing\Loader\RouterLoaderDecorator;
use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker;
use Stixx\OpenApiCommandBundle\Routing\RouteSpecificitySorter;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
Expand All @@ -19,17 +22,32 @@
->arg('$routesLocator', service('stixx_openapi_command.nelmio.routes_locator'))
->arg('$pathPatterns', param('stixx_openapi_command.nelmio.path_patterns'));

$services
->set(RouteSpecificitySorter::class);

$services
->set(CommandRouteClassLoader::class)
->arg('$env', param('kernel.environment'))
->arg('$controllerClasses', param('stixx_openapi_command.controller_classes'))
->tag('routing.loader');
->arg('$env', param('kernel.environment'))
->arg('$controllerClasses', param('stixx_openapi_command.controller_classes'))
// Supports $routes->import(SomeCommand::class, 'attribute').
->tag('routing.loader');

$services
->set(AttributeDirectoryLoaderDecorator::class)
->decorate('routing.loader.attribute.directory')
->arg('$inner', service('.inner'))
->set(CommandRouteDirectoryLoader::class)
->arg('$locator', service('file_locator'))
->arg('$commandAttributeLoader', service(CommandRouteClassLoader::class))
->arg('$projectDir', param('kernel.project_dir'));
->arg('$loader', service(CommandRouteClassLoader::class))
// Supports $routes->import('../src/Command', 'stixx_openapi_command.command_attributes').
->tag('routing.loader');

$services
->set(CommandRouteDiscovery::class)
->arg('$directoryLoader', service(CommandRouteDirectoryLoader::class))
->arg('$commandPaths', param('stixx_openapi_command.command_paths'))
->arg('$sorter', service(RouteSpecificitySorter::class));

$services
->set(RouterLoaderDecorator::class)
->decorate('routing.loader')
->arg('$inner', service('.inner'))
->arg('$discovery', service(CommandRouteDiscovery::class));
};
45 changes: 39 additions & 6 deletions docs/command-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,49 @@ sequenceDiagram
Starting with this version, you do not need to add any custom route import for command DTOs.

How it works
- The bundle decorates Symfony’s `AttributeDirectoryLoader` (the same mechanism used to load controller routes from attributes).
- During the normal route-building process, we automatically scan your project’s `%kernel.project_dir%/src` directory and add routes for command classes that meet the criteria: have class-level OpenAPI operation attributes (e.g., `#[OA\Post]`, `#[OA\Get]`, …) and are not controllers.
- This happens once per router build and coexists with your existing controller routes and any manually configured routes.

- The bundle decorates `routing.loader`, the loader the router asks for when it builds its route collection. It runs once per router build, for the root routing resource, so command routes are added no matter how your application declares its own routes — or whether it declares any at all.
- During that build, the bundle scans the configured `command_paths` and adds routes for command classes that meet the criteria: class-level OpenAPI operation attributes (e.g., `#[OA\Post]`, `#[OA\Get]`, …) and not a controller.
- Discovered routes coexist with your existing controller routes and any manually configured routes.

Notes
- No additional routing import is necessary. The bundle augments the standard attribute route loading automatically.
- The scan is recursive and limited to `%kernel.project_dir%/src`.
- No additional routing import is necessary.
- The scan is recursive and covers the directories listed under `command_paths`, which defaults to `%kernel.project_dir%/src`:

```yaml
stixx_openapi_command:
command_paths:
- '%kernel.project_dir%/src/Command'
- '%kernel.project_dir%/lib/Billing/Command'
```

Configured paths that do not exist are skipped, so listing a directory that only some environments have is safe.
- Only classes that are annotated with OpenAPI operation attributes (e.g., `#[OA\Post]`) at class level and are not recognized controllers (`AbstractController`, `#[AsController]`, or having method-level `#[Route]`) will produce routes.
- Because of this, ensure your commands are plain DTOs and do not extend `AbstractController`, do not use `#[AsController]`, and do not declare method-level `#[Route]` attributes.
- If a route name is already present in the collection — because you imported the command explicitly — the bundle leaves your route alone rather than replacing it.
- Two different command classes resolving to the same route name is an error, and the container fails to compile naming both classes. Without `operationId` the name is derived from the class short name, so `Billing\CreateInvoiceCommand` and `Sales\CreateInvoiceCommand` both resolve to `command_createinvoicecommand`; give at least one of them an explicit `operationId`. Loading the same class twice — via discovery and an explicit import — is not a conflict.

### Declaring command routes explicitly

Discovery is optional. To control exactly what gets routed, set `command_paths: []` and import commands from your routing config, either one class at a time:

```php
// config/routes/commands.php
use App\Command\CreateProjectCommand;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

return static function (RoutingConfigurator $routes): void {
$routes->import(CreateProjectCommand::class, 'attribute');
};
```

or a directory at a time, using the bundle's routing type:

```yaml
# config/routes/commands.yaml
commands:
resource: '../../src/Command'
type: stixx_openapi_command.command_attributes
```


## Use OpenAPI attributes on command classes (no Symfony #[Route])
Expand Down
5 changes: 5 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('cache_control')
->defaultValue('no-store')
->end()
->arrayNode('command_paths')
->info('Directories scanned for command DTOs carrying OpenAPI operation attributes.')
->scalarPrototype()->end()
->defaultValue(['%kernel.project_dir%/src'])
->end()
->arrayNode('openapi')
->addDefaultsIfNotSet()
->children()
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/StixxOpenApiCommandExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public function load(array $configs, ContainerBuilder $container): void
$cacheControl = $config['cache_control'];
$container->setParameter('stixx_openapi_command.cache_control', $cacheControl);

/** @var list<string> $commandPaths */
$commandPaths = $config['command_paths'];
$container->setParameter('stixx_openapi_command.command_paths', $commandPaths);

$container
->registerForAutoconfiguration(ResponderInterface::class)
->addTag(ResponderInterface::TAG_NAME);
Expand Down
64 changes: 64 additions & 0 deletions src/Routing/CommandRouteDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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 Stixx\OpenApiCommandBundle\Routing\Loader\CommandRouteDirectoryLoader;
use Symfony\Component\Routing\RouteCollection;

/**
* Finds command DTOs in the configured paths and turns their OpenAPI operation attributes into routes.
*
* @internal
*/
final class CommandRouteDiscovery
{
private ?RouteCollection $collection = null;

/**
* @param list<string> $commandPaths
*/
public function __construct(
private readonly CommandRouteDirectoryLoader $directoryLoader,
private readonly array $commandPaths,
private readonly RouteSpecificitySorter $sorter = new RouteSpecificitySorter(),
) {
}

/**
* Scans every configured path once, sorting the result so concrete paths are matched before templated ones.
*/
public function discover(): RouteCollection
{
return $this->collection ??= $this->scan();
}

private function scan(): RouteCollection
{
$discovered = new RouteCollection();

foreach ($this->commandPaths as $path) {
// Configured paths may legitimately be absent, including the default %kernel.project_dir%/src.
if (!is_dir($path)) {
continue;
}

$routes = $this->directoryLoader->load($path, CommandRouteDirectoryLoader::TYPE);
if ($routes instanceof RouteCollection) {
$discovered->addCollection($routes);
}
}

return $this->sorter->sort($discovered);
}
}
63 changes: 0 additions & 63 deletions src/Routing/Loader/AttributeDirectoryLoaderDecorator.php

This file was deleted.

29 changes: 29 additions & 0 deletions src/Routing/Loader/CommandRouteClassLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Stixx\OpenApiCommandBundle\Routing\Loader;

use LogicException;
use OpenApi\Annotations\Operation;
use OpenApi\Attributes as OA;
use OpenApi\Generator;
Expand All @@ -30,6 +31,13 @@
*/
final class CommandRouteClassLoader extends AttributeClassLoader
{
/**
* Route name => the command class that claimed it, across every class this loader has seen.
*
* @var array<string, class-string>
*/
private array $routeOwners = [];

/**
* @param array<string, string> $controllerClasses
*/
Expand Down Expand Up @@ -106,6 +114,7 @@ public function load(mixed $class, ?string $type = null): RouteCollection

$name = $this->routeNameFromOperation($operation, $reflectionClass);
$finalName = $this->ensureUniqueName($collection, $name);
$this->assertNameIsNotTakenByAnotherCommand($finalName, $class);
$collection->add($finalName, $route);
}

Expand All @@ -130,6 +139,26 @@ private function defaultNameFromClass(ReflectionClass $class): string
return 'command_'.$base;
}

/**
* Route names are unique per class via {@see ensureUniqueName()}, but each class is loaded into its own
* collection and those are merged with addCollection(), which overwrites by name. Two commands deriving
* the same name would silently cost one of them its endpoint, so fail loudly instead.
*
* Loading the same class twice — discovery plus an explicit import — is not a conflict.
*
* @param class-string $class
*/
private function assertNameIsNotTakenByAnotherCommand(string $name, string $class): void
{
$owner = $this->routeOwners[$name] ?? null;

if ($owner !== null && $owner !== $class) {
throw new LogicException(sprintf('Command classes "%s" and "%s" both produce the route name "%s". Set a distinct operationId on one of their OpenAPI operation attributes.', $owner, $class, $name));
}

$this->routeOwners[$name] = $class;
}

private function ensureUniqueName(RouteCollection $collection, string $name): string
{
if ($collection->get($name) === null) {
Expand Down
Loading
Loading