Skip to content

feat(metadata): resource and operation level itemUriTemplate - #8479

Open
Amoifr wants to merge 2 commits into
api-platform:mainfrom
Amoifr:feat-8075-item-uri-template
Open

feat(metadata): resource and operation level itemUriTemplate#8479
Amoifr wants to merge 2 commits into
api-platform:mainfrom
Amoifr:feat-8075-item-uri-template

Conversation

@Amoifr

@Amoifr Amoifr commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Implements the plan laid out in #8075 (comment) (resource + operation itemUriTemplate), closes #8075.

When an item operation has a custom uriTemplate (new Patch(uriTemplate: '/purchases/{id}/billing-address')), the response @id reuses that URI instead of the canonical one. itemUriTemplate existed only on Post and GetCollection; this lifts it up:

  • Op-level: itemUriTemplate moves to HttpOperation (with getItemUriTemplate()/withItemUriTemplate()), so Get, Patch and Put accept it; Post and GetCollection keep their signature and delegate.
  • Resource-level: ApiResource gains itemUriTemplate as a DRY default; the propagation to operations is automatic through the generic cascadeFromResource()/copyFrom() mechanism, with op-level precedence (op ?? resource ?? null).
  • IriConverter (Symfony and Laravel; Mcp delegates): when the resolved operation is a non-collection HttpOperation carrying an itemUriTemplate, the target operation is resolved through OperationMetadataFactory::create(). The block is skipped when $context['item_uri_template'] was already resolved at the top of the method, which avoids a double resolution through the cascaded template of the target operation (caught by the functional test on op-level precedence).
  • Extractors: XML + YAML support itemUriTemplate at the resource level, and the op-level whitelist accepts Get, Patch and Put (it threw before); XSD updated.
  • Serializer context: nothing to change, SerializerContextBuilder, OperationContextTrait and the JSON:API ItemNormalizer already feed item_uri_template through method_exists($operation, 'getItemUriTemplate').

Additive, no behavior change when the option is unset.

Test coverage, corrected

An earlier version of this description claimed the functional tests covered the IriConverter change. They do not, as @soyuka pointed out and as I verified: with both IriConverter hunks reverted, CanonicalIriTest stays green. What it actually covers is the lifting of itemUriTemplate onto HttpOperation, which is enough on its own to make SerializerContextBuilder set item_uri_template for a Patch, and the canonical operation is then resolved at the top of getIriFromResource().

  • Unit, Symfony IriConverter: an item operation carrying an itemUriTemplate generates the IRI of the canonical operation, and an unresolvable template leaves the operation alone.
  • Functional, CanonicalIriTest: the canonical @id for a PATCH on a custom URI, with the resource-level default and with an op-level override taking precedence.
  • ResourceMetadataCompatibilityTest covers the new resource property through the XML/YAML adapters.

A test on a caller that actually reaches the new converter block is still missing; which one to write depends on the open questions in the review.

@soyuka
soyuka force-pushed the feat-8075-item-uri-template branch from a2497c0 to 235acb3 Compare September 2, 2026 09:31

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reviewing this I'm unsure of the approach, I especially think that itemUriTemplate is a wrong name for that feature. From what I understand all you wanted to fix is Patch therefore shouldn't we just focus on the Patch operation?

The review comments were done by a few claude models.

$identifiersExtractorOperation = $operation;

