diff --git a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift index 8f4a8cf1..1a2f5983 100644 --- a/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift +++ b/Sources/_OpenAPIGeneratorCore/GeneratorPipeline.swift @@ -109,9 +109,10 @@ func makeGeneratorPipeline( return filteredDoc } let validateDoc = { (doc: OpenAPI.Document) -> OpenAPI.Document in - let validationDiagnostics = try validator(doc, config) + let sanitizedDoc = sanitizeSchemaNulls(doc) + let validationDiagnostics = try validator(sanitizedDoc, config) for diagnostic in validationDiagnostics { try diagnostics.emit(diagnostic) } - return doc + return sanitizedDoc } return .init( parseOpenAPIFileStage: .init( @@ -133,3 +134,177 @@ func makeGeneratorPipeline( ) ) } + +extension JSONSchema { + /// Recursively removes null type entries from anyOf and oneOf arrays in the schema + /// When null is removed, it will set the schema context for that field as nullable to preserve semantics + /// This approach may not be 100% correct but it enables functionality that would otherwise fail. + /// + /// Background: currently, there are challenges with supporting OpenAPI definitions like this: + /// ``` + /// "phoneNumber": { + /// "description": "phone number", + /// "anyOf": [ + /// { "$ref": "#/components/schemas/PhoneNumber" }, + /// { "type": "null" } + /// ] + /// } + /// "phoneNumber2": { + /// "description": "phone number", + /// "oneOf": [ + /// { "$ref": "#/components/schemas/PhoneNumber" }, + /// { "type": "null" } + /// ] + /// } + /// "phoneNumber3": { + /// "description": "phone number", + /// "oneOf": [ + /// { "$ref": "#/components/schemas/PhoneNumber" }, + /// { "$ref": "#/components/schemas/PhoneNumber2" }, + /// { "type": "null" } + /// ] + /// } + /// ``` + /// This code will effectively treat those definitions as the following while marking them as nullable. + /// ``` + /// "phoneNumber": { + /// "description": "phone number", + /// "$ref": "#/components/schemas/PhoneNumber" + /// } + /// "phoneNumber2": { + /// "description": "phone number", + /// "$ref": "#/components/schemas/PhoneNumber" + /// } + /// "phoneNumber3": { + /// "description": "phone number", + /// "oneOf": [ + /// { "$ref": "#/components/schemas/PhoneNumber" }, + /// { "$ref": "#/components/schemas/PhoneNumber2" } + /// ] + /// } + /// ``` + func removingNullFromAnyOfAndOneOf() -> JSONSchema { + switch self.value { + case .object(let coreContext, let objectContext): + // Handle object properties + var newProperties = OrderedDictionary() + for (key, value) in objectContext.properties { newProperties[key] = value.removingNullFromAnyOfAndOneOf() } + // Handle additionalProperties if it exists + let newAdditionalProperties: Either? + if let additionalProps = objectContext.additionalProperties { + switch additionalProps { + case .a(let boolValue): newAdditionalProperties = .a(boolValue) + case .b(let schema): newAdditionalProperties = .b(schema.removingNullFromAnyOfAndOneOf()) + } + } else { + newAdditionalProperties = nil + } + // Create new ObjectContext + let newObjectContext = JSONSchema.ObjectContext( + properties: newProperties, + additionalProperties: newAdditionalProperties, + maxProperties: objectContext.maxProperties, + minProperties: objectContext.minProperties + ) + return JSONSchema(schema: .object(coreContext, newObjectContext)) + case .array(let coreContext, let arrayContext): + // Handle array items + let newItems = arrayContext.items?.removingNullFromAnyOfAndOneOf() + let newArrayContext = JSONSchema.ArrayContext( + items: newItems, + maxItems: arrayContext.maxItems, + minItems: arrayContext.minItems, + prefixItems: arrayContext.prefixItems?.map { $0.removingNullFromAnyOfAndOneOf() }, + uniqueItems: arrayContext.uniqueItems + ) + return JSONSchema(schema: .array(coreContext, newArrayContext)) + case .all(of: let schemas, core: let coreContext): + // Handle allOf + let newSchemas = schemas.map { $0.removingNullFromAnyOfAndOneOf() } + return JSONSchema(schema: .all(of: newSchemas, core: coreContext)) + case .one(of: let schemas, core: let coreContext): + // Handle oneOf - apply same null removal logic as anyOf + let filteredSchemas = schemas.compactMap { schema -> JSONSchema? in + // Remove schemas that are just null types + if case .null = schema.value { return nil } + return schema.removingNullFromAnyOfAndOneOf() + } + // Check if we removed any null schemas + let hadNullSchema = schemas.count > filteredSchemas.count + // If we only have one schema left after filtering, return it directly (and make it nullable if we removed null) + if filteredSchemas.count == 1 { + let resultSchema = filteredSchemas[0] + return hadNullSchema ? resultSchema.nullableSchemaObjectCopy() : resultSchema + } else if filteredSchemas.isEmpty { + // If all schemas were null, return a null schema (edge case) + return JSONSchema(schema: .null(coreContext)) + } else { + // Multiple schemas remain, keep as oneOf (and make nullable if we removed null) + let resultSchema = JSONSchema(schema: .one(of: filteredSchemas, core: coreContext)) + return hadNullSchema ? resultSchema.nullableSchemaObjectCopy() : resultSchema + } + case .any(of: let schemas, core: let coreContext): + // Handle anyOf - this is where we remove null types + let filteredSchemas = schemas.compactMap { schema -> JSONSchema? in + // Remove schemas that are just null types + if case .null = schema.value { return nil } + return schema.removingNullFromAnyOfAndOneOf() + } + // Check if we removed any null schemas + let hadNullSchema = schemas.count > filteredSchemas.count + // If we only have one schema left after filtering, return it directly (and make it nullable if we removed null) + if filteredSchemas.count == 1 { + let resultSchema = filteredSchemas[0] + return hadNullSchema ? resultSchema.nullableSchemaObjectCopy() : resultSchema + } else if filteredSchemas.isEmpty { + // If all schemas were null, return a null schema (edge case) + return JSONSchema(schema: .null(coreContext)) + } else { + // Multiple schemas remain, keep as anyOf (and make nullable if we removed null) + let resultSchema = JSONSchema(schema: .any(of: filteredSchemas, core: coreContext)) + return hadNullSchema ? resultSchema.nullableSchemaObjectCopy() : resultSchema + } + case .not(let schema, core: let coreContext): + // Handle not + return JSONSchema(schema: .not(schema.removingNullFromAnyOfAndOneOf(), core: coreContext)) + case .reference: + // References remain unchanged + return self + default: + // For primitive types (string, number, integer, boolean, null, fragment), return as-is + return self + } + } +} + +/// Extension for OpenAPI.ComponentDictionary +/// Need to constrain both the Key and Value types properly +extension OrderedDictionary where Key == OpenAPI.ComponentKey, Value == JSONSchema { + /// Removes null types from anyOf arrays in all JSONSchemas in the component dictionary + func removingNullFromAnyOfAndOneOf() -> OpenAPI.ComponentDictionary { + self.mapValues { schema in schema.removingNullFromAnyOfAndOneOf() } + } +} + +/// uses `removingNullFromAnyOfAndOneOf()` to remove from an OpenAPI Document +/// resulting in removing the nulls from anyOf/oneOf while marking it as nullable +/// - Parameter doc: the `OpenAPI.Document` to remove the nulls from +/// - Returns: a revised `OpenAPI.Document` +func sanitizeSchemaNulls(_ doc: OpenAPI.Document) -> OpenAPI.Document { + var doc = doc + doc.components.schemas = doc.components.schemas.removingNullFromAnyOfAndOneOf() + return doc +} + +extension JSONSchema { + /// this simply makes a copy changing on the value of nullable to true, it handles `.reference` + /// directly or calls nullableSchemaObject()` located in `OpenAPIKit` + /// - Returns: a nullable copy of the `JSONSchema` + public func nullableSchemaObjectCopy() -> JSONSchema { + if case let .reference(schema, core) = value { + return .init(schema: .reference(schema, core.nullableContext())) + } else { + return self.nullableSchemaObject() + } + } +} diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index d1e6341c..f46a3124 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -844,6 +844,145 @@ final class SnippetBasedReferenceTests: XCTestCase { """ ) } + + func testComponentsSchemasAnyOfWithNull_singleRef() throws { + try self.assertSchemasTranslation( + """ + schemas: + A: + type: object + properties: + b: + anyOf: + - $ref: "#/components/schemas/B" + - type: "null" + B: + type: object + required: ["c"] + properties: + c: + type: string + """, + """ + public enum Schemas { + public struct A: Codable, Hashable, Sendable { + public var b: Components.Schemas.B? + public init(b: Components.Schemas.B? = nil) { + self.b = b + } + public enum CodingKeys: String, CodingKey { + case b + } + } + 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 + } + } + } + """ + ) + } + + func testComponentsSchemasAnyOfWithNull_multiRef() throws { + try self.assertSchemasTranslation( + """ + schemas: + A: + type: object + properties: + b: + anyOf: + - $ref: "#/components/schemas/B1" + - $ref: "#/components/schemas/B2" + - type: "null" + B1: + type: object + required: ["c"] + properties: + c: + type: string + B2: + type: object + required: ["d"] + properties: + d: + type: string + """, + """ + public enum Schemas { + public struct A: Codable, Hashable, Sendable { + public struct bPayload: Codable, Hashable, Sendable { + public var value1: Components.Schemas.B1? + public var value2: Components.Schemas.B2? + public init( + value1: Components.Schemas.B1? = nil, + value2: Components.Schemas.B2? = nil + ) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self.value1 = try .init(from: decoder) + } catch { + errors.append(error) + } + do { + self.value2 = try .init(from: decoder) + } catch { + errors.append(error) + } + try Swift.DecodingError.verifyAtLeastOneSchemaIsNotNil( + [ + self.value1, + self.value2 + ], + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + try self.value1?.encode(to: encoder) + try self.value2?.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 + } + } + 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 + } + } + } + """ + ) + } func testComponentsSchemasOneOfWithDiscriminator() throws { try self.assertSchemasTranslation( @@ -1099,6 +1238,140 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasOneOfWithNull_singleRef_closed() throws { + try self.assertSchemasTranslation( + """ + schemas: + A: + type: object + properties: + b: + oneOf: + - $ref: "#/components/schemas/B" + - type: "null" + B: + type: object + required: ["c"] + properties: + c: + type: string + """, + """ + public enum Schemas { + public struct A: Codable, Hashable, Sendable { + public var b: Components.Schemas.B? + public init(b: Components.Schemas.B? = nil) { + self.b = b + } + public enum CodingKeys: String, CodingKey { + case b + } + } + 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 + } + } + } + """ + ) + } + + func testComponentsSchemasOneOfWithNull_multiRef_closed() throws { + try self.assertSchemasTranslation( + """ + schemas: + A: + type: object + properties: + b: + oneOf: + - $ref: "#/components/schemas/B1" + - $ref: "#/components/schemas/B2" + - type: "null" + B1: + type: object + required: ["c"] + properties: + c: + type: string + B2: + type: object + required: ["d"] + properties: + d: + type: string + """, + """ + public enum Schemas { + 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 Decoder) throws { + var errors: [any 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 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 + } + } + 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 + } + } + } + """ + ) + } + func testComponentsSchemasOneOf_open_pattern() throws { try self.assertSchemasTranslation( """ @@ -6240,10 +6513,12 @@ final class SnippetBasedReferenceTests: XCTestCase { extension SnippetBasedReferenceTests { func makeTypesTranslator(openAPIDocumentYAML: String) throws -> TypesFileTranslator { let document = try YAMLDecoder().decode(OpenAPI.Document.self, from: openAPIDocumentYAML) + // removingNullFromAnyOfAndOneOf() to match what we do in `GeneratorPipeline` + let sanitizedDocument = sanitizeSchemaNulls(document) return TypesFileTranslator( config: Config(mode: .types, access: .public, namingStrategy: .defensive), diagnostics: XCTestDiagnosticCollector(test: self), - components: document.components + components: sanitizedDocument.components ) } @@ -6256,7 +6531,9 @@ extension SnippetBasedReferenceTests { ignoredDiagnosticMessages: Set = [], componentsYAML: String ) throws -> TypesFileTranslator { - let components = try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) + var components = try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) + // removingNullFromAnyOfAndOneOf() to match what we do in `GeneratorPipeline` + components.schemas = components.schemas.removingNullFromAnyOfAndOneOf() return TypesFileTranslator( config: Config( mode: .types,