From 022fb26d3839996ef5cc36346f84ab7c696181fb Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 16 Aug 2023 17:21:21 +0200 Subject: [PATCH 1/3] Stop generating an undocumented case for enums/oneOfs --- .../_OpenAPIGeneratorCore/FeatureFlags.swift | 6 + .../translateAllAnyOneOf.swift | 31 ++- .../CommonTranslations/translateCodable.swift | 113 +++++--- .../translateStringEnum.swift | 246 +++++++++--------- .../Translator/CommonTypes/Constants.swift | 7 +- .../FileTranslator+FeatureFlags.swift | 27 ++ .../Articles/Useful-OpenAPI-patterns.md | 74 ++++++ .../Swift-OpenAPI-Generator.md | 1 + .../SnippetBasedReferenceTests.swift | 219 +++++++++++++++- 9 files changed, 552 insertions(+), 172 deletions(-) create mode 100644 Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift create mode 100644 Sources/swift-openapi-generator/Documentation.docc/Articles/Useful-OpenAPI-patterns.md diff --git a/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift b/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift index 7d0fc7ea..a2a1bae2 100644 --- a/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift +++ b/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift @@ -44,6 +44,12 @@ public enum FeatureFlag: String, Hashable, Equatable, Codable, CaseIterable { /// /// Check for structural issues and detect cycles proactively. case strictOpenAPIValidation + + /// Removed the generation of an undocumented case in enums/oneOfs. + /// + /// Tracking issue: + /// - https://github.com/apple/swift-openapi-generator/issues/204 + case closedEnumsAndOneOfs } /// A set of enabled feature flags. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift index 698bd784..a567862f 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift @@ -226,15 +226,26 @@ extension FileTranslator { ) } - let undocumentedCase: Declaration = .commentable( - .doc("Parsed a case that was not defined in the OpenAPI document."), - .enumCase( - name: Constants.OneOf.undocumentedCaseName, - kind: .nameWithAssociatedValues([ - .init(type: undocumentedType.fullyQualifiedSwiftName) - ]) + let generateUndocumentedCase = shouldGenerateUndocumentedCaseForEnumsAndOneOfs + + let otherCases: [Declaration] + if generateUndocumentedCase { + let undocumentedCase: Declaration = .commentable( + .doc("Parsed a case that was not defined in the OpenAPI document."), + .enumCase( + name: Constants.OneOf.undocumentedCaseName, + kind: .nameWithAssociatedValues([ + .init(type: undocumentedType.fullyQualifiedSwiftName) + ]) + ) ) - ) + otherCases = [ + undocumentedCase + ] + } else { + otherCases = [] + } + let encoder = translateOneOfEncoder(caseNames: caseNames) let comment: Comment? = @@ -245,9 +256,7 @@ extension FileTranslator { accessModifier: config.access, name: typeName.shortSwiftName, conformances: Constants.ObjectStruct.conformances, - members: caseDecls + [ - undocumentedCase - ] + codingKeysDecls + [ + members: caseDecls + otherCases + codingKeysDecls + [ decoder, encoder, ] diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift index 4795a4ef..eb7ca565 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift @@ -338,52 +338,78 @@ extension FileTranslator { ) ) } - let decodeUndocumentedExprs: [CodeBlock] = [ - .declaration( - .variable( - kind: .let, - left: "container", - right: .try( - .identifier("decoder") - .dot("singleValueContainer") - .call([]) + + let generateUndocumentedCase = shouldGenerateUndocumentedCaseForEnumsAndOneOfs + let otherExprs: [CodeBlock] + if generateUndocumentedCase { + otherExprs = [ + .declaration( + .variable( + kind: .let, + left: "container", + right: .try( + .identifier("decoder") + .dot("singleValueContainer") + .call([]) + ) ) - ) - ), - .declaration( - .variable( - kind: .let, - left: "value", - right: .try( - .identifier("container") - .dot("decode") + ), + .declaration( + .variable( + kind: .let, + left: "value", + right: .try( + .identifier("container") + .dot("decode") + .call([ + .init( + label: nil, + expression: + .identifier( + TypeName + .valueContainer + .fullyQualifiedSwiftName + ) + .dot("self") + ) + ]) + ) + ) + ), + .expression( + .assignment( + left: .identifier("self"), + right: .dot(Constants.OneOf.undocumentedCaseName) + .call([ + .init(label: nil, expression: .identifier("value")) + ]) + ) + ), + ] + } else { + otherExprs = [ + .expression( + .unaryKeyword( + kind: .throw, + expression: .identifier("DecodingError") + .dot("failedToDecodeOneOfSchema") .call([ .init( - label: nil, - expression: - .identifier( - TypeName - .valueContainer - .fullyQualifiedSwiftName - ) - .dot("self") - ) + label: "type", + expression: .identifier("Self").dot("self") + ), + .init( + label: "codingPath", + expression: .identifier("decoder").dot("codingPath") + ), ]) ) ) - ), - .expression( - .assignment( - left: .identifier("self"), - right: .dot(Constants.OneOf.undocumentedCaseName) - .call([ - .init(label: nil, expression: .identifier("value")) - ]) - ) - ), - ] + ] + } + return decoderInitializer( - body: (assignExprs).map { .expression($0) } + decodeUndocumentedExprs + body: (assignExprs).map { .expression($0) } + otherExprs ) } @@ -507,9 +533,16 @@ extension FileTranslator { func translateOneOfEncoder( caseNames: [String] ) -> Declaration { + let generateUndocumentedCase = shouldGenerateUndocumentedCaseForEnumsAndOneOfs + let otherCaseNames: [String] + if generateUndocumentedCase { + otherCaseNames = [Constants.OneOf.undocumentedCaseName] + } else { + otherCaseNames = [] + } let switchExpr: Expression = .switch( switchedExpression: .identifier("self"), - cases: (caseNames + [Constants.OneOf.undocumentedCaseName]) + cases: (caseNames + otherCaseNames) .map { caseName in .init( kind: .case(.dot(caseName), ["value"]), diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift index cd0ac7eb..76226046 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStringEnum.swift @@ -43,168 +43,176 @@ extension FileTranslator { return string } + let generateUnknownCases = shouldGenerateUndocumentedCaseForEnumsAndOneOfs let knownCases: [Declaration] = rawValues .map { rawValue in let caseName = swiftSafeName(for: rawValue) return .enumCase( name: caseName, - kind: .nameOnly + kind: generateUnknownCases ? .nameOnly : .nameWithRawValue(rawValue) ) } - let undocumentedCase: Declaration = .commentable( - .doc("Parsed a raw value that was not defined in the OpenAPI document."), - .enumCase( - name: Constants.StringEnum.undocumentedCaseName, - kind: .nameWithAssociatedValues([ - .init(type: "String") - ]) + + let otherMembers: [Declaration] + if generateUnknownCases { + let undocumentedCase: Declaration = .commentable( + .doc("Parsed a raw value that was not defined in the OpenAPI document."), + .enumCase( + name: Constants.StringEnum.undocumentedCaseName, + kind: .nameWithAssociatedValues([ + .init(type: "String") + ]) + ) ) - ) - let rawRepresentableInitializer: Declaration - do { - let knownCases: [SwitchCaseDescription] = rawValues.map { rawValue in - .init( - kind: .case(.literal(rawValue)), + let rawRepresentableInitializer: Declaration + do { + let knownCases: [SwitchCaseDescription] = rawValues.map { rawValue in + .init( + kind: .case(.literal(rawValue)), + body: [ + .expression( + .assignment( + Expression + .identifier("self") + .equals( + .dot(swiftSafeName(for: rawValue)) + ) + ) + ) + ] + ) + } + let unknownCase = SwitchCaseDescription( + kind: .default, body: [ .expression( .assignment( Expression .identifier("self") .equals( - .dot(swiftSafeName(for: rawValue)) + .functionCall( + calledExpression: .dot( + Constants + .StringEnum + .undocumentedCaseName + ), + arguments: [ + .identifier("rawValue") + ] + ) ) ) ) ] ) - } - let unknownCase = SwitchCaseDescription( - kind: .default, - body: [ - .expression( - .assignment( - Expression - .identifier("self") - .equals( - .functionCall( - calledExpression: .dot( - Constants - .StringEnum - .undocumentedCaseName - ), - arguments: [ - .identifier("rawValue") - ] - ) + rawRepresentableInitializer = .function( + .init( + accessModifier: config.access, + kind: .initializer(failable: true), + parameters: [ + .init(label: "rawValue", type: "String") + ], + body: [ + .expression( + .switch( + switchedExpression: .identifier("rawValue"), + cases: knownCases + [unknownCase] ) - ) + ) + ] ) - ] - ) - rawRepresentableInitializer = .function( - .init( - accessModifier: config.access, - kind: .initializer(failable: true), - parameters: [ - .init(label: "rawValue", type: "String") - ], + ) + } + + let rawValueGetter: Declaration + do { + let knownCases: [SwitchCaseDescription] = rawValues.map { rawValue in + .init( + kind: .case(.dot(swiftSafeName(for: rawValue))), + body: [ + .expression( + .return(.literal(rawValue)) + ) + ] + ) + } + let unknownCase = SwitchCaseDescription( + kind: .case( + .valueBinding( + kind: .let, + value: .init( + calledExpression: .dot( + Constants.StringEnum.undocumentedCaseName + ), + arguments: [ + .identifier("string") + ] + ) + ) + ), body: [ .expression( - .switch( - switchedExpression: .identifier("rawValue"), - cases: knownCases + [unknownCase] - ) + .return(.identifier("string")) ) ] ) - ) - } - - let rawValueGetter: Declaration - do { - let knownCases: [SwitchCaseDescription] = rawValues.map { rawValue in - .init( - kind: .case(.dot(swiftSafeName(for: rawValue))), + let variableDescription = VariableDescription( + accessModifier: config.access, + kind: .var, + left: "rawValue", + type: "String", body: [ .expression( - .return(.literal(rawValue)) + .switch( + switchedExpression: .identifier("self"), + cases: [unknownCase] + knownCases + ) ) ] ) + rawValueGetter = .variable( + variableDescription + ) } - let unknownCase = SwitchCaseDescription( - kind: .case( - .valueBinding( - kind: .let, - value: .init( - calledExpression: .dot( - Constants.StringEnum.undocumentedCaseName - ), - arguments: [ - .identifier("string") - ] - ) - ) - ), - body: [ - .expression( - .return(.identifier("string")) - ) - ] - ) - let variableDescription = VariableDescription( - accessModifier: config.access, - kind: .var, - left: "rawValue", - type: "String", - body: [ - .expression( - .switch( - switchedExpression: .identifier("self"), - cases: [unknownCase] + knownCases - ) + let allCasesGetter: Declaration + do { + let caseExpressions: [Expression] = rawValues.map { rawValue in + .memberAccess(.init(right: swiftSafeName(for: rawValue))) + } + allCasesGetter = .variable( + .init( + accessModifier: config.access, + isStatic: true, + kind: .var, + left: "allCases", + type: typeName.asUsage.asArray.shortSwiftName, + body: [ + .expression(.literal(.array(caseExpressions))) + ] ) - ] - ) - - rawValueGetter = .variable( - variableDescription - ) - } - - let allCasesGetter: Declaration - do { - let caseExpressions: [Expression] = rawValues.map { rawValue in - .memberAccess(.init(right: swiftSafeName(for: rawValue))) - } - allCasesGetter = .variable( - .init( - accessModifier: config.access, - isStatic: true, - kind: .var, - left: "allCases", - type: typeName.asUsage.asArray.shortSwiftName, - body: [ - .expression(.literal(.array(caseExpressions))) - ] ) - ) + } + otherMembers = [ + undocumentedCase, + rawRepresentableInitializer, + rawValueGetter, + allCasesGetter, + ] + } else { + otherMembers = [] } + let baseConformance = + generateUnknownCases ? Constants.StringEnum.baseConformanceOpen : Constants.StringEnum.baseConformanceClosed let enumDescription = EnumDescription( isFrozen: true, accessModifier: config.access, name: typeName.shortSwiftName, - conformances: Constants.StringEnum.conformances, - members: knownCases + [ - undocumentedCase, - rawRepresentableInitializer, - rawValueGetter, - allCasesGetter, - ] + conformances: [baseConformance] + Constants.StringEnum.conformances, + members: knownCases + otherMembers ) let comment: Comment? = diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift index 5809bae6..c4a274ad 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift @@ -145,9 +145,14 @@ enum Constants { /// The name of the undocumented enum case. static let undocumentedCaseName = "undocumented" + /// The name of the base conformance when enums are open. + static let baseConformanceOpen: String = "RawRepresentable" + + /// The name of the base conformance when enums are closed. + static let baseConformanceClosed: String = "String" + /// The types that every enum conforms to. static let conformances: [String] = [ - "RawRepresentable", "Codable", "Equatable", "Hashable", diff --git a/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift b/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift new file mode 100644 index 00000000..66ea70e5 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// 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 OpenAPIKit30 + +extension FileTranslator { + + /// Returns a Boolean value indicating whether an undocumented case should + /// be generated for enums and oneOfs. + var shouldGenerateUndocumentedCaseForEnumsAndOneOfs: Bool { + if config.featureFlags.contains(.closedEnumsAndOneOfs) { + return false + } + // The 0.1.x default. + return true + } +} diff --git a/Sources/swift-openapi-generator/Documentation.docc/Articles/Useful-OpenAPI-patterns.md b/Sources/swift-openapi-generator/Documentation.docc/Articles/Useful-OpenAPI-patterns.md new file mode 100644 index 00000000..358bf066 --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Articles/Useful-OpenAPI-patterns.md @@ -0,0 +1,74 @@ +# Useful OpenAPI patterns + +Explore OpenAPI patterns for common data representations. + +## Overview + +This document lists some common OpenAPI patterns that have been tested to work well with Swift OpenAPI Generator. + +### Open enums and oneOfs + +While `enum` and `oneOf` are closed by default in OpenAPI, meaning that decoding fails if an unknown value is encountered, it can be a good practice to instead use open enums and oneOfs in your API, as it allows adding new cases over time without having to roll a new API-breaking version. + +#### Enums + +A simple enum looks like: + +```yaml +type: string +enum: + - foo + - bar + - baz +``` + +To create an open enum, in other words an enum that has a "default" value that doesn't fail during decoding, but instead preserves the unknown value, wrap the enum in an `anyOf` and add a string schema as the second subschema. + +```yaml +anyOf: + - type: string + enum: + - foo + - bar + - baz + - type: string +``` + +When accessing this data on the generated Swift code, first check if the first value (closed enum) is non-nil – if so, one of the known enum values were provided. If the enum value is nil, the second string value will contain the raw value that was provided, which you can log or pass through your program. + +#### oneOfs + +A simple oneOf looks like: + +```yaml +oneOf: + - #/components/schemas/Foo + - #/components/schemas/Bar + - #/components/schemas/Baz +``` + +To create an open oneOf, wrap it in an `anyOf`, and provide a fragment as the second schema, or a more constrained container if you know that the payload will always follow a certain structure. + +```yaml +MyOpenOneOf: + anyOf: + - oneOf: + - #/components/schemas/Foo + - #/components/schemas/Bar + - #/components/schemas/Baz + - {} +``` + +The above is the most flexible, any JSON payload that doesn't match any of the cases in oneOf will be saved into the second schema. + +If you know the payload is, for example, always a JSON object, you can constrain the second schema further, like this: + +``` +MyOpenOneOf: + anyOf: + - oneOf: + - #/components/schemas/Foo + - #/components/schemas/Bar + - #/components/schemas/Baz + - type: object +``` diff --git a/Sources/swift-openapi-generator/Documentation.docc/Swift-OpenAPI-Generator.md b/Sources/swift-openapi-generator/Documentation.docc/Swift-OpenAPI-Generator.md index 53622cfb..b30a00e1 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Swift-OpenAPI-Generator.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Swift-OpenAPI-Generator.md @@ -64,6 +64,7 @@ The generated code, runtime library, and transports are supported on more platfo ### OpenAPI - +- - ### Generator plugin and CLI diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index e3bd26d5..df663230 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -446,6 +446,139 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasOneOf_closed() throws { + try self.assertSchemasTranslation( + featureFlags: [.closedEnumsAndOneOfs], + """ + schemas: + A: {} + MyOneOf: + oneOf: + - type: string + - type: integer + - $ref: '#/components/schemas/A' + """, + """ + public enum Schemas { + public typealias A = OpenAPIRuntime.OpenAPIValueContainer + @frozen public enum MyOneOf: Codable, Equatable, Hashable, Sendable { + case case1(Swift.String) + case case2(Swift.Int) + case A(Components.Schemas.A) + public init(from decoder: any Decoder) throws { + do { + self = .case1(try .init(from: decoder)) + return + } catch {} + do { + self = .case2(try .init(from: decoder)) + return + } catch {} + do { + self = .A(try .init(from: decoder)) + return + } catch {} + throw DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .case1(value): try value.encode(to: encoder) + case let .case2(value): try value.encode(to: encoder) + case let .A(value): try value.encode(to: encoder) + } + } + } + } + """ + ) + } + + func testComponentsSchemasOneOf_open_pattern() throws { + try self.assertSchemasTranslation( + featureFlags: [.closedEnumsAndOneOfs], + """ + schemas: + A: + type: object + additionalProperties: false + MyOpenOneOf: + anyOf: + - oneOf: + - type: string + - type: integer + - $ref: '#/components/schemas/A' + - {} + """, + """ + public enum Schemas { + public struct A: Codable, Equatable, Hashable, Sendable { + public init() {} + public init(from decoder: any Decoder) throws { + try decoder.ensureNoAdditionalProperties(knownKeys: []) + } + } + public struct MyOpenOneOf: Codable, Equatable, Hashable, Sendable { + @frozen public enum Value1Payload: Codable, Equatable, Hashable, Sendable { + case case1(Swift.String) + case case2(Swift.Int) + case A(Components.Schemas.A) + public init(from decoder: any Decoder) throws { + do { + self = .case1(try .init(from: decoder)) + return + } catch {} + do { + self = .case2(try .init(from: decoder)) + return + } catch {} + do { + self = .A(try .init(from: decoder)) + return + } catch {} + throw DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .case1(value): try value.encode(to: encoder) + case let .case2(value): try value.encode(to: encoder) + case let .A(value): try value.encode(to: encoder) + } + } + } + public var value1: Components.Schemas.MyOpenOneOf.Value1Payload? + public var value2: OpenAPIRuntime.OpenAPIValueContainer? + public init( + value1: Components.Schemas.MyOpenOneOf.Value1Payload? = nil, + value2: OpenAPIRuntime.OpenAPIValueContainer? = nil + ) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + value1 = try? .init(from: decoder) + value2 = try? .init(from: decoder) + try DecodingError.verifyAtLeastOneSchemaIsNotNil( + [value1, value2], + type: Self.self, + codingPath: decoder.codingPath + ) + } + public func encode(to encoder: any Encoder) throws { + try value1?.encode(to: encoder) + try value2?.encode(to: encoder) + } + } + } + """ + ) + } + func testComponentsSchemasAllOfOneStringRef() throws { try self.assertSchemasTranslation( """ @@ -591,6 +724,86 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasEnum_closed() throws { + try self.assertSchemasTranslation( + featureFlags: [.closedEnumsAndOneOfs], + """ + schemas: + MyEnum: + type: string + enum: + - one + - + - $tart + - public + """, + """ + public enum Schemas { + @frozen + public enum MyEnum: String, Codable, Equatable, Hashable, Sendable, + _AutoLosslessStringConvertible, CaseIterable + { + case one = "one" + case _empty = "" + case _tart = "$tart" + case _public = "public" + } + } + """ + ) + } + + func testComponentsSchemasEnum_open_pattern() throws { + try self.assertSchemasTranslation( + featureFlags: [.closedEnumsAndOneOfs], + """ + schemas: + MyOpenEnum: + anyOf: + - type: string + enum: + - one + - two + - type: string + """, + """ + public enum Schemas { + public struct MyOpenEnum: Codable, Equatable, Hashable, Sendable { + @frozen + public enum Value1Payload: String, Codable, Equatable, Hashable, Sendable, + _AutoLosslessStringConvertible, CaseIterable + { + case one = "one" + case two = "two" + } + public var value1: Components.Schemas.MyOpenEnum.Value1Payload? + public var value2: Swift.String? + public init( + value1: Components.Schemas.MyOpenEnum.Value1Payload? = nil, + value2: Swift.String? = nil + ) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + value1 = try? .init(from: decoder) + value2 = try? .init(from: decoder) + try DecodingError.verifyAtLeastOneSchemaIsNotNil( + [value1, value2], + type: Self.self, + codingPath: decoder.codingPath + ) + } + public func encode(to encoder: any Encoder) throws { + try value1?.encode(to: encoder) + try value2?.encode(to: encoder) + } + } + } + """ + ) + } + func testComponentsSchemasDeprecatedObject() throws { try self.assertSchemasTranslation( """ @@ -1322,12 +1535,16 @@ extension SnippetBasedReferenceTests { } func assertSchemasTranslation( + featureFlags: FeatureFlags = [], _ componentsYAML: String, _ expectedSwift: String, file: StaticString = #filePath, line: UInt = #line ) throws { - let translator = try makeTypesTranslator(componentsYAML: componentsYAML) + let translator = try makeTypesTranslator( + featureFlags: featureFlags, + componentsYAML: componentsYAML + ) let translation = try translator.translateSchemas(translator.components.schemas) try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) } From 03b1ce441019e4f16db4a881639c4e022ee1b3eb Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 17 Aug 2023 08:35:57 +0200 Subject: [PATCH 2/3] Enable the feature flag in the file-based ref test that includes upcoming features --- .../CommonTranslations/translateCodable.swift | 126 ++++++++------- .../FileBasedReferenceTests.swift | 2 + .../Types.swift | 152 ++++-------------- .../Test_Types.swift | 11 -- 4 files changed, 103 insertions(+), 188 deletions(-) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift index eb7ca565..0f187a82 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift @@ -389,21 +389,7 @@ extension FileTranslator { } else { otherExprs = [ .expression( - .unaryKeyword( - kind: .throw, - expression: .identifier("DecodingError") - .dot("failedToDecodeOneOfSchema") - .call([ - .init( - label: "type", - expression: .identifier("Self").dot("self") - ), - .init( - label: "codingPath", - expression: .identifier("decoder").dot("codingPath") - ), - ]) - ) + translateOneOfDecoderThrowOnUnknownExpr() ) ] } @@ -413,6 +399,26 @@ extension FileTranslator { ) } + /// Returns an expression that throws an error when a oneOf failed + /// to match any documented cases. + func translateOneOfDecoderThrowOnUnknownExpr() -> Expression { + .unaryKeyword( + kind: .throw, + expression: .identifier("DecodingError") + .dot("failedToDecodeOneOfSchema") + .call([ + .init( + label: "type", + expression: .identifier("Self").dot("self") + ), + .init( + label: "codingPath", + expression: .identifier("decoder").dot("codingPath") + ), + ]) + ) + } + /// Returns a declaration of a oneOf with a discriminator decoder implementation. /// - Parameters: /// - caseNames: The cases to decode, first element is the raw string to check for, the second @@ -444,50 +450,60 @@ extension FileTranslator { ] ) } - let decodeUndocumentedBody: [CodeBlock] = [ - .declaration( - .variable( - kind: .let, - left: "container", - right: .try( - .identifier("decoder") - .dot("singleValueContainer") - .call([]) + let generateUndocumentedCase = shouldGenerateUndocumentedCaseForEnumsAndOneOfs + let otherExprs: [CodeBlock] + if generateUndocumentedCase { + otherExprs = [ + .declaration( + .variable( + kind: .let, + left: "container", + right: .try( + .identifier("decoder") + .dot("singleValueContainer") + .call([]) + ) ) - ) - ), - .declaration( - .variable( - kind: .let, - left: "value", - right: .try( - .identifier("container") - .dot("decode") + ), + .declaration( + .variable( + kind: .let, + left: "value", + right: .try( + .identifier("container") + .dot("decode") + .call([ + .init( + label: nil, + expression: + .identifier( + TypeName + .objectContainer + .fullyQualifiedSwiftName + ) + .dot("self") + ) + ]) + ) + ) + ), + .expression( + .assignment( + left: .identifier("self"), + right: .dot(Constants.OneOf.undocumentedCaseName) .call([ - .init( - label: nil, - expression: - .identifier( - TypeName - .objectContainer - .fullyQualifiedSwiftName - ) - .dot("self") - ) + .init(label: nil, expression: .identifier("value")) ]) ) + ), + ] + } else { + otherExprs = [ + .expression( + translateOneOfDecoderThrowOnUnknownExpr() ) - ), - .expression( - .assignment( - left: .identifier("self"), - right: .dot(Constants.OneOf.undocumentedCaseName) - .call([ - .init(label: nil, expression: .identifier("value")) - ]) - ) - ), - ] + ] + } let body: [CodeBlock] = [ .declaration(.decoderContainerOfKeysVar()), .declaration( @@ -516,7 +532,7 @@ extension FileTranslator { cases: cases + [ .init( kind: .default, - body: decodeUndocumentedBody + body: otherExprs ) ] ) diff --git a/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift index 591615bc..1ef23440 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/FileBasedReferenceTests.swift @@ -62,6 +62,8 @@ class FileBasedReferenceTests: XCTestCase { featureFlags: [ .multipleContentTypes, .proposal0001, + .strictOpenAPIValidation, + .closedEnumsAndOneOfs, ] ) } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Types.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Types.swift index 199f1c58..9fdfb9d3 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Types.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore_FF_MultipleContentTypes/Types.swift @@ -99,42 +99,15 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/PetKind`. @frozen - public enum PetKind: RawRepresentable, Codable, Equatable, Hashable, Sendable, + public enum PetKind: String, Codable, Equatable, Hashable, Sendable, _AutoLosslessStringConvertible, CaseIterable { - case cat - case dog - case ELEPHANT - case BIG_ELEPHANT_1 - case _dollar_nake - case _public - /// Parsed a raw value that was not defined in the OpenAPI document. - case undocumented(String) - public init?(rawValue: String) { - switch rawValue { - case "cat": self = .cat - case "dog": self = .dog - case "ELEPHANT": self = .ELEPHANT - case "BIG_ELEPHANT_1": self = .BIG_ELEPHANT_1 - case "$nake": self = ._dollar_nake - case "public": self = ._public - default: self = .undocumented(rawValue) - } - } - public var rawValue: String { - switch self { - case let .undocumented(string): return string - case .cat: return "cat" - case .dog: return "dog" - case .ELEPHANT: return "ELEPHANT" - case .BIG_ELEPHANT_1: return "BIG_ELEPHANT_1" - case ._dollar_nake: return "$nake" - case ._public: return "public" - } - } - public static var allCases: [PetKind] { - [.cat, .dog, .ELEPHANT, .BIG_ELEPHANT_1, ._dollar_nake, ._public] - } + case cat = "cat" + case dog = "dog" + case ELEPHANT = "ELEPHANT" + case BIG_ELEPHANT_1 = "BIG_ELEPHANT_1" + case _dollar_nake = "$nake" + case _public = "public" } /// - Remark: Generated from `#/components/schemas/CreatePetRequest`. public struct CreatePetRequest: Codable, Equatable, Hashable, Sendable { @@ -226,31 +199,12 @@ public enum Components { public struct PetFeeding: Codable, Equatable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PetFeeding/schedule`. @frozen - public enum schedulePayload: RawRepresentable, Codable, Equatable, Hashable, Sendable, + public enum schedulePayload: String, Codable, Equatable, Hashable, Sendable, _AutoLosslessStringConvertible, CaseIterable { - case hourly - case daily - case weekly - /// Parsed a raw value that was not defined in the OpenAPI document. - case undocumented(String) - public init?(rawValue: String) { - switch rawValue { - case "hourly": self = .hourly - case "daily": self = .daily - case "weekly": self = .weekly - default: self = .undocumented(rawValue) - } - } - public var rawValue: String { - switch self { - case let .undocumented(string): return string - case .hourly: return "hourly" - case .daily: return "daily" - case .weekly: return "weekly" - } - } - public static var allCases: [schedulePayload] { [.hourly, .daily, .weekly] } + case hourly = "hourly" + case daily = "daily" + case weekly = "weekly" } /// - Remark: Generated from `#/components/schemas/PetFeeding/schedule`. public var schedule: Components.Schemas.PetFeeding.schedulePayload? @@ -456,8 +410,6 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/OneOfAny/case4`. case case4(Components.Schemas.OneOfAny.Case4Payload) - /// Parsed a case that was not defined in the OpenAPI document. - case undocumented(OpenAPIRuntime.OpenAPIValueContainer) public init(from decoder: any Decoder) throws { do { self = .case1(try .init(from: decoder)) @@ -475,9 +427,10 @@ public enum Components { self = .case4(try .init(from: decoder)) return } catch {} - let container = try decoder.singleValueContainer() - let value = try container.decode(OpenAPIRuntime.OpenAPIValueContainer.self) - self = .undocumented(value) + throw DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath + ) } public func encode(to encoder: any Encoder) throws { switch self { @@ -485,7 +438,6 @@ public enum Components { case let .case2(value): try value.encode(to: encoder) case let .CodeError(value): try value.encode(to: encoder) case let .case4(value): try value.encode(to: encoder) - case let .undocumented(value): try value.encode(to: encoder) } } } @@ -564,8 +516,6 @@ public enum Components { case Walk(Components.Schemas.Walk) /// - Remark: Generated from `#/components/schemas/OneOfObjectsWithDiscriminator/case2`. case MessagedExercise(Components.Schemas.MessagedExercise) - /// Parsed a case that was not defined in the OpenAPI document. - case undocumented(OpenAPIRuntime.OpenAPIObjectContainer) public enum CodingKeys: String, CodingKey { case kind } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -574,16 +524,16 @@ public enum Components { case "Walk": self = .Walk(try .init(from: decoder)) case "MessagedExercise": self = .MessagedExercise(try .init(from: decoder)) default: - let container = try decoder.singleValueContainer() - let value = try container.decode(OpenAPIRuntime.OpenAPIObjectContainer.self) - self = .undocumented(value) + throw DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath + ) } } public func encode(to encoder: any Encoder) throws { switch self { case let .Walk(value): try value.encode(to: encoder) case let .MessagedExercise(value): try value.encode(to: encoder) - case let .undocumented(value): try value.encode(to: encoder) } } } @@ -741,65 +691,23 @@ public enum Operations { public var limit: Swift.Int32? /// - Remark: Generated from `#/paths/pets/GET/query/habitat`. @frozen - public enum habitatPayload: RawRepresentable, Codable, Equatable, Hashable, - Sendable, _AutoLosslessStringConvertible, CaseIterable + public enum habitatPayload: String, Codable, Equatable, Hashable, Sendable, + _AutoLosslessStringConvertible, CaseIterable { - case water - case land - case air - case _empty - /// Parsed a raw value that was not defined in the OpenAPI document. - case undocumented(String) - public init?(rawValue: String) { - switch rawValue { - case "water": self = .water - case "land": self = .land - case "air": self = .air - case "": self = ._empty - default: self = .undocumented(rawValue) - } - } - public var rawValue: String { - switch self { - case let .undocumented(string): return string - case .water: return "water" - case .land: return "land" - case .air: return "air" - case ._empty: return "" - } - } - public static var allCases: [habitatPayload] { [.water, .land, .air, ._empty] } + case water = "water" + case land = "land" + case air = "air" + case _empty = "" } public var habitat: Operations.listPets.Input.Query.habitatPayload? /// - Remark: Generated from `#/paths/pets/GET/query/feedsPayload`. @frozen - public enum feedsPayloadPayload: RawRepresentable, Codable, Equatable, Hashable, - Sendable, _AutoLosslessStringConvertible, CaseIterable + public enum feedsPayloadPayload: String, Codable, Equatable, Hashable, Sendable, + _AutoLosslessStringConvertible, CaseIterable { - case omnivore - case carnivore - case herbivore - /// Parsed a raw value that was not defined in the OpenAPI document. - case undocumented(String) - public init?(rawValue: String) { - switch rawValue { - case "omnivore": self = .omnivore - case "carnivore": self = .carnivore - case "herbivore": self = .herbivore - default: self = .undocumented(rawValue) - } - } - public var rawValue: String { - switch self { - case let .undocumented(string): return string - case .omnivore: return "omnivore" - case .carnivore: return "carnivore" - case .herbivore: return "herbivore" - } - } - public static var allCases: [feedsPayloadPayload] { - [.omnivore, .carnivore, .herbivore] - } + case omnivore = "omnivore" + case carnivore = "carnivore" + case herbivore = "herbivore" } /// - Remark: Generated from `#/paths/pets/GET/query/feeds`. public typealias feedsPayload = [Operations.listPets.Input.Query diff --git a/Tests/PetstoreConsumerTestsFFMultipleContentTypes/Test_Types.swift b/Tests/PetstoreConsumerTestsFFMultipleContentTypes/Test_Types.swift index 4ec146af..dabfe9d1 100644 --- a/Tests/PetstoreConsumerTestsFFMultipleContentTypes/Test_Types.swift +++ b/Tests/PetstoreConsumerTestsFFMultipleContentTypes/Test_Types.swift @@ -188,9 +188,6 @@ final class Test_Types: XCTestCase { try _testRoundtrip( Components.Schemas.OneOfAny.case4(.init(message: "hello")) ) - try _testRoundtrip( - Components.Schemas.OneOfAny.undocumented(true) - ) } func testOneOfWithDiscriminator_roundtrip() throws { @@ -212,14 +209,6 @@ final class Test_Types: XCTestCase { ) ) ) - try _testRoundtrip( - Components.Schemas.OneOfObjectsWithDiscriminator - .undocumented( - .init(unvalidatedValue: [ - "kind": "nope" - ]) - ) - ) } func testOneOfWithDiscriminator_invalidDiscriminator() throws { From 590df9ddca96dadf4020ef5e9599b5ebd878ec39 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 17 Aug 2023 15:57:44 +0200 Subject: [PATCH 3/3] Bump runtime version --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index d1b0ddd8..6761285a 100644 --- a/Package.swift +++ b/Package.swift @@ -78,7 +78,7 @@ let package = Package( ), // Tests-only: Runtime library linked by generated code - .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.8")), + .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.9")), // Build and preview docs .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),