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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

# Changelog

## 0.6.0 (2026-10-15)

### Added

- `relation_labels` (opt-in, default off): `RelationLabelNormalizer` decorates `api_platform.jsonld.normalizer.item` and embeds `{"@id": <iri>, <label>: <value>}` instead of the plain IRI for to-one relations in JSON-LD collection responses, so lists can show a related record by name. Item responses keep plain IRIs (forms edit relations as `iri-reference` strings, and API Platform rejects nested documents on write). Relations already embedded via serialization groups are left alone; a related row that cannot be loaded (e.g. hidden by row-level security) keeps its IRI.
- `x-label-property` on the resource's own definition, so clients can title a record the same way a relation to it is labelled. Only set when the label property is a mapped field; an existing hint is kept.

### Changed

- The label property inference moved into `RelationLabelPropertyResolver`, shared by `RelationFieldSchemaDecorator` and `RelationLabelNormalizer`, so schema hint and embedded label always name the same property.

## 0.5.0 (2026-10-09)

### Added
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Generic helpers for API Platform projects.
- DB-JSON CLI tools (`db:export-json`, `db:import-json`)
- `AbstractJsonSchemaInputCommand` base class for JSON-Schema-validated CLI
- Generated sort parameters, default sort and a stable-pagination tie-breaker (`auto_order`, `#[AutoOrder]`, `stable_order`)
- Relation label (`x-label-property`) in input and output schemas
- Relation label (`x-label-property`) in input and output schemas, and embedded in collection responses (`relation_labels`)

## Hydra operations (`hydra:operation`)

Expand Down Expand Up @@ -110,7 +110,23 @@ dmstr_api_platform_utils:

`RelationFieldSchemaDecorator` adds `x-*` extensions to relation properties (`format: iri-reference`) of Doctrine resources:

Input and output schemas get the same five extensions: `x-collection`, `x-label-property`, `x-value-property`, `x-search-property` and `x-resource-class`. Forms built from the read schema (resources with serialization groups have no plain schema) get the autocomplete picker, and lists can show and sort a relation by its label (`order[<relation>.<label>]`, offered by `auto_order` only when the label is one of the candidates). The label is the first candidate the target class declares, else its first string property. Properties inside `allOf` are decorated too, which is where JSON-LD output definitions keep them.
Input and output schemas get the same five extensions: `x-collection`, `x-label-property`, `x-value-property`, `x-search-property` and `x-resource-class`. Forms built from the read schema (resources with serialization groups have no plain schema) get the autocomplete picker, and lists can show and sort a relation by its label (`order[<relation>.<label>]`, offered by `auto_order` only when the label is one of the candidates). The label is the first candidate the target class declares, else its first string property. Properties inside `allOf` are decorated too, which is where JSON-LD output definitions keep them. The resource's own definition gets `x-label-property` as well, so a client can title a record with the same property a relation to it uses.

### Labels in collection responses (`relation_labels`)

The schema names the label property, but a plain relation value is just the IRI. With `relation_labels` enabled, JSON-LD collection responses embed the label next to it:

```json
"parent": { "@id": "/api/ort/einheit/0b1…", "bezeichnung": "Erdgeschoss" }
```

```yaml
dmstr_api_platform_utils:
relation_labels:
enabled: true
```

Only collection operations are affected; item responses keep the plain IRI, because forms edit relations as `iri-reference` strings and API Platform rejects nested documents on write. Each embedded label initialises the related entity, one query per distinct related row and page unless the collection query fetch-joins it.

## License

Expand Down
21 changes: 21 additions & 0 deletions config/services_relation_labels.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# file generated with AI assistance: Claude Code - 2026-10-14 21:30:00 UTC

services:
_defaults:
autowire: false
autoconfigure: false
public: false

Dmstr\ApiPlatformUtils\Service\RelationLabelPropertyResolver:
arguments:
$labelPropertyCandidates: '%dmstr_api_platform_utils.relation_field_decorator.label_property_candidates%'
$logger: '@?logger'

