diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php index 9f951261a4e..4f8cb15f650 100644 --- a/src/Metadata/Util/ContentNegotiationTrait.php +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -119,12 +119,12 @@ private function getRequestFormat(Request $request, array $formats, bool $throw if (null !== $requestFormat) { $mimeType = $request->getMimeType($requestFormat); - if (isset($flattenedMimeTypes[$mimeType])) { + if (null !== $mimeType && isset($flattenedMimeTypes[$mimeType])) { return $requestFormat; } if ($throw) { - throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); + throw $this->getNotAcceptableHttpException($mimeType ?? $requestFormat, $flattenedMimeTypes); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 14410c5ff01..9b9019b368c 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -365,6 +365,11 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); + // The entrypoint only has normalizers for hypermedia formats (jsonld, jsonhal, jsonapi) and a + // dedicated Swagger UI code path for html. Other documentation formats (e.g. openapi, yamlopenapi) + // have no Entrypoint normalizer and must not be advertised, otherwise content negotiation lets them + // through and the Symfony ObjectNormalizer fallback leaks the internal ResourceNameCollection FQCNs. + $container->setParameter('api_platform.entrypoint_formats', array_intersect_key($docsFormats, array_flip(['jsonld', 'jsonhal', 'jsonapi', 'html']))); $container->setParameter('api_platform.jsonschema_formats', []); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 2ac24e613e0..3bbed6d6106 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -36,7 +36,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.main'), service('api_platform.state_processor.main'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..91b784f07eb 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -184,7 +184,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.documentation'), service('api_platform.state_processor.documentation'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/tests/Functional/EntrypointFormatTest.php b/tests/Functional/EntrypointFormatTest.php new file mode 100644 index 00000000000..b1ac4df6780 --- /dev/null +++ b/tests/Functional/EntrypointFormatTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MultipleResourceBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * The entrypoint must only expose hypermedia formats that have a dedicated + * EntrypointNormalizer (jsonld, jsonhal, jsonapi). Documentation formats + * (openapi, html) have no such normalizer and must not be served, otherwise + * the Symfony ObjectNormalizer fallback dumps the public ResourceNameCollection, + * leaking internal PHP FQCNs. + * + * @see https://github.com/api-platform/core/issues/8361 + */ +final class EntrypointFormatTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultipleResourceBook::class]; + } + + public function testEntrypointRejectsOpenApiAcceptHeader(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(406); + } + + /** + * The ".jsonopenapi"/".yamlopenapi" URL suffixes are resolved by routing before + * content negotiation runs, so an unsupported route format yields a 404 + * (consistent with any other resource requested with an unsupported format + * suffix), not a 406. + */ + public function testEntrypointRejectsOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.jsonopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointRejectsYamlOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.yamlopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+yaml'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointStillServesHtmlAsSwaggerUi(): void + { + $response = self::createClient()->request('GET', '/index.html', [ + 'headers' => ['accept' => 'text/html'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('swagger-ui', $response->getContent()); + } + + public function testEntrypointStillServesJsonLd(): void + { + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('multipleResourceBook2', $data); + $this->assertEquals('/multi_route_books', $data['multipleResourceBook2']); + $this->assertStringNotContainsString('resourceNameCollection', $response->getContent()); + } + + public function testEntrypointStillServesJsonHal(): void + { + $response = self::createClient()->request('GET', '/index.jsonhal', [ + 'headers' => ['accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/hal+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('_links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['_links']); + } + + public function testEntrypointStillServesJsonApi(): void + { + $response = self::createClient()->request('GET', '/index.jsonapi', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['links']); + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 8cb8120b63d..1a59307a44f 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -686,24 +686,30 @@ public function testOpenApiUiIsEnabledForDocsEndpointWithDummyObject(): void $this->assertResponseIsSuccessful(); } - public function testRetrieveTheEntrypoint(): void + /** + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormat(): void { $response = self::createClient()->request('GET', '/', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(406); } - public function testRetrieveTheEntrypointWithUrlFormat(): void + /** + * The ".jsonopenapi" URL suffix is resolved by routing before content negotiation + * runs, so an unsupported route format yields a 404 (consistent with any other + * resource requested with an unsupported format suffix), not a 406. + * + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormatWithUrlFormat(): void { $response = self::createClient()->request('GET', '/index.jsonopenapi', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(404); } public function testOpenApiSchemaWithNormalizationAttributes(): void