Problem/Motivation
Discovered at #2929932-15: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands:
JSON API apparently does not denormalize the
@DataType-level data it receives! 😱 You'd think that #2950486: Test coverage: custom modules' format-agnostic @FieldType and @DataType-level normalizers behavior in JSON API proves this is actually does work, but you'd be mistaken: that only handles normalization, not denormalization. You can easily observe this yourself by runningNodeTest::testPatchIndividual()and putting a breakpoint in\Drupal\jsonapi\Normalizer\EntityNormalizer::prepareInput().
\Drupal\jsonapi\Normalizer\FieldItemNormalizer::normalize()calls core's@serializerservice's::normalize()method on a field item's properties. And\Drupal\jsonapi\Normalizer\FieldItemNormalizer::denormalize()simply throws an exception.This means that in the current (and all prior) versions of JSON API, any
@DataType-level normalization only works for reading, not for writing … 😰
Proposed resolution
- Fix
\Drupal\jsonapi\Normalizer\FieldItemNormalizer::denormalize() Add denormalization test coverage to the test coverage that #2950486: Test coverage: custom modules' format-agnostic @FieldType and @DataType-level normalizers behavior in JSON API added.
Remaining tasks
TBD
User interface changes
TBD
API changes
TBD
Data model changes
TBD
| Comment | File | Size | Author |
|---|---|---|---|
| #33 | 2955615-33.patch | 46.98 KB | wim leers |
| #33 | interdiff.txt | 2.71 KB | wim leers |
| #32 | 2955615-32.patch | 47.24 KB | wim leers |
| #32 | interdiff.txt | 856 bytes | wim leers |
| #30 | 2955615-29.patch | 47.15 KB | wim leers |
Comments
Comment #2
wim leersThis hard-blocks #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands, which in turn hard blocks #2931785: The path for JSON API to core.
Comment #3
wim leersBefore I really start digging into this, I'd like @e0ipso's thoughts and explicit +1. I don't want to sink hours into this to then get a veto. Attached is a very rough initial patch to show where changes would need to be made at minimum.
Comment #4
e0ipsoLooking good @Wim Leers. I don't see any other way to fix this.
Do we really need to alter the signature here?
$formatwill be'api_json', and$contextis not used anywhere and could be omitted and default to[].Yay! Good start. Will this work if the user input string is denormalized to a PHP object? Ultimately this value goes into
Entity::create.Comment #5
e0ipsoComment #6
wim leersWhat this blocks and its relation to existing core issues
I started working on #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands again, and this is a blocker for that.
In parallel with this issue, a similar issue was filed against the
serialization.modulecomponent in Drupal core: #2957385: FieldItemNormalizer never calls @DataType-level normalizer service' ::denormalize() method. That is basically about the exact same problem. (It was discovered independently, in #2926507-36: Discourage @FieldType-level normalizers, encourage @DataType-level normalizers, to strengthen the API-First ecosystem.)Of course, if this is an existing bug in Drupal core … then arguably this issue should not be in the the path of blocking JSON API getting into core. This issue blocks #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands (and was discovered there), which in turn blocks/is required by #2931785: The path for JSON API to core, which is the issue that lists all must-haves before JSON API can go into core.
Nuance
There is a nuanced difference between how Drupal core is impacted and how JSON API is impacted though.
Drupal core has a history/tradition of creating
@FieldType-level normalizers. Those that do exist will not go away. A notable example isTimestampItemNormalizer, which #2926508: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API is deprecating in favor of the@DataType-levelTimestampNormalizer.It is thanks to the fact that
TimestampItemNormalizerdoes still exist for Drupal core that this is less of a problem for Drupal core: we can't removeTimestampItemNormalizerdue to BC reasons: some developers may have code extending this class or customizing it.But in JSON API, we do not have a
@FieldType-level equivalent. In fact, since #2933343: Define, document and mark scope of PHP and HTTP API,jsonapi.api.phpexplicitly documents that JSON API does NOT support@FieldType-level normalizers! Hence #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands is hard-blocked on this issue: because the work-around that core uses is not available in JSON API! (And core only has this work-around through sheer accident.)Possible solutions
Based on the analysis above, I see only two ways forward:
@FieldType-level normalizers. This has enormous long-term maintainability consequences, including API-First ecosystem consequences. (To have a new field type/data type work in JSON API and REST as expected, you'd have to implement different normalizers for each! At least twice the work!Conclusion & next steps
Solution #1 would be a big step backwards. Therefore, I'm working on solution #2. Stay tuned for a patch.
Comment #7
wim leersFirst, let's add that test coverage. (This patch is not relative to #3.)
The tests should FAIL.
Comment #9
wim leersDrupal\Tests\jsonapi\Functional\ExternalNormalizersTestfailed as expected in #7, great!The next step is to actually do what the IS describes:
So, in this patch (relative to #3+#7):
EntityNormalizer, and that class still ends up doing aEntity::create(…)call with an array of dataEntityNormalizernow denormalizes the received field value into the field item list class for that particular field type (yet still expects an array so that the above still works)FieldNormalizernow denormalizes the received field value per item, into the field item class for that particular field type (yet still expects an array so that the above still works)FieldItemNormalizernow denormalizes the received field item value per property (yet still expects an array so that the above still works)IOW: we still end up doing
, but at least now each of those levels can choose to have its own denormalization logic. This unblocks #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands. It'd be nicer to actually denormalize into those various objects, but … core's
serializationmodule doesn't do that either; that's a nice-to-have follow-up for the future (@todos in place).Comment #11
wim leersAhhh, lots of test failures now. Clearly, I missed something! But what?
Turns out that any entity type that uses bundles is currently failing, because those bundles are actually entity reference fields, yet
\Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::denormalize()was designed only for denormalizing relationships (for updating relationships).This denormalization logic was introduced in #2738117: [FEATURE] Add support for POST in relationships, and is specifically designed for the
/jsonapi/…/relationships/{related}endpoint, which does:Because JSON API has been denormalizing entities by passing all the received field values simply as-is to
Entity::create([…]), it's easy to see how\Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::denormalize()was only ever called/exercised by that relationship route! And that in turn explains this weird new error:EntityReferenceFieldNormalizer::denormalize()is tightly coupled to\Drupal\jsonapi\Controller\EntityResource::createRelationship().Making all this clean and elegant is out-of-scope. We should make the minimal amount of changes necessary to still allow the relationship route to denormalize received data, while also allowing "normal" entity denormalization to work correctly: entity types with bundles should be able to denormalize their bundle entity references.
Turns out that's quite simple:
Relationship::classas the serialization class rather thanEntityReferenceFieldItemList::class.EntityReferenceFieldNormalizertoRelationshipNormalizer.(Which means that
EntityReferenceFieldNormalizerreuses/falls back toFieldNormalizer's denormalization logic, which is fine!)In other words: this fixes unforeseen consequences of #2738117: [FEATURE] Add support for POST in relationships's less-than-tidy-but-good-enough-at-the-time implementation.
Comment #13
wim leersIn doing this, the changes that #2946746: Unhandled exceptions/fatal errors when POST/PATCH documents contain unknown field names made to
EntityResource::createIndividual()andEntityResource::patchIndividual()can be massively simplified. And in fact, they need to be, because HEAD's denormalization logic happily accepts values for non-existing fields, but as of this change that's no longer true (because we look up the various field item (list) classes, and denormalize into them).Comment #15
wim leersJsonApiDocumentTopLevelNormalizerTest::testDenormalizeUuid()failed, because entity denormalization no longer accepts values for non-existing field blindly, only to discard them. Turns out that the test coverage had a small bug all this time! Strictness FTW, because it makes for fewer edge cases :)Drupal\Tests\jsonapi\Unit\Routing\RoutesTestfailed, because #11 hadn't updated the route definition expectations yet.EntityReferenceFieldNormalizerTestfailed, because I failed to notice in #11 that 100% of the test coverage was for denormalization. So, renamed this toRelationshipFieldNormalizerTestConfigEntityNormalizerTestfailed because it talked to the container directly (using\Drupal::service()). I still had to update its constructor.With all that done, this should now be green!
Comment #17
wim leersTypo. 😅
Comment #18
wim leersFixed CS violations.
Comment #19
wim leersOver at #2929932-19: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands, I wrote:
Unfortunately, the same basic truth applies here too. If JSON API 8.x-1.x gets this committed, then once Drupal 8.6 is released with #2926508: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API, JSON API 8.x-1.x on Drupal 8.6.x will have a different normalization. This will be perceived as a BC break by JSON API users.
Therefore, even though this is a critical bugfix, it should only happen in the 8.x-2.x branch, to minimize disruption. All users will soon be encouraged to update to 8.x-2.x, but those on 8.x-1.x should be able to continue to use it they choose to do so.
Comment #20
wim leers#2942561: Assert denormalizing the JSON API response results in the expected object just landed. Reuploading for retesting. Based on the unexpected fails in #2929932-24: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands, it seems that that the test coverage that #2942561 added only works for content entities as of this patch.
Comment #21
wim leersThat failed, as predicted.
This should solve it.
Comment #22
wim leersComment #23
wim leersYay! That passed as expected 🎉
IMHO this is RTBC.
Comment #24
gabesulliceReroll.
Comment #25
gabesulliceThis is what got lost in the merge conflict. We updated this in #2976371: Impossible to POST|PATCH relationship to bundle-less entity or entity reference field without target bundles after the patch was made and so that change also needs to be moved into
EntityNormalizerEDIT: ignore patch for 24, that shouldn't have been uploaded in this comment.
Comment #26
gabesulliceDerp
Comment #27
gabesullicePhew, massive effort @Wim Leers! Good job. Sorry it took so long for me to review. I let it fall in the queue at which point it was out of site and out of mind.
Nit: Can we do this so we can avoid the
unsets?Is there an issue # for this? Is this pending a Drupal Core change? Essentially, why don't we do this here?
This is where we actually accomplish the mission of this issue, right?
Should we have an error/follow-up/test for submitting an itemized field to a single cardinality field?
Same as before.
Nit:
s/fields/field types/"If we get here, it's via a relationship POST/PATCH"
Is that understanding correct? If so, let's update to the comment. That'll also fix the grammar issues.
Invalidates the class comment.
Same.
Is this necessary? Perhaps it was a development aid?
Whoa, what was that doing in there to begin with?
Comment #28
wim leersThis is still blocking #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands, which is otherwise RTBC.
Rebased #26.
Comment #30
wim leers#27:
Addressed everything except points 2 and 5. All other points were super trivial.
Comment #32
wim leers#2934149: [>=8.5] JSON API routes not specifying _content_type_format route requirement, resulting in bad DX is what triggered that new failure.
Comment #33
wim leersAddressed points 2 and 5, by stepping through them with a debugger. Conclusions:
createFieldItemList()requires the target entity. Which we don't have in this case. So this needs to remain the same.(Also fixed CS violations.)
Comment #34
gabesulliceComment #38
wim leers