# Embeds {"@id", <label>} for to-one relations in JSON-LD collection
# responses (see RelationLabelNormalizer for the limits).
Dmstr\ApiPlatformUtils\Serializer\RelationLabelNormalizer:
decorates: 'api_platform.jsonld.normalizer.item'
arguments:
$decorated: '@.inner'
$entityManager: '@doctrine.orm.entity_manager'
$labelPropertyResolver: '@Dmstr\ApiPlatformUtils\Service\RelationLabelPropertyResolver'
7 changes: 7 additions & 0 deletions src/DependencyInjection/ApiPlatformUtilsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('dmstr_api_platform_utils.auto_order.default_order', $config['auto_order']['default_order']);

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

// Load service definitions
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
Expand All @@ -75,6 +76,12 @@ public function load(array $configs, ContainerBuilder $container): void
if ($config['stable_order']['enabled']) {
$loader->load('services_stable_order.yaml');
}

// Conditionally load the relation label normalizer. Off by default:
// it changes the shape of relation values in collection responses.
if ($config['relation_labels']['enabled']) {
$loader->load('services_relation_labels.yaml');
}
}

public function getAlias(): string
Expand Down
11 changes: 11 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()

// Labels of to-one relations embedded in collection responses
->arrayNode('relation_labels')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultFalse()
->info('Embed {"@id": <iri>, <label>: <value>} instead of the plain IRI for to-one relations in JSON-LD collection responses, so lists can show the related record by name. The label property is the one announced as x-label-property (relation_field_decorator.label_property_candidates). Item responses keep plain IRIs. Costs one query per distinct related row and page unless fetch-joined.')
->end()
->end()
->end()

// Tie-breaker for stable pagination
->arrayNode('stable_order')
->addDefaultsIfNotSet()
Expand Down
64 changes: 30 additions & 34 deletions src/OpenApi/RelationFieldSchemaDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Dmstr\ApiPlatformUtils\Service\RelationLabelPropertyResolver;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Psr\Log\LoggerInterface;
use ReflectionClass;

/**
* Decorator that adds OpenAPI x-* extensions to relation properties for autocomplete rendering.
Expand Down Expand Up @@ -41,6 +41,8 @@
*/
class RelationFieldSchemaDecorator implements SchemaFactoryInterface
{
private ?RelationLabelPropertyResolver $labelPropertyResolver = null;

public function __construct(
private readonly SchemaFactoryInterface $decorated,
private readonly EntityManagerInterface $entityManager,
Expand Down Expand Up @@ -129,12 +131,37 @@ public function buildSchema(

$this->processDefinitionProperties($definitions, $key, $metadata);
}
$this->addOwnLabelProperty($definitions, $schema->getRootDefinitionKey() ?? $schema->getItemsDefinitionKey(), $metadata);
$schema['definitions'] = $definitions;
}

return $schema;
}

/**
* Announces the label of the resource itself (`x-label-property` on its
* own definition), so clients can title a record the same way a relation
* to it is labelled. Only the definition of the built class is touched —
* other definitions in the same schema belong to other classes — and an
* existing hint is kept.
*/
private function addOwnLabelProperty(\ArrayObject $definitions, ?string $key, ClassMetadata $metadata): void
{
if ($key === null || !isset($definitions[$key])) {
return;
}
$definition = $definitions[$key];
if (isset($definition['x-label-property'])) {
return;
}
$labelProperty = $this->inferLabelProperty($metadata->getName());
if (!$metadata->hasField($labelProperty)) {
return;
}
$definition['x-label-property'] = $labelProperty;
$definitions[$key] = $definition;
}

