Repository navigation
Description
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 {}DELETEanswered 204 and did not delete,PATCHoverwrote unsent fields. Fixed in fix(state): map to the resource on mapped write operations #8848.POSTreturns theBookshape, OpenAPI and Hydra documentBookOutput.- a custom processor that returns its own
BookOutputgets it mapped again toBookbyObjectMapperOutputProcessor.
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.
isResourceClassusesis_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
idproperty and never gets aNotExposedroute, 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:
LocationandContent-Locationare about the resource the operation acted on. On a 201 it is the createdBook, 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
- the provider maps to the resource on unsafe methods (fix(state): map to the resource on mapped write operations #8848, 5.0)
- 5.1:
ObjectMapperOutputProcessormaps to the output class,LocationandContent-Locationare computed from the resource - 5.1:
itemUriTemplateon everyHttpOperation, cascading from the resource (feat(metadata): add canonicalUriTemplate to resources and HTTP operations #8479) - 6.0: collect
inputandoutputclasses as resources, with the metadata-time errors - 6.0: the blank node rule, blank nodes are embedded in every format
- 6.0: a view has the
@typeof its resource - 6.0: remove the anonymous code paths for declared classes
- docs: rewrite the DTO chapter around views (docs#2321)
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
_embeddedand JSON:APIattributesfor 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