Skip to content

Commit ecce1b7

Browse files
authored
Skip duplicate enum values (#674)
### Motivation Some OpenAPI docs, usually through imperfect conversion from another representation, end up with duplicate raw values in `enum` schemas. The OpenAPI specification (by referencing the JSON Schema specification) [says](https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.20): ``` The value of this keyword MUST be an array. This array SHOULD have at least one element. Elements in the array SHOULD be unique. ``` So elements **should** be unique, but don't have to be. Today the generator fails to generate documents with such duplicate values. ### Modifications Gracefully handle duplicate values by emitting a warning diagnostic and skipping it. ### Result This unblocks generating OpenAPI documents with duplicate enum values, such as the [Bluesky OpenAPI doc](https://github.com./bluesky-social/bsky-docs/blob/main/atproto-openapi-types/spec/api.json). ### Test Plan Added a unit test for duplicates.
1 parent 7fcef1d commit ecce1b7

File tree

4 files changed

+171
-87
lines changed

4 files changed

+171
-87
lines changed

Package.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ let package = Package(
4949

5050
// General algorithms
5151
.package(url: "https://github.com./apple/swift-algorithms", from: "1.2.0"),
52+
.package(url: "https://github.com./apple/swift-collections", from: "1.1.4"),
5253

5354
// Read OpenAPI documents
5455
.package(url: "https://github.com./mattpolzin/OpenAPIKit", from: "3.3.0"),
@@ -72,7 +73,9 @@ let package = Package(
7273
.product(name: "OpenAPIKit", package: "OpenAPIKit"),
7374
.product(name: "OpenAPIKit30", package: "OpenAPIKit"),
7475
.product(name: "OpenAPIKitCompat", package: "OpenAPIKit"),
75-
.product(name: "Algorithms", package: "swift-algorithms"), .product(name: "Yams", package: "Yams"),
76+
.product(name: "Algorithms", package: "swift-algorithms"),
77+
.product(name: "OrderedCollections", package: "swift-collections"),
78+
.product(name: "Yams", package: "Yams"),
7679
],
7780
swiftSettings: swiftSettings
7881
),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import OpenAPIKit
15+
import OrderedCollections
16+
17+
/// The backing type of a raw enum.
18+
enum RawEnumBackingType {
19+
20+
/// Backed by a `String`.
21+
case string
22+
23+
/// Backed by an `Int`.
24+
case integer
25+
}
26+
27+
/// The extracted enum value's identifier.
28+
private enum EnumCaseID: Hashable, CustomStringConvertible {
29+
30+
/// A string value.
31+
case string(String)
32+
33+
/// An integer value.
34+
case integer(Int)
35+
36+
var description: String {
37+
switch self {
38+
case .string(let value): return "\"\(value)\""
39+
case .integer(let value): return String(value)
40+
}
41+
}
42+
}
43+
44+
/// A wrapper for the metadata about the raw enum case.
45+
private struct EnumCase {
46+
47+
/// Used for checking uniqueness.
48+
var id: EnumCaseID
49+
50+
/// The raw Swift-safe name for the case.
51+
var caseName: String
52+
53+
/// The literal value of the enum case.
54+
var literal: LiteralDescription
55+
}
56+
57+
extension EnumCase: Equatable { static func == (lhs: EnumCase, rhs: EnumCase) -> Bool { lhs.id == rhs.id } }
58+
59+
extension EnumCase: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } }
60+
61+
extension FileTranslator {
62+
63+
/// Returns a declaration of the specified raw value-based enum schema.
64+
/// - Parameters:
65+
/// - backingType: The backing type of the enum.
66+
/// - typeName: The name of the type to give to the declared enum.
67+
/// - userDescription: A user-specified description from the OpenAPI
68+
/// document.
69+
/// - isNullable: Whether the enum schema is nullable.
70+
/// - allowedValues: The enumerated allowed values.
71+
/// - Throws: A `GenericError` if a disallowed value is encountered.
72+
/// - Returns: A declaration of the specified raw value-based enum schema.
73+
func translateRawEnum(
74+
backingType: RawEnumBackingType,
75+
typeName: TypeName,
76+
userDescription: String?,
77+
isNullable: Bool,
78+
allowedValues: [AnyCodable]
79+
) throws -> Declaration {
80+
var cases: OrderedSet<EnumCase> = []
81+
func addIfUnique(id: EnumCaseID, caseName: String) throws {
82+
let literal: LiteralDescription
83+
switch id {
84+
case .string(let string): literal = .string(string)
85+
case .integer(let int): literal = .int(int)
86+
}
87+
guard cases.append(.init(id: id, caseName: caseName, literal: literal)).inserted else {
88+
try diagnostics.emit(
89+
.warning(
90+
message: "Duplicate enum value, skipping",
91+
context: ["id": "\(id)", "foundIn": typeName.description]
92+
)
93+
)
94+
return
95+
}
96+
}
97+
for anyValue in allowedValues.map(\.value) {
98+
switch backingType {
99+
case .string:
100+
// In nullable enum schemas, empty strings are parsed as Void.
101+
// This is unlikely to be fixed, so handling that case here.
102+
// https://github.com./apple/swift-openapi-generator/issues/118
103+
if isNullable && anyValue is Void {
104+
try addIfUnique(id: .string(""), caseName: context.asSwiftSafeName(""))
105+
} else {
106+
guard let rawValue = anyValue as? String else {
107+
throw GenericError(message: "Disallowed value for a string enum '\(typeName)': \(anyValue)")
108+
}
109+
let caseName = context.asSwiftSafeName(rawValue)
110+
try addIfUnique(id: .string(rawValue), caseName: caseName)
111+
}
112+
case .integer:
113+
let rawValue: Int
114+
if let intRawValue = anyValue as? Int {
115+
rawValue = intRawValue
116+
} else if let stringRawValue = anyValue as? String, let intRawValue = Int(stringRawValue) {
117+
rawValue = intRawValue
118+
} else {
119+
throw GenericError(message: "Disallowed value for an integer enum '\(typeName)': \(anyValue)")
120+
}
121+
let caseName = rawValue < 0 ? "_n\(abs(rawValue))" : "_\(rawValue)"
122+
try addIfUnique(id: .integer(rawValue), caseName: caseName)
123+
}
124+
}
125+
let baseConformance: String
126+
switch backingType {
127+
case .string: baseConformance = Constants.RawEnum.baseConformanceString
128+
case .integer: baseConformance = Constants.RawEnum.baseConformanceInteger
129+
}
130+
let conformances = [baseConformance] + Constants.RawEnum.conformances
131+
return try translateRawRepresentableEnum(
132+
typeName: typeName,
133+
conformances: conformances,
134+
userDescription: userDescription,
135+
cases: cases.map { ($0.caseName, $0.literal) },
136+
unknownCaseName: nil,
137+
unknownCaseDescription: nil
138+
)
139+
}
140+
}

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift

-86
This file was deleted.

Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift

+27
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,33 @@ final class SnippetBasedReferenceTests: XCTestCase {
13101310
)
13111311
}
13121312

1313+
func testComponentsSchemasStringEnumWithDuplicates() throws {
1314+
try self.assertSchemasTranslation(
1315+
ignoredDiagnosticMessages: ["Duplicate enum value, skipping"],
1316+
"""
1317+
schemas:
1318+
MyEnum:
1319+
type: string
1320+
enum:
1321+
- one
1322+
- two
1323+
- three
1324+
- two
1325+
- four
1326+
""",
1327+
"""
1328+
public enum Schemas {
1329+
@frozen public enum MyEnum: String, Codable, Hashable, Sendable, CaseIterable {
1330+
case one = "one"
1331+
case two = "two"
1332+
case three = "three"
1333+
case four = "four"
1334+
}
1335+
}
1336+
"""
1337+
)
1338+
}
1339+
13131340
func testComponentsSchemasIntEnum() throws {
13141341
try self.assertSchemasTranslation(
13151342
"""

0 commit comments

Comments
 (0)