CustomOperationHydraFactory decides what is a custom action from the segment count of uriTemplate alone. That misclassifies in both directions as soon as a resource does not sit at a single-segment path.
What we see
In an application whose resources are grouped under a path prefix — /api/reporting/case, /api/reporting/case/{key}, /api/reporting/protocol — every read ends up marked @type: ["hydra:Operation", "schema:Action"]. 17 operations in our case.
A generic JSON-LD admin UI that builds its Actions menu from that marker then lists all of them as actions, labelled with the API Platform auto-generated operation name (_api_/reporting/protocol/{id}_get), because markAsCustom() uses $operation->getName() as hydra:title.
Five of them are NotExposed stubs that exist only for IRI generation. They route to api_platform.action.not_exposed, so those menu entries can only ever return 404.
Cause
isCustomOperation() (src/Metadata/CustomOperationHydraFactory.php:75) counts the segments of getUriTemplate(): one segment = collection, x/{id} = item, everything else = custom action.
- False positives — a resource at
/reporting/case is two segments, so its GetCollection becomes an action; /reporting/case/{key} is three, so the item Get does too.
- False negatives — a genuine action on a single-segment path (
POST /api/order_cancellation, a state transition rather than a create) keeps schema:CreateAction and never shows up as an action at all.
routePrefix is not part of the calculation, so two identical URLs are classified differently depending on how they were assembled: dmstr/flowable-bundle uses routePrefix: '/flowable' + uriTemplate: '/tasks' and comes out correct, while the same URL written as uriTemplate: '/flowable/tasks' does not.
Workaround we use today
Authoring hydraContext on the operation wins, because both layers fill in by array union and leave existing keys alone (DocumentationNormalizer::getHydraOperation() in api-platform/core, markAsCustom() here):
new GetCollection(
uriTemplate: '/reporting/case',
hydraContext: ['@type' => ['hydra:Operation', 'schema:FindAction']],
)
That is 17 copies of the same line, and it only cures the symptom.
Worth noting the asymmetry: forcing the other direction additionally needs @id or hydra:uriTemplate written by hand, because urlCarrier() runs only inside markAsCustom().
Proposal
-
An explicit marker takes precedence over the heuristic. extraProperties is the bag API Platform intends for this and needs no new attribute — operations are constructor objects, so a PHP attribute is not an option anyway:
new Post(uriTemplate: '/order_cancellation', extraProperties: ['custom_action' => true])
true forces the action marker, false suppresses it, absent falls back to the heuristic.
-
Make the fallback relative to the resource's own base path instead of an absolute segment count: the shortest uriTemplate among a resource's operations is the base, base and base/{id} are standard, anything beyond is an action — with routePrefix folded in so identical URLs classify identically.
Checked against all 42 operations of our application, that rule gets every one right.
Also
The factory has neither a README section nor tests (tests/ only has OpenApi/ and Service/). Happy to open a PR with both if the direction is agreed.
CustomOperationHydraFactorydecides what is a custom action from the segment count ofuriTemplatealone. That misclassifies in both directions as soon as a resource does not sit at a single-segment path.What we see
In an application whose resources are grouped under a path prefix —
/api/reporting/case,/api/reporting/case/{key},/api/reporting/protocol— every read ends up marked@type: ["hydra:Operation", "schema:Action"]. 17 operations in our case.A generic JSON-LD admin UI that builds its Actions menu from that marker then lists all of them as actions, labelled with the API Platform auto-generated operation name (
_api_/reporting/protocol/{id}_get), becausemarkAsCustom()uses$operation->getName()ashydra:title.Five of them are
NotExposedstubs that exist only for IRI generation. They route toapi_platform.action.not_exposed, so those menu entries can only ever return 404.Cause
isCustomOperation()(src/Metadata/CustomOperationHydraFactory.php:75) counts the segments ofgetUriTemplate(): one segment = collection,x/{id}= item, everything else = custom action./reporting/caseis two segments, so itsGetCollectionbecomes an action;/reporting/case/{key}is three, so the itemGetdoes too.POST /api/order_cancellation, a state transition rather than a create) keepsschema:CreateActionand never shows up as an action at all.routePrefixis not part of the calculation, so two identical URLs are classified differently depending on how they were assembled:dmstr/flowable-bundleusesroutePrefix: '/flowable'+uriTemplate: '/tasks'and comes out correct, while the same URL written asuriTemplate: '/flowable/tasks'does not.Workaround we use today
Authoring
hydraContexton the operation wins, because both layers fill in by array union and leave existing keys alone (DocumentationNormalizer::getHydraOperation()in api-platform/core,markAsCustom()here):That is 17 copies of the same line, and it only cures the symptom.
Worth noting the asymmetry: forcing the other direction additionally needs
@idorhydra:uriTemplatewritten by hand, becauseurlCarrier()runs only insidemarkAsCustom().Proposal
An explicit marker takes precedence over the heuristic.
extraPropertiesis the bag API Platform intends for this and needs no new attribute — operations are constructor objects, so a PHP attribute is not an option anyway:trueforces the action marker,falsesuppresses it, absent falls back to the heuristic.Make the fallback relative to the resource's own base path instead of an absolute segment count: the shortest
uriTemplateamong a resource's operations is the base,baseandbase/{id}are standard, anything beyond is an action — withroutePrefixfolded in so identical URLs classify identically.Checked against all 42 operations of our application, that rule gets every one right.
Also
The factory has neither a README section nor tests (
tests/only hasOpenApi/andService/). Happy to open a PR with both if the direction is agreed.