Skip to content

Commit bd2eadb

Browse files
authored
Merge pull request #1798 from antograssiot/format-by-resource
[RFR] Allow to specify formats per resources/operations
2 parents c81543a + ab48edb commit bd2eadb

File tree

20 files changed

+655
-37
lines changed

20 files changed

+655
-37
lines changed

features/main/content_negotiation.feature

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,36 @@ Feature: Content Negotiation support
115115
And I send a "GET" request to "/dummies/666"
116116
Then the response status code should be 404
117117
And the header "Content-Type" should be equal to "text/html; charset=utf-8"
118+
119+
Scenario: Retrieve a collection in JSON should not be possible if the format has been removed at resource level
120+
When I add "Accept" header equal to "application/json"
121+
And I send a "GET" request to "/dummy_custom_formats"
122+
Then the response status code should be 406
123+
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
124+
125+
Scenario: Post an CSV body allowed on a single resource
126+
When I add "Accept" header equal to "application/xml"
127+
And I add "Content-Type" header equal to "text/csv"
128+
And I send a "POST" request to "/dummy_custom_formats" with body:
129+
"""
130+
name
131+
Kevin
132+
"""
133+
Then the response status code should be 201
134+
And the header "Content-Type" should be equal to "application/xml; charset=utf-8"
135+
And the response should be equal to
136+
"""
137+
<?xml version="1.0"?>
138+
<response><id>1</id><name>Kevin</name></response>
139+
"""
140+
141+
Scenario: Retrieve a collection in CSV should be possible if the format is at resource level
142+
When I add "Accept" header equal to "text/csv"
143+
And I send a "GET" request to "/dummy_custom_formats"
144+
Then the response status code should be 200
145+
And the header "Content-Type" should be equal to "text/csv; charset=utf-8"
146+
And the response should be equal to
147+
"""
148+
id,name
149+
1,Kevin
150+
"""

src/Annotation/ApiResource.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
* @Attribute("description", type="string"),
3333
* @Attribute("fetchPartial", type="bool"),
3434
* @Attribute("forceEager", type="bool"),
35+
* @Attribute("formats", type="array"),
3536
* @Attribute("filters", type="string[]"),
3637
* @Attribute("graphql", type="array"),
3738
* @Attribute("hydraContext", type="array"),
@@ -135,6 +136,13 @@ final class ApiResource
135136
*/
136137
private $forceEager;
137138

139+
/**
140+
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
141+
*
142+
* @var array
143+
*/
144+
private $formats;
145+
138146
/**
139147
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
140148
*

src/Api/FormatsProvider.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Api;
15+
16+
use ApiPlatform\Core\Exception\InvalidArgumentException;
17+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
18+
19+
/**
20+
* {@inheritdoc}
21+
*
22+
* @author Anthony GRASSIOT <[email protected]>
23+
*/
24+
final class FormatsProvider implements FormatsProviderInterface
25+
{
26+
private $configuredFormats;
27+
private $resourceMetadataFactory;
28+
29+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, array $configuredFormats)
30+
{
31+
$this->resourceMetadataFactory = $resourceMetadataFactory;
32+
$this->configuredFormats = $configuredFormats;
33+
}
34+
35+
/**
36+
* {@inheritdoc}
37+
*
38+
* @throws InvalidArgumentException
39+
*/
40+
public function getFormatsFromAttributes(array $attributes): array
41+
{
42+
if (!$attributes || !isset($attributes['resource_class'])) {
43+
return $this->configuredFormats;
44+
}
45+
46+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
47+
48+
if (!$formats = $resourceMetadata->getOperationAttribute($attributes, 'formats', [], true)) {
49+
return $this->configuredFormats;
50+
}
51+
52+
if (!\is_array($formats)) {
53+
throw new InvalidArgumentException(sprintf("The 'formats' attributes must be an array, %s given for resource class '%s'.", \gettype($formats), $attributes['resource_class']));
54+
}
55+
56+
return $this->getOperationFormats($formats);
57+
}
58+
59+
/**
60+
* Filter and populate the acceptable formats.
61+
*
62+
* @throws InvalidArgumentException
63+
*/
64+
private function getOperationFormats(array $annotationFormats): array
65+
{
66+
$resourceFormats = [];
67+
foreach ($annotationFormats as $format => $value) {
68+
if (!is_numeric($format)) {
69+
$resourceFormats[$format] = (array) $value;
70+
continue;
71+
}
72+
if (!\is_string($value)) {
73+
throw new InvalidArgumentException(sprintf("The 'formats' attributes value must be a string when trying to include an already configured format, %s given.", \gettype($value)));
74+
}
75+
if (array_key_exists($value, $this->configuredFormats)) {
76+
$resourceFormats[$value] = $this->configuredFormats[$value];
77+
continue;
78+
}
79+
80+
throw new InvalidArgumentException(sprintf("You either need to add the format '%s' to your project configuration or declare a mime type for it in your annotation.", $value));
81+
}
82+
83+
return $resourceFormats;
84+
}
85+
}

