Skip to content

Commit 1d4690b

Browse files
authored
Merge pull request #1225 from soyuka/swagger-subresource
Add subresources to hydra
2 parents e3501a4 + 150b5a3 commit 1d4690b

File tree

8 files changed

+120
-54
lines changed

8 files changed

+120
-54
lines changed

src/Bridge/Symfony/Routing/ApiLoader.php

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2222
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
2323
use ApiPlatform\Core\PathResolver\OperationPathResolverInterface;
24-
use Doctrine\Common\Inflector\Inflector;
2524
use Symfony\Component\Config\FileLocator;
2625
use Symfony\Component\Config\Loader\Loader;
2726
use Symfony\Component\Config\Resource\DirectoryResource;
@@ -106,20 +105,6 @@ public function load($data, $type = null): RouteCollection
106105
return $routeCollection;
107106
}
108107

109-
/**
110-
* Transforms a given string to a tableized, pluralized string.
111-
*
112-
* @param string $name usually a ResourceMetadata shortname
113-
*
114-
* @return string A string that is a part of the route name
115-
*/
116-
private function routeNameResolver(string $name, bool $pluralize = true): string
117-
{
118-
$name = Inflector::tableize($name);
119-
120-
return $pluralize ? Inflector::pluralize($name) : $name;
121-
}
122-
123108
/**
124109
* Handles subresource operations recursively and declare their corresponding routes.
125110
*
@@ -146,14 +131,13 @@ private function computeSubresourceOperations(RouteCollection $routeCollection,
146131
}
147132

148133
$subresource = $propertyMetadata->getSubresource();
149-
$propertyName = $this->routeNameResolver($property, $subresource->isCollection());
150134

151135
$operation = [
152136
'property' => $property,
153137
'collection' => $subresource->isCollection(),
154138
];
155139

156-
$visiting = "$rootResourceClass $resourceClass $propertyName {$subresource->getResourceClass()}";
140+
$visiting = "$rootResourceClass $resourceClass $property {$subresource->isCollection()} {$subresource->getResourceClass()}";
157141

158142
if (in_array($visiting, $visited, true)) {
159143
continue;
@@ -164,15 +148,14 @@ private function computeSubresourceOperations(RouteCollection $routeCollection,
164148
if (null === $parentOperation) {
165149
$rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass);
166150
$rootShortname = $rootResourceMetadata->getShortName();
167-
$resourceRouteName = $this->routeNameResolver($rootShortname);
168151

169152
$operation['identifiers'] = [['id', $rootResourceClass]];
170-
$operation['route_name'] = sprintf('%s%s_%s%s', RouteNameGenerator::ROUTE_NAME_PREFIX, $resourceRouteName, $propertyName, self::SUBRESOURCE_SUFFIX);
153+
$operation['route_name'] = RouteNameGenerator::generate('get', $rootShortname, OperationType::SUBRESOURCE, $operation);
171154
$operation['path'] = $this->operationPathResolver->resolveOperationPath($rootShortname, $operation, OperationType::SUBRESOURCE, $operation['route_name']);
172155
} else {
173156
$operation['identifiers'] = $parentOperation['identifiers'];
174157
$operation['identifiers'][] = [$parentOperation['property'], $resourceClass];
175-
$operation['route_name'] = str_replace(self::SUBRESOURCE_SUFFIX, "_$propertyName".self::SUBRESOURCE_SUFFIX, $parentOperation['route_name']);
158+
$operation['route_name'] = str_replace('get'.RouteNameGenerator::SUBRESOURCE_SUFFIX, RouteNameGenerator::routeNameResolver($property, $operation['collection']).'_get'.RouteNameGenerator::SUBRESOURCE_SUFFIX, $parentOperation['route_name']);
176159
$operation['path'] = $this->operationPathResolver->resolveOperationPath($parentOperation['path'], $operation, OperationType::SUBRESOURCE, $operation['route_name']);
177160
}
178161

src/Bridge/Symfony/Routing/RouteNameGenerator.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
class RouteNameGenerator
2929
{
3030
const ROUTE_NAME_PREFIX = 'api_';
31+
const SUBRESOURCE_SUFFIX = '_subresource';
3132

3233
private function __construct()
3334
{
@@ -39,23 +40,49 @@ private function __construct()
3940
* @param string $operationName
4041
* @param string $resourceShortName
4142
* @param string|bool $operationType
43+
* @param array $subresourceContext
4244
*
4345
* @throws InvalidArgumentException
4446
*
4547
* @return string
4648
*/
47-
public static function generate(string $operationName, string $resourceShortName, $operationType): string
49+
public static function generate(string $operationName, string $resourceShortName, $operationType, array $subresourceContext = []): string
4850
{
4951
if (OperationType::SUBRESOURCE === $operationType = OperationTypeDeprecationHelper::getOperationType($operationType)) {
50-
throw new InvalidArgumentException(sprintf('%s::SUBRESOURCE is not supported as operation type by %s().', OperationType::class, __METHOD__));
52+
if (!isset($subresourceContext['property'])) {
53+
throw new InvalidArgumentException('Missing "property" to generate a route name from a subresource');
54+
}
55+
56+
return sprintf(
57+
'%s%s_%s_%s%s',
58+
static::ROUTE_NAME_PREFIX,
59+
self::routeNameResolver($resourceShortName),
60+
self::routeNameResolver($subresourceContext['property'], $subresourceContext['collection'] ?? false),
61+
$operationName,
62+
self::SUBRESOURCE_SUFFIX
63+
);
5164
}
5265

5366
return sprintf(
5467
'%s%s_%s_%s',
5568
static::ROUTE_NAME_PREFIX,
56-
Inflector::pluralize(Inflector::tableize($resourceShortName)),
69+
self::routeNameResolver($resourceShortName),
5770
$operationName,
5871
$operationType
5972
);
6073
}
74+
75+
/**
76+
* Transforms a given string to a tableized, pluralized string.
77+
*
78+
* @param string $name usually a ResourceMetadata shortname
79+
*
80+
* @return string A string that is a part of the route name
81+
*/
82+
public static function routeNameResolver(string $name, bool $pluralize = true): string
83+
{
84+
$name = Inflector::tableize($name);
85+
86+
return $pluralize ? Inflector::pluralize($name) : $name;
87+
}
6188
}

