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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ may contain breaking changes; read the **Upgrading** notes before bumping a mino

## [Unreleased]

## [0.13.1] - 2026-09-06

### Fixed

- Requests are validated against the OpenAPI document of **their own** Nelmio area. Previously every
request was validated against the `default` area's document, while route detection accepted a route
belonging to any area — so in a multi-area application a valid request to a non-default area was
rejected with a `400` `openapi_request_validation`. Single-area applications are unaffected.
- An area whose name is numeric (`2024`) no longer raises a `TypeError` during route lookup. PHP stores
such a key as an `int`, which `ServiceLocator::get(string $id)` rejects under `strict_types`.

## [0.12.4] - 2026-08-30

Documentation only — no code changes since `0.12.3`.
Expand Down Expand Up @@ -208,7 +219,8 @@ Installing it with Composer is not enough. See the README's installation section
details outside debug mode, and a `CommandValueResolver` that supports list endpoints and the combination of
parameters with a request body.

[Unreleased]: https://github.com/stixx/openapi-command-bundle/compare/0.12.4...HEAD
[Unreleased]: https://github.com/stixx/openapi-command-bundle/compare/0.13.1...HEAD
[0.13.1]: https://github.com/stixx/openapi-command-bundle/compare/0.13.0...0.13.1
[0.12.4]: https://github.com/stixx/openapi-command-bundle/compare/0.12.3...0.12.4
[0.12.3]: https://github.com/stixx/openapi-command-bundle/compare/0.12.2...0.12.3
[0.12.2]: https://github.com/stixx/openapi-command-bundle/compare/0.12.1...0.12.2
Expand Down
3 changes: 3 additions & 0 deletions config/validators.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Nyholm\Psr7\Factory\Psr17Factory;
use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker;
use Stixx\OpenApiCommandBundle\Validator\RequestValidator;
use Stixx\OpenApiCommandBundle\Validator\RequestValidatorChain;
use Stixx\OpenApiCommandBundle\Validator\ValidatorInterface as StixxValidatorInterface;
Expand Down Expand Up @@ -37,5 +38,7 @@
->set(RequestValidator::class)
->arg('$apiDocGenerator', service('nelmio_api_doc.generator.default'))
->arg('$psrHttpFactory', service('stixx_openapi_command.psr_http_factory'))
->arg('$generatorsLocator', service('stixx_openapi_command.nelmio.generators_locator'))
->arg('$areaRoutesChecker', service(NelmioAreaRoutesChecker::class))
->tag(StixxValidatorInterface::TAG_NAME);
};
2 changes: 2 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ services:

All tagged validators are executed in a chain during the `kernel.request` event, but only for routes that are managed by this bundle (detected via `NelmioAreaRoutesChecker`). If any validator throws an exception, the request cycle is interrupted.

When several Nelmio areas are configured, each has its own OpenAPI document. `NelmioAreaRoutesChecker` resolves the area a request belongs to, and the request is validated against that area's document. Areas are checked in registration order and the first match wins.

---

## Customizing Error Responses
Expand Down
12 changes: 12 additions & 0 deletions src/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public function process(ContainerBuilder $container): void
$routesMap = [];
$pathPatterns = [];

$generatorsMap = [];

foreach ($areas as $area) {
$serviceId = sprintf('nelmio_api_doc.routes.%s', $area);

Expand All @@ -45,13 +47,23 @@ public function process(ContainerBuilder $container): void

$routesMap[$area] = new Reference($serviceId);
$pathPatterns[$area] = $this->extractPathPatterns($container, $serviceId);

$generatorId = sprintf('nelmio_api_doc.generator.%s', $area);
if ($container->has($generatorId)) {
$generatorsMap[$area] = new Reference($generatorId);
}
}

$container->register('stixx_openapi_command.nelmio.routes_locator', ServiceLocator::class)
->addTag('container.service_locator')
->setPublic(false)
->setArguments([$routesMap]);

$container->register('stixx_openapi_command.nelmio.generators_locator', ServiceLocator::class)
->addTag('container.service_locator')
->setPublic(false)
->setArguments([$generatorsMap]);

$container->setParameter('stixx_openapi_command.nelmio.path_patterns', $pathPatterns);
}

Expand Down
31 changes: 21 additions & 10 deletions src/Routing/NelmioAreaRoutesChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,21 @@ public function __construct(
}

public function isApiRoute(Request $request): bool
{
return null !== $this->areaFor($request);
}