src/Api/FormatsProviderInterface.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Api;
15+
16+
/**
17+
* Extracts formats for a given operation according to the retrieved Metadata.
18+
*
19+
* @author Anthony GRASSIOT <[email protected]>
20+
*/
21+
interface FormatsProviderInterface
22+
{
23+
/**
24+
* Finds formats for an operation.
25+
*/
26+
public function getFormatsFromAttributes(array $attributes): array;
27+
}

src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Action;
1515

16+
use ApiPlatform\Core\Api\FormatsProviderInterface;
1617
use ApiPlatform\Core\Documentation\Documentation;
18+
use ApiPlatform\Core\Exception\InvalidArgumentException;
1719
use ApiPlatform\Core\Exception\RuntimeException;
1820
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
1921
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
22+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
2023
use Symfony\Component\HttpFoundation\Request;
2124
use Symfony\Component\HttpFoundation\Response;
2225
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -37,7 +40,7 @@ final class SwaggerUiAction
3740
private $title;
3841
private $description;
3942
private $version;
40-
private $formats;
43+
private $formats = [];
4144
private $oauthEnabled;
4245
private $oauthClientId;
4346
private $oauthClientSecret;
@@ -46,8 +49,12 @@ final class SwaggerUiAction
4649
private $oauthTokenUrl;
4750
private $oauthAuthorizationUrl;
4851
private $oauthScopes;
52+
private $formatsProvider;
4953

50-
public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, \Twig_Environment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', array $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [])
54+
/**
55+
* @throws InvalidArgumentException
56+
*/
57+
public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, \Twig_Environment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', /* FormatsProviderInterface */ $formatsProvider = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [])
5158
{
5259
$this->resourceNameCollectionFactory = $resourceNameCollectionFactory;
5360
$this->resourceMetadataFactory = $resourceMetadataFactory;
@@ -57,7 +64,6 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName
5764
$this->title = $title;
5865
$this->description = $description;
5966
$this->version = $version;
60-
$this->formats = $formats;
6167
$this->oauthEnabled = $oauthEnabled;
6268
$this->oauthClientId = $oauthClientId;
6369
$this->oauthClientSecret = $oauthClientSecret;
@@ -66,10 +72,30 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName
6672
$this->oauthTokenUrl = $oauthTokenUrl;
6773
$this->oauthAuthorizationUrl = $oauthAuthorizationUrl;
6874
$this->oauthScopes = $oauthScopes;
75+
76+
if (\is_array($formatsProvider)) {
77+
if ($formatsProvider) {
78+
// Only trigger notification for non-default argument
79+
@trigger_error('Using an array as formats provider is deprecated since API Platform 2.3 and will not be possible anymore in API Platform 3', E_USER_DEPRECATED);
80+
}
81+
$this->formats = $formatsProvider;
82+
83+
return;
84+
}
85+
if (!$formatsProvider instanceof FormatsProviderInterface) {
86+
throw new InvalidArgumentException(sprintf('The "$formatsProvider" argument is expected to be an implementation of the "%s" interface.', FormatsProviderInterface::class));
87+
}
88+
89+
$this->formatsProvider = $formatsProvider;
6990
}
7091

7192
public function __invoke(Request $request)
7293
{
94+
// BC check to be removed in 3.0
95+
if (null !== $this->formatsProvider) {
96+
$this->formats = $this->formatsProvider->getFormatsFromAttributes(RequestAttributesExtractor::extractAttributes($request));
97+
}
98+
7399
$documentation = new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats);
74100

75101
return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $this->getContext($request, $documentation)));

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@
6363
</service>
6464
<service id="ApiPlatform\Core\Api\IriConverterInterface" alias="api_platform.iri_converter" />
6565

66+
<service id="api_platform.formats_provider" class="ApiPlatform\Core\Api\FormatsProvider">
67+
<argument type="service" id="api_platform.metadata.resource.metadata_factory"></argument>
68+
<argument>%api_platform.formats%</argument>
69+
</service>
70+
6671
<!-- Serializer -->
6772

6873
<service id="api_platform.serializer.context_builder" class="ApiPlatform\Core\Serializer\SerializerContextBuilder" public="false">
@@ -130,7 +135,7 @@
130135
<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
131136
<service id="api_platform.listener.request.add_format" class="ApiPlatform\Core\EventListener\AddFormatListener">
132137
<argument type="service" id="api_platform.negotiator" />
133-
<argument>%api_platform.formats%</argument>
138+
<argument type="service" id="api_platform.formats_provider"></argument>
134139

135140
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="7" />
136141
</service>
@@ -154,7 +159,7 @@
154159
<service id="api_platform.listener.request.deserialize" class="ApiPlatform\Core\EventListener\DeserializeListener">
155160
<argument type="service" id="api_platform.serializer" />
156161
<argument type="service" id="api_platform.serializer.context_builder" />
157-
<argument>%api_platform.formats%</argument>
162+
<argument type="service" id="api_platform.formats_provider" />
158163

159164
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="2" />
160165
</service>
@@ -205,7 +210,7 @@
205210
<argument>%api_platform.title%</argument>
206211
<argument>%api_platform.description%</argument>
207212
<argument>%api_platform.version%</argument>
208-
<argument>%api_platform.formats%</argument>
213+
<argument type="service" id="api_platform.formats_provider" />
209214
</service>
210215

211216
<service id="api_platform.action.exception" class="ApiPlatform\Core\Action\ExceptionAction" public="true">

src/Bridge/Symfony/Bundle/Resources/config/swagger.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
<argument>%api_platform.title%</argument>
5959
<argument>%api_platform.description%</argument>
6060
<argument>%api_platform.version%</argument>
61-
<argument>%api_platform.formats%</argument>
61+
<argument type="service" id="api_platform.formats_provider" />
6262
<argument>%api_platform.oauth.enabled%</argument>
6363
<argument>%api_platform.oauth.clientId%</argument>
6464
<argument>%api_platform.oauth.clientSecret%</argument>

src/Documentation/Action/DocumentationAction.php

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313

1414
namespace ApiPlatform\Core\Documentation\Action;
1515

16+
use ApiPlatform\Core\Api\FormatsProviderInterface;
1617
use ApiPlatform\Core\Documentation\Documentation;
18+
use ApiPlatform\Core\Exception\InvalidArgumentException;
1719
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
20+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
1821
use Symfony\Component\HttpFoundation\Request;
1922

2023
/**
@@ -28,15 +31,32 @@ final class DocumentationAction
2831
private $title;
2932
private $description;
3033
private $version;
31-
private $formats;
34+
private $formats = [];
35+
private $formatsProvider;
3236

33-
public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', array $formats = [])
37+
/**
38+
* @throws InvalidArgumentException
39+
*/
40+
public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', /* FormatsProviderInterface */ $formatsProvider = [])
3441
{
3542
$this->resourceNameCollectionFactory = $resourceNameCollectionFactory;
3643
$this->title = $title;
3744
$this->description = $description;
3845
$this->version = $version;
39-
$this->formats = $formats;
46+
if (\is_array($formatsProvider)) {
47+
if ($formatsProvider) {
48+
// Only trigger notification for non-default argument
49+
@trigger_error('Using an array as formats provider is deprecated since API Platform 2.3 and will not be possible anymore in API Platform 3', E_USER_DEPRECATED);
50+
}
51+
$this->formats = $formatsProvider;
52+
53+
return;
54+
}
55+
if (!$formatsProvider instanceof FormatsProviderInterface) {
56+
throw new InvalidArgumentException(sprintf('The "$formatsProvider" argument is expected to be an implementation of the "%s" interface.', FormatsProviderInterface::class));
57+
}
58+
59+
$this->formatsProvider = $formatsProvider;
4060
}
4161

4262
public function __invoke(Request $request = null): Documentation
@@ -47,6 +67,12 @@ public function __invoke(Request $request = null): Documentation
4767
$context['api_gateway'] = true;
4868
}
4969
$request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context);
70+
71+
$attributes = RequestAttributesExtractor::extractAttributes($request);
72+
}
73+
// BC check to be removed in 3.0
74+
if (null !== $this->formatsProvider) {
75+
$this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []);
5076
}
5177

5278
return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats);

0 commit comments

Comments
 (0)