Skip to content

[RFC] An operation's output is its own resource #8849

Open

Description

@soyuka

We've been asked four times to map the entity to the output class on write operations when the Object Mapper is used (#7611, #7940, #8420, #8497) and I closed three of them. I still think the patches were wrong, but our answer was not good either: today output works with the mapper on GET and not on POST or PATCH, and the alternative we give ("declare a second resource") does not hold. This is an attempt at a rule we can keep.

What goes wrong today

#[ApiResource(
    stateOptions: new Options(entityClass: BookEntity::class),
    output: BookOutput::class,
    operations: [new Get(), new Post(), new Patch(), new Delete()],
)]
#[Map(target: BookEntity::class)]
class Book {}
  • DELETE answered 204 and did not delete, PATCH overwrote unsent fields. Fixed in fix(state): map to the resource on mapped write operations #8848.
  • POST returns the Book shape, OpenAPI and Hydra document BookOutput.
  • a custom processor that returns its own BookOutput gets it mapped again to Book by ObjectMapperOutputProcessor.

Behind all of them there is one thing: we decide what the data is by looking at its class at runtime. Location, @id, @type, the Doctrine manager, the JSON-LD context all ask isResourceClass($data::class). When the object is not a resource we even build an operation on the fly, see ContextBuilder::getAnonymousResourceContext (new Get(shortName: ...)).

Proposal

output and input are a shortcut to declare a quick resource. We collect every class declared on a resource or on its operations when the resource names are collected, up the chain, and isResourceClass() is true for them. They are tracked with the operation that declares them, and the context of that operation (groups, etc.) keeps applying as it does today.

We already have that kind of resource. A class with no item operation gets a NotExposed one, and when it has no identifier it is a blank node: Chair in NotExposedTest is serialized with /.well-known/genid/... as @id and Chair as @type.

Rules:

  • an output must be a concrete class. isResourceClass uses is_a, an interface or a parent class would turn every implementation into a resource, we throw at metadata time
  • two collected classes with the same short name throw at metadata time, /contexts/{shortName} has to stay unique
  • an output that already is an #[ApiResource] needs nothing, it is a resource
  • a collected class does not get an identifier from its id property and never gets a NotExposed route, its identity is the item it is a view of (see below) or none
  • a resource without identity (no item operation, no identifier) is a blank node, it is always embedded and never linked. Same when we denormalize, it is read from an embedded object and never from an IRI
  • a plain object that is only typed on a property is not collected, that needs the property metadata and it would be circular

The blank node rule fixes something that is broken today for the resources we already have. A property typed with Chair (no item operation, no identifier), with no genId: false and no groups, gives on 5.0:

{
  "@id": "/furniture_showrooms/1",
  "@type": "FurnitureShowroom",
  "chair": "/.well-known/genid/114bf4add4f082164789",
  "chairs": ["/.well-known/genid/274b3ff6326a76b33e1f", "/.well-known/genid/2d29acbda8bbebc90bfe"]
}

The data of the chair never reaches the client and the IRI changes on every request. HAL puts the same genid in _links, JSON:API in relationships with no included. With the rule the chair is embedded: an object in JSON-LD and JSON, _embedded in HAL, attributes in JSON:API (a blank node has no id to make a resource identifier from). A resource that has an identifier and no item operation (/tables/t1, served by the NotExposed route) is not concerned, it keeps its IRI.

Identity: a view or another thing

@type is the rdf:type of the node named by @id, it describes the thing and not the representation (JSON-LD 1.1 §3.5, RDF Schema §3.3). There are two cases and the operation says which one it is.

A view. The output is another shape of the resource, this is what https://github.com/mtarld/apip-ddd does and it replaces serialization groups:

new GetCollection(
    output: BookListItemOutput::class,
    itemUriTemplate: '/books/{id}{._format}',
    provider: BookCollectionProvider::class,
),

itemUriTemplate declares the item the output stands for. The output gets the @id of that item and the @type of the resource, Book and not BookListItemOutput, which is what groups would give. JSON Schema and OpenAPI keep describing the shape under the name of the output class. In the Hydra documentation returns is the resource class and the view is not a supportedClass, a property that the view does not carry is just absent from the representation. Someone who needs a really different representation creates a DTO and a resource.

It is always declared, we do not guess it from the URI of the operation: Post('/books/{id}/discount', output: DiscountResult::class) is an item operation and its output is another thing. So itemUriTemplate has to exist on every HttpOperation (#8479) and it cascades from the resource:

#[ApiResource(output: BookView::class, itemUriTemplate: '/books/{id}{._format}')]

This works on write operations too.

Another thing. No item is declared: the output is a blank node with its own type (the result of a command for example).

This is why the "second #[ApiResource] on the same entity" I recommended in #8420 is not a good answer, it publishes one thing under two types. A view does that job.

Two consequences:

  • Location and Content-Location are about the resource the operation acted on. On a 201 it is the created Book, also when the body is another thing. Today it falls back to the collection IRI.
  • the mapper can build the output on write operations as it does on reads. What stays refused from fix(state): support output DTOs on write operations with ObjectMapper #8497 is setting the item template implicitly, it has to be declared on the operation.

Steps

This goes with #8840 (several resources on a shared class), an output class used by two resources is that case, and the call sites @alanpoulain listed there are the ones we touch here.

Open questions

  • HAL _embedded and JSON:API attributes for a blank node is my proposal, not sure about JSON:API.
  • Laravel and GraphQL do not run the mapper at the same place as Symfony, GraphQL mutations skip both mapper processors.

Thoughts?

Edited after reading mtarld/apip-ddd: the first version said an output is always another node and sent views back to serialization groups, and it deprecated itemUriTemplate. Views are the main use, so both are gone. input is now part of it. Second edit: itemUriTemplate is always declared, Hydra returns for a view, and the blank node rule with what 5.0 does today.

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