Skip to content

Commit 601fd0b

Browse files
committed
#[HydraOperation] attriute
1 parent 9d81e83 commit 601fd0b

22 files changed

Lines changed: 728 additions & 147 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
// ---
3+
// slug: declare-hydra-operations
4+
// name: Declare Hydra operations
5+
// position: 21
6+
// executable: false
7+
// tags: design, hydra, jsonld
8+
// ---
9+
10+
namespace App\ApiResource {
11+
use ApiPlatform\Metadata\ApiResource;
12+
use ApiPlatform\Metadata\Get;
13+
use ApiPlatform\Metadata\GetCollection;
14+
use ApiPlatform\Metadata\HydraOperation;
15+
use ApiPlatform\Metadata\Post;
16+
17+
// Issues are publicly readable and may be reported by any authenticated
18+
// user. Only an administrator may delete an issue — and rather than
19+
// exposing the DELETE operation globally on the resource (which would
20+
// leak its existence to every consumer of the Hydra documentation), the
21+
// operation is declared **per representation** with `#[HydraOperation]`.
22+
//
23+
// The `security` expression is evaluated when the issue is serialized; the
24+
// expression has access to `object` (the current Issue), `user` (the
25+
// current security token's user) and `request`.
26+
#[ApiResource(
27+
operations: [
28+
new Get(),
29+
new GetCollection(),
30+
new Post(),
31+
],
32+
)]
33+
#[HydraOperation(
34+
method: 'DELETE',
35+
title: 'Delete this issue',
36+
security: "is_granted('ROLE_ADMIN')",
37+
)]
38+
class Issue
39+
{
40+
public string $id;
41+
public string $title;
42+
public string $reporter;
43+
}
44+
}
45+
46+
// When an admin requests `/issues/42`, the JSON-LD response carries an extra
47+
// `hydra:operation` entry advertising the DELETE capability:
48+
//
49+
// ```json
50+
// {
51+
// "@context": "/contexts/Issue",
52+
// "@id": "/issues/42",
53+
// "@type": "Issue",
54+
// "title": "Login fails on Firefox",
55+
// "reporter": "/users/7",
56+
// "hydra:operation": [
57+
// {
58+
// "@type": ["hydra:Operation", "schema:DeleteAction"],
59+
// "hydra:method": "DELETE",
60+
// "hydra:title": "Delete this issue",
61+
// "returns": "owl:Nothing"
62+
// }
63+
// ]
64+
// }
65+
// ```
66+
//
67+
// When the same resource is requested by a non-admin, the `hydra:operation`
68+
// property is omitted entirely.

phpstan.neon.dist

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,6 @@ parameters:
9999
message: '#^Service "[^"]+" is private.$#'
100100
path: src
101101

102-
-
103-
message: '#Access to an undefined property .*DocumentationNormalizer::\$resourceMetadataCollectionFactory#'
104-
path: src/Hydra/Serializer/DocumentationNormalizer.php
105-
106102
# Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130
107103
- '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'
108104

src/Hydra/Serializer/CollectionNormalizer.php

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait;
1919
use ApiPlatform\Metadata\IriConverterInterface;
2020
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
21+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
2122
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2223
use ApiPlatform\Metadata\UrlGeneratorInterface;
2324
use ApiPlatform\Serializer\AbstractCollectionNormalizer;
@@ -44,7 +45,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer
4445
self::PRESERVE_COLLECTION_KEYS => false,
4546
];
4647

47-
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null)
48+
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
4849
{
4950
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
5051

@@ -72,15 +73,17 @@ protected function getPaginationData(iterable $object, array $context = []): arr
7273
$data[$hydraPrefix.'totalItems'] = \count($object);
7374
}
7475

75-
if (null !== $this->resourceMetadataCollectionFactory && ($context['hydra_operations'] ?? $this->defaultContext['hydra_operations'] ?? false)) {
76-
$allHydraOperations = $this->getHydraOperationsFromResourceMetadatas(
76+
if (null !== $this->resourceMetadataCollectionFactory) {
77+
$hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes(
7778
$resourceClass,
7879
true,
80+
null,
81+
$context,
7982
$hydraPrefix
8083
);
8184

82-
if (!empty($allHydraOperations)) {
83-
$data[$hydraPrefix.'operation'] = $allHydraOperations;
85+
if (!empty($hydraOperationsFromAttributes)) {
86+
$data[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes;
8487
}
8588
}
8689

src/Hydra/Serializer/DocumentationNormalizer.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2626
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2727
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
28+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
2829
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2930
use ApiPlatform\Metadata\UrlGeneratorInterface;
3031
use ApiPlatform\Metadata\Util\TypeHelper;
@@ -51,6 +52,9 @@ final class DocumentationNormalizer implements NormalizerInterface
5152
use HydraPrefixTrait;
5253
public const FORMAT = 'jsonld';
5354

55+
private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
56+
private ?ResourceAccessCheckerInterface $resourceAccessChecker;
57+
5458
public function __construct(
5559
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
5660
private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
@@ -60,7 +64,10 @@ public function __construct(
6064
private readonly ?NameConverterInterface $nameConverter = null,
6165
private readonly ?array $defaultContext = [],
6266
private readonly ?bool $entrypointEnabled = true,
67+
?ResourceAccessCheckerInterface $resourceAccessChecker = null,
6368
) {
69+
$this->resourceMetadataCollectionFactory = $resourceMetadataFactory;
70+
$this->resourceAccessChecker = $resourceAccessChecker;
6471
}
6572

6673
/**

src/Hydra/Serializer/HydraOperationsTrait.php

Lines changed: 115 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,36 @@
1717
use ApiPlatform\Metadata\ApiResource;
1818
use ApiPlatform\Metadata\CollectionOperationInterface;
1919
use ApiPlatform\Metadata\HttpOperation;
20+
use ApiPlatform\Metadata\HydraOperation;
21+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
22+
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
2023

2124
/**
2225
* Generates Hydra operations for JSON-LD responses.
2326
*
2427
* @author Kévin Dunglas <dunglas@gmail.com>
28+
*
29+
* @property ResourceMetadataCollectionFactoryInterface|null $resourceMetadataCollectionFactory
30+
* @property ResourceAccessCheckerInterface|null $resourceAccessChecker
2531
*/
2632
trait HydraOperationsTrait
2733
{
2834
/**
29-
* Gets Hydra operations from all resource metadata.
35+
* Gets Hydra operations from all HydraOperation attributes.
3036
*/
31-
private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
37+
private function getHydraOperationsFromAttributes(string $resourceClass, bool $collection, ?object $object, array $context, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
3238
{
3339
$allHydraOperations = [];
3440
$operationNames = [];
3541

3642
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) {
37-
$hydraOperations = $this->getHydraOperationsFromResourceMetadata(
43+
$hydraOperations = $this->getHydraOperationsFromAttributesForResource(
3844
$collection,
3945
$resourceMetadata,
4046
$hydraPrefix,
47+
$resourceClass,
48+
$object,
49+
$context,
4150
$operationNames
4251
);
4352

@@ -50,35 +59,117 @@ private function getHydraOperationsFromResourceMetadatas(string $resourceClass,
5059
/**
5160
* Gets Hydra operations from a single resource metadata.
5261
*/
53-
private function getHydraOperationsFromResourceMetadata(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, array &$operationNames): array
62+
private function getHydraOperationsFromAttributesForResource(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, string $resourceClass, ?object $object, array $context, array &$operationNames): array
5463
{
5564
$operations = [];
56-
$hydraOperations = $this->getHydraOperations(
57-
$collection,
58-
$resourceMetadata,
59-
$hydraPrefix
60-
);
61-
62-
if (!empty($hydraOperations)) {
63-
foreach ($hydraOperations as $operation) {
64-
$operationName = $operation[$hydraPrefix.'method'];
65-
if (!\in_array($operationName, $operationNames, true)) {
66-
$operationNames[] = $operationName;
67-
$operations[] = $operation;
68-
}
65+
66+
foreach ($resourceMetadata->getHydraOperations() ?? [] as $hydraOperation) {
67+
if ($hydraOperation->getCollection() !== $collection) {
68+
continue;
69+
}
70+
71+
$method = $hydraOperation->getMethod();
72+
if (\in_array($method, $operationNames, true)) {
73+
continue;
74+
}
75+
76+
if (!$this->isHydraOperationGranted($hydraOperation, $resourceClass, $object, $context)) {
77+
continue;
6978
}
79+
80+
$operationNames[] = $method;
81+
$operations[] = $this->normalizeHydraOperationAttribute($hydraOperation, $resourceMetadata->getShortName(), $hydraPrefix);
7082
}
7183

7284
return $operations;
7385
}
7486

87+
private function isHydraOperationGranted(HydraOperation $hydraOperation, string $resourceClass, ?object $object, array $context): bool
88+
{
89+
if (null === $expression = $hydraOperation->getSecurity()) {
90+
return true;
91+
}
92+
93+
if (null === $this->resourceAccessChecker) {
94+
return false;
95+
}
96+
97+
$extraVariables = ['object' => $object];
98+
if (isset($context['request'])) {
99+
$extraVariables['request'] = $context['request'];
100+
}
101+
102+
return $this->resourceAccessChecker->isGranted($resourceClass, $expression, $extraVariables);
103+
}
104+
105+
/**
106+
* Normalizes a HydraOperation attribute into a JSON-LD array.
107+
*/
108+
private function normalizeHydraOperationAttribute(HydraOperation $hydraOperation, ?string $shortName, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
109+
{
110+
$method = $hydraOperation->getMethod();
111+
$output = $hydraOperation->getExtraProperties();
112+
113+
$output['@type'] = $hydraOperation->getTypes() ?? $this->defaultHydraOperationTypes($method, $hydraPrefix);
114+
115+
if (null !== ($description = $hydraOperation->getDescription())) {
116+
$output[$hydraPrefix.'description'] = $description;
117+
}
118+
119+
if (null !== ($expects = $hydraOperation->getExpects())) {
120+
$output['expects'] = $expects;
121+
} elseif (\in_array($method, ['POST', 'PUT', 'PATCH'], true) && null !== $shortName) {
122+
$output['expects'] = $shortName;
123+
}
124+
125+
if (null !== ($returns = $hydraOperation->getReturns())) {
126+
$output['returns'] = $returns;
127+
} elseif ('DELETE' === $method) {
128+
$output['returns'] = 'owl:Nothing';
129+
} elseif (null !== $shortName) {
130+
$output['returns'] = $shortName;
131+
}
132+
133+
$output[$hydraPrefix.'method'] = $method;
134+
$output[$hydraPrefix.'title'] = $hydraOperation->getTitle()
135+
?? $this->defaultHydraOperationTitle($method, $shortName, $hydraOperation->getCollection() && 'GET' === $method);
136+
137+
if (null === $output[$hydraPrefix.'title']) {
138+
unset($output[$hydraPrefix.'title']);
139+
}
140+
141+
ksort($output);
142+
143+
return $output;
144+
}
145+
146+
private function defaultHydraOperationTypes(string $method, string $hydraPrefix): array|string
147+
{
148+
return match ($method) {
149+
'GET' => [$hydraPrefix.'Operation', 'schema:FindAction'],
150+
'POST' => [$hydraPrefix.'Operation', 'schema:CreateAction'],
151+
'PUT' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'],
152+
'DELETE' => [$hydraPrefix.'Operation', 'schema:DeleteAction'],
153+
default => $hydraPrefix.'Operation',
154+
};
155+
}
156+
157+
private function defaultHydraOperationTitle(string $method, ?string $shortName, bool $isCollection): ?string
158+
{
159+
if (null === $shortName) {
160+
return null;
161+
}
162+
163+
return strtolower($method).$shortName.($isCollection ? 'Collection' : '');
164+
}
165+
75166
/**
76167
* Gets Hydra operations.
77168
*/
78169
private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
79170
{
80171
$hydraOperations = [];
81-
foreach ($resourceMetadata->getOperations() as $operation) {
172+
foreach ($resourceMetadata->getOperations() ?? [] as $operation) {
82173
if (true === $operation->getHideHydraOperation()) {
83174
continue;
84175
}
@@ -112,21 +203,22 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho
112203
$inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false;
113204
$outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false;
114205

115-
if ('GET' === $method && $operation instanceof CollectionOperationInterface) {
206+
$isCollection = $operation instanceof CollectionOperationInterface;
207+
208+
$hydraOperation += ['@type' => 'PATCH' === $method ? $hydraPrefix.'Operation' : $this->defaultHydraOperationTypes($method, $hydraPrefix)];
209+
210+
if ('GET' === $method && $isCollection) {
116211
$hydraOperation += [
117-
'@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
118212
$hydraPrefix.'description' => "Retrieves the collection of $shortName resources.",
119213
'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection',
120214
];
121215
} elseif ('GET' === $method) {
122216
$hydraOperation += [
123-
'@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
124217
$hydraPrefix.'description' => "Retrieves a $shortName resource.",
125218
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
126219
];
127220
} elseif ('PATCH' === $method) {
128221
$hydraOperation += [
129-
'@type' => $hydraPrefix.'Operation',
130222
$hydraPrefix.'description' => "Updates the $shortName resource.",
131223
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
132224
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
@@ -144,28 +236,25 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho
144236
}
145237
} elseif ('POST' === $method) {
146238
$hydraOperation += [
147-
'@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'],
148239
$hydraPrefix.'description' => "Creates a $shortName resource.",
149240
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
150241
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
151242
];
152243
} elseif ('PUT' === $method) {
153244
$hydraOperation += [
154-
'@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'],
155245
$hydraPrefix.'description' => "Replaces the $shortName resource.",
156246
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
157247
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
158248
];
159249
} elseif ('DELETE' === $method) {
160250
$hydraOperation += [
161-
'@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'],
162251
$hydraPrefix.'description' => "Deletes the $shortName resource.",
163252
'returns' => 'owl:Nothing',
164253
];
165254
}
166255

167256
$hydraOperation[$hydraPrefix.'method'] ??= $method;
168-
$hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : '');
257+
$hydraOperation[$hydraPrefix.'title'] ??= $this->defaultHydraOperationTitle($method, $shortName, $isCollection);
169258

170259
ksort($hydraOperation);
171260

0 commit comments

Comments
 (0)