feat(metadata): resource and operation level itemUriTemplate - #8479
feat(metadata): resource and operation level itemUriTemplate#8479Amoifr wants to merge 2 commits into
Conversation
a2497c0 to
235acb3
Compare
soyuka
left a comment
There was a problem hiding this comment.
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())) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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())) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); | ||
|
|
There was a problem hiding this comment.
These two tests don't execute the new IriConverter blocks, so the description overstates what they cover. The chain:
State/Processor/SerializeProcessor.php:71-74passes the request'sPatchasoperation.Serializer/SerializerContextBuilder.php:71-72sets$context['item_uri_template']for any non-collection operation wheremethod_exists($operation, 'getItemUriTemplate')returns non-null — and lifting the method ontoHttpOperationis by itself enough to make that true forPatch. Forrenamethe value is the cascaded resource-level template; forpatch_overridethe explicit one.JsonLd/Serializer/ItemNormalizer.php:121forwards that context (line 84 doesn't short-circuit, there's nooutput:).Symfony/Routing/IriConverter.php:128-130resolves 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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 withoutput:no longer short-circuit toparent::normalize()JsonLd/Serializer/ItemNormalizer.php:147-166—@typeresolution takes theitem_uri_templatebranchJsonLd/ContextBuilder.php:136—getResourceContext()stops emitting@idSymfony/Routing/IriConverter.php:137and:145— skips the skolem fallback and thegetResourceClass()inheritance handlingJsonApi/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.
There was a problem hiding this comment.
I'm actually wondering if we shouldn't just put the item_uri_template where it makes sense...
There was a problem hiding this comment.
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:
- Interface + trait on the five operations, or keep
HttpOperationand scope the cascade? - Skip the cascade when the operation's own
uriTemplateequals the template — regardless of 1? - 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}'); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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@idreuses that URI instead of the canonical one.itemUriTemplateexisted only onPostandGetCollection; this lifts it up:itemUriTemplatemoves toHttpOperation(withgetItemUriTemplate()/withItemUriTemplate()), soGet,PatchandPutaccept it;PostandGetCollectionkeep their signature and delegate.ApiResourcegainsitemUriTemplateas a DRY default; the propagation to operations is automatic through the genericcascadeFromResource()/copyFrom()mechanism, with op-level precedence (op ?? resource ?? null).HttpOperationcarrying anitemUriTemplate, the target operation is resolved throughOperationMetadataFactory::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).itemUriTemplateat the resource level, and the op-level whitelist acceptsGet,PatchandPut(it threw before); XSD updated.SerializerContextBuilder,OperationContextTraitand the JSON:APIItemNormalizeralready feeditem_uri_templatethroughmethod_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
IriConverterchange. They do not, as @soyuka pointed out and as I verified: with bothIriConverterhunks reverted,CanonicalIriTeststays green. What it actually covers is the lifting ofitemUriTemplateontoHttpOperation, which is enough on its own to makeSerializerContextBuildersetitem_uri_templatefor aPatch, and the canonical operation is then resolved at the top ofgetIriFromResource().IriConverter: an item operation carrying anitemUriTemplategenerates the IRI of the canonical operation, and an unresolvable template leaves the operation alone.CanonicalIriTest: the canonical@idfor a PATCH on a custom URI, with the resource-level default and with an op-level override taking precedence.ResourceMetadataCompatibilityTestcovers 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.