private function processProperties(Schema $schema, ClassMetadata $metadata, string $propertiesKey): void
{
$properties = $schema[$propertiesKey] ?? [];
Expand Down Expand Up @@ -368,40 +395,9 @@ private function getCollectionPath(ResourceMetadataCollection $resourceMetadata)

private function inferLabelProperty(string $className): string
{
try {
$reflectionClass = new ReflectionClass($className);

// Priority 1: Check for properties with common label names
foreach ($this->labelPropertyCandidates as $candidate) {
if ($reflectionClass->hasProperty($candidate)) {
return $candidate;
}
}

// Priority 2: Find first non-ID string property
foreach ($reflectionClass->getProperties() as $property) {
$propertyName = $property->getName();

// Skip ID fields
if ($propertyName === 'id') {
continue;
}

// Check if it's a string type (via PHP 7.4+ typed property or docblock)
$type = $property->getType();
if ($type !== null && $type->getName() === 'string') {
return $propertyName;
}
}
} catch (\ReflectionException $e) {
$this->logger->warning('Failed to infer label property', [
'className' => $className,
'error' => $e->getMessage()
]);
}
$this->labelPropertyResolver ??= new RelationLabelPropertyResolver($this->labelPropertyCandidates, $this->logger);

// Fallback to first label candidate if nothing found
return $this->labelPropertyCandidates[0] ?? 'name';
return $this->labelPropertyResolver->resolve($className);
}

private function getShortName(string $className): string
Expand Down
140 changes: 140 additions & 0 deletions src/Serializer/RelationLabelNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-14 21:30:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\Serializer;

use ApiPlatform\Metadata\CollectionOperationInterface;
use Dmstr\ApiPlatformUtils\Service\RelationLabelPropertyResolver;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Embeds the label of to-one relations in JSON-LD collection responses:
* `"parent": "/api/einheits/0b1…"` becomes
* `"parent": {"@id": "/api/einheits/0b1…", "bezeichnung": "Erdgeschoss"}`.
*
* The label property is the one the schema announces as `x-label-property`
* (shared {@see RelationLabelPropertyResolver}), so a client reads the label
* from exactly the property the schema names.
*
* Deliberately limited:
* - collection operations only — item responses keep plain IRIs, because
* forms edit relations as `iri-reference` strings and API Platform rejects
* nested documents on write;
* - only values the inner normalizer emitted as IRI strings — relations a
* resource already embeds via serialization groups are left untouched;
* - a related row that cannot be loaded (e.g. hidden by row-level security)
* keeps its plain IRI.
*
* Every embedded label initialises the related entity: one query per distinct
* related row and page, unless the collection query fetch-joins it.
*/
final class RelationLabelNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
private const FORMAT = 'jsonld';

public function __construct(
private readonly NormalizerInterface $decorated,
private readonly EntityManagerInterface $entityManager,
private readonly RelationLabelPropertyResolver $labelPropertyResolver,
) {
}

public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$normalized = $this->decorated->normalize($data, $format, $context);

if (self::FORMAT !== $format
|| !\is_array($normalized)
|| !\is_object($data)
|| !(($context['root_operation'] ?? $context['operation'] ?? null) instanceof CollectionOperationInterface)
) {
return $normalized;
}

return $this->embedLabels($data, $normalized);
}

/**
* @param array<string, mixed> $normalized
*
* @return array<string, mixed>
*/
private function embedLabels(object $entity, array $normalized): array
{
if ($this->entityManager->getMetadataFactory()->isTransient($entity::class)) {
return $normalized; // not a Doctrine entity (DTO, output class)
}
$metadata = $this->entityManager->getClassMetadata($entity::class);

foreach ($metadata->getAssociationNames() as $field) {
if (!$metadata->isSingleValuedAssociation($field) || !\is_string($normalized[$field] ?? null)) {
continue;
}

$related = $metadata->getFieldValue($entity, $field);
if (!\is_object($related)) {
continue;
}

$targetMetadata = $this->entityManager->getClassMetadata($metadata->getAssociationTargetClass($field));
$labelProperty = $this->labelPropertyResolver->resolve($targetMetadata->getName());
if (!$targetMetadata->hasField($labelProperty)) {
continue;
}

try {
$this->entityManager->getUnitOfWork()->initializeObject($related);
} catch (EntityNotFoundException) {
continue;
}

$label = $targetMetadata->getFieldValue($related, $labelProperty);
if (!\is_scalar($label) || '' === (string) $label) {
continue;
}

$normalized[$field] = ['@id' => $normalized[$field], $labelProperty => (string) $label];
}

return $normalized;
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $this->decorated->supportsNormalization($data, $format, $context);
}

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
if (!$this->decorated instanceof DenormalizerInterface) {
throw new \LogicException(\sprintf('The decorated normalizer must implement "%s".', DenormalizerInterface::class));
}

return $this->decorated->denormalize($data, $type, $format, $context);
}

public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $this->decorated instanceof DenormalizerInterface
&& $this->decorated->supportsDenormalization($data, $type, $format, $context);
}

public function getSupportedTypes(?string $format): array
{
return $this->decorated->getSupportedTypes($format);
}

public function setSerializer(SerializerInterface $serializer): void
{
if ($this->decorated instanceof SerializerAwareInterface) {
$this->decorated->setSerializer($serializer);
}
}
}
Loading
Loading