Skip to content

Commit 33a9984

Browse files
authored
Merge pull request #356 from brandonbloom/nullable-decoding
fix "nullable" decoding
2 parents 2834548 + d35921c commit 33a9984

File tree

4 files changed

+147
-28
lines changed

4 files changed

+147
-28
lines changed

Sources/OpenAPIKit/Schema Object/JSONSchema.swift

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,16 +1807,20 @@ extension JSONSchema: Decodable {
18071807

18081808
if let ref = try? JSONReference<JSONSchema>(from: decoder) {
18091809
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1810-
self = .reference(ref, coreContext)
1810+
self = .init(warnings: coreContext.warnings, schema: .reference(ref, coreContext))
18111811
return
18121812
}
18131813

18141814
let container = try decoder.container(keyedBy: SubschemaCodingKeys.self)
18151815

18161816
if container.contains(.allOf) {
1817-
var schema: JSONSchema = .all(
1818-
of: try container.decode([JSONSchema].self, forKey: .allOf),
1819-
core: try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1817+
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1818+
var schema: JSONSchema = .init(
1819+
warnings: coreContext.warnings,
1820+
schema: .all(
1821+
of: try container.decode([JSONSchema].self, forKey: .allOf),
1822+
core: coreContext
1823+
)
18201824
)
18211825
if schema.subschemas.contains(where: { $0.nullable }) {
18221826
schema = schema.nullableSchemaObject()
@@ -1827,9 +1831,13 @@ extension JSONSchema: Decodable {
18271831
}
18281832

18291833
if container.contains(.anyOf) {
1830-
var schema: JSONSchema = .any(
1831-
of: try container.decode([JSONSchema].self, forKey: .anyOf),
1832-
core: try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1834+
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1835+
var schema: JSONSchema = .init(
1836+
warnings: coreContext.warnings,
1837+
schema: .any(
1838+
of: try container.decode([JSONSchema].self, forKey: .anyOf),
1839+
core: coreContext
1840+
)
18331841
)
18341842
if schema.subschemas.contains(where: { $0.nullable }) {
18351843
schema = schema.nullableSchemaObject()
@@ -1840,9 +1848,12 @@ extension JSONSchema: Decodable {
18401848
}
18411849

18421850
if container.contains(.oneOf) {
1843-
var schema: JSONSchema = .one(
1844-
of: try container.decode([JSONSchema].self, forKey: .oneOf),
1845-
core: try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1851+
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1852+
var schema: JSONSchema = .init(warnings: coreContext.warnings,
1853+
schema: .one(
1854+
of: try container.decode([JSONSchema].self, forKey: .oneOf),
1855+
core: coreContext
1856+
)
18461857
)
18471858
if schema.subschemas.contains(where: { $0.nullable }) {
18481859
schema = schema.nullableSchemaObject()
@@ -1853,9 +1864,12 @@ extension JSONSchema: Decodable {
18531864
}
18541865

18551866
if container.contains(.not) {
1856-
let schema: JSONSchema = .not(
1857-
try container.decode(JSONSchema.self, forKey: .not),
1858-
core: try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1867+
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1868+
let schema: JSONSchema = .init(warnings: coreContext.warnings,
1869+
schema: .not(
1870+
try container.decode(JSONSchema.self, forKey: .not),
1871+
core: coreContext
1872+
)
18591873
)
18601874

18611875
self = schema
@@ -1915,34 +1929,48 @@ extension JSONSchema: Decodable {
19151929
let value: Schema
19161930
if typeHint == .null {
19171931
let coreContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1932+
_warnings += coreContext.warnings
19181933
value = .null(coreContext)
19191934

19201935
} else if typeHint == .integer || typeHint == .number || (typeHint == nil && !numericOrIntegerContainer.allKeys.isEmpty) {
19211936
if typeHint == .integer {
1922-
value = .integer(try CoreContext<JSONTypeFormat.IntegerFormat>(from: decoder),
1937+
let coreContext = try CoreContext<JSONTypeFormat.IntegerFormat>(from: decoder)
1938+
_warnings += coreContext.warnings
1939+
value = .integer(coreContext,
19231940
try IntegerContext(from: decoder))
19241941
} else {
1925-
value = .number(try CoreContext<JSONTypeFormat.NumberFormat>(from: decoder),
1942+
let coreContext = try CoreContext<JSONTypeFormat.NumberFormat>(from: decoder)
1943+
_warnings += coreContext.warnings
1944+
value = .number(coreContext,
19261945
try NumericContext(from: decoder))
19271946
}
19281947

19291948
} else if typeHint == .string || (typeHint == nil && !stringContainer.allKeys.isEmpty) {
1930-
value = .string(try CoreContext<JSONTypeFormat.StringFormat>(from: decoder),
1949+
let coreContext = try CoreContext<JSONTypeFormat.StringFormat>(from: decoder)
1950+
_warnings += coreContext.warnings
1951+
value = .string(coreContext,
19311952
try StringContext(from: decoder))
19321953

19331954
} else if typeHint == .array || (typeHint == nil && !arrayContainer.allKeys.isEmpty) {
1934-
value = .array(try CoreContext<JSONTypeFormat.ArrayFormat>(from: decoder),
1955+
let coreContext = try CoreContext<JSONTypeFormat.ArrayFormat>(from: decoder)
1956+
_warnings += coreContext.warnings
1957+
value = .array(coreContext,
19351958
try ArrayContext(from: decoder))
19361959

19371960
} else if typeHint == .object || (typeHint == nil && !objectContainer.allKeys.isEmpty) {
1938-
value = .object(try CoreContext<JSONTypeFormat.ObjectFormat>(from: decoder),
1961+
let coreContext = try CoreContext<JSONTypeFormat.ObjectFormat>(from: decoder)
1962+
_warnings += coreContext.warnings
1963+
value = .object(coreContext,
19391964
try ObjectContext(from: decoder))
19401965

19411966
} else if typeHint == .boolean {
1942-
value = .boolean(try CoreContext<JSONTypeFormat.BooleanFormat>(from: decoder))
1967+
let coreContext = try CoreContext<JSONTypeFormat.BooleanFormat>(from: decoder)
1968+
_warnings += coreContext.warnings
1969+
value = .boolean(coreContext)
19431970

19441971
} else {
19451972
let fragmentContext = try CoreContext<JSONTypeFormat.AnyFormat>(from: decoder)
1973+
_warnings += fragmentContext.warnings
19461974
if fragmentContext.isEmpty && hintContainerCount > 0 {
19471975
_warnings.append(
19481976
.underlyingError(

Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ extension JSONSchemaContext {
135135

136136
extension JSONSchema {
137137
/// The context that applies to all schemas.
138-
public struct CoreContext<Format: OpenAPIFormat>: JSONSchemaContext, Equatable {
138+
public struct CoreContext<Format: OpenAPIFormat>: JSONSchemaContext, HasWarnings {
139+
public let warnings: [OpenAPI.Warning]
140+
139141
public let format: Format
140142
public let required: Bool // default true
141143
public let nullable: Bool // default false
@@ -229,6 +231,7 @@ extension JSONSchema {
229231
vendorExtensions: [String: AnyCodable] = [:],
230232
_inferred: Bool = false
231233
) {
234+
self.warnings = []
232235
self.format = format
233236
self.required = required
234237
self.nullable = nullable
@@ -260,6 +263,7 @@ extension JSONSchema {
260263
examples: [String],
261264
vendorExtensions: [String: AnyCodable] = [:]
262265
) {
266+
self.warnings = []
263267
self.format = format
264268
self.required = required
265269
self.nullable = nullable
@@ -278,6 +282,24 @@ extension JSONSchema {
278282
}
279283
}
280284

285+
extension JSONSchema.CoreContext: Equatable {
286+
public static func == (lhs: JSONSchema.CoreContext<Format>, rhs: JSONSchema.CoreContext<Format>) -> Bool {
287+
lhs.format == rhs.format
288+
&& lhs.required == rhs.required
289+
&& lhs.nullable == rhs.nullable
290+
&& lhs._permissions == rhs._permissions
291+
&& lhs._deprecated == rhs._deprecated
292+
&& lhs.title == rhs.title
293+
&& lhs.description == rhs.description
294+
&& lhs.externalDocs == rhs.externalDocs
295+
&& lhs.discriminator == rhs.discriminator
296+
&& lhs.allowedValues == rhs.allowedValues
297+
&& lhs.defaultValue == rhs.defaultValue
298+
&& lhs.vendorExtensions == rhs.vendorExtensions
299+
&& lhs.inferred == rhs.inferred
300+
}
301+
}
302+
281303
// MARK: - Transformations
282304

283305
extension JSONSchema.CoreContext {
@@ -768,6 +790,7 @@ extension JSONSchema {
768790
// not nested because Context is a generic type
769791
internal enum ContextCodingKeys: String, CodingKey {
770792
case type
793+
case nullable
771794
case format
772795
case title
773796
case description
@@ -849,12 +872,15 @@ extension JSONSchema.CoreContext: Encodable {
849872

850873
extension JSONSchema.CoreContext: Decodable {
851874
public init(from decoder: Decoder) throws {
875+
var warnings: [OpenAPI.Warning] = []
876+
852877
let container = try decoder.container(keyedBy: JSONSchema.ContextCodingKeys.self)
853878

854879
format = try container.decodeIfPresent(Format.self, forKey: .format) ?? .unspecified
855880

856-
let nullable = try Self.decodeNullable(from: container)
881+
let (nullable, nullableWarnings) = try Self.decodeNullable(from: container)
857882
self.nullable = nullable
883+
warnings += nullableWarnings
858884

859885
// default to `true` at decoding site.
860886
// It is the responsibility of decoders farther upstream
@@ -913,6 +939,8 @@ extension JSONSchema.CoreContext: Decodable {
913939
// full JSON Schema.
914940
vendorExtensions = [:]
915941
inferred = false
942+
943+
self.warnings = warnings
916944
}
917945

918946
/// Support both `enum` and `const` when decoding allowed values for the schema.
@@ -927,14 +955,32 @@ extension JSONSchema.CoreContext: Decodable {
927955
}
928956

929957
/// Decode whether or not this is a nullable JSONSchema.
930-
private static func decodeNullable(from container: KeyedDecodingContainer<JSONSchema.ContextCodingKeys>) throws -> Bool {
931-
if let types = try? container.decodeIfPresent([JSONType].self, forKey: .type) {
932-
return types.contains(JSONType.null)
958+
private static func decodeNullable(from container: KeyedDecodingContainer<JSONSchema.ContextCodingKeys>) throws -> (Bool, [OpenAPI.Warning]) {
959+
let nullable: Bool
960+
var warnings: [OpenAPI.Warning] = []
961+
962+
if let _nullable = try? container.decodeIfPresent(Bool.self, forKey: .nullable) {
963+
nullable = _nullable
964+
warnings.append(
965+
.underlyingError(
966+
InconsistencyError(
967+
subjectName: "OpenAPI Schema",
968+
details: "Found 'nullable' property. This property is not supported by OpenAPI v3.1.0. OpenAPIKit has translated it into 'type: [\"null\", ...]'.",
969+
codingPath: container.codingPath
970+
)
971+
)
972+
)
973+
933974
}
934-
if let type = try? container.decodeIfPresent(JSONType.self, forKey: .type) {
935-
return type == JSONType.null
975+
else if let types = try? container.decodeIfPresent([JSONType].self, forKey: .type) {
976+
nullable = types.contains(JSONType.null)
977+
}
978+
else if let type = try? container.decodeIfPresent(JSONType.self, forKey: .type) {
979+
nullable = type == JSONType.null
980+
} else {
981+
nullable = false
936982
}
937-
return false
983+
return (nullable, warnings)
938984
}
939985
}
940986

Tests/OpenAPIKitErrorReportingTests/SchemaErrorTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,46 @@ final class SchemaErrorTests: XCTestCase {
4848
])
4949
}
5050
}
51+
52+
func test_nullablePropertyInsteadOfNullType() throws {
53+
let documentYML =
54+
"""
55+
openapi: "3.1.0"
56+
info:
57+
title: test
58+
version: 1.0
59+
paths:
60+
/hello/world:
61+
get:
62+
responses:
63+
'200':
64+
description: hello
65+
content:
66+
'application/json':
67+
schema:
68+
type: integer
69+
nullable: true
70+
"""
71+
72+
let document = try testDecoder.decode(OpenAPI.Document.self, from: documentYML)
73+
XCTAssertThrowsError(try document.validate()) { error in
74+
75+
let openAPIError = OpenAPI.Error(from: error)
76+
77+
XCTAssertEqual(openAPIError.localizedDescription,
78+
"""
79+
Inconsistency encountered when parsing `OpenAPI Schema`: Found 'nullable' property. This property is not supported by OpenAPI v3.1.0. OpenAPIKit has translated it into 'type: ["null", ...]'.. at path: .paths['/hello/world'].get.responses.200.content['application/json'].schema
80+
""")
81+
XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [
82+
"paths",
83+
"/hello/world",
84+
"get",
85+
"responses",
86+
"200",
87+
"content",
88+
"application/json",
89+
"schema"
90+
])
91+
}
92+
}
5193
}

Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1933,7 +1933,8 @@ extension SchemaObjectTests {
19331933

19341934
func test_decodeBoolean() throws {
19351935
let booleanData = #"{"type": "boolean"}"#.data(using: .utf8)!
1936-
let nullableBooleanData = #"{"type": ["boolean", "null"]}"#.data(using: .utf8)!
1936+
let booleanOrNullData = #"{"type": ["boolean", "null"]}"#.data(using: .utf8)!
1937+
let nullableBooleanData = #"{"type": "boolean", "nullable": true}"#.data(using: .utf8)!
19371938
let readOnlyBooleanData = #"{"type": "boolean", "readOnly": true}"#.data(using: .utf8)!
19381939
let writeOnlyBooleanData = #"{"type": "boolean", "writeOnly": true}"#.data(using: .utf8)!
19391940
let deprecatedBooleanData = #"{"type": "boolean", "deprecated": true}"#.data(using: .utf8)!
@@ -1943,6 +1944,7 @@ extension SchemaObjectTests {
19431944
let discriminatorBooleanData = #"{"type": "boolean", "discriminator": { "propertyName": "hello" }}"#.data(using: .utf8)!
19441945

19451946
let boolean = try orderUnstableDecode(JSONSchema.self, from: booleanData)
1947+
let booleanOrNull = try orderUnstableDecode(JSONSchema.self, from: booleanOrNullData)
19461948
let nullableBoolean = try orderUnstableDecode(JSONSchema.self, from: nullableBooleanData)
19471949
let readOnlyBoolean = try orderUnstableDecode(JSONSchema.self, from: readOnlyBooleanData)
19481950
let writeOnlyBoolean = try orderUnstableDecode(JSONSchema.self, from: writeOnlyBooleanData)
@@ -1953,6 +1955,7 @@ extension SchemaObjectTests {
19531955
let discriminatorBoolean = try orderUnstableDecode(JSONSchema.self, from: discriminatorBooleanData)
19541956

19551957
XCTAssertEqual(boolean, JSONSchema.boolean(.init(format: .generic)))
1958+
XCTAssertEqual(booleanOrNull, JSONSchema.boolean(.init(format: .generic, nullable: true)))
19561959
XCTAssertEqual(nullableBoolean, JSONSchema.boolean(.init(format: .generic, nullable: true)))
19571960
XCTAssertEqual(readOnlyBoolean, JSONSchema.boolean(.init(format: .generic, permissions: .readOnly)))
19581961
XCTAssertEqual(writeOnlyBoolean, JSONSchema.boolean(.init(format: .generic, permissions: .writeOnly)))

0 commit comments

Comments
 (0)