// The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate"
if (!isset($context['item_uri_template']) && $this->operationMetadataFactory && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things on this block:

1. Unguarded null. OperationMetadataFactoryInterface::create() returns ?Operation (src/Metadata/Operation/Factory/OperationMetadataFactory.php:44 returns null when nothing matches by uriTemplate or name). The result is assigned to $operation and the first dereference is !$operation->getName() at line 168, with no guard in between → fatal on an unresolvable template. Note the pre-existing branch at 128-130 is null-tolerant by construction ($operation?->getName() at 132, if ($operation && …) at 133, if (!$operation) { $operation = new Get() } at 149); this new block is the only unguarded create() consumer in the method. The unit test mocks the factory to always return an operation, so it can't catch this. A resource-level itemUriTemplate cascades to every item operation, so one typo breaks every IRI of the resource.

2. $context['item_uri_template'] is not set here. IdentifiersExtractor::getIdentifierValue() has a fallback gated on that key (src/Metadata/IdentifiersExtractor.php:116), reached only when the item isn't an instance of the operation's class. On the mainline this doesn't bite — the canonical op has the same class, so line 107 handles it. It does bite when itemUriTemplate crosses classes (output DTO, stateOptions entity class, or a template belonging to another resource): the SerializerContextBuilder path is covered by line 116, this path falls through to the property-metadata scan at line 128. Edge case, but the two routes to the same target operation shouldn't behave differently.

3. Placement. This runs before the !$operation->getName() fallback at 167-177. When the caller passes no operation, line 149 substitutes a synthetic new Get() that carries no itemUriTemplate, so the block no-ops, and the operation resolved at 171 via getOperation(null, false, true) is never re-checked. That null-operation path is the normal one for relation IRIs (src/Serializer/AbstractItemNormalizer.php:982) and for embedded JSON-LD resources (OperationContextTrait.php:53 unsets operation, so JsonLd/Serializer/ItemNormalizer.php:121 passes null).

Concretely: a resource declaring Get('/books/{id}/summary') before Get('/books/{id}') with resource-level itemUriTemplate: '/books/{id}' gets the canonical @id at the root but /books/1/summary in every relation pointing at it — getOperation() returns the first declared item op. Canonicalizing relation IRIs looks like the headline use case for the resource-level option, so this should probably run after the fallback, or run again on the resolved operation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Points 1 and 2 are fixed; point 3 I would like your call on, because it moves the same code the thread on HttpOperation may move.

1. Real bug, thanks. The result of create() is now kept only when it resolves, so an unresolvable template leaves the operation untouched instead of fataling on every IRI of the resource. Covered by testGetIriFromItemOperationWithAnUnresolvableItemUriTemplate, which mocks the factory to return null.

2. Fixed: the branch now sets $context['item_uri_template'] before generating, so the two routes to the same canonical operation feed IdentifiersExtractor the same context.

3. You are right that relation IRIs are the interesting case and that they never reach this block, since the synthetic new Get() from line 149 carries no template. Moving the block after the name-resolution fallback fixes that, and canonicalizing relations does look like the point of the resource-level option. I have not done it yet: if itemUriTemplate stops living on HttpOperation, this block changes shape anyway, so I would rather settle that first and do it once.

$identifiersExtractorOperation = $operation;

// The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate"
if (!isset($context['item_uri_template']) && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same three points as the Symfony counterpart: nullable create() result dereferenced at line 147 with no guard, missing $context['item_uri_template'], and placement before the name-resolution fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same two fixes applied here: the create() result is only used when it resolves, and the branch sets $context['item_uri_template']. Placement waits on the same decision as the Symfony one.


$this->recreateSchema([CanonicalIriEntity::class]);
$this->createEntity();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two tests don't execute the new IriConverter blocks, so the description overstates what they cover. The chain:

  • State/Processor/SerializeProcessor.php:71-74 passes the request's Patch as operation.
  • Serializer/SerializerContextBuilder.php:71-72 sets $context['item_uri_template'] for any non-collection operation where method_exists($operation, 'getItemUriTemplate') returns non-null — and lifting the method onto HttpOperation is by itself enough to make that true for Patch. For rename the value is the cascaded resource-level template; for patch_override the explicit one.
  • JsonLd/Serializer/ItemNormalizer.php:121 forwards that context (line 84 doesn't short-circuit, there's no output:).
  • Symfony/Routing/IriConverter.php:128-130 resolves the canonical op; the new block at 161 is then skipped by its own !isset($context['item_uri_template']) guard.

So both tests stay green with the two IriConverter hunks reverted — they prove the lifting onto HttpOperation, not the converter change. (They would fail on main purely because Patch lacked the method.)

The production callers that actually reach the new block pass an explicit item operation with no item_uri_template in context. The notable one is Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php:222-223 — a resource-level itemUriTemplate now changes the id/iri published in Mercure updates for deleted objects. Also JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php:41 and State/Util/HttpResponseHeadersTrait.php:110 (Location on 3xx). Whichever of these the block is meant to serve should get the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and I checked it rather than taking it on trust: with both IriConverter hunks reverted, the two functional tests still pass. They prove the lifting onto HttpOperation, not the converter change. The PR description is wrong on that and I will fix it.

So the block needs a test on a caller that actually reaches it. PublishMercureUpdatesListener is the one with a visible consequence — a resource-level itemUriTemplate changes the id/iri published for deleted objects — and a relation IRI would be the other, if the block moves after the name fallback as discussed in the other thread. Tell me which behaviour you want to guarantee and I will write that one.

?bool $throwOnNotFound = null,
array $extraProperties = [],
?bool $map = null,
protected ?string $itemUriTemplate = null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting itemUriTemplate on HttpOperation has a side effect that reaches well beyond IRI generation, because SerializerContextBuilder.php:71 gates on method_exists($operation, 'getItemUriTemplate'). Before this PR only Post could satisfy it on a non-collection operation; now every Get/Put/Patch on a resource that uses the resource-level default does, so $context['item_uri_template'] is set at the root of ordinary item requests — including on the canonical Get, which cascade makes point at itself.

Branches that consequently switch on for plain item requests:

  • JsonLd/Serializer/ItemNormalizer.php:84 — resources with output: no longer short-circuit to parent::normalize()
  • JsonLd/Serializer/ItemNormalizer.php:147-166@type resolution takes the item_uri_template branch
  • JsonLd/ContextBuilder.php:136getResourceContext() stops emitting @id
  • Symfony/Routing/IriConverter.php:137 and :145 — skips the skolem fallback and the getResourceClass() inheritance handling
  • JsonApi/Serializer/ItemNormalizer.php:120

MCP is the one I'd worry about most. McpTool and McpResource extend HttpOperation (src/Metadata/McpTool.php:26, McpResource.php:26) and are built through getOperationWithDefaults() (MetadataCollectionFactoryTrait.php:104), so they receive the cascade too. Mcp/Routing/IriConverter.php:37 suppresses IRI generation for MCP operations only while !isset($context['item_uri_template']) — and Mcp/State/StructuredContentProcessor.php:61 goes through SerializerContextBuilder. Net effect: any resource with a resource-level itemUriTemplate starts emitting @id/IRIs in MCP structured content. That looks unintended.

None of this is covered by tests. Worth either scoping the cascade (skip operations whose own uriTemplate already equals the itemUriTemplate, and skip MCP operations) or explicitly deciding these are wanted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually wondering if we shouldn't just put the item_uri_template where it makes sense...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on all of it, and the MCP one is the worst: nothing about McpTool/McpResource says they want an @id, and they get one purely because they extend HttpOperation.

The root cause of most of that list is narrower than it looks. SerializerContextBuilder gates on method_exists($operation, 'getItemUriTemplate') and a non-null value, so the branches only flip because the cascade hands the canonical Get a template pointing at itself. Skipping the cascade when an operation's own uriTemplate already equals the resource itemUriTemplate removes the whole plain-item-request fallout, whatever we decide about the class model.

On your follow-up, "where it makes sense": my reading is Get, Put, Patch plus the two that already had it, Post and GetCollection, and nothing else. Delete, NotExposed, Error, McpTool and McpResource have no item IRI to canonicalize. So the shape would be an interface plus a small trait carrying the getter and the wither, implemented by those five, rather than a constructor parameter on HttpOperation. SerializerContextBuilder's method_exists() gate then stays false for MCP by construction, and the extractor rule in the other thread becomes that interface instead of is_a(..., HttpOperation::class).

That is a rewrite of the metadata half of the PR, so I would rather have your yes before doing it. Three questions:

  1. Interface + trait on the five operations, or keep HttpOperation and scope the cascade?
  2. Skip the cascade when the operation's own uriTemplate equals the template — regardless of 1?
  3. If the interface route, should the five get the constructor parameter as well, so it is reachable from PHP attributes and not only through cascade?

$item->setId(1);

// e.g. a PATCH operation with a custom URI template, whose IRI must point to the canonical operation
$operation = (new \ApiPlatform\Metadata\Patch())->withName('patch_custom')->withItemUriTemplate('/dummies/{id}{._format}');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline FQCN — please add use ApiPlatform\Metadata\Patch; at the top and write new Patch().

Two more: the method sits between getResourceClassResolver() and getIriConverter(), i.e. among the private helpers rather than with the other tests; and new test code should use PHPUnit mocks rather than Prophecy (the surrounding file is legacy, we're not extending it).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three done: use ApiPlatform\Metadata\Patch; at the top, the test moved up with the other tests, and it builds its doubles with createMock()/createStub() and its own IriConverter rather than going through the Prophecy helper. The new test for the unresolvable template does the same.

}

if (\in_array((string) $operation['class'], [GetCollection::class, Post::class], true)) {
if (\in_array((string) $operation['class'], [GetCollection::class, Post::class, Get::class, Patch::class, Put::class], true)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that itemUriTemplate lives on HttpOperation, this hardcoded list is out of step with the class model: Delete, NotExposed, Error, McpTool and McpResource all extend HttpOperation, so they inherit the getter/wither and receive the resource-level cascade, yet XML/YAML rejects the option on them with "not allowed". The list also rejects user subclasses of Get. is_a($operation['class'], HttpOperation::class, true) expresses the actual rule. Same at YamlResourceExtractor.php:356.

Related: none of Delete/NotExposed/Error/McpTool/McpResource got a constructor parameter, so the option is unreachable from PHP attributes on them while still arriving via cascade. Worth making deliberate either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the hardcoded list contradicts the class model. I have not changed it yet because the right rule falls out of the HttpOperation thread: is_a($class, HttpOperation::class, true) if the option stays there, or the new interface if it moves onto the five operations that have an item IRI. Same for your related point — whichever set ends up carrying the getter should also carry the constructor parameter, so the option is reachable from attributes and not only through the cascade. I will do both in one go once you pick.

protected array $extraProperties = [],
?bool $map = null,
protected ?array $mcp = null,
protected ?string $itemUriTemplate = null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user-facing option deserves a docblock in the style of the neighbouring parameters — in particular the precedence rule (op ?? resource ?? null), and the fact that the default also lands on GetCollection/Post, where itemUriTemplate already had a meaning. That one is semantically coherent (both already mean "items of this collection point at that template"), just worth stating.

Also needs a docs PR on api-platform/docs before this ships.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, in the style of the neighbouring parameters, with the precedence rule and the note that on GetCollection and Post the option keeps the meaning it already had.

Docs PR on api-platform/docs once the shape settles, which I would rather not write twice.

Following review: OperationMetadataFactory::create() can return null, so an
unresolvable itemUriTemplate now leaves the operation alone instead of fataling
on every IRI of the resource, on Symfony and on Laravel. The branch also sets
$context['item_uri_template'], so the two routes to the same canonical operation
give IdentifiersExtractor the same context.

The IriConverter test moves up with the other tests, uses PHPUnit doubles rather
than the Prophecy helper, and gains a case for the unresolvable template.
ApiResource::$itemUriTemplate gets a docblock in the style of its neighbours.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IRI of entities with custom controller contain route/path information

2 participants