/**
* The Nelmio area a request belongs to, or null if none. Areas are checked in registration order.
*/
public function areaFor(Request $request): ?string
{
$routeName = $request->attributes->get('_route', '');
if (is_string($routeName) && $routeName !== '' && $this->matchesByRouteName($routeName)) {
return true;
if (is_string($routeName) && $routeName !== '') {
$area = $this->matchesByRouteName($routeName);
if (null !== $area) {
return $area;
}
}

// Symfony does not set _route when the path doesn't match any route (404) or when no method
Expand All @@ -45,32 +56,32 @@ public function isApiRoute(Request $request): bool
return $this->matchesByPath($request->getPathInfo());
}

private function matchesByRouteName(string $routeName): bool
private function matchesByRouteName(string $routeName): ?string
{
foreach (array_keys($this->routesLocator->getProvidedServices()) as $area) {
$routeCollection = $this->routesLocator->get($area);
$routeCollection = $this->routesLocator->get((string) $area);
if (!$routeCollection instanceof RouteCollection) {
continue;
}

if (null !== $routeCollection->get($routeName)) {
return true;
return (string) $area;
}
}

return false;
return null;
}

private function matchesByPath(string $path): bool
private function matchesByPath(string $path): ?string
{
foreach ($this->pathPatterns as $patterns) {
foreach ($this->pathPatterns as $area => $patterns) {
foreach ($patterns as $pattern) {
if (preg_match('{'.$pattern.'}', $path) === 1) {
return true;
return (string) $area;
}
}
}

return false;
return null;
}
}
33 changes: 26 additions & 7 deletions src/Validator/RequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,55 @@
use League\OpenAPIValidation\PSR7\RequestValidator as OpenApiRequestValidator;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Nelmio\ApiDocBundle\ApiDocGenerator;
use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;

/**
* @internal
*/
final class RequestValidator implements ValidatorInterface
{
private ?OpenApiRequestValidator $cachedValidator = null;
/** @var array<string, OpenApiRequestValidator> */
private array $cachedValidators = [];

/**
* @param ServiceLocator<ApiDocGenerator>|null $generatorsLocator
*/
public function __construct(
private readonly ApiDocGenerator $apiDocGenerator,
private readonly HttpMessageFactoryInterface $psrHttpFactory,
private readonly ?ServiceLocator $generatorsLocator = null,
private readonly ?NelmioAreaRoutesChecker $areaRoutesChecker = null,
) {
}

public function validate(Request $request): void
{
$psrRequest = $this->psrHttpFactory->createRequest($request);
$this->getValidator()->validate($psrRequest);
$this->getValidator($this->areaFor($request))->validate($psrRequest);
}

private function getValidator(): OpenApiRequestValidator
private function areaFor(Request $request): string
{
if ($this->cachedValidator !== null) {
return $this->cachedValidator;
return $this->areaRoutesChecker?->areaFor($request) ?? 'default';
}

private function getValidator(string $area): OpenApiRequestValidator
{
if (isset($this->cachedValidators[$area])) {
return $this->cachedValidators[$area];
}

$apiDoc = $this->apiDocGenerator->generate();
$generator = $this->generatorsLocator?->has($area) === true
? $this->generatorsLocator->get($area)
: $this->apiDocGenerator;

$apiDoc = $generator->generate();

return $this->cachedValidator = new ValidatorBuilder()->fromJson($apiDoc->toJson())->getRequestValidator();
return $this->cachedValidators[$area] = new ValidatorBuilder()
->fromJson($apiDoc->toJson())
->getRequestValidator();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public function testProcessWithAreas(): void
$container->setDefinition('nelmio_api_doc.routes.default', new Definition(stdClass::class));
// 'nelmio_api_doc.routes.internal' is missing on purpose — it should be skipped silently.

$container->setDefinition('nelmio_api_doc.generator.default', new Definition(stdClass::class));

$pass = new CollectNelmioApiDocRoutesPass();

// Act
Expand All @@ -56,6 +58,17 @@ public function testProcessWithAreas(): void
];
self::assertEquals([$expectedMap], $definition->getArguments());

self::assertTrue($container->hasDefinition('stixx_openapi_command.nelmio.generators_locator'));
$generatorsDefinition = $container->getDefinition('stixx_openapi_command.nelmio.generators_locator');

self::assertSame(ServiceLocator::class, $generatorsDefinition->getClass());
self::assertTrue($generatorsDefinition->hasTag('container.service_locator'));

$expectedGeneratorsMap = [
'default' => new Reference('nelmio_api_doc.generator.default'),
];
self::assertEquals([$expectedGeneratorsMap], $generatorsDefinition->getArguments());

self::assertTrue($container->hasParameter('stixx_openapi_command.nelmio.path_patterns'));
self::assertSame(
['default' => []],
Expand Down
108 changes: 108 additions & 0 deletions tests/Unit/Validator/RequestValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@
use OpenApi\Annotations\Schema;
use OpenApi\Context;
use PHPUnit\Framework\TestCase;
use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker;
use Stixx\OpenApiCommandBundle\Validator\RequestValidator;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

final class RequestValidatorTest extends TestCase
{
Expand Down Expand Up @@ -109,6 +113,56 @@ public function describe(OpenApi $api): void
self::assertSame(1, $describer->describeCalls);
}

public function testValidatesAgainstTheRequestsOwnAreaNotTheDefaultArea(): void
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
// Arrange
$defaultGenerator = new ApiDocGenerator([$this->createDescriberForPath('/default')], []);
$internalGenerator = new ApiDocGenerator([$this->createDescriberForPath('/internal')], []);

$request = Request::create('/internal', 'POST');
$request->attributes->set('_route', 'internal_route');

$validator = new RequestValidator(
$defaultGenerator,
$this->createPsrHttpFactory(),
$this->createGeneratorsLocator($defaultGenerator, $internalGenerator),
$this->createAreaChecker(['default' => '/default', 'internal' => '/internal']),
);

// Act
$validator->validate($request);

// Assert: /internal exists only in the internal area's document; the default document would raise NoPath.
$this->expectNotToPerformAssertions();
}

public function testKeepsASeparateValidatorPerArea(): void
{
// Arrange
$defaultGenerator = new ApiDocGenerator([$this->createDescriberForPath('/default')], []);
$internalGenerator = new ApiDocGenerator([$this->createDescriberForPath('/internal')], []);

$validator = new RequestValidator(
$defaultGenerator,
$this->createPsrHttpFactory(),
$this->createGeneratorsLocator($defaultGenerator, $internalGenerator),
$this->createAreaChecker(['default' => '/default', 'internal' => '/internal']),
);

$internal = Request::create('/internal', 'POST');
$internal->attributes->set('_route', 'internal_route');

$default = Request::create('/default', 'POST');
$default->attributes->set('_route', 'default_route');

// Act: the internal area first, so its validator is memoised before the default area is used.
$validator->validate($internal);
$validator->validate($default);

// Assert: a shared memo would validate /default against the internal document and raise NoPath.
$this->expectNotToPerformAssertions();
}

private function createPsrHttpFactory(): PsrHttpFactory
{
$psr17 = new Psr17Factory();
Expand Down Expand Up @@ -149,4 +203,58 @@ public function describe(OpenApi $api): void
}
};
}

private function createDescriberForPath(string $path): DescriberInterface
{
return new class ($path) implements DescriberInterface {
public function __construct(private readonly string $path)
{
}

public function describe(OpenApi $api): void
{
$api->info = new Info(['title' => 'Test', 'version' => '1.0.0', '_context' => new Context(['version' => '3.0.0'], null)]);
$api->paths = [
new PathItem([
'path' => $this->path,
'post' => new Post([
'responses' => [new Response(['response' => 200, 'description' => 'ok', '_context' => new Context(['version' => '3.0.0'], null)])],
'_context' => new Context(['version' => '3.0.0'], null),
]),
'_context' => new Context(['version' => '3.0.0'], null),
]),
];
}
};
}

/** @return ServiceLocator<ApiDocGenerator> */
private function createGeneratorsLocator(ApiDocGenerator $default, ApiDocGenerator $internal): ServiceLocator
{
/** @var ServiceLocator<ApiDocGenerator> $locator */
$locator = new ServiceLocator([
'default' => static fn (): ApiDocGenerator => $default,
'internal' => static fn (): ApiDocGenerator => $internal,
]);

return $locator;
}

/** @param array<string, string> $areaToPath */
private function createAreaChecker(array $areaToPath): NelmioAreaRoutesChecker
{
$routes = [];
$patterns = [];
foreach ($areaToPath as $area => $path) {
$collection = new RouteCollection();
$collection->add($area.'_route', new Route($path));
$routes[$area] = static fn (): RouteCollection => $collection;
$patterns[$area] = ['^'.preg_quote($path, '{}')];
}

/** @var ServiceLocator<RouteCollection> $locator */
$locator = new ServiceLocator($routes);

return new NelmioAreaRoutesChecker($locator, $patterns);
}
}