diff --git a/Package.swift b/Package.swift index 1043f555..36b1d24c 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,7 @@ let package = Package( // General algorithms .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.1.4"), // Read OpenAPI documents .package(url: "https://github.com/mattpolzin/OpenAPIKit", from: "3.3.0"), @@ -72,7 +73,9 @@ let package = Package( .product(name: "OpenAPIKit", package: "OpenAPIKit"), .product(name: "OpenAPIKit30", package: "OpenAPIKit"), .product(name: "OpenAPIKitCompat", package: "OpenAPIKit"), - .product(name: "Algorithms", package: "swift-algorithms"), .product(name: "Yams", package: "Yams"), + .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Yams", package: "Yams"), ], swiftSettings: swiftSettings ), diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawEnum.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawEnum.swift new file mode 100644 index 00000000..ed513551 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawEnum.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit +import OrderedCollections + +/// The backing type of a raw enum. +enum RawEnumBackingType { + + /// Backed by a `String`. + case string + + /// Backed by an `Int`. + case integer +} + +/// The extracted enum value's identifier. +private enum EnumCaseID: Hashable, CustomStringConvertible { + + /// A string value. + case string(String) + + /// An integer value. + case integer(Int) + + var description: String { + switch self { + case .string(let value): return "\"\(value)\"" + case .integer(let value): return String(value) + } + } +} + +/// A wrapper for the metadata about the raw enum case. +private struct EnumCase { + + /// Used for checking uniqueness. + var id: EnumCaseID + + /// The raw Swift-safe name for the case. + var caseName: String + + /// The literal value of the enum case. + var literal: LiteralDescription +} + +extension EnumCase: Equatable { static func == (lhs: EnumCase, rhs: EnumCase) -> Bool { lhs.id == rhs.id } } + +extension EnumCase: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } } + +extension FileTranslator { + + /// Returns a declaration of the specified raw value-based enum schema. + /// - Parameters: + /// - backingType: The backing type of the enum. + /// - typeName: The name of the type to give to the declared enum. + /// - userDescription: A user-specified description from the OpenAPI + /// document. + /// - isNullable: Whether the enum schema is nullable. + /// - allowedValues: The enumerated allowed values. + /// - Throws: A `GenericError` if a disallowed value is encountered. + /// - Returns: A declaration of the specified raw value-based enum schema. + func translateRawEnum( + backingType: RawEnumBackingType, + typeName: TypeName, + userDescription: String?, + isNullable: Bool, + allowedValues: [AnyCodable] + ) throws -> Declaration { + var cases: OrderedSet = [] + func addIfUnique(id: EnumCaseID, caseName: String) throws { + let literal: LiteralDescription + switch id { + case .string(let string): literal = .string(string) + case .integer(let int): literal = .int(int) + } + guard cases.append(.init(id: id, caseName: caseName, literal: literal)).inserted else { + try diagnostics.emit( + .warning( + message: "Duplicate enum value, skipping", + context: ["id": "\(id)", "foundIn": typeName.description] + ) + ) + return + } + } + for anyValue in allowedValues.map(\.value) { + switch backingType { + case .string: + // In nullable enum schemas, empty strings are parsed as Void. + // This is unlikely to be fixed, so handling that case here. + // https://github.com/apple/swift-openapi-generator/issues/118 + if isNullable && anyValue is Void { + try addIfUnique(id: .string(""), caseName: context.asSwiftSafeName("")) + } else { + guard let rawValue = anyValue as? String else { + throw GenericError(message: "Disallowed value for a string enum '\(typeName)': \(anyValue)") + } + let caseName = context.asSwiftSafeName(rawValue) + try addIfUnique(id: .string(rawValue), caseName: caseName) + } + case .integer: + let rawValue: Int + if let intRawValue = anyValue as? Int { + rawValue = intRawValue + } else if let stringRawValue = anyValue as? String, let intRawValue = Int(stringRawValue) { + rawValue = intRawValue + } else { + throw GenericError(message: "Disallowed value for an integer enum '\(typeName)': \(anyValue)") + } + let caseName = rawValue < 0 ? "_n\(abs(rawValue))" : "_\(rawValue)" + try addIfUnique(id: .integer(rawValue), caseName: caseName) + } + } + let baseConformance: String + switch backingType { + case .string: baseConformance = Constants.RawEnum.baseConformanceString + case .integer: baseConformance = Constants.RawEnum.baseConformanceInteger + } + let conformances = [baseConformance] + Constants.RawEnum.conformances + return try translateRawRepresentableEnum( + typeName: typeName, + conformances: conformances, + userDescription: userDescription, + cases: cases.map { ($0.caseName, $0.literal) }, + unknownCaseName: nil, + unknownCaseDescription: nil + ) + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift deleted file mode 100644 index 9add9482..00000000 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift +++ /dev/null @@ -1,86 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -import OpenAPIKit - -/// The backing type of a raw enum. -enum RawEnumBackingType { - - /// Backed by a `String`. - case string - - /// Backed by an `Int`. - case integer -} - -extension FileTranslator { - - /// Returns a declaration of the specified raw value-based enum schema. - /// - Parameters: - /// - backingType: The backing type of the enum. - /// - typeName: The name of the type to give to the declared enum. - /// - userDescription: A user-specified description from the OpenAPI - /// document. - /// - isNullable: Whether the enum schema is nullable. - /// - allowedValues: The enumerated allowed values. - /// - Throws: A `GenericError` if a disallowed value is encountered. - /// - Returns: A declaration of the specified raw value-based enum schema. - func translateRawEnum( - backingType: RawEnumBackingType, - typeName: TypeName, - userDescription: String?, - isNullable: Bool, - allowedValues: [AnyCodable] - ) throws -> Declaration { - let cases: [(String, LiteralDescription)] = try allowedValues.map(\.value) - .map { anyValue in - switch backingType { - case .string: - // In nullable enum schemas, empty strings are parsed as Void. - // This is unlikely to be fixed, so handling that case here. - // https://github.com/apple/swift-openapi-generator/issues/118 - if isNullable && anyValue is Void { return (context.asSwiftSafeName(""), .string("")) } - guard let rawValue = anyValue as? String else { - throw GenericError(message: "Disallowed value for a string enum '\(typeName)': \(anyValue)") - } - let caseName = context.asSwiftSafeName(rawValue) - return (caseName, .string(rawValue)) - case .integer: - let rawValue: Int - if let intRawValue = anyValue as? Int { - rawValue = intRawValue - } else if let stringRawValue = anyValue as? String, let intRawValue = Int(stringRawValue) { - rawValue = intRawValue - } else { - throw GenericError(message: "Disallowed value for an integer enum '\(typeName)': \(anyValue)") - } - let caseName = rawValue < 0 ? "_n\(abs(rawValue))" : "_\(rawValue)" - return (caseName, .int(rawValue)) - } - } - let baseConformance: String - switch backingType { - case .string: baseConformance = Constants.RawEnum.baseConformanceString - case .integer: baseConformance = Constants.RawEnum.baseConformanceInteger - } - let conformances = [baseConformance] + Constants.RawEnum.conformances - return try translateRawRepresentableEnum( - typeName: typeName, - conformances: conformances, - userDescription: userDescription, - cases: cases, - unknownCaseName: nil, - unknownCaseDescription: nil - ) - } -} diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index ba5e5905..05e9c70e 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -1310,6 +1310,33 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasStringEnumWithDuplicates() throws { + try self.assertSchemasTranslation( + ignoredDiagnosticMessages: ["Duplicate enum value, skipping"], + """ + schemas: + MyEnum: + type: string + enum: + - one + - two + - three + - two + - four + """, + """ + public enum Schemas { + @frozen public enum MyEnum: String, Codable, Hashable, Sendable, CaseIterable { + case one = "one" + case two = "two" + case three = "three" + case four = "four" + } + } + """ + ) + } + func testComponentsSchemasIntEnum() throws { try self.assertSchemasTranslation( """