From 5316d462427eead26763ac7f10152bf633466134 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Thu, 30 Jul 2026 13:25:32 +0200 Subject: [PATCH] Handle JSON null when reading object/array-typed properties JSONSchemaObject>>readUsing: and JSONSchemaArray>>readUsing: went straight to a structural parse (parseMapKeysDo:/parseListDo:, expecting { or [ respectively) with no concept of null, even though any property can legitimately be JSON null regardless of its declared type - Stripe's responses do this constantly (address, discount, items, and most other optional fields are null unless set). Added a shared JSONSchema>>peekNullThenReadUsing:ifNotNull: (uses parseConstantDo:, which does not consume the stream on a non-match, to peek for null/true/false before committing to the structural read) and wired both readUsing: methods through it. A literal true/false where an object/array was expected now raises a clear JSONTypeError instead of an opaque NeoJSONParseError ('{ expected'/'[ expected'). Found the same way as the rest of today's fixes: driving OpenApiClient against the real Stripe spec + a live stripe-mock instance. The full Customer and Subscription response bodies - both deeply nested, both full of null fields - now read back correctly end to end. --- .../JSONSchemaTests.class.st | 36 +++++++++++++++ source/JSONSchema-Core/JSONSchema.class.st | 18 ++++++++ .../JSONSchema-Core/JSONSchemaArray.class.st | 9 ++-- .../JSONSchema-Core/JSONSchemaObject.class.st | 44 +++++++++---------- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/source/JSONSchema-Core-Tests/JSONSchemaTests.class.st b/source/JSONSchema-Core-Tests/JSONSchemaTests.class.st index d1f4e73..4c111c8 100644 --- a/source/JSONSchema-Core-Tests/JSONSchemaTests.class.st +++ b/source/JSONSchema-Core-Tests/JSONSchemaTests.class.st @@ -57,6 +57,31 @@ JSONSchemaTests >> testSchemaReadArrayMultipleItems [ self assert: object foo third equals: (DateAndTime year: 2019 month: 10 day: 9 hour: 13 minute: 15 second: 8 offset: 1 hour). ] +{ #category : 'tests' } +JSONSchemaTests >> testSchemaReadArrayPropertyThatIsNull [ + "A schema saying a property is an array does not forbid the actual + value being JSON null - readString: must not crash trying to parse + null as a list." + | schema object | + schema := { + #foo -> (JSONSchemaArray new items: JSONSchema string). + } asJSONSchema. + object := schema readString: '{"foo":null}'. + self assert: object foo equals: nil +] + +{ #category : 'tests' } +JSONSchemaTests >> testSchemaReadArrayPropertyThatIsWrongType [ + "A literal true/false where an array was expected is invalid JSON for + this schema and should raise a clear type error, not an obscure parser + error from trying to read it as a list." + | schema | + schema := { + #foo -> (JSONSchemaArray new items: JSONSchema string). + } asJSONSchema. + self should: [ schema readString: '{"foo":true}' ] raise: JSONTypeError +] + { #category : 'tests' } JSONSchemaTests >> testSchemaReadNested [ @@ -75,6 +100,17 @@ JSONSchemaTests >> testSchemaReadNested [ ] +{ #category : 'tests' } +JSONSchemaTests >> testSchemaReadNestedObjectPropertyThatIsNull [ + "Same as above for an object-typed property." + | schema object | + schema := { + #bar -> { #one -> JSONSchema string } + } asJSONSchema. + object := schema readString: '{"bar":null}'. + self assert: object bar equals: nil +] + { #category : 'tests' } JSONSchemaTests >> testSchemaWriteArray [ diff --git a/source/JSONSchema-Core/JSONSchema.class.st b/source/JSONSchema-Core/JSONSchema.class.st index 49e511b..91477b9 100644 --- a/source/JSONSchema-Core/JSONSchema.class.st +++ b/source/JSONSchema-Core/JSONSchema.class.st @@ -164,6 +164,24 @@ JSONSchema >> neoJsonOn: jsonWriter [ ] +{ #category : 'private' } +JSONSchema >> peekNullThenReadUsing: aReader ifNotNull: aBlock [ + "Both object- and array-typed schemas hand off straight to a structural + parse (parseMapKeysDo:/parseListDo:), which expects { or [ and has no + concept of null - but any OpenAPI/JSON-Schema property can legitimately + be null regardless of its declared type. Peek for a null/true/false + literal first (parseConstantDo: does not consume the stream if there is + no match) before committing to the structural read." + | matched value | + matched := false. + value := nil. + aReader parseConstantDo: [ :v | matched := true. value := v ]. + (matched and: [ value isNil ]) ifTrue: [ ^ nil ]. + matched ifTrue: [ + ^ JSONTypeError signal: self class typeName, ' cannot be ', value printString ]. + ^ aBlock value +] + { #category : 'instance creation' } JSONSchema >> readFrom: aStream [ ^ self readUsing: (NeoJSONReader on: aStream) diff --git a/source/JSONSchema-Core/JSONSchemaArray.class.st b/source/JSONSchema-Core/JSONSchemaArray.class.st index b35583e..ed7cc9e 100644 --- a/source/JSONSchema-Core/JSONSchemaArray.class.st +++ b/source/JSONSchema-Core/JSONSchemaArray.class.st @@ -61,15 +61,16 @@ JSONSchemaArray >> read: aString object: object [ { #category : 'private' } JSONSchemaArray >> readUsing: aReader [ - | index | - index := 0. - ^ Array streamContents: [:stream | + ^ self peekNullThenReadUsing: aReader ifNotNull: [ + | index | + index := 0. + Array streamContents: [:stream | aReader parseListDo: [ stream nextPut: (items isCollection ifTrue: [ index := index + 1. (items at: index) readUsing: aReader ] - ifFalse: [ items readUsing: aReader ])]] + ifFalse: [ items readUsing: aReader ])]] ] ] { #category : 'accessing' } diff --git a/source/JSONSchema-Core/JSONSchemaObject.class.st b/source/JSONSchema-Core/JSONSchemaObject.class.st index cb9800d..18fddcd 100644 --- a/source/JSONSchema-Core/JSONSchemaObject.class.st +++ b/source/JSONSchema-Core/JSONSchemaObject.class.st @@ -149,27 +149,24 @@ JSONSchemaObject >> read: string object: object [ ] { #category : 'as yet unclassified' } -JSONSchemaObject >> readKey: key reader: reader [ - properties ifNotNil: [ - properties - at: key - ifPresent: [ :propertySchema | - "schema for property is existing using it to read the value" - ^ propertySchema readUsing: reader ] ]. - - (additionalProperties isNil | (additionalProperties = true)) - ifTrue: [ - "if additionProperties is absent or true any additional property - is allowed" - ^ reader parseValue ]. - - (additionalProperties = false) - ifTrue: [ - "addtional properties are disallowed" - JSONTypeError signal: 'additional properties are not allowed' ]. - - "if additionalProperty is present and not a boolean it is a - schema defining the type of the addtional properties" +JSONSchemaObject >> readKey: key reader: reader [ + properties ifNotNil: [ + properties + at: key + ifPresent: [ :propertySchema | + "schema for property is existing using it to read the value" + ^ propertySchema readUsing: reader ] ]. + (additionalProperties isNil | (additionalProperties = true)) + ifTrue: [ + "if additionProperties is absent or true any additional property + is allowed" + ^ reader parseValue ]. + (additionalProperties = false) + ifTrue: [ + "addtional properties are disallowed" + JSONTypeError signal: 'additional properties are not allowed' ]. + "if additionalProperty is present and not a boolean it is a + schema defining the type of the addtional properties" ^ additionalProperties readUsing: reader ] @@ -194,9 +191,8 @@ JSONSchemaObject >> readString: aString [ { #category : 'private' } JSONSchemaObject >> readUsing: reader [ - ^ self - readUsing: reader - object: self newInstance + ^ self peekNullThenReadUsing: reader ifNotNull: [ + self readUsing: reader object: self newInstance ] ] { #category : 'private' }