diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index 0498ba1bb..f49fe06f8 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -389,6 +389,29 @@ extension JSONSchema { } return context } + + /// Get subschemas if this schema is an anyOf, allOf, etc. + /// Returns an empty array for any schema that does not have + /// subschemas. + /// + /// - IMPORTANT: An object's properties are NOT considered + /// subschemas. + public var subschemas: [JSONSchema] { + switch self.value { + case .not(let schema, core: _): + return [schema] + case .array(_, let arrayContext): + return arrayContext.items.map { [$0] } ?? [] + case .all(of: let schemas, core: _): + return schemas + case .any(of: let schemas, core: _): + return schemas + case .one(of: let schemas, core: _): + return schemas + default: + return [] + } + } } // MARK: - Vendor Extensions @@ -1778,34 +1801,51 @@ extension JSONSchema: Decodable { let container = try decoder.container(keyedBy: SubschemaCodingKeys.self) if container.contains(.allOf) { - self = .all( + var schema: JSONSchema = .all( of: try container.decode([JSONSchema].self, forKey: .allOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.anyOf) { - self = .any( + var schema: JSONSchema = .any( of: try container.decode([JSONSchema].self, forKey: .anyOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.oneOf) { - self = .one( + var schema: JSONSchema = .one( of: try container.decode([JSONSchema].self, forKey: .oneOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.not) { - self = .not( + let schema: JSONSchema = .not( try container.decode(JSONSchema.self, forKey: .not), core: try CoreContext(from: decoder) ) + + self = schema return } diff --git a/Sources/OpenAPIKit30/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit30/Schema Object/JSONSchema.swift index 6281f273e..25cdece8b 100644 --- a/Sources/OpenAPIKit30/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit30/Schema Object/JSONSchema.swift @@ -425,6 +425,29 @@ extension JSONSchema { } return context } + + /// Get subschemas if this schema is an anyOf, allOf, etc. + /// Returns an empty array for any schema that does not have + /// subschemas. + /// + /// - IMPORTANT: An object's properties are NOT considered + /// subschemas. + public var subschemas: [JSONSchema] { + switch self.value { + case .not(let schema, core: _): + return [schema] + case .array(_, let arrayContext): + return arrayContext.items.map { [$0] } ?? [] + case .all(of: let schemas, core: _): + return schemas + case .any(of: let schemas, core: _): + return schemas + case .one(of: let schemas, core: _): + return schemas + default: + return [] + } + } } // MARK: - Transformations @@ -1725,34 +1748,51 @@ extension JSONSchema: Decodable { let container = try decoder.container(keyedBy: SubschemaCodingKeys.self) if container.contains(.allOf) { - self = .all( + var schema: JSONSchema = .all( of: try container.decode([JSONSchema].self, forKey: .allOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.anyOf) { - self = .any( + var schema: JSONSchema = .any( of: try container.decode([JSONSchema].self, forKey: .anyOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.oneOf) { - self = .one( + var schema: JSONSchema = .one( of: try container.decode([JSONSchema].self, forKey: .oneOf), core: try CoreContext(from: decoder) ) + if schema.subschemas.contains(where: { $0.nullable }) { + schema = schema.nullableSchemaObject() + } + + self = schema return } if container.contains(.not) { - self = .not( + let schema: JSONSchema = .not( try container.decode(JSONSchema.self, forKey: .not), core: try CoreContext(from: decoder) ) + + self = schema return } diff --git a/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift index e655d95df..401ca78fb 100644 --- a/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKit30Tests/Schema Object/JSONSchemaTests.swift @@ -858,6 +858,36 @@ final class SchemaObjectTests: XCTestCase { XCTAssertNil(fragment.stringContext) } + func test_subschemasAccessor() { + let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) + let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) + let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean)) + let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) + let integer = JSONSchema.integer(.init(format: .unspecified, required: true), .init()) + let string = JSONSchema.string(.init(format: .unspecified, required: true), .init(maxLength: 5)) + + let allOf = JSONSchema.all(of: [.string(.init(), .init())]) + let anyOf = JSONSchema.any(of: [boolean]) + let oneOf = JSONSchema.one(of: [boolean]) + let not = JSONSchema.not(boolean) + let reference = JSONSchema.reference(.external(URL(string: "hello/world.json#/hello")!)) + let fragment = JSONSchema.fragment(.init(description: "hello world")) + + XCTAssertEqual(boolean.subschemas, []) + XCTAssertEqual(object.subschemas, []) + XCTAssertEqual(array.subschemas, [.boolean]) + XCTAssertEqual(number.subschemas, []) + XCTAssertEqual(integer.subschemas, []) + XCTAssertEqual(string.subschemas, []) + + XCTAssertEqual(allOf.subschemas, [.string]) + XCTAssertEqual(anyOf.subschemas, [.boolean]) + XCTAssertEqual(oneOf.subschemas, [.boolean]) + XCTAssertEqual(not.subschemas, [.boolean]) + XCTAssertEqual(reference.subschemas, []) + XCTAssertEqual(fragment.subschemas, []) + } + func test_numericContextFromIntegerContext() { let i1 = JSONSchema.IntegerContext(multipleOf: 2) let i2 = JSONSchema.IntegerContext(maximum: (10, exclusive: false)) @@ -4812,6 +4842,15 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let allWithNullableSchemaData = """ + { + "allOf": [ + { "type": "string" }, + { "type": "number", "nullable": true } + ] + } + """.data(using: .utf8)! + let nestedOptionalAllData = """ { "type": "object", @@ -4831,6 +4870,7 @@ extension SchemaObjectTests { let allWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: allWithDiscriminatorData) let allWithReference = try orderUnstableDecode(JSONSchema.self, from: allWithReferenceData) let allWithReferenceAndDescription = try orderUnstableDecode(JSONSchema.self, from: allWithReferenceAndDescriptionData) + let allWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: allWithNullableSchemaData) let nestedOptionalAll = try orderUnstableDecode(JSONSchema.self, from: nestedOptionalAllData) XCTAssertEqual( @@ -4885,6 +4925,17 @@ extension SchemaObjectTests { ) ) + XCTAssertEqual( + allWithNullableSchema, + JSONSchema.all( + of: [ + .string(), + .number(nullable: true) + ], + core: .init(nullable: true) + ) + ) + XCTAssertEqual( nestedOptionalAll, JSONSchema.object( @@ -5056,10 +5107,20 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let oneWithNullableSchemaData = """ + { + "oneOf": [ + { "type": "string" }, + { "type": "number", "nullable": true } + ] + } + """.data(using: .utf8)! + let one = try orderUnstableDecode(JSONSchema.self, from: oneData) let oneWithTitle = try orderUnstableDecode(JSONSchema.self, from: oneWithTitleData) let oneWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: oneWithDiscriminatorData) let oneWithReference = try orderUnstableDecode(JSONSchema.self, from: oneWithReferenceData) + let oneWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: oneWithNullableSchemaData) XCTAssertEqual( one, @@ -5102,6 +5163,17 @@ extension SchemaObjectTests { ] ) ) + + XCTAssertEqual( + oneWithNullableSchema, + JSONSchema.one( + of: [ + .string(), + .number(nullable: true) + ], + core: .init(nullable: true) + ) + ) } func test_encodeAny() { @@ -5262,10 +5334,20 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let anyWithNullableSchemaData = """ + { + "anyOf": [ + { "type": "string" }, + { "type": "number", "nullable": true } + ] + } + """.data(using: .utf8)! + let any = try orderUnstableDecode(JSONSchema.self, from: anyData) let anyWithTitle = try orderUnstableDecode(JSONSchema.self, from: anyWithTitleData) let anyWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: anyWithDiscriminatorData) let anyWithReference = try orderUnstableDecode(JSONSchema.self, from: anyWithReferenceData) + let anyWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: anyWithNullableSchemaData) XCTAssertEqual( any, @@ -5308,6 +5390,17 @@ extension SchemaObjectTests { ] ) ) + + XCTAssertEqual( + anyWithNullableSchema, + JSONSchema.any( + of: [ + .string(), + .number(nullable: true) + ], + core: .init(nullable: true) + ) + ) } func test_encodeNot() { @@ -5368,11 +5461,19 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let notWithNullableSchemaData = """ + { + "not": { "type": "string", "nullable": true } + } + """.data(using: .utf8)! + let not = try orderUnstableDecode(JSONSchema.self, from: notData) let notWithTitle = try orderUnstableDecode(JSONSchema.self, from: notWithTitleData) + let notWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: notWithNullableSchemaData) XCTAssertEqual(not, JSONSchema.not(.boolean(.init(format: .generic)))) XCTAssertEqual(notWithTitle, JSONSchema.not(.boolean(.init(format: .generic)), core: .init(title: "hello"))) + XCTAssertEqual(notWithNullableSchema, JSONSchema.not(.string(nullable: true), core: .init())) } func test_encodeFileReference() { diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift index c1d7af681..99b130641 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift @@ -917,6 +917,38 @@ final class SchemaObjectTests: XCTestCase { XCTAssertNil(null.stringContext) } + func test_subschemasAccessor() { + let null = JSONSchema.null() + let boolean = JSONSchema.boolean(.init(format: .unspecified, required: true)) + let object = JSONSchema.object(.init(format: .unspecified, required: true), .init(properties: [:])) + let array = JSONSchema.array(.init(format: .unspecified, required: true), .init(items: .boolean)) + let number = JSONSchema.number(.init(format: .unspecified, required: true), .init()) + let integer = JSONSchema.integer(.init(format: .unspecified, required: true), .init()) + let string = JSONSchema.string(.init(format: .unspecified, required: true), .init(maxLength: 5)) + + let allOf = JSONSchema.all(of: [.string(.init(), .init())]) + let anyOf = JSONSchema.any(of: [boolean]) + let oneOf = JSONSchema.one(of: [boolean]) + let not = JSONSchema.not(boolean) + let reference = JSONSchema.reference(.external(URL(string: "hello/world.json#/hello")!)) + let fragment = JSONSchema.fragment(.init(description: "hello world")) + + XCTAssertEqual(boolean.subschemas, []) + XCTAssertEqual(object.subschemas, []) + XCTAssertEqual(array.subschemas, [.boolean]) + XCTAssertEqual(number.subschemas, []) + XCTAssertEqual(integer.subschemas, []) + XCTAssertEqual(string.subschemas, []) + + XCTAssertEqual(allOf.subschemas, [.string]) + XCTAssertEqual(anyOf.subschemas, [.boolean]) + XCTAssertEqual(oneOf.subschemas, [.boolean]) + XCTAssertEqual(not.subschemas, [.boolean]) + XCTAssertEqual(reference.subschemas, []) + XCTAssertEqual(fragment.subschemas, []) + XCTAssertEqual(null.subschemas, []) + } + func test_numericContextFromIntegerContext() { let i1 = JSONSchema.IntegerContext(multipleOf: 2) let i2 = JSONSchema.IntegerContext(maximum: (10, exclusive: false)) @@ -5209,6 +5241,15 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let allWithNullableSchemaData = """ + { + "allOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + """.data(using: .utf8)! + let nestedOptionalAllData = """ { "type": "object", @@ -5228,6 +5269,7 @@ extension SchemaObjectTests { let allWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: allWithDiscriminatorData) let allWithReference = try orderUnstableDecode(JSONSchema.self, from: allWithReferenceData) let allWithReferenceAndDescription = try orderUnstableDecode(JSONSchema.self, from: allWithReferenceAndDescriptionData) + let allWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: allWithNullableSchemaData) let nestedOptionalAll = try orderUnstableDecode(JSONSchema.self, from: nestedOptionalAllData) XCTAssertEqual( @@ -5282,6 +5324,17 @@ extension SchemaObjectTests { ) ) + XCTAssertEqual( + allWithNullableSchema, + JSONSchema.all( + of: [ + .string(), + .null() + ], + core: .init(nullable: true) + ) + ) + XCTAssertEqual( nestedOptionalAll, JSONSchema.object( @@ -5453,10 +5506,20 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let oneWithNullableSchemaData = """ + { + "oneOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + """.data(using: .utf8)! + let one = try orderUnstableDecode(JSONSchema.self, from: oneData) let oneWithTitle = try orderUnstableDecode(JSONSchema.self, from: oneWithTitleData) let oneWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: oneWithDiscriminatorData) let oneWithReference = try orderUnstableDecode(JSONSchema.self, from: oneWithReferenceData) + let oneWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: oneWithNullableSchemaData) XCTAssertEqual( one, @@ -5499,6 +5562,17 @@ extension SchemaObjectTests { ] ) ) + + XCTAssertEqual( + oneWithNullableSchema, + JSONSchema.one( + of: [ + .string(), + .null() + ], + core: .init(nullable: true) + ) + ) } func test_encodeAny() { @@ -5659,10 +5733,20 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let anyWithNullableSchemaData = """ + { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + """.data(using: .utf8)! + let any = try orderUnstableDecode(JSONSchema.self, from: anyData) let anyWithTitle = try orderUnstableDecode(JSONSchema.self, from: anyWithTitleData) let anyWithDiscriminator = try orderUnstableDecode(JSONSchema.self, from: anyWithDiscriminatorData) let anyWithReference = try orderUnstableDecode(JSONSchema.self, from: anyWithReferenceData) + let anyWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: anyWithNullableSchemaData) XCTAssertEqual( any, @@ -5705,6 +5789,17 @@ extension SchemaObjectTests { ] ) ) + + XCTAssertEqual( + anyWithNullableSchema, + JSONSchema.any( + of: [ + .string(), + .null() + ], + core: .init(nullable: true) + ) + ) } func test_encodeNot() { @@ -5765,11 +5860,19 @@ extension SchemaObjectTests { } """.data(using: .utf8)! + let notWithNullableSchemaData = """ + { + "not": { "type": [ "string", "null" ] } + } + """.data(using: .utf8)! + let not = try orderUnstableDecode(JSONSchema.self, from: notData) let notWithTitle = try orderUnstableDecode(JSONSchema.self, from: notWithTitleData) + let notWithNullableSchema = try orderUnstableDecode(JSONSchema.self, from: notWithNullableSchemaData) XCTAssertEqual(not, JSONSchema.not(.boolean(.init(format: .generic)))) XCTAssertEqual(notWithTitle, JSONSchema.not(.boolean(.init(format: .generic)), core: .init(title: "hello"))) + XCTAssertEqual(notWithNullableSchema, JSONSchema.not(.string(nullable: true), core: .init(nullable: false))) } func test_encodeFileReference() {