Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!-- file generated with AI assistance: Claude Code - 2026-07-22 -->

# Changelog

## Unreleased

### Added

- `hydra:operation` on collection GET responses (`hydra:Collection`): collection-level operations (create `POST`, custom collection actions), analogous to the existing item enrichment (#5)
- Security filtering for `hydra:operation`: operations whose `security` expression does not grant access to the current token are omitted, evaluated via API Platform's `ResourceAccessChecker`; item operations are checked with the loaded entity as `object` (#5)
- New config flag `hydra_operations.filter_operations_by_security` (default `true`) to switch the filtering off (#5)
- `Vary: Authorization` header on enriched responses while filtering is active — the advertised operations are user-dependent (#5)

### Fixed

- Duplicate `partial_uuid_item_provider` node in the bundle configuration tree (and the duplicate parameter assignment in the extension)

## 0.3.1 and earlier

See git history.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,41 @@ Generic helpers for API Platform projects.
- DB-JSON CLI tools (`db:export-json`, `db:import-json`)
- `AbstractJsonSchemaInputCommand` base class for JSON-Schema-validated CLI

## Hydra operations (`hydra:operation`)

`AddHydraOperationsSubscriber` enriches JSON-LD runtime responses with a `hydra:operation` array so clients can discover the available operations without parsing the static docs:

- **Item GET responses** list the item-level operations (`GET`, `PUT`, `PATCH`, `DELETE`, custom `/resource/{id}/<verb>` actions) with the `{id}` placeholder resolved to the actual identifier.
- **Collection GET responses** (`hydra:Collection`) list the collection-level operations (`GET`, create `POST`, custom `/resource/<verb>` actions).

### Security filtering (HATEOAS)

By default each operation is checked against the current security token via API Platform's `ResourceAccessChecker` (`api_platform.security.resource_access_checker`); operations whose `security` expression does not grant access are omitted. The server therefore only advertises what the current user may actually execute — a UI can derive `canCreate`/`canUpdate`/`canDelete` directly from the runtime `hydra:operation` instead of the role-independent static docs.

Rules:

- Operations **without** a `security` expression stay visible (default: allowed).
- Item operations are evaluated with the loaded entity as `object` (when resolvable from the request); collection operations are evaluated with `object = null`.
- Only collection-level operations are checked on collection responses — one check per operation, never per `hydra:member`.
- Expression evaluation errors fail closed (the operation is hidden).
- Without `symfony/security-bundle` the checker service is absent and filtering is skipped.

Configuration:

```yaml
dmstr_api_platform_utils:
hydra_operations:
enabled: true
api_prefix: '/api'
event_priority: -10
# set to false to restore the unfiltered (pre-0.4) behavior
filter_operations_by_security: true
```

### HTTP caching

With filtering active, item and collection responses become **user-dependent**: the subscriber emits `Vary: Authorization` so shared HTTP caches never serve one user's operation set to another. The static `/api/docs.jsonld` stays role-independent and cacheable. If your stack caches API responses in a reverse proxy, verify it honors `Vary` — otherwise exclude these responses from caching.

## License

MIT © diemeisterei GmbH
3 changes: 3 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ services:
arguments:
$resourceMetadataFactory: '@api_platform.metadata.resource.metadata_collection_factory'
$apiPrefix: '%dmstr_api_platform_utils.hydra_operations.api_prefix%'
# optional — absent without symfony/security-bundle; filtering is skipped then
$resourceAccessChecker: '@?api_platform.security.resource_access_checker'
$filterOperationsBySecurity: '%dmstr_api_platform_utils.hydra_operations.filter_operations_by_security%'
tags:
- { name: kernel.event_subscriber }

Expand Down
3 changes: 1 addition & 2 deletions src/DependencyInjection/ApiPlatformUtilsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('dmstr_api_platform_utils.hydra_operations.enabled', $config['hydra_operations']['enabled']);
$container->setParameter('dmstr_api_platform_utils.hydra_operations.api_prefix', $config['hydra_operations']['api_prefix']);
$container->setParameter('dmstr_api_platform_utils.hydra_operations.event_priority', $config['hydra_operations']['event_priority']);
$container->setParameter('dmstr_api_platform_utils.hydra_operations.filter_operations_by_security', $config['hydra_operations']['filter_operations_by_security']);

$container->setParameter('dmstr_api_platform_utils.custom_operation_hydra.enabled', $config['custom_operation_hydra']['enabled']);
$container->setParameter('dmstr_api_platform_utils.custom_operation_hydra.api_prefix', $config['custom_operation_hydra']['api_prefix']);
Expand All @@ -41,8 +42,6 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('dmstr_api_platform_utils.partial_uuid_item_provider.enabled', $config['partial_uuid_item_provider']['enabled']);

$container->setParameter('dmstr_api_platform_utils.partial_uuid_item_provider.enabled', $config['partial_uuid_item_provider']['enabled']);

// Load service definitions
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yaml');
Expand Down
15 changes: 4 additions & 11 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultValue(-10)
->info('Event subscriber priority (negative runs after API Platform)')
->end()
->booleanNode('filter_operations_by_security')
->defaultTrue()
->info('Omit operations from hydra:operation whose security expression does not grant access to the current token (requires symfony/security-bundle; operations without a security expression stay visible)')
->end()
->end()
->end()

Expand Down Expand Up @@ -119,17 +123,6 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()

// Partial UUID Item Provider
->arrayNode('partial_uuid_item_provider')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultFalse()
->info('Decorate the Doctrine ORM item provider so that {id} URI variables are resolved as full OR partial UUIDs project-wide. Convenience layer — full UUIDs still take the fast indexed path. Off by default to avoid surprising existing consumers.')
->end()
->end()
->end()
->end();

return $treeBuilder;
Expand Down
130 changes: 113 additions & 17 deletions src/EventSubscriber/AddHydraOperationsSubscriber.php
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
<?php
// file generated with AI assistance: Claude Code - 2025-11-22
// file generated with AI assistance: Claude Code - 2025-11-22, updated 2026-07-22

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\EventSubscriber;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Adds hydra:operation array to JSON-LD item responses
* Adds hydra:operation array to JSON-LD item and collection responses
*
* This enriches API Platform JSON-LD responses with complete operation metadata,
* making the API more discoverable and self-documenting.
*
* Item responses list the item-level operations (URI templates containing
* {id}), collection responses list the collection-level operations (create +
* custom collection actions).
*
* When filtering is enabled (default) and API Platform's ResourceAccessChecker
* is available, operations whose `security` expression does not grant access
* to the current token are omitted — the server advertises only what the
* current user may actually execute (HATEOAS). Operations without a `security`
* expression stay visible. Responses become user-dependent, so a
* `Vary: Authorization` header is emitted whenever filtering is active.
*/
final class AddHydraOperationsSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
private readonly string $apiPrefix = '/api'
private readonly string $apiPrefix = '/api',
private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null,
private readonly bool $filterOperationsBySecurity = true,
) {
}

Expand All @@ -44,15 +57,16 @@ public function addHydraOperations(ResponseEvent $event): void
return;
}

// Only process GET item operations (not collections)
// Only process GET operations
if ($operation->getMethod() !== 'GET') {
return;
}

$uriTemplate = $operation->getUriTemplate();
if (!$uriTemplate || !str_contains($uriTemplate, '{id}')) {
if (!$uriTemplate) {
return;
}
$isItemRequest = str_contains($uriTemplate, '{id}');

// Only process JSON-LD content
$contentType = $response->headers->get('Content-Type');
Expand All @@ -66,24 +80,49 @@ public function addHydraOperations(ResponseEvent $event): void
}

$data = json_decode($content, true);
if (!is_array($data) || !isset($data['@id']) || !isset($data['@type'])) {
if (!is_array($data)) {
return;
}

if ($isItemRequest) {
if (!isset($data['@id']) || !isset($data['@type'])) {
return;
}
} else {
// Collection responses are typed hydra:Collection (possibly among
// other types); skip anything else (e.g. custom GET endpoints).
$types = (array) ($data['@type'] ?? []);
if (!in_array('hydra:Collection', $types, true)) {
return;
}
}

// Get resource class
$resourceClass = $operation->getClass();
if (!$resourceClass) {
return;
}

try {
// Get the resource ID from the @id field
$resourceId = $data['id'] ?? null;
if (!$resourceId) {
// Try to extract from @id
$atId = $data['@id'] ?? '';
if (preg_match('/\/([^\/]+)$/', $atId, $matches)) {
$resourceId = $matches[1];
$resourceId = null;
$subject = null;
if ($isItemRequest) {
// Get the resource ID from the @id field
$resourceId = $data['id'] ?? null;
if (!$resourceId) {
// Try to extract from @id
$atId = $data['@id'] ?? '';
if (preg_match('/\/([^\/]+)$/', $atId, $matches)) {
$resourceId = $matches[1];
}
}

// The loaded entity — API Platform stores the controller
// result in the `data` request attribute. Used as `object`
// when evaluating operation security expressions.
$requestData = $request->attributes->get('data');
if (is_object($requestData)) {
$subject = $requestData;
}
}

Expand All @@ -100,9 +139,14 @@ public function addHydraOperations(ResponseEvent $event): void
continue;
}

// Only include item operations (not collection operations)
// Item responses advertise item operations, collection
// responses advertise collection operations.
$opUriTemplate = $op->getUriTemplate();
if (!$opUriTemplate || !str_contains($opUriTemplate, '{id}')) {
if (!$opUriTemplate || str_contains($opUriTemplate, '{id}') !== $isItemRequest) {
continue;
}

if (!$this->isOperationGranted($op, $subject)) {
continue;
}

Expand Down Expand Up @@ -164,15 +208,67 @@ public function addHydraOperations(ResponseEvent $event): void
$data['hydra:operation'] = $operations;
$response->setContent(json_encode($data));
}

// With security filtering active the advertised operations depend
// on the current token — mark the response as per-credential for
// any shared HTTP cache. Also emitted when all operations were
// filtered out (an empty result is user-dependent information too).
if ($this->isFilteringActive()) {
$vary = $response->getVary();
if (!in_array('Authorization', $vary, true)) {
$response->setVary(array_merge($vary, ['Authorization']));
}
}
} catch (\Exception $e) {
// Silently fail - don't break the API
}
}

private function isFilteringActive(): bool
{
return $this->filterOperationsBySecurity && null !== $this->resourceAccessChecker;
}

/**
* An operation is advertised when filtering is inactive, when it carries
* no security expression (default: allowed), or when the expression
* grants access for the current token. Evaluation errors fail closed
* (operation hidden) — the actual request would fail anyway.
*/
private function isOperationGranted(HttpOperation $op, ?object $subject): bool
{
if (!$this->isFilteringActive()) {
return true;
}

$security = $op->getSecurity();
if (null === $security || '' === (string) $security) {
return true;
}

$resourceClass = $op->getClass();
if (!$resourceClass) {
return true;
}

try {
// `object` is always defined (null on collections / when the
// loaded entity is not resolvable) so expressions referencing it
// evaluate instead of raising an undefined-variable error.
return $this->resourceAccessChecker->isGranted(
$resourceClass,
(string) $security,
['object' => $subject]
);
} catch (\Throwable) {
return false;
}
}

private function generateTitle(HttpOperation $operation): string
{
$method = $operation->getMethod() ?? 'GET';
$name = $operation->getName();
$name = $operation->getName() ?? '';
$uriTemplate = $operation->getUriTemplate() ?? '';

// Detect if this is a standard CRUD operation or a custom operation
Expand Down
Loading
Loading