Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension TypesFileTranslator {
func boxRecursiveTypes(_ decls: [Declaration]) throws -> [Declaration] {

let nodes = decls.compactMap(DeclarationRecursionDetector.Node.init)
let nodeLookup = Dictionary(uniqueKeysWithValues: nodes.map { ($0.name, $0) })
let nodeLookup = Dictionary(nodes.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
let container = DeclarationRecursionDetector.Container(lookupMap: nodeLookup)

let boxedNames = try RecursionDetector.computeBoxedTypes(rootNodes: nodes, container: container)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ extension TypesFileTranslator {
isMultipartContent: multipartSchemaNames.contains(key)
)
}
try emitDuplicateTypeNameDiagnostic(in: decls)
let declsWithBoxingApplied = try boxRecursiveTypes(decls)
let componentsSchemasEnum = Declaration.commentable(
JSONSchema.sectionComment(),
Expand All @@ -75,4 +76,26 @@ extension TypesFileTranslator {
)
return componentsSchemasEnum
}

/// Emits an error when multiple top-level schema declarations have the same generated Swift type name.
/// - Parameter declarations: The declarations generated in the `Components.Schemas` namespace.
/// - Throws: An error if the diagnostic collector throws while receiving the collision diagnostic.
private func emitDuplicateTypeNameDiagnostic(in declarations: [Declaration]) throws {
var observedNames: Set<String> = []
var duplicateNames: Set<String> = []
for name in declarations.compactMap(\.name) where !observedNames.insert(name).inserted {
duplicateNames.insert(name)
}
guard !duplicateNames.isEmpty else { return }

let nameList = duplicateNames.sorted().map { "'\($0)'" }.joined(separator: ", ")
try diagnostics.emit(
.error(
message: "Multiple schemas in '#/components/schemas' map to the same generated Swift type names "
+ "\(nameList), which is not supported. Use the 'defensive' naming strategy or add "
+ "'nameOverrides' entries so every schema generates a unique name.",
context: ["names": nameList]
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,88 @@ class Test_translateSchemas: Test_Core {
XCTAssertEqual(collector.diagnostics.map(\.description), diagnosticDescriptions)
}
}

func testDuplicateGeneratedSchemaNamesThrowErrorDiagnostic() throws {
let schemas: OpenAPI.ComponentDictionary<JSONSchema> = ["NullTime": .string, "nullTime": .string]
let diagnostics = ErrorThrowingDiagnosticCollector(upstream: StdErrPrintingDiagnosticCollector())
let translator = makeTranslator(
components: .init(schemas: schemas),
diagnostics: diagnostics,
namingStrategy: .idiomatic
)

XCTAssertThrowsError(try translator.translateSchemas(schemas, multipartSchemaNames: [])) { error in
guard let diagnostic = error as? Diagnostic else { return XCTFail("Expected a Diagnostic, got \(error)") }
XCTAssertEqual(diagnostic.severity, .error)
XCTAssertEqual(diagnostic.context, ["names": "'NullTime'"])
}
}

func testDuplicateGeneratedSchemaNamesDoNotTrapWithNonThrowingCollector() throws {
let schemas: OpenAPI.ComponentDictionary<JSONSchema> = ["NullTime": .string, "nullTime": .string]
let collector = AccumulatingDiagnosticCollector()
let translator = makeTranslator(
components: .init(schemas: schemas),
diagnostics: collector,
namingStrategy: .idiomatic
)

_ = try translator.translateSchemas(schemas, multipartSchemaNames: [])

XCTAssertEqual(
collector.diagnostics.map(\.description),
[
"error: Multiple schemas in '#/components/schemas' map to the same generated Swift type names "
+ "'NullTime', which is not supported. Use the 'defensive' naming strategy or add "
+ "'nameOverrides' entries so every schema generates a unique name. [context: names='NullTime']"
]
)
}

func testMultipleDuplicateGeneratedSchemaNamesAreReportedDeterministically() throws {
let schemas: OpenAPI.ComponentDictionary<JSONSchema> = [
"Zulu": .string, "zulu": .string, "Alpha": .string, "alpha": .string,
]
let collector = AccumulatingDiagnosticCollector()
let translator = makeTranslator(
components: .init(schemas: schemas),
diagnostics: collector,
namingStrategy: .idiomatic
)

_ = try translator.translateSchemas(schemas, multipartSchemaNames: [])

XCTAssertEqual(collector.diagnostics.count, 1)
XCTAssertEqual(collector.diagnostics.first?.context, ["names": "'Alpha', 'Zulu'"])
}

func testDuplicateNestedBasenamesInDifferentNamespacesAreValid() throws {
let schemas: OpenAPI.ComponentDictionary<JSONSchema> = [
"A": .object(properties: ["t": .object(properties: ["value": .string])]),
"B": .object(properties: ["t": .object(properties: ["value": .string])]),
]
let collector = AccumulatingDiagnosticCollector()
let translator = makeTranslator(
components: .init(schemas: schemas),
diagnostics: collector,
namingStrategy: .idiomatic
)

let declaration = try translator.translateSchemas(schemas, multipartSchemaNames: [])

guard case .enum(let schemasNamespace) = declaration.strippingTopComment else {
return XCTFail("Expected the Components.Schemas namespace")
}
let topLevelNames = schemasNamespace.members.compactMap(\.name).sorted()
let nestedNames = schemasNamespace.members
.flatMap { declaration -> [String] in
guard case .struct(let description) = declaration.strippingTopComment else { return [] }
return description.members.compactMap(\.name).map { "\(description.name).\($0)" }
}
.filter { $0.hasSuffix(".TPayload") }.sorted()

XCTAssertEqual(topLevelNames, ["A", "B"])
XCTAssertEqual(nestedNames, ["A.TPayload", "B.TPayload"])
XCTAssertTrue(collector.diagnostics.isEmpty)
}
}
Loading