Skip to content

CustomOperationHydraFactory misclassifies reads on multi-segment resource paths #8

Description

@schmunk42

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/protocolevery 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

  1. 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.

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions