API Platform Returned an IRI Instead of the Object, and Saving the Form Cleared the Owner

By Lars Nielsen ·

In CaRMen, my CRM, every deal has an owner and every activity has an assignee: a user. For a while the deal list showed "-" in the Owner column for every deal, which looked like a display bug. It was worse than that. Opening a deal and saving it, without touching the owner field, removed the owner from the deal. The same happened to an activity's assignee.

Both symptoms came from one thing: API Platform was sending the owner as an IRI, a link of the form /api/users/{id}, instead of an object, and the frontend expected an object.

IRI or object: what decides it

API Platform's documentation is clear about the default. Relations between resources are represented as dereferenceable IRIs, links the client can follow with another request to get the details. To get the related object inside the response instead, you use serialization groups. The rule, in the docs' words: "As soon as any of the author's attributes is in the book group, the author will be embedded."

In CaRMen, a deal is read with the deal:read normalization group. The User entity's fields, including id and the computed fullName, were only in user:read. None of the user's attributes were in deal:read, so by that rule the owner was serialized as an IRI string. Nothing was wrong with the API's output; it did exactly what the groups said.

How a link became a deleted owner

The frontend's deal form fills its owner dropdown from the deal it's editing. The line that does it reads:

  • owner: initial?.owner?.id ? `/api/users/${initial.owner.id}` : '',

That expects owner to be an object with an id. With an IRI string instead, initial.owner.id is undefined, so the dropdown starts empty. When the form is submitted, the payload uses form.owner || null, and an empty string becomes null. The PATCH request said, in effect, "this deal has no owner", and the API saved that.

The "-" in the list had the same cause: the list renders d.owner?.fullName ?? '-', and a string has no fullName.

This is the kind of bug that survives because each half is reasonable on its own. The API's response is valid and documented behavior. The form's code is fine for the object it was written against. The failure is in the contract between them, and nothing checked the contract.

The fix: two attributes, two fields

The fix was to put exactly the fields the UI needs into the deal and activity groups, on the User entity:

  • #[Groups(['user:read', 'deal:read', 'activity:read'])] on $id
  • the same groups on getFullName()

With two of the user's attributes now in deal:read, the owner is embedded as a small object, {"id": ..., "fullName": ...}, and the form can prefill the dropdown and the list can show the name.

The other part of the fix is what stays out. Only id and fullName got the new groups. A user's email address and roles are still only in user:read, so a deal response doesn't expose them. Putting all the user's fields into deal:read would have been simpler, and would have put users' email addresses into every deal response.

Tests that pin down the shape

Two tests went in with the change. One fetches the deal collection as an admin, picks the first deal with an owner, and asserts that the owner is an array (an object in JSON, not an IRI string), that it has an id and a non-empty fullName, and that it has no email and no roles. The other checks activity assignees the same way, as a sales user, apart from the roles assertion. If someone later removes the groups, or widens them, a test fails instead of a deal silently losing its owner.

This was the second time CaRMen's frontend and API disagreed about a response shape without either side reporting an error; the first was the hydra prefix that API Platform 4 dropped. Both times the fix was small. Both times the lesson was that a response's shape is part of the API, and it deserves a test of its own.

Filed under

Leave a comment

Comments are reviewed before they appear.

Basic HTML allowed: links, emphasis, code and lists. Web addresses become links automatically.