From 0d495c768dce9c169d98f573deaf9f1ba5e91b4b Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Thu, 5 Nov 2026 10:42:19 +0100 Subject: [PATCH 1/3] fix(laravel): throw routing exceptions from Router::match() --- src/Laravel/Routing/Router.php | 13 ++++++- src/Laravel/Tests/Unit/Routing/RouterTest.php | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Laravel/Routing/Router.php b/src/Laravel/Routing/Router.php index 76da146cb36..e09735bc829 100644 --- a/src/Laravel/Routing/Router.php +++ b/src/Laravel/Routing/Router.php @@ -17,6 +17,10 @@ use Illuminate\Http\Request as LaravelRequest; use Illuminate\Routing\Router as BaseRouter; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Generator\UrlGenerator; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RouteCollection; @@ -86,7 +90,14 @@ public function getRouteCollection(): RouteCollection public function match(string $pathInfo): array { $request = LaravelRequest::create($pathInfo, Request::METHOD_GET); - $route = $this->router->getRoutes()->match($request); + + try { + $route = $this->router->getRoutes()->match($request); + } catch (NotFoundHttpException $e) { + throw new ResourceNotFoundException(\sprintf('No routes found for "%s".', $pathInfo), 0, $e); + } catch (MethodNotAllowedHttpException $e) { + throw new MethodNotAllowedException(explode(', ', $e->getHeaders()['Allow'] ?? ''), $e->getMessage(), 0, $e); + } return $route->defaults + ['uri_variables' => array_diff_key($route->parameters, $route->defaults)]; } diff --git a/src/Laravel/Tests/Unit/Routing/RouterTest.php b/src/Laravel/Tests/Unit/Routing/RouterTest.php index d8d7e84164b..901318dcad7 100644 --- a/src/Laravel/Tests/Unit/Routing/RouterTest.php +++ b/src/Laravel/Tests/Unit/Routing/RouterTest.php @@ -17,6 +17,10 @@ use Illuminate\Routing\RouteCollection as IlluminateRouteCollection; use Illuminate\Routing\Router as BaseRouter; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\RouteCollection; class RouterTest extends TestCase @@ -41,4 +45,34 @@ public function testGetRouteCollectionIsMemoizedAcrossCalls(): void $this->assertSame($symfonyRoutes, $first); $this->assertSame($first, $second); } + + public function testMatchThrowsResourceNotFoundException(): void + { + $illuminateRoutes = $this->createStub(IlluminateRouteCollection::class); + $illuminateRoutes->method('match')->willThrowException(new NotFoundHttpException()); + + $baseRouter = $this->createStub(BaseRouter::class); + $baseRouter->method('getRoutes')->willReturn($illuminateRoutes); + + $this->expectException(ResourceNotFoundException::class); + + (new Router($baseRouter))->match('/api/nope/1'); + } + + public function testMatchThrowsMethodNotAllowedException(): void + { + $illuminateRoutes = $this->createStub(IlluminateRouteCollection::class); + $illuminateRoutes->method('match')->willThrowException(new MethodNotAllowedHttpException(['POST', 'PUT'])); + + $baseRouter = $this->createStub(BaseRouter::class); + $baseRouter->method('getRoutes')->willReturn($illuminateRoutes); + + try { + (new Router($baseRouter))->match('/api/books'); + $this->fail('A MethodNotAllowedException should have been thrown.'); + } catch (MethodNotAllowedException $e) { + $this->assertSame(['POST', 'PUT'], $e->getAllowedMethods()); + $this->assertInstanceOf(MethodNotAllowedHttpException::class, $e->getPrevious()); + } + } } From 024207ce77b1e57c70e393ad112fc76b2e7fd6d1 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Thu, 5 Nov 2026 10:45:09 +0100 Subject: [PATCH 2/3] fix(laravel): return an invalid IRI error when no route matches --- src/Laravel/Routing/IriConverter.php | 7 ++- .../Tests/Unit/Routing/IriConverterTest.php | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index d695a341155..029c31b703c 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -68,7 +68,12 @@ public function __construct(private readonly ProviderInterface $provider, privat */ public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object { - $parameters = $this->router->match($iri); + try { + $parameters = $this->router->match($iri); + } catch (RoutingExceptionInterface $e) { + throw new InvalidArgumentException(\sprintf('No route matches "%s".', $iri), $e->getCode(), $e); + } + if (!isset($parameters['_api_resource_class'], $parameters['_api_operation_name'], $parameters['uri_variables'])) { throw new InvalidArgumentException(\sprintf('No resource associated to "%s".', $iri)); } diff --git a/src/Laravel/Tests/Unit/Routing/IriConverterTest.php b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php index cd526285c36..6904708474f 100644 --- a/src/Laravel/Tests/Unit/Routing/IriConverterTest.php +++ b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Laravel\Routing\IriConverter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IdentifiersExtractorInterface; @@ -28,6 +29,8 @@ use ApiPlatform\State\ProviderInterface; use Illuminate\Database\Eloquent\Relations\Relation; use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\RouterInterface; use Workbench\App\Models\Book; use Workbench\App\Models\BookVariant; @@ -220,4 +223,44 @@ private function createIriConverter(RouterInterface $router, ResourceMetadataCol $resourceMetadataFactory, ); } + + public function testGetResourceFromIriWithoutRoute(): void + { + $router = $this->createStub(RouterInterface::class); + $router->method('match')->willThrowException(new ResourceNotFoundException()); + + $iriConverter = new IriConverter( + $this->createStub(ProviderInterface::class), + $this->createStub(OperationMetadataFactoryInterface::class), + $router, + $this->createStub(IdentifiersExtractorInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No route matches "/api/nope/1".'); + + $iriConverter->getResourceFromIri('/api/nope/1'); + } + + public function testGetResourceFromIriWithoutGetRoute(): void + { + $router = $this->createStub(RouterInterface::class); + $router->method('match')->willThrowException(new MethodNotAllowedException(['POST'])); + + $iriConverter = new IriConverter( + $this->createStub(ProviderInterface::class), + $this->createStub(OperationMetadataFactoryInterface::class), + $router, + $this->createStub(IdentifiersExtractorInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No route matches "/api/only_posts".'); + + $iriConverter->getResourceFromIri('/api/only_posts'); + } } From abc5e4d3475fb0fbf5fbceb69c9e86df5e1d2297 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Thu, 5 Nov 2026 10:46:48 +0100 Subject: [PATCH 3/3] fix(laravel): map serializer and invalid argument errors to 400 --- src/Laravel/Exception/ErrorRenderer.php | 6 +++ src/Laravel/Tests/JsonLdTest.php | 72 +++++++++++++++++++++++++ src/Laravel/config/api-platform.php | 4 ++ 3 files changed, 82 insertions(+) diff --git a/src/Laravel/Exception/ErrorRenderer.php b/src/Laravel/Exception/ErrorRenderer.php index 21ac699043a..d42c5e9ef73 100644 --- a/src/Laravel/Exception/ErrorRenderer.php +++ b/src/Laravel/Exception/ErrorRenderer.php @@ -16,6 +16,7 @@ use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\Controller\ApiPlatformController; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface; @@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; +use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; class ErrorRenderer @@ -195,6 +197,10 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err return 400; } + if ($exception instanceof SerializerExceptionInterface || $exception instanceof InvalidArgumentException) { + return 400; + } + // if ($exception instanceof ValidationException) { // return 422; // } diff --git a/src/Laravel/Tests/JsonLdTest.php b/src/Laravel/Tests/JsonLdTest.php index 202b3ce8c2b..da71613632b 100644 --- a/src/Laravel/Tests/JsonLdTest.php +++ b/src/Laravel/Tests/JsonLdTest.php @@ -14,9 +14,12 @@ namespace ApiPlatform\Laravel\Tests; use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Attributes\DefineEnvironment; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use Workbench\App\Models\Author; @@ -103,6 +106,75 @@ public function testCreateBook(): void $this->assertMatchesRegularExpression('~^/api/books/~', $response->json('@id')); } + public function testCreateBookWithAnIriWithoutRoute(): void + { + $response = $this->postJson( + '/api/books', + [ + 'name' => 'Don Quichotte', + 'author' => '/api/nope/1', + 'isbn' => fake()->isbn13(), + ], + [ + 'accept' => 'application/ld+json', + 'content_type' => 'application/ld+json', + ] + ); + + $response->assertStatus(400); + $this->assertStringContainsString('Invalid IRI "/api/nope/1"', $response->json('detail')); + } + + public function testCreateBookWithAnInvalidType(): void + { + AuthorFactory::new()->create(); + $response = $this->postJson( + '/api/books', + [ + 'name' => 123, + 'author' => $this->getIriFromResource(Author::find(1)), + 'isbn' => fake()->isbn13(), + ], + [ + 'accept' => 'application/ld+json', + 'content_type' => 'application/ld+json', + ] + ); + + $response->assertStatus(400); + } + + /** + * @param Application $app + */ + protected function usePreviouslyPublishedExceptionToStatus($app): void + { + $app['config']->set('api-platform.exception_to_status', [ + AuthenticationException::class => 401, + AuthorizationException::class => 403, + ]); + } + + #[DefineEnvironment('usePreviouslyPublishedExceptionToStatus')] + public function testCreateBookWithAnInvalidTypeAndAPreviouslyPublishedConfig(): void + { + AuthorFactory::new()->create(); + $response = $this->postJson( + '/api/books', + [ + 'name' => 123, + 'author' => $this->getIriFromResource(Author::find(1)), + 'isbn' => fake()->isbn13(), + ], + [ + 'accept' => 'application/ld+json', + 'content_type' => 'application/ld+json', + ] + ); + + $response->assertStatus(400); + } + public function testUpdateBook(): void { BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 52ae847eb32..ac1cb892a22 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -11,10 +11,12 @@ declare(strict_types=1); +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation\UnderscorePathSegmentNameGenerator; use ApiPlatform\Metadata\UrlGeneratorInterface; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; +use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; return [ @@ -116,6 +118,8 @@ 'exception_to_status' => [ AuthenticationException::class => 401, AuthorizationException::class => 403, + SerializerExceptionInterface::class => 400, + InvalidArgumentException::class => 400, ], 'redoc' => [