From 35f8bbad27e2050d9f360194cc40b6dbf4b2dbb4 Mon Sep 17 00:00:00 2001 From: Thomas Dutartre Date: Thu, 6 Aug 2026 16:31:51 +0200 Subject: [PATCH] Support type: null in anyOf/oneOf/allOf as a nullability marker Treat a `type: null` branch inside anyOf/oneOf/allOf purely as a nullability marker (already reflected on the composition's core context by OpenAPIKit) instead of an unsupported member. Such compositions are now generated as an optional wrapper without collapsing, keeping API evolution deterministic. Also preserve array element optionality so nullable elements are emitted as `[Element?]`, consistent with builtin element types, allowing arrays that mix values and nulls while preserving order. Adds snippet tests for oneOf/anyOf with null (single and multiple refs) and for arrays with a nullable oneOf element. Co-authored-by: Cursor --- .../Extensions/OpenAPIKit.swift | 6 + .../translateAllAnyOneOf.swift | 4 + .../CommonTranslations/translateArray.swift | 3 +- .../TypeAssignment/isSchemaSupported.swift | 4 + .../SnippetBasedReferenceTests.swift | 298 ++++++++++++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) diff --git a/Sources/_OpenAPIGeneratorCore/Extensions/OpenAPIKit.swift b/Sources/_OpenAPIGeneratorCore/Extensions/OpenAPIKit.swift index 791c11c11..b52e50b19 100644 --- a/Sources/_OpenAPIGeneratorCore/Extensions/OpenAPIKit.swift +++ b/Sources/_OpenAPIGeneratorCore/Extensions/OpenAPIKit.swift @@ -109,4 +109,10 @@ extension JSONSchema { /// Returns a human-readable description of the schema. var prettyDescription: String { value.prettyDescription } + + /// A Boolean value indicating whether the schema is the `null` type. + var isNullType: Bool { + if case .null = value { return true } + return false + } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift index 1816cabc9..43453a4da 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift @@ -40,6 +40,8 @@ extension TypesFileTranslator { func translateAllOrAnyOf(typeName: TypeName, openAPIDescription: String?, type: AllOrAnyOf, schemas: [JSONSchema]) throws -> Declaration { + // A `null` branch only marks the composition as nullable, it has no member. + let schemas = schemas.filter { !$0.isNullType } let properties: [(property: PropertyBlueprint, isKeyValuePair: Bool)] = try schemas.enumerated() .map { index, schema in let key = "value\(index+1)" @@ -129,6 +131,8 @@ extension TypesFileTranslator { discriminator: OpenAPI.Discriminator?, schemas: [JSONSchema] ) throws -> Declaration { + // A `null` branch only marks the oneOf as nullable, it has no case. + let schemas = schemas.filter { !$0.isNullType } let cases: [(String, [String]?, Bool, Comment?, TypeUsage, [Declaration])] if let discriminator { // > When using the discriminator, inline schemas will not be considered. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift index c9958420c..37cd6e2fa 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift @@ -58,7 +58,8 @@ extension TypesFileTranslator { .`typealias`( accessModifier: config.access, name: typeName.shortSwiftName, - existingType: .init(elementType.typeName.asUsage.asArray) + // Preserve the element's optionality, like builtin `[Element?]`. + existingType: .init(elementType.asArray) ) ) return inline + [arrayDecl] diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift index 1a4153121..785f1996f 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift @@ -157,12 +157,16 @@ extension FileTranslator { // an array is supported iff its element schema is supported return try isSchemaSupported(items, referenceStack: &referenceStack) case .all(of: let schemas, _): + // A `null` branch only marks the composition as nullable, ignore it here. + let schemas = schemas.filter { !$0.isNullType } guard !schemas.isEmpty else { return .unsupported(reason: .noSubschemas, schema: schema) } return try areSchemasSupported(schemas, referenceStack: &referenceStack) case .any(of: let schemas, _): + let schemas = schemas.filter { !$0.isNullType } guard !schemas.isEmpty else { return .unsupported(reason: .noSubschemas, schema: schema) } return try areSchemasSupported(schemas, referenceStack: &referenceStack) case .one(of: let schemas, let context): + let schemas = schemas.filter { !$0.isNullType } guard !schemas.isEmpty else { return .unsupported(reason: .noSubschemas, schema: schema) } guard context.discriminator != nil else { return try areSchemasSupported(schemas, referenceStack: &referenceStack) diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index 6ea8d60b6..c3afe7909 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -1099,6 +1099,304 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasOneOfWithNull_singleRef() throws { + try self.assertSchemasTranslation( + """ + schemas: + B: + type: object + required: [c] + properties: + c: + type: string + A: + type: object + required: [b] + properties: + b: + oneOf: + - $ref: '#/components/schemas/B' + - type: 'null' + """, + """ + public enum Schemas { + public struct B: Codable, Hashable, Sendable { + public var c: Swift.String + public init(c: Swift.String) { + self.c = c + } + public enum CodingKeys: String, CodingKey { + case c + } + } + public struct A: Codable, Hashable, Sendable { + @frozen public enum bPayload: Codable, Hashable, Sendable { + case B(Components.Schemas.B) + public init(from decoder: any Swift.Decoder) throws { + var errors: [any Swift.Error] = [] + do { + self = .B(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Swift.Encoder) throws { + switch self { + case let .B(value): + try value.encode(to: encoder) + } + } + } + public var b: Components.Schemas.A.bPayload? + public init(b: Components.Schemas.A.bPayload? = nil) { + self.b = b + } + public enum CodingKeys: String, CodingKey { + case b + } + } + } + """ + ) + } + + func testComponentsSchemasOneOfWithNull_multiRef() throws { + try self.assertSchemasTranslation( + """ + schemas: + B1: + type: object + required: [c] + properties: + c: + type: string + B2: + type: object + required: [d] + properties: + d: + type: string + A: + type: object + required: [b] + properties: + b: + oneOf: + - $ref: '#/components/schemas/B1' + - $ref: '#/components/schemas/B2' + - type: 'null' + """, + """ + public enum Schemas { + public struct B1: Codable, Hashable, Sendable { + public var c: Swift.String + public init(c: Swift.String) { + self.c = c + } + public enum CodingKeys: String, CodingKey { + case c + } + } + public struct B2: Codable, Hashable, Sendable { + public var d: Swift.String + public init(d: Swift.String) { + self.d = d + } + public enum CodingKeys: String, CodingKey { + case d + } + } + public struct A: Codable, Hashable, Sendable { + @frozen public enum bPayload: Codable, Hashable, Sendable { + case B1(Components.Schemas.B1) + case B2(Components.Schemas.B2) + public init(from decoder: any Swift.Decoder) throws { + var errors: [any Swift.Error] = [] + do { + self = .B1(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .B2(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Swift.Encoder) throws { + switch self { + case let .B1(value): + try value.encode(to: encoder) + case let .B2(value): + try value.encode(to: encoder) + } + } + } + public var b: Components.Schemas.A.bPayload? + public init(b: Components.Schemas.A.bPayload? = nil) { + self.b = b + } + public enum CodingKeys: String, CodingKey { + case b + } + } + } + """ + ) + } + + func testComponentsSchemasAnyOfWithNull_singleRef() throws { + try self.assertSchemasTranslation( + """ + schemas: + B: + type: object + required: [c] + properties: + c: + type: string + A: + type: object + required: [b] + properties: + b: + anyOf: + - $ref: '#/components/schemas/B' + - type: 'null' + """, + """ + public enum Schemas { + public struct B: Codable, Hashable, Sendable { + public var c: Swift.String + public init(c: Swift.String) { + self.c = c + } + public enum CodingKeys: String, CodingKey { + case c + } + } + public struct A: Codable, Hashable, Sendable { + public struct bPayload: Codable, Hashable, Sendable { + public var value1: Components.Schemas.B? + public init(value1: Components.Schemas.B? = nil) { + self.value1 = value1 + } + public init(from decoder: any Swift.Decoder) throws { + var errors: [any Swift.Error] = [] + do { + self.value1 = try .init(from: decoder) + } catch { + errors.append(error) + } + try Swift.DecodingError.verifyAtLeastOneSchemaIsNotNil( + [ + self.value1 + ], + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Swift.Encoder) throws { + try self.value1?.encode(to: encoder) + } + } + public var b: Components.Schemas.A.bPayload? + public init(b: Components.Schemas.A.bPayload? = nil) { + self.b = b + } + public enum CodingKeys: String, CodingKey { + case b + } + } + } + """ + ) + } + + func testComponentsSchemasArrayWithNullableOneOfItems() throws { + try self.assertSchemasTranslation( + """ + schemas: + B: + type: object + required: [c] + properties: + c: + type: string + A: + type: object + required: [items] + properties: + items: + type: array + items: + oneOf: + - $ref: '#/components/schemas/B' + - type: 'null' + """, + """ + public enum Schemas { + public struct B: Codable, Hashable, Sendable { + public var c: Swift.String + public init(c: Swift.String) { + self.c = c + } + public enum CodingKeys: String, CodingKey { + case c + } + } + public struct A: Codable, Hashable, Sendable { + @frozen public enum itemsPayloadPayload: Codable, Hashable, Sendable { + case B(Components.Schemas.B) + public init(from decoder: any Swift.Decoder) throws { + var errors: [any Swift.Error] = [] + do { + self = .B(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Swift.Encoder) throws { + switch self { + case let .B(value): + try value.encode(to: encoder) + } + } + } + public typealias itemsPayload = [Components.Schemas.A.itemsPayloadPayload?] + public var items: Components.Schemas.A.itemsPayload + public init(items: Components.Schemas.A.itemsPayload) { + self.items = items + } + public enum CodingKeys: String, CodingKey { + case items + } + } + } + """ + ) + } + func testComponentsSchemasOneOf_open_pattern() throws { try self.assertSchemasTranslation( """