Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/guides/declare-hydra-operations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
// ---
// slug: declare-hydra-operations
// name: Declare Hydra operations
// position: 21
// executable: false
// tags: design, hydra, jsonld
// ---

namespace App\ApiResource {
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\HydraOperation;
use ApiPlatform\Metadata\Post;

// Issues are publicly readable and may be reported by any authenticated
// user. Only an administrator may delete an issue — and rather than
// exposing the DELETE operation globally on the resource (which would
// leak its existence to every consumer of the Hydra documentation), the
// operation is declared **per representation** with `#[HydraOperation]`.
//
// The `security` expression is evaluated when the issue is serialized; the
// expression has access to `object` (the current Issue), `user` (the
// current security token's user) and `request`.
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
],
)]
#[HydraOperation(
method: 'DELETE',
title: 'Delete this issue',
security: "is_granted('ROLE_ADMIN')",
)]
class Issue
{
public string $id;
public string $title;
public string $reporter;
}
}

// When an admin requests `/issues/42`, the JSON-LD response carries an extra
// `hydra:operation` entry advertising the DELETE capability:
//
// ```json
// {
// "@context": "/contexts/Issue",
// "@id": "/issues/42",
// "@type": "Issue",
// "title": "Login fails on Firefox",
// "reporter": "/users/7",
// "hydra:operation": [
// {
// "@type": ["hydra:Operation", "schema:DeleteAction"],
// "hydra:method": "DELETE",
// "hydra:title": "Delete this issue",
// "returns": "owl:Nothing"
// }
// ]
// }
// ```
//
// When the same resource is requested by a non-admin, the `hydra:operation`
// property is omitted entirely.
1 change: 0 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ parameters:
message: '#^Service "[^"]+" is private.$#'
path: src


# Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130
- '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'

Expand Down
19 changes: 18 additions & 1 deletion src/Hydra/Serializer/CollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait;
use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Serializer\AbstractCollectionNormalizer;
Expand All @@ -31,6 +33,7 @@
*/
final class CollectionNormalizer extends AbstractCollectionNormalizer
{
use HydraOperationsTrait;
use HydraPrefixTrait;
use JsonLdContextTrait;

Expand All @@ -42,7 +45,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer
self::PRESERVE_COLLECTION_KEYS => false,
];

public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [])
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)
{
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);

Expand Down Expand Up @@ -70,6 +73,20 @@ protected function getPaginationData(iterable $object, array $context = []): arr
$data[$hydraPrefix.'totalItems'] = \count($object);
}

if (null !== $this->resourceMetadataCollectionFactory) {
$hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes(
$resourceClass,
true,
null,
$context,
$hydraPrefix
);

if (!empty($hydraOperationsFromAttributes)) {
$data[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes;
}
}

return $data;
}

Expand Down
109 changes: 8 additions & 101 deletions src/Hydra/Serializer/DocumentationNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\ErrorResource;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\TypeHelper;
Expand All @@ -48,9 +48,13 @@
*/
final class DocumentationNormalizer implements NormalizerInterface
{
use HydraOperationsTrait;
use HydraPrefixTrait;
public const FORMAT = 'jsonld';

private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
private ?ResourceAccessCheckerInterface $resourceAccessChecker;

public function __construct(
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
Expand All @@ -60,7 +64,10 @@ public function __construct(
private readonly ?NameConverterInterface $nameConverter = null,
private readonly ?array $defaultContext = [],
private readonly ?bool $entrypointEnabled = true,
?ResourceAccessCheckerInterface $resourceAccessChecker = null,
) {
$this->resourceMetadataCollectionFactory = $resourceMetadataFactory;
$this->resourceAccessChecker = $resourceAccessChecker;
}

/**
Expand Down Expand Up @@ -250,106 +257,6 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource
return $properties;
}

/**
* Gets Hydra operations.
*/
private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
{
$hydraOperations = [];
foreach ($resourceMetadata->getOperations() as $operation) {
if (true === $operation->getHideHydraOperation()) {
continue;
}

if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) {
continue;
}

$hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix);
}

return $hydraOperations;
}

/**
* Gets and populates if applicable a Hydra operation.
*/
private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix): array
{
$method = $operation->getMethod() ?: 'GET';

$hydraOperation = $operation->getHydraContext() ?? [];
if ($operation->getDeprecationReason()) {
$hydraOperation['owl:deprecated'] = true;
}

$shortName = $operation->getShortName();
$inputMetadata = $operation->getInput() ?? [];
$outputMetadata = $operation->getOutput() ?? [];

$inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false;
$outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false;

if ('GET' === $method && $operation instanceof CollectionOperationInterface) {
$hydraOperation += [
'@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
$hydraPrefix.'description' => "Retrieves the collection of $shortName resources.",
'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection',
];
} elseif ('GET' === $method) {
$hydraOperation += [
'@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
$hydraPrefix.'description' => "Retrieves a $shortName resource.",
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
];
} elseif ('PATCH' === $method) {
$hydraOperation += [
'@type' => $hydraPrefix.'Operation',
$hydraPrefix.'description' => "Updates the $shortName resource.",
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
];

if (null !== $inputClass) {
$possibleValue = [];
foreach ($operation->getInputFormats() ?? [] as $mimeTypes) {
foreach ($mimeTypes as $mimeType) {
$possibleValue[] = $mimeType;
}
}

$hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]];
}
} elseif ('POST' === $method) {
$hydraOperation += [
'@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'],
$hydraPrefix.'description' => "Creates a $shortName resource.",
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
];
} elseif ('PUT' === $method) {
$hydraOperation += [
'@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'],
$hydraPrefix.'description' => "Replaces the $shortName resource.",
'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
];
} elseif ('DELETE' === $method) {
$hydraOperation += [
'@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'],
$hydraPrefix.'description' => "Deletes the $shortName resource.",
'returns' => 'owl:Nothing',
];
}

$hydraOperation[$hydraPrefix.'method'] ??= $method;
$hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : '');

ksort($hydraOperation);

return $hydraOperation;
}

/**
* Gets the range of the property.
*/
Expand Down
Loading
Loading