Skip to content

feat(jsonapi): allow opt-in client-generated IDs on POST per spec#7930

Open
abderrahimghazali wants to merge 4 commits intoapi-platform:mainfrom
abderrahimghazali:fix/jsonapi-post-id-not-required
Open

feat(jsonapi): allow opt-in client-generated IDs on POST per spec#7930
abderrahimghazali wants to merge 4 commits intoapi-platform:mainfrom
abderrahimghazali:fix/jsonapi-post-id-not-required

Conversation

@abderrahimghazali
Copy link
Copy Markdown

Q A
Branch? main
Bug fix? yes
New feature? no (opt-in flag)
Deprecations? no
Issues Closes #6738
License MIT

What's in this PR?

Two related JSON:API issues prevent valid POST requests with client-generated IDs (per JSON:API §7.3):

  1. Schema (SchemaFactory) declared data.id required for every operation, including the request body of a POST. The spec says id MAY be supplied by the client and is otherwise optional on creation.
  2. Denormalizer (ItemNormalizer) treated any incoming data.id as a hint to load an existing resource, then either threw Update is not allowed for this operation or failed to resolve the IRI when the client passed a fresh UUID.

Together they make it impossible to POST {"data":{"type":"…","id":"<uuid>","attributes":{…}}} even when the application is designed for client-generated identifiers (Doctrine UUID PK, ULID, etc.).

Fix

JsonApi\JsonSchema\SchemaFactory

  • For Schema::TYPE_INPUT on a Post operation, data.required is now ["type"]. Output schemas and non-Post operations remain ["type", "id"] (response payloads still always carry an id).
  • The active operation is captured at the start of buildDefinitionPropertiesSchema() because the relationship loop reassigns \$operation.

Before (POST request body):
```json
"required": ["type", "id"]
```
After:
```json
"required": ["type"]
```

JsonApi\Serializer\ItemNormalizer

  • New opt-in context flag ItemNormalizer::ALLOW_CLIENT_GENERATED_ID ('allow_client_generated_id').
  • On a Post, an incoming data.id no longer triggers an existing-resource lookup.
    • If the flag is off (default): throws NotNormalizableValueException with a clear message — no behaviour change for endpoints that don't expect client-generated IDs, and no risk of silently letting a client spoof an ID.
    • If the flag is on: the id is merged into the denormalized payload and applied to the new entity via the property setter. The IRI converter is not queried.
  • Existing PUT/PATCH path (load by IRI / OBJECT_TO_POPULATE) is preserved.

The flag is off by default to keep the change non-breaking and to avoid an ID-spoofing footgun on public endpoints. Applications opt in per-operation by passing the flag in the denormalization context (or via a state processor / serializer context builder).

Tests

  • SchemaFactoryTest::testBuildSchemaForPostInputDoesNotRequireId — POST input drops id from required.
  • SchemaFactoryTest::testBuildSchemaForPostOutputStillRequiresId — POST output still requires id.
  • ItemNormalizerTest::testDenormalizePostWithIdThrowsWithoutOptIn — POST with client id throws without opt-in.
  • ItemNormalizerTest::testDenormalizePostWithIdSucceedsWithOptIn — POST with client id succeeds with opt-in, IRI converter is not called, id is set on the new entity.

Full src/JsonApi/Tests suite: 57 tests / 145 assertions, all green (the 2 PHPUnit notices are pre-existing).

Spec references

Credit to @cay89 for the original analysis in #6738.

// Per JSON:API spec, `id` is optional in the request body of a creation:
// https://jsonapi.org/format/#crud-creating
$required = ['type', 'id'];
if (Schema::TYPE_INPUT === $type && $resourceOperation instanceof Post) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we prefer $resourceOperation->getMethod()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, I switched to 'POST' === $resourceOperation->getMethod() and dropped the now-unused Post import. Added a null guard since $resourceOperation is typed ?Operation. Thanks! 🙂

getMethod() is declared on HttpOperation, not on the abstract Operation,
so PHPStan flagged the call. Use instanceof HttpOperation as the guard.
// Avoid issues with proxies if we populated the object
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
if (true !== ($context['api_allow_update'] ?? true)) {
if ($operation instanceof Post) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same lets prefer the getMethod approach

@soyuka
Copy link
Copy Markdown
Member

soyuka commented Apr 27, 2026

Good alignment with the spec, we need to provide a configuration option that sets this context value, also this will be considered as a new feature and commit/pr title should be renamed accordingly.

Address review feedback from @soyuka:
- ItemNormalizer now uses HttpOperation::getMethod() instead of `instanceof Post`.
- The ALLOW_CLIENT_GENERATED_ID flag can now be enabled declaratively on the
  operation via `extraProperties`, in addition to the denormalization context:
  `#[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])]`.
@abderrahimghazali abderrahimghazali changed the title fix(jsonapi): allow opt-in client-generated IDs on POST per spec feat(jsonapi): allow opt-in client-generated IDs on POST per spec Apr 27, 2026
@abderrahimghazali
Copy link
Copy Markdown
Author

Done in 251eb81ItemNormalizer now uses getMethod() (and the Post import is gone), and ALLOW_CLIENT_GENERATED_ID can be enabled declaratively per operation via extraProperties:

#[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])]

Added a test covering that path. Renamed the PR title to feat(jsonapi): … accordingly. Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

For JSON:API, the ID is not required in the POST request

2 participants