From da81a20305999b53031daad337ce21c4a358ca63 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 21 May 2026 14:23:58 +0200 Subject: [PATCH] fix(metadata): negotiate wildcard Accept with parameters willdurand/negotiation treats media-range parameters as match constraints, so `*/*; charset=utf-8` (sent by default by some clients like PhpStorm) fails to match offered MIME types that do not carry the same parameter, returning 406 Not Acceptable. Retry with a bare `*/*` on failure when the Accept header contains a wildcard. Concrete media types are left untouched so vendor- specific negotiation (e.g. JSON:API `profile`) is preserved. Closes #1532 --- src/Metadata/Util/ContentNegotiationTrait.php | 10 ++++- .../WildcardAcceptFormat.php | 43 +++++++++++++++++++ tests/Functional/FormatTest.php | 36 +++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/WildcardAcceptFormat/WildcardAcceptFormat.php diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php index 6690cca7041..9f951261a4e 100644 --- a/src/Metadata/Util/ContentNegotiationTrait.php +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -97,7 +97,15 @@ private function getRequestFormat(Request $request, array $formats, bool $throw /** @var string|null $accept */ $accept = $request->headers->get('Accept'); if (null !== $accept) { - if ($mediaType = $this->negotiator->getBest($accept, $mimeTypes)) { + $mediaType = $this->negotiator->getBest($accept, $mimeTypes); + // willdurand/negotiation treats media-range parameters as match + // constraints, so a wildcard carrying informational params + // (e.g. `*\/*; charset=utf-8` from PhpStorm) fails negotiation. + // Retry on a bare wildcard. See #1532. + if (!$mediaType && str_contains($accept, '*/*')) { + $mediaType = $this->negotiator->getBest('*/*', $mimeTypes); + } + if ($mediaType) { return $this->getMimeTypeFormat($mediaType->getType(), $formats); } diff --git a/tests/Fixtures/TestBundle/ApiResource/WildcardAcceptFormat/WildcardAcceptFormat.php b/tests/Fixtures/TestBundle/ApiResource/WildcardAcceptFormat/WildcardAcceptFormat.php new file mode 100644 index 00000000000..9e848194266 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/WildcardAcceptFormat/WildcardAcceptFormat.php @@ -0,0 +1,43 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\WildcardAcceptFormat; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\HttpFoundation\Response; + +#[Get( + shortName: 'WildcardAcceptFormat', + uriTemplate: '/wildcard_accept_format/{id}', + outputFormats: ['jsonld' => ['application/ld+json'], 'html' => ['text/html']], + provider: [self::class, 'provide'], + extraProperties: ['_api_disable_swagger_provider' => true] +)] +class WildcardAcceptFormat +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $name = 'hello'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self|Response + { + if ('html' === $context['request']?->getRequestFormat()) { + return new Response('

hello

', 200, ['Content-Type' => 'text/html']); + } + + return new self(); + } +} diff --git a/tests/Functional/FormatTest.php b/tests/Functional/FormatTest.php index 80aab72cf95..4fb21227b98 100644 --- a/tests/Functional/FormatTest.php +++ b/tests/Functional/FormatTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6384\AcceptHtml; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WildcardAcceptFormat\WildcardAcceptFormat; use ApiPlatform\Tests\SetupClassResourcesTrait; final class FormatTest extends ApiTestCase @@ -28,7 +29,7 @@ final class FormatTest extends ApiTestCase */ public static function getResources(): array { - return [AcceptHtml::class]; + return [AcceptHtml::class, WildcardAcceptFormat::class]; } public function testShouldReturnHtml(): void @@ -37,4 +38,37 @@ public function testShouldReturnHtml(): void $this->assertResponseIsSuccessful(); $this->assertEquals($r->getContent(), '

hello

'); } + + /** + * @see https://github.com/api-platform/core/issues/1532 + */ + public function testWildcardAcceptHeaderPicksFirstConfiguredFormat(): void + { + $response = self::createClient()->request('GET', '/wildcard_accept_format/1', ['headers' => ['Accept' => '*/*']]); + $this->assertResponseIsSuccessful(); + $this->assertStringStartsWith('application/ld+json', $response->getHeaders()['content-type'][0]); + } + + /** + * @see https://github.com/api-platform/core/issues/1532 + */ + public function testWildcardAcceptHeaderWithParametersPicksFirstConfiguredFormat(): void + { + $response = self::createClient()->request('GET', '/wildcard_accept_format/1', ['headers' => ['Accept' => '*/*; charset=utf-8']]); + $this->assertResponseIsSuccessful(); + $this->assertStringStartsWith('application/ld+json', $response->getHeaders()['content-type'][0]); + } + + public function testWildcardAcceptHeaderRespectsQualityOfConcreteType(): void + { + $response = self::createClient()->request('GET', '/wildcard_accept_format/1', ['headers' => ['Accept' => '*/*; charset=utf-8; q=0.1, text/html; q=0.9']]); + $this->assertResponseIsSuccessful(); + $this->assertStringStartsWith('text/html', $response->getHeaders()['content-type'][0]); + } + + public function testConcreteAcceptHeaderWithUnsupportedTypeReturnsNotAcceptable(): void + { + self::createClient()->request('GET', '/wildcard_accept_format/1', ['headers' => ['Accept' => 'application/xml']]); + $this->assertResponseStatusCodeSame(406); + } }