Problem/Motivation
Normalizers that provide JSON Schema disagree on how to describe optional data. See the change record for the JSON Schema support added to the serializer.
PrimitiveDataNormalizer::getNormalizationSchema()checksisRequired()and adds anullbranch for optional data.DateTimeNormalizer,TimestampNormalizer, andDateTimeIso8601Normalizeruse the defaultgetNormalizationSchema()fromSchematicNormalizerTraitorSchematicNormalizerHelperTrait. It reflects theJsonSchemaattribute and never checks the definition.DateTimeNormalizer::doNormalize()returnsNULLwhen there is no date, so the schema rejects valid output.TypedDataNormalizer::getNormalizationSchema()checksisRequired(), but returns invalid JSON Schema for stringable values.oneOftakes an array of schemas, not type names.
// TypedDataNormalizer::getNormalizationSchema()
return $nullable
? ['oneOf' => ['string', 'null']]
: ['type' => 'string'];
Steps to reproduce
$serializer = \Drupal::service('serializer');
$typed_data_manager = \Drupal::typedDataManager();
$string = $typed_data_manager->create(DataDefinition::create('string'));
$serializer->getJsonSchema($string, []);
// ['oneOf' => [['type' => 'string'], ['type' => 'null']]]
$timestamp = $typed_data_manager->create(DataDefinition::create('timestamp'));
$serializer->getJsonSchema($timestamp, []);
// ['type' => 'string', 'format' => 'date-time']
$serializer->normalize($timestamp);
// NULL
Both definitions are optional. Only the string schema allows null.
Proposed resolution
Decide nullability in one place and pass it to getJsonSchemaForMethod() from both default getNormalizationSchema() implementations. Use the rule PrimitiveDataNormalizer already follows: optional typed data is nullable, and so is a class name, because there is no definition to check.
protected function isNullable(mixed $object): bool {
return is_string($object)
|| ($object instanceof TypedDataInterface && !$object->getDataDefinition()->isRequired());
}
// SchematicNormalizerTrait
protected function getNormalizationSchema(mixed $object, array $context = []): array {
return $this->getJsonSchemaForMethod(
$this,
'doNormalize',
['$comment' => static::generateNoSchemaAvailableMessage($object)],
$this->isNullable($object),
);
}
PrimitiveDataNormalizer and TypedDataNormalizer switch to isNullable(). TypedDataNormalizer also fixes its stringable branch:
return $nullable
? ['oneOf' => [['type' => 'string'], ['type' => 'null']]]
: ['type' => 'string'];
Remaining tasks
- Merge request with test coverage for optional and required data in each normalizer.
User interface changes
None.
Introduced terminology
None.
API changes
JSON Schema for optional dates, timestamps, and stringable typed data allows null. Normalization output does not change.
Data model changes
None.
Release notes snippet
AI-Generated: Yes. I used Claude Code to turn my notes into this issue summary and to check the proposed resolution against the Drupal 11.4.6 source. I reviewed and edited the result. The proposed code is a sketch and has not been run.
Issue fork drupal-3627331
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mglamanComment #4
dries commentedI ran into this bug: my custom photo album module describes its data with Tool API. See https://dri.es/one-description-inside-and-outside-drupal for background. An image's date is optional, but it gets a schema that doesn't allow NULL.
As a first step, I looked into whether core's existing tests could reproduce this bug report. The good news: the date normalizers already have tests that check each example value against its schema. The not so good news: every example has a date, but none empty/optional.
The merge request adds an empty optional date to each of the three date normalizer tests, plus a test for the invalid schema of an optional
Markupvalue, the issue's other bug. The new test cases should fail, confirming the bug is in core.PrimitiveDataNormalizeralready allows NULL and has tests for it.PrimitiveDataNormalizerTesthas three null cases,string-null,integer-nullandboolean-null.Comment #6
dries commentedThe test job correctly failed on the four new test cases:
Comment #7
dries commentedI've now pushed a fix to the same MR. Disclaimer: I worked on the fix with the help of Claude Code.
The idea behind the fix:
PrimitiveDataNormalizerandTypedDataNormalizereach figured out on their own whether a schema should allow null. I moved that logic into a small method,isNullable(), inSchematicNormalizerHelperTrait. The date normalizers now get it through the defaultgetNormalizationSchema().I also added a few more tests. Taking a closer look at the fix showed that my first tests were incomplete (thank you, Claude). The fix basically has to get two things right: an optional date's schema should allow null, and a required date's schema should not allow null. My tests only checked the first. Each date normalizer test now also has
testJsonSchemaNullable(), which checks both, the same waytestStringableSchema()does.All tests should pass with this fix, but it's a good idea to get eyes on it from someone more experienced with the serialization system.
Comment #8
dries commentedOne gotcha I forgot to mention. I found a related problem that I didn't fix here, because it's part of JSON:API, not serialization. Plus, it's sort of an existing problem.
JSON:API adds its own null option to optional fields. When a field type has a single optional property, that property's schema already allows null, so null ends up in the field's schema twice. You get something weird like this:
{"oneOf": [{"oneOf": [{"type": "string"}, {"type": "null"}]}, {"type": "null"}]}Now that a date property's schema supports null, dates have the same problem. Prior to this fix, dates were not a problem, but it was sort of "by accident only".
I believe the fix for that belongs in a separate issue, but I wanted to mention it here. There are no tests for this in core so it won't fail any tests. I created a new issue for it at #3634179: JSON:API's field schema rejects its own output when a field item has a single optional property.
Comment #9
dries commentedTests pass so setting this to 'Needs review'.
Comment #10
mglamanisNullable()rule differ from the proposed resolution on purpose? The MR treats any object that isn't typed data as nullable, while the issue summary only treats class names and optional typed data as nullable. I'd go with the issue summary's rule and have normalizers declare#[JsonSchema(['type' => 'null'])]when they can returnNULL. If the broader rule stays, the issue summary needs to match it.TypedDataNormalizerTest::testStringableSchema()should validate the schema withJsonSchemaTestTraitinstead of only comparing against a hardcoded array.Generated with the help of an LLM
Comment #11
dries commentedThank you for the review, Matt. I updated the code to use the summary's proposal. I also updated the MR. More work might be needed, but take a look.
Comment #12
f0ns commentedI had a closer look at this one this morning.
Ran some tests and Matt's points in #10 all look addressed to me.
I did find one case that breaks:
#10 suggests normalizers add
#[JsonSchema(['type' => 'null'])]when they can return NULL. If a normalizer does that and uses the defaultgetNormalizationSchema(), optional data now gets null added twice:oneOfmeans "exactly one of these", and NULL now matches two of them, so it gets rejected.A small fix in
getJsonSchemaForMethod()works for me locally: only add the null branch when it isn't already there.The serialization and JSON:API unit tests still pass with it. A test for this case would be a good thing to add.
This only catches the exact
['type' => 'null']form, so it might need a broader check (e.g.['type' => ['string', 'null']]).We are very close!
Comment #13
f0ns commented