src/Bridge/Symfony/Routing/RouterOperationPathResolver.php

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Symfony\Routing;
1515

16-
use ApiPlatform\Core\Api\OperationType;
1716
use ApiPlatform\Core\Api\OperationTypeDeprecationHelper;
1817
use ApiPlatform\Core\Exception\InvalidArgumentException;
1918
use ApiPlatform\Core\PathResolver\OperationPathResolverInterface;
@@ -50,16 +49,12 @@ public function resolveOperationPath(string $resourceShortName, array $operation
5049
$operationName = null;
5150
}
5251

53-
if (OperationType::SUBRESOURCE === $operationType = OperationTypeDeprecationHelper::getOperationType($operationType)) {
54-
return $this->deferred->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
55-
}
56-
5752
if (isset($operation['route_name'])) {
5853
$routeName = $operation['route_name'];
5954
} elseif (null !== $operationName) {
60-
$routeName = RouteNameGenerator::generate($operationName, $resourceShortName, $operationType);
55+
$routeName = RouteNameGenerator::generate($operationName, $resourceShortName, $operationType, $operation);
6156
} else {
62-
return $this->deferred->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
57+
return $this->deferred->resolveOperationPath($resourceShortName, $operation, OperationTypeDeprecationHelper::getOperationType($operationType), $operationName);
6358
}
6459

6560
if (!$route = $this->router->getRouteCollection()->get($routeName)) {

src/Hydra/Serializer/DocumentationNormalizer.php

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
namespace ApiPlatform\Core\Hydra\Serializer;
1515

1616
use ApiPlatform\Core\Api\OperationMethodResolverInterface;
17+
use ApiPlatform\Core\Api\OperationType;
1718
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
1819
use ApiPlatform\Core\Api\UrlGeneratorInterface;
1920
use ApiPlatform\Core\Documentation\Documentation;
2021
use ApiPlatform\Core\JsonLd\ContextBuilderInterface;
2122
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2223
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2324
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
25+
use ApiPlatform\Core\Metadata\Property\SubresourceMetadata;
2426
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2527
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
2628
use Symfony\Component\PropertyInfo\Type;
@@ -210,7 +212,20 @@ private function getHydraOperations(string $resourceClass, ResourceMetadata $res
210212

211213
$hydraOperations = [];
212214
foreach ($operations as $operationName => $operation) {
213-
$hydraOperations[] = $this->getHydraOperation($resourceClass, $resourceMetadata, $operationName, $operation, $prefixedShortName, $collection);
215+
$hydraOperations[] = $this->getHydraOperation($resourceClass, $resourceMetadata, $operationName, $operation, $prefixedShortName, $collection ? OperationType::COLLECTION : OperationType::ITEM);
216+
}
217+
218+
foreach ($this->propertyNameCollectionFactory->create($resourceClass, $this->getPropertyNameCollectionFactoryContext($resourceMetadata)) as $propertyName) {
219+
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
220+
221+
if (!$propertyMetadata->hasSubresource()) {
222+
continue;
223+
}
224+
225+
$subresourceMetadata = $this->resourceMetadataFactory->create($propertyMetadata->getSubresource()->getResourceClass());
226+
$prefixedShortName = "#{$subresourceMetadata->getShortName()}";
227+
228+
$hydraOperations[] = $this->getHydraOperation($resourceClass, $subresourceMetadata, $operationName, $operation, $prefixedShortName, OperationType::SUBRESOURCE, $propertyMetadata->getSubresource());
214229
}
215230

216231
return $hydraOperations;
@@ -219,32 +234,41 @@ private function getHydraOperations(string $resourceClass, ResourceMetadata $res
219234
/**
220235
* Gets and populates if applicable a Hydra operation.
221236
*
222-
* @param string $resourceClass
223-
* @param ResourceMetadata $resourceMetadata
224-
* @param string $operationName
225-
* @param array $operation
226-
* @param string $prefixedShortName
227-
* @param bool $collection
237+
* @param string $resourceClass
238+
* @param ResourceMetadata $resourceMetadata
239+
* @param string $operationName
240+
* @param array $operation
241+
* @param string $prefixedShortName
242+
* @param string $operationType
243+
* @param SubresourceMetadata $operationType
228244
*
229245
* @return array
230246
*/
231-
private function getHydraOperation(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, array $operation, string $prefixedShortName, bool $collection): array
247+
private function getHydraOperation(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, array $operation, string $prefixedShortName, string $operationType, SubresourceMetadata $subresourceMetadata = null): array
232248
{
233-
if ($collection) {
249+
if (OperationType::COLLECTION === $operationType) {
234250
$method = $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName);
235-
} else {
251+
} elseif (OperationType::ITEM === $operationType) {
236252
$method = $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName);
253+
} else {
254+
$method = 'GET';
237255
}
238256

239257
$hydraOperation = $operation['hydra_context'] ?? [];
240258
$shortName = $resourceMetadata->getShortName();
241259

242-
if ('GET' === $method && $collection) {
260+
if ('GET' === $method && OperationType::COLLECTION === $operationType) {
243261
$hydraOperation = [
244262
'@type' => ['hydra:Operation', 'schema:FindAction'],
245263
'hydra:title' => "Retrieves the collection of $shortName resources.",
246264
'returns' => 'hydra:Collection',
247265
] + $hydraOperation;
266+
} elseif ('GET' === $method && OperationType::SUBRESOURCE === $operationType) {
267+
$hydraOperation = [
268+
'@type' => ['hydra:Operation', 'schema:FindAction'],
269+
'hydra:title' => $subresourceMetadata->isCollection() ? "Retrieves the collection of $shortName resources." : "Retrieves a $shortName resource.",
270+
'returns' => "#$shortName",
271+
] + $hydraOperation;
248272
} elseif ('GET' === $method) {
249273
$hydraOperation = [
250274
'@type' => ['hydra:Operation', 'schema:FindAction'],

src/PathResolver/UnderscoreOperationPathResolver.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
use ApiPlatform\Core\Api\OperationType;
1717
use ApiPlatform\Core\Api\OperationTypeDeprecationHelper;
18-
use Doctrine\Common\Inflector\Inflector;
18+
use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator;
1919

2020
/**
2121
* Generates a path with words separated by underscores.
@@ -38,7 +38,7 @@ public function resolveOperationPath(string $resourceShortName, array $operation
3838
if ($operationType === OperationType::SUBRESOURCE && 1 < count($operation['identifiers'])) {
3939
$path = str_replace('.{_format}', '', $resourceShortName);
4040
} else {
41-
$path = '/'.Inflector::pluralize(Inflector::tableize($resourceShortName));
41+
$path = '/'.RouteNameGenerator::routeNameResolver($resourceShortName, true);
4242
}
4343

4444
if ($operationType === OperationType::ITEM) {
@@ -47,7 +47,7 @@ public function resolveOperationPath(string $resourceShortName, array $operation
4747

4848
if ($operationType === OperationType::SUBRESOURCE) {
4949
list($key) = end($operation['identifiers']);
50-
$property = true === $operation['collection'] ? Inflector::pluralize(Inflector::tableize($operation['property'])) : Inflector::tableize($operation['property']);
50+
$property = RouteNameGenerator::routeNameResolver($operation['property'], $operation['collection']);
5151
$path .= sprintf('/{%s}/%s', $key, $property);
5252
}
5353

tests/Bridge/Symfony/Routing/RouteNameGeneratorTest.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ public function testLegacyGenerate()
3636
$this->assertEquals('api_foos_get_collection', RouteNameGenerator::generate('get', 'Foo', true));
3737
}
3838

39-
/**
40-
* @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException
41-
* @expectedExceptionMessage ApiPlatform\Core\Api\OperationType::SUBRESOURCE is not supported as operation type by ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator::generate().
42-
*/
4339
public function testGenerateWithSubresource()
4440
{
45-
RouteNameGenerator::generate('api_foos_bars_get_subresource', 'Bar', OperationType::SUBRESOURCE);
41+
$this->assertEquals('api_foos_bar_get_subresource', RouteNameGenerator::generate('get', 'Foo', OperationType::SUBRESOURCE, ['property' => 'bar', 'collection' => false]));
42+
}
43+
44+
public function testGenerateWithSubresourceCollection()
45+
{
46+
$this->assertEquals('api_foos_bars_get_subresource', RouteNameGenerator::generate('get', 'Foo', OperationType::SUBRESOURCE, ['property' => 'bar', 'collection' => true]));
4647
}
4748
}

tests/Bridge/Symfony/Routing/RouterOperationPathResolverTest.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ public function testResolveOperationPath()
4242

4343
public function testResolveOperationPathWithSubresource()
4444
{
45-
$operationPathResolverProphecy = $this->prophesize(OperationPathResolverInterface::class);
46-
$operationPathResolverProphecy->resolveOperationPath('Bar', Argument::type('array'), OperationType::SUBRESOURCE, 'api_foos_bars_get_subresource')->willReturn('/foos/{id}/bars.{_format}')->shouldBeCalled();
45+
$routeCollection = new RouteCollection();
46+
$routeCollection->add('api_foos_bars_get_subresource', new Route('/foos/{id}/bars'));
4747

48-
$operationPathResolver = new RouterOperationPathResolver($this->prophesize(RouterInterface::class)->reveal(), $operationPathResolverProphecy->reveal());
48+
$routerProphecy = $this->prophesize(RouterInterface::class);
49+
$routerProphecy->getRouteCollection()->willReturn($routeCollection)->shouldBeCalled();
50+
51+
$operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), $this->prophesize(OperationPathResolverInterface::class)->reveal());
4952

50-
$this->assertEquals('/foos/{id}/bars.{_format}', $operationPathResolver->resolveOperationPath('Bar', [], OperationType::SUBRESOURCE, 'api_foos_bars_get_subresource'));
53+
$this->assertEquals('/foos/{id}/bars', $operationPathResolver->resolveOperationPath('Foo', ['property' => 'bar', 'collection' => true], OperationType::SUBRESOURCE, 'get'));
5154
}
5255

5356
public function testResolveOperationPathWithRouteNameGeneration()

0 commit comments

Comments
 (0)