diff --git a/Package.swift b/Package.swift index 937b3c92..2592967e 100644 --- a/Package.swift +++ b/Package.swift @@ -89,7 +89,7 @@ let package = Package( // Tests-only: Runtime library linked by generated code, and also // helps keep the runtime library new enough to work with the generated // code. - .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.2")), + .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.3")), // Build and preview docs .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), diff --git a/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift b/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift index a14ddccd..8e5cbc58 100644 --- a/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift +++ b/Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift @@ -44,29 +44,5 @@ func validateDoc(_ doc: ParsedOpenAPIRepresentation, config: Config) throws -> [ ] ) } - - // Validate that the document is dereferenceable, which - // catches reference cycles, which we don't yet support. - _ = try doc.locallyDereferenced() - - // Also explicitly dereference the parts of components - // that the generator uses. `locallyDereferenced()` above - // only dereferences paths/operations, but not components. - let components = doc.components - try components.schemas.forEach { schema in - _ = try schema.value.dereferenced(in: components) - } - try components.parameters.forEach { schema in - _ = try schema.value.dereferenced(in: components) - } - try components.headers.forEach { schema in - _ = try schema.value.dereferenced(in: components) - } - try components.requestBodies.forEach { schema in - _ = try schema.value.dereferenced(in: components) - } - try components.responses.forEach { schema in - _ = try schema.value.dereferenced(in: components) - } return diagnostics } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift index 087e1ff5..18d44f90 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift @@ -85,8 +85,10 @@ extension FileTranslator { associatedDeclarations: associatedDeclarations, asSwiftSafeName: swiftSafeName ) + var referenceStack = ReferenceStack.empty let isKeyValuePairSchema = try TypeMatcher.isKeyValuePair( schema, + referenceStack: &referenceStack, components: components ) return (blueprint, isKeyValuePairSchema) @@ -196,8 +198,10 @@ extension FileTranslator { } else { associatedDeclarations = [] } + var referenceStack = ReferenceStack.empty let isKeyValuePair = try TypeMatcher.isKeyValuePair( schema, + referenceStack: &referenceStack, components: components ) return (caseName, nil, isKeyValuePair, comment, childType, associatedDeclarations) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift index 84f1cf03..2301a585 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift @@ -312,6 +312,12 @@ enum Constants { /// The name of the namespace. static let namespace: String = "Schemas" + + /// The full namespace components. + static let components: [String] = [ + Constants.Components.namespace, + Constants.Components.Schemas.namespace, + ] } /// Constants related to the Parameters namespace. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Recursion/DeclarationRecursionDetector.swift b/Sources/_OpenAPIGeneratorCore/Translator/Recursion/DeclarationRecursionDetector.swift new file mode 100644 index 00000000..f1a9f306 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Recursion/DeclarationRecursionDetector.swift @@ -0,0 +1,204 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A set of specialized types for using the recursion detector for +/// declarations. +struct DeclarationRecursionDetector { + + /// A node for a pair of a Swift type name and a corresponding declaration. + struct Node: TypeNode, Equatable { + + /// The type of the name is a string. + typealias NameType = String + + /// The name of the node. + var name: NameType + + /// Whether the type can be boxed. + var isBoxable: Bool + + /// The names of nodes pointed to by this node. + var edges: [NameType] + + /// The declaration represented by this node. + var decl: Declaration + + /// Creates a new node. + /// - Parameters: + /// - name: The name of the node. + /// - isBoxable: Whether the type can be boxed. + /// - edges: The names of nodes pointed to by this node. + /// - decl: The declaration represented by this node. + private init(name: NameType, isBoxable: Bool, edges: [NameType], decl: Declaration) { + self.name = name + self.isBoxable = isBoxable + self.edges = edges + self.decl = decl + } + + /// Creates a new node from the provided declaration. + /// + /// Returns nil when the declaration is missing a name. + /// - Parameter decl: A declaration. + init?(_ decl: Declaration) { + guard let name = decl.name else { + return nil + } + let edges = decl.schemaComponentNamesOfUnbreakableReferences + self.init( + name: name, + isBoxable: decl.isBoxable, + edges: edges, + decl: decl + ) + } + } + + /// A container for declarations. + struct Container: TypeNodeContainer { + + /// The type of the node. + typealias Node = DeclarationRecursionDetector.Node + + /// An error thrown by the container. + enum ContainerError: Swift.Error { + + /// The node for the provided name was not found. + case nodeNotFound(Node.NameType) + } + + /// The lookup map from the name to the node. + var lookupMap: [String: Node] + + func lookup(_ name: String) throws -> DeclarationRecursionDetector.Node { + guard let node = lookupMap[name] else { + throw ContainerError.nodeNotFound(name) + } + return node + } + } +} + +extension Declaration { + + /// A name of the declaration, if it has one. + var name: String? { + switch self { + case .struct(let desc): + return desc.name + case .enum(let desc): + return desc.name + case .typealias(let desc): + return desc.name + case .commentable(_, let decl), .deprecated(_, let decl): + return decl.name + case .variable, .extension, .protocol, .function, .enumCase: + return nil + } + } + + /// A Boolean value representing whether this declaration can be boxed. + var isBoxable: Bool { + switch self { + case .struct, .enum: + return true + case .commentable(_, let decl), .deprecated(_, let decl): + return decl.isBoxable + case .typealias, .variable, .extension, .protocol, .function, .enumCase: + return false + } + } + + /// An array of names that can be found in `#/components/schemas` in + /// the OpenAPI document that represent references that can cause + /// a reference cycle. + var schemaComponentNamesOfUnbreakableReferences: [String] { + switch self { + case .struct(let desc): + return desc + .members + .compactMap { (member) -> [String]? in + switch member.strippingTopComment { + case .variable, // A reference to a reusable type. + .struct, .enum: // An inline type. + return member.schemaComponentNamesOfUnbreakableReferences + default: + return nil + } + } + .flatMap { $0 } + case .enum(let desc): + return desc + .members + .compactMap { (member) -> [String]? in + guard case .enumCase = member.strippingTopComment else { + return nil + } + return member + .schemaComponentNamesOfUnbreakableReferences + } + .flatMap { $0 } + case .commentable(_, let decl), .deprecated(_, let decl): + return decl + .schemaComponentNamesOfUnbreakableReferences + case .typealias(let desc): + return desc + .existingType + .referencedSchemaComponentName + .map { [$0] } ?? [] + case .variable(let desc): + return desc.type?.referencedSchemaComponentName.map { [$0] } ?? [] + case .enumCase(let desc): + switch desc.kind { + case .nameWithAssociatedValues(let values): + return values.compactMap { $0.type.referencedSchemaComponentName } + default: + return [] + } + case .extension, .protocol, .function: + return [] + } + } +} + +fileprivate extension Array where Element == String { + + /// The name in the `Components.Schemas.` namespace. + var nameIfTopLevelSchemaComponent: String? { + let components = self + guard + components.count == 3, + components.starts(with: Constants.Components.Schemas.components) + else { + return nil + } + return components[2] + } +} + +extension ExistingTypeDescription { + + /// The name in the `Components.Schemas.` namespace, if the type can appear + /// there. Nil otherwise. + var referencedSchemaComponentName: String? { + switch self { + case .member(let components): + return components.nameIfTopLevelSchemaComponent + case .array(let desc), .dictionaryValue(let desc), .any(let desc), .optional(let desc): + return desc.referencedSchemaComponentName + case .generic: + return nil + } + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Recursion/RecursionDetector.swift b/Sources/_OpenAPIGeneratorCore/Translator/Recursion/RecursionDetector.swift new file mode 100644 index 00000000..de718928 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Recursion/RecursionDetector.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation + +/// A uniquely named node which can point to other nodes. +protocol TypeNode { + + /// The type of the name. + associatedtype NameType: Hashable & CustomStringConvertible + + /// A unique name. + var name: NameType { get } + + /// Whether it can be boxed in a reference type to break cycles. + var isBoxable: Bool { get } + + /// The names of nodes pointed to by this node. + var edges: [NameType] { get } +} + +/// A container of nodes that allows looking up nodes by a name. +protocol TypeNodeContainer { + + /// The type of the node. + associatedtype Node: TypeNode + + /// Looks up a node for the provided name. + /// - Parameter name: A unique name of a node. + /// - Returns: The node found in the container. + /// - Throws: If no node was found for the name. + func lookup(_ name: Node.NameType) throws -> Node +} + +/// A set of utility functions for recursive type support. +struct RecursionDetector { + + /// An error thrown by the recursion detector. + enum RecursionError: Swift.Error, LocalizedError, CustomStringConvertible { + + /// The recursion is not allowed (for example, a ref pointing to itself.) + case invalidRecursion(String) + + var description: String { + switch self { + case .invalidRecursion(let string): + return + "Invalid recursion found at type '\(string)'. This type cannot be constructed, cycles must contain at least one struct, not just typealiases." + } + } + } + + /// Computes the types that are involved in recursion. + /// + /// This is used to decide which types should have a reference type for + /// internal storage, allowing to break infinite recursion and support + /// recursive types. + /// + /// Note that this function encompasses the full algorithm, to allow future + /// optimization without breaking the API. + /// - Parameters: + /// - rootNodes: The named root nodes. + /// - container: The container capable of resolving a name to a node. + /// - Returns: The types that cause recusion and should have a reference + /// type for internal storage. + /// - Throws: If a referenced node is not found in the container. + static func computeBoxedTypes( + rootNodes: [Node], + container: Container + ) throws -> Set where Container.Node == Node { + + // The current algorithm works as follows: + // - Iterate over the types, in the order provided in the OpenAPI + // document. + // - Walk all references and keep track of names already visited. + // - If visiting a schema that is already in the stack, we found a cycle. + // - In the cycle, first identify the set of types involved in it, and + // check if any of the types is already recorded as a recursive type. + // If so, no action needed and terminate this branch and continue with + // the next one. + // - If no type in the cycle is already included in the set of recursive + // types, find the first boxable type starting from the current one + // ("causing" the recursion) following the cycle, and add it to this + // set, and then terminate this branch and continue. + // - At the end, return the set of recursive types. + + var seen: Set = [] + var boxed: Set = [] + var stack: [Node] = [] + var stackSet: Set = [] + + func visit(_ node: Node) throws { + let name = node.name + + // Check if we've seen this node yet. + if !seen.contains(name) { + + // Add to the stack. + stack.append(node) + stackSet.insert(name) + defer { + stackSet.remove(name) + stack.removeLast() + } + + // Not seen this node yet, so add it to seen, and then + // visit its edges. + seen.insert(name) + for edge in node.edges { + try visit(container.lookup(edge)) + } + return + } + + // We have seen this node. + + // If the name is not in the stack, this is not a cycle. + if !stackSet.contains(name) { + return + } + + // It is in the stack, so we just closed a cycle. + + // Identify the names involved in the cycle. + // Right now, the stack must have the current node there twice. + // Ignore everything before the first occurrence. + + let cycleNodes = stack.drop(while: { $0.name != name }) + let cycleNames = Set(cycleNodes.map(\.name)) + + // Check if any of the names are already boxed. + if cycleNames.contains(where: { boxed.contains($0) }) { + // Found one, so we know this cycle will already be broken. + // No need to add any other type, just return from this + // visit. + return + } + + // We now choose which node will be marked as recursive. + // Only consider boxable nodes, trying from the start of the cycle. + guard let firstBoxable = cycleNodes.first(where: \.isBoxable) else { + throw RecursionError.invalidRecursion(name.description) + } + + // None of the types are boxed yet, so add the current node. + boxed.insert(firstBoxable.name) + } + + for node in rootNodes { + try visit(node) + } + + return boxed + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift index 0cae3e01..bb50d9ba 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift @@ -105,4 +105,9 @@ extension TypeName { static var serverRequestMetadata: TypeName { .runtime("ServerRequestMetadata") } + + /// Returns the type name for the copy-on-write box type. + static var box: TypeName { + .runtime("CopyOnWriteBox") + } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/ReferenceStack.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/ReferenceStack.swift new file mode 100644 index 00000000..5351428e --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/ReferenceStack.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// A stack with efficient checking if a specific item is included. +struct ReferenceStack { + + /// The current stack of names. + private var stack: [String] + + /// The names seen so far. + private var names: Set + + /// Creates a new stack. + /// - Parameters: + /// - stack: The initial stack of names. + /// - names: The names seen so far. + init(stack: [String], names: Set) { + self.stack = stack + self.names = names + } + + /// An empty stack. + static var empty: Self { + .init(stack: [], names: []) + } + + /// Pushes the provided name to the stack. + /// - Parameter name: The name to push. + mutating func push(_ name: String) { + stack.append(name) + names.insert(name) + } + + /// Pushes the provided ref to the stack. + /// - Parameter ref: The ref to push. + /// - Throws: When the reference isn't an internal component one. + mutating func push(_ ref: JSONReference) throws { + try push(ref.requiredName) + } + + /// Removes the top item from the stack. + mutating func pop() { + let name = stack.removeLast() + names.remove(name) + } + + /// Returns whether the provided name is present in the stack. + /// - Parameter name: The name to check. + /// - Returns: `true` if present, `false` otherwise. + func contains(_ name: String) -> Bool { + names.contains(name) + } + + /// Returns whether the provided ref is present in the stack. + /// - Parameter ref: The ref to check. + /// - Returns: `true` if present, `false` otherwise. + /// - Throws: When the reference isn't an internal component one. + func contains(_ ref: JSONReference) throws -> Bool { + try contains(ref.requiredName) + } +} + +extension JSONReference { + + /// Returns the name of the reference. + /// + /// - Throws: If the reference is not an internal component one. + var requiredName: String { + get throws { + guard + case .internal(let internalReference) = self, + case .component(name: let name) = internalReference + else { + throw JSONReferenceParsingError.externalPathsUnsupported(absoluteString) + } + return name + } + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift index 71741fc3..2a756415 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift @@ -193,11 +193,13 @@ struct TypeMatcher { /// /// - Parameters: /// - schema: The schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. /// - components: The reusable components from the OpenAPI document. /// - Throws: An error if there's an issue while checking the schema. /// - Returns: `true` if the schema is a key-value pair; `false` otherwise. static func isKeyValuePair( _ schema: JSONSchema, + referenceStack: inout ReferenceStack, components: OpenAPI.Components ) throws -> Bool { switch schema.value { @@ -208,14 +210,40 @@ struct TypeMatcher { case .all(let subschemas, _): // An allOf is a key-value pair schema iff all of its subschemas // also are. - return try subschemas.allSatisfy { try isKeyValuePair($0, components: components) } + return try subschemas.allSatisfy { + try isKeyValuePair( + $0, + referenceStack: &referenceStack, + components: components + ) + } case .one(let subschemas, _), .any(let subschemas, _): // A oneOf/anyOf is a key-value pair schema if at least one // subschema is as well, unfortunately the rest is only known // at runtime, so we can't validate beyond that here. - return try subschemas.contains { try isKeyValuePair($0, components: components) } + return try subschemas.contains { + try isKeyValuePair( + $0, + referenceStack: &referenceStack, + components: components + ) + } case .reference(let ref, _): - return try isKeyValuePair(components.lookup(ref), components: components) + if try referenceStack.contains(ref) { + // Encountered a cycle, but that's okay - return true as + // only key-value pair schemas can be valid recursive types. + return true + } + let targetSchema = try components.lookup(ref) + try referenceStack.push(ref) + defer { + referenceStack.pop() + } + return try isKeyValuePair( + targetSchema, + referenceStack: &referenceStack, + components: components + ) } } @@ -229,11 +257,13 @@ struct TypeMatcher { /// /// - Parameters: /// - schema: The schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. /// - components: The reusable components from the OpenAPI document. /// - Throws: An error if there's an issue while checking the schema. /// - Returns: `true` if the schema is a key-value pair; `false` otherwise. static func isKeyValuePair( _ schema: UnresolvedSchema?, + referenceStack: inout ReferenceStack, components: OpenAPI.Components ) throws -> Bool { guard let schema else { @@ -247,7 +277,11 @@ struct TypeMatcher { case let .b(schema): schemaToCheck = schema } - return try isKeyValuePair(schemaToCheck, components: components) + return try isKeyValuePair( + schemaToCheck, + referenceStack: &referenceStack, + components: components + ) } /// Returns a Boolean value indicating whether the schema is optional. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift index 7279cbf3..4fbe129c 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift @@ -67,7 +67,8 @@ extension FileTranslator { _ schema: JSONSchema, foundIn: String ) throws -> Bool { - switch try isSchemaSupported(schema) { + var referenceStack = ReferenceStack.empty + switch try isSchemaSupported(schema, referenceStack: &referenceStack) { case .supported: return true case .unsupported(reason: let reason, schema: let schema): @@ -92,7 +93,8 @@ extension FileTranslator { _ schema: UnresolvedSchema?, foundIn: String ) throws -> Bool { - switch try isSchemaSupported(schema) { + var referenceStack = ReferenceStack.empty + switch try isSchemaSupported(schema, referenceStack: &referenceStack) { case .supported: return true case .unsupported(reason: let reason, schema: let schema): @@ -108,11 +110,14 @@ extension FileTranslator { /// Returns whether the schema is supported. /// /// If a schema is not supported, no references to it should be emitted. - /// - Parameter schema: The schema to validate. + /// - Parameters: + /// - schema: The schema to validate. + /// - referenceStack: A stack of reference names that lead to this schema. /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is supported or unsupported. /// - Throws: An error if there's an issue during the validation process. func isSchemaSupported( - _ schema: JSONSchema + _ schema: JSONSchema, + referenceStack: inout ReferenceStack ) throws -> IsSchemaSupportedResult { switch schema.value { case .string, @@ -127,16 +132,30 @@ extension FileTranslator { .fragment: return .supported case .reference(let ref, _): + if try referenceStack.contains(ref) { + // Encountered a cycle, but that's okay - return supported. + return .supported + } // reference is supported iff the existing type is supported let existingSchema = try components.lookup(ref) - return try isSchemaSupported(existingSchema) + try referenceStack.push(ref) + defer { + referenceStack.pop() + } + return try isSchemaSupported( + existingSchema, + referenceStack: &referenceStack + ) case .array(_, let array): guard let items = array.items else { // an array of fragments is supported return .supported } // an array is supported iff its element schema is supported - return try isSchemaSupported(items) + return try isSchemaSupported( + items, + referenceStack: &referenceStack + ) case .all(of: let schemas, _): guard !schemas.isEmpty else { return .unsupported( @@ -144,7 +163,10 @@ extension FileTranslator { schema: schema ) } - return try areSchemasSupported(schemas) + return try areSchemasSupported( + schemas, + referenceStack: &referenceStack + ) case .any(of: let schemas, _): guard !schemas.isEmpty else { return .unsupported( @@ -152,7 +174,10 @@ extension FileTranslator { schema: schema ) } - return try areSchemasSupported(schemas) + return try areSchemasSupported( + schemas, + referenceStack: &referenceStack + ) case .one(of: let schemas, let context): guard !schemas.isEmpty else { return .unsupported( @@ -161,11 +186,17 @@ extension FileTranslator { ) } guard context.discriminator != nil else { - return try areSchemasSupported(schemas) + return try areSchemasSupported( + schemas, + referenceStack: &referenceStack + ) } // > When using the discriminator, inline schemas will not be considered. // > — https://spec.openapis.org/oas/v3.0.3#discriminator-object - return try areRefsToObjectishSchemaAndSupported(schemas.filter(\.isReference)) + return try areRefsToObjectishSchemaAndSupported( + schemas.filter(\.isReference), + referenceStack: &referenceStack + ) case .not, .null: return .unsupported( reason: .schemaType, @@ -177,11 +208,14 @@ extension FileTranslator { /// Returns a result indicating whether the schema is supported. /// /// If a schema is not supported, no references to it should be emitted. - /// - Parameter schema: The schema to validate. + /// - Parameters: + /// - schema: The schema to validate. + /// - referenceStack: A stack of reference names that lead to this schema. /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is supported or unsupported. /// - Throws: An error if there's an issue during the validation process. func isSchemaSupported( - _ schema: UnresolvedSchema? + _ schema: UnresolvedSchema?, + referenceStack: inout ReferenceStack ) throws -> IsSchemaSupportedResult { guard let schema else { // fragment type is supported @@ -192,18 +226,28 @@ extension FileTranslator { // references are supported return .supported case let .b(schema): - return try isSchemaSupported(schema) + return try isSchemaSupported(schema, referenceStack: &referenceStack) } } /// Returns a result indicating whether the provided schemas /// are supported. - /// - Parameter schemas: Schemas to check. - /// - Returns: An `IsSchemaSupportedResult` indicating whether all schemas are supported or if there's an unsupported schema. + /// - Parameters: + /// - schemas: Schemas to check. + /// - referenceStack: A stack of reference names that lead to these + /// schemas. + /// - Returns: An `IsSchemaSupportedResult` indicating whether all schemas + /// are supported or if there's an unsupported schema. /// - Throws: An error if there's an issue during the validation process. - func areSchemasSupported(_ schemas: [JSONSchema]) throws -> IsSchemaSupportedResult { + func areSchemasSupported( + _ schemas: [JSONSchema], + referenceStack: inout ReferenceStack + ) throws -> IsSchemaSupportedResult { for schema in schemas { - let result = try isSchemaSupported(schema) + let result = try isSchemaSupported( + schema, + referenceStack: &referenceStack + ) guard result == .supported else { return result } @@ -213,17 +257,32 @@ extension FileTranslator { /// Returns a result indicating whether the provided schema /// is an reference, object, or allOf (object-ish) schema and is supported. - /// - Parameter schema: A schemas to check. - /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is supported or not. + /// - Parameters: + /// - schema: A schemas to check. + /// - referenceStack: A stack of reference names that lead to this schema. + /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is + /// supported or not. /// - Throws: An error if there's an issue during the validation process. - func isObjectishSchemaAndSupported(_ schema: JSONSchema) throws -> IsSchemaSupportedResult { + func isObjectishSchemaAndSupported( + _ schema: JSONSchema, + referenceStack: inout ReferenceStack + ) throws -> IsSchemaSupportedResult { switch schema.value { case .object: - return try isSchemaSupported(schema) + return try isSchemaSupported( + schema, + referenceStack: &referenceStack + ) case .reference: - return try isRefToObjectishSchemaAndSupported(schema) + return try isRefToObjectishSchemaAndSupported( + schema, + referenceStack: &referenceStack + ) case .all(of: let schemas, _), .any(of: let schemas, _), .one(of: let schemas, _): - return try areObjectishSchemasAndSupported(schemas) + return try areObjectishSchemasAndSupported( + schemas, + referenceStack: &referenceStack + ) default: return .unsupported( reason: .notObjectish, @@ -234,12 +293,21 @@ extension FileTranslator { /// Returns a result indicating whether the provided schemas /// are object-ish schemas and supported. - /// - Parameter schemas: Schemas to check. + /// - Parameters: + /// - schemas: Schemas to check. + /// - referenceStack: A stack of reference names that lead to these + /// schemas. /// - Throws: An error if there's an issue while checking the schemas. /// - Returns: `.supported` if all schemas match; `.unsupported` otherwise. - func areObjectishSchemasAndSupported(_ schemas: [JSONSchema]) throws -> IsSchemaSupportedResult { + func areObjectishSchemasAndSupported( + _ schemas: [JSONSchema], + referenceStack: inout ReferenceStack + ) throws -> IsSchemaSupportedResult { for schema in schemas { - let result = try isObjectishSchemaAndSupported(schema) + let result = try isObjectishSchemaAndSupported( + schema, + referenceStack: &referenceStack + ) guard result == .supported else { return result } @@ -249,12 +317,20 @@ extension FileTranslator { /// Returns a result indicating whether the provided schemas /// are reference schemas that point to object-ish schemas and supported. - /// - Parameter schemas: Schemas to check. + /// - Parameters: + /// - schemas: Schemas to check. + /// - referenceStack: A stack of reference names that lead to this schema. /// - Returns: `.supported` if all schemas match; `.unsupported` otherwise. /// - Throws: An error if there's an issue during the validation process. - func areRefsToObjectishSchemaAndSupported(_ schemas: [JSONSchema]) throws -> IsSchemaSupportedResult { + func areRefsToObjectishSchemaAndSupported( + _ schemas: [JSONSchema], + referenceStack: inout ReferenceStack + ) throws -> IsSchemaSupportedResult { for schema in schemas { - let result = try isRefToObjectishSchemaAndSupported(schema) + let result = try isRefToObjectishSchemaAndSupported( + schema, + referenceStack: &referenceStack + ) guard result == .supported else { return result } @@ -262,16 +338,34 @@ extension FileTranslator { return .supported } - /// Returns a result indicating whether the provided schema - /// is a reference schema that points to an object-ish schema and is supported. - /// - Parameter schema: A schema to check. - /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is supported or not. + /// Returns a result indicating whether the provided schema is a reference + /// schema that points to an object-ish schema and is supported. + /// - Parameters: + /// - schema: A schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. + /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is + /// supported or not. /// - Throws: An error if there's an issue during the validation process. - func isRefToObjectishSchemaAndSupported(_ schema: JSONSchema) throws -> IsSchemaSupportedResult { + func isRefToObjectishSchemaAndSupported( + _ schema: JSONSchema, + referenceStack: inout ReferenceStack + ) throws -> IsSchemaSupportedResult { switch schema.value { case let .reference(ref, _): + if try referenceStack.contains(ref) { + // Encountered a cycle, but that's okay - return supported. + return .supported + } + // reference is supported iff the existing type is supported let referencedSchema = try components.lookup(ref) - return try isObjectishSchemaAndSupported(referencedSchema) + try referenceStack.push(ref) + defer { + referenceStack.pop() + } + return try isObjectishSchemaAndSupported( + referencedSchema, + referenceStack: &referenceStack + ) default: return .unsupported( reason: .notRef, diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift new file mode 100644 index 00000000..68a947fd --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift @@ -0,0 +1,305 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +extension TypesFileTranslator { + + /// Finds and boxes types that participate in recursion. + /// + /// For a conceptual overview, see the article `Supporting recursive types`. + /// - Parameter decls: Declarations of `Components.Schemas.*` types. + /// - Returns: All the declarations, with the types that participate in + /// recursion with boxed internal storage. + /// - Throws: If an unsupported reference cycle is detected. + func boxRecursiveTypes(_ decls: [Declaration]) throws -> [Declaration] { + + let nodes = decls.compactMap(DeclarationRecursionDetector.Node.init) + let nodeLookup = Dictionary(uniqueKeysWithValues: nodes.map { ($0.name, $0) }) + let container = DeclarationRecursionDetector.Container( + lookupMap: nodeLookup + ) + + let boxedNames = try RecursionDetector.computeBoxedTypes( + rootNodes: nodes, + container: container + ) + + var decls = decls + for (index, decl) in decls.enumerated() { + guard let name = decl.name, boxedNames.contains(name) else { + continue + } + diagnostics.emit( + .note( + message: "Detected a recursive type; it will be boxed to break the reference cycle.", + context: [ + "name": name + ] + ) + ) + decls[index] = boxedType(decl) + } + return decls + } + + /// Boxes the provided declaration, given that the concrete declaration + /// kind supports boxing. + /// - Parameter decl: A declaration to be boxed. + /// - Returns: A boxed variant of the provided declaration. + private func boxedType(_ decl: Declaration) -> Declaration { + switch decl { + case .commentable(let comment, let declaration): + return .commentable(comment, boxedType(declaration)) + case .deprecated(let deprecationDescription, let declaration): + return .deprecated(deprecationDescription, boxedType(declaration)) + case .struct(let structDescription): + return .struct(boxedStruct(structDescription)) + case .enum(let enumDescription): + return .enum(boxedEnum(enumDescription)) + case .variable, .extension, .typealias, .protocol, .function, .enumCase: + preconditionFailure("Unexpected boxed type: \(decl.name ?? "")") + } + } + + /// Boxes the provided struct description. + /// - Parameter desc: The struct description to box. + /// - Returns: A boxed variant of the provided struct description. + private func boxedStruct(_ desc: StructDescription) -> StructDescription { + + // Start with a copy of the public struct, then modify it. + var storageDesc = desc + + storageDesc.name = "Storage" + storageDesc.accessModifier = .private + + // Remove the explicit initializer's comment. + storageDesc.members = storageDesc.members.map { member in + guard + case .function(let funcDesc) = member.strippingTopComment, + funcDesc.signature.kind == .initializer(failable: false), + funcDesc.signature.parameters.first?.name != "decoder" + else { + return member + } + return member.strippingTopComment + } + + // Make all members internal by removing the explicit access modifier. + storageDesc.members = storageDesc.members.map { member in + var member = member + member.accessModifier = nil + return member + } + + // Change CodingKeys, if present, into a typealias to the outer struct. + storageDesc.members = storageDesc.members.map { member in + guard + case .enum(let enumDescription) = member, + enumDescription.name == Constants.Codable.codingKeysName + else { + return member + } + return .typealias( + name: Constants.Codable.codingKeysName, + existingType: .member( + Constants.Components.Schemas.components + [ + desc.name, Constants.Codable.codingKeysName, + ] + ) + ) + } + + var desc = desc + + // Define explicit setters/getters for properties and call into storage. + desc.members = desc.members.map { member in + guard + case .commentable(let comment, let commented) = member, + case .variable(var variableDescription) = commented + else { + return member + } + let name = variableDescription.left + variableDescription.getter = [ + .expression( + .identifierPattern("storage") + .dot("value") + .dot(name) + ) + ] + variableDescription.modify = [ + .expression( + .yield( + .inOut( + .identifierPattern("storage") + .dot("value") + .dot(name) + ) + ) + ) + ] + return .commentable(comment, .variable(variableDescription)) + } + + // Change the initializer to call into storage instead. + desc.members = desc.members.map { member in + guard + case .commentable(let comment, let commented) = member, + case .function(var funcDesc) = commented, + funcDesc.signature.kind == .initializer(failable: false), + funcDesc.signature.parameters.first?.name != "decoder" + else { + return member + } + let propertyNames: [String] = desc.members.compactMap { member in + guard case .variable(let variableDescription) = member.strippingTopComment else { + return nil + } + return variableDescription.left + } + funcDesc.body = [ + .expression( + .assignment( + left: .identifierPattern("storage"), + right: .dot("init") + .call([ + .init( + label: "value", + expression: .dot("init") + .call( + propertyNames.map { + .init(label: $0, expression: .identifierPattern($0)) + } + ) + ) + ]) + ) + ) + ] + return .commentable(comment, .function(funcDesc)) + } + + // Define a custom encoder/decoder to call into storage. + // First remove any existing ones, then add the new ones. + desc.members = desc.members.filter { member in + guard + case .function(let funcDesc) = member, + funcDesc.signature.kind == .initializer(failable: false), + funcDesc.signature.parameters.first?.name == "decoder" + else { + return true + } + return false + } + desc.members = desc.members.filter { member in + guard + case .function(let funcDesc) = member, + funcDesc.signature.kind == .function(name: "encode", isStatic: false) + else { + return true + } + return false + } + desc.members.append( + .function( + accessModifier: desc.accessModifier, + kind: .initializer(failable: false), + parameters: [ + .init( + label: "from", + name: "decoder", + type: .any(.member("Decoder")) + ) + ], + keywords: [ + .throws + ], + body: [ + .expression( + .assignment( + left: .identifierPattern("storage"), + right: .try( + .dot("init") + .call([ + .init( + label: "from", + expression: .identifierPattern("decoder") + ) + ]) + ) + ) + ) + ] + ) + ) + desc.members.append( + .function( + accessModifier: desc.accessModifier, + kind: .function(name: "encode"), + parameters: [ + .init( + label: "to", + name: "encoder", + type: .any(.member("Encoder")) + ) + ], + keywords: [ + .throws + ], + body: [ + .expression( + .try( + .identifierPattern("storage") + .dot("encode") + .call([ + .init( + label: "to", + expression: .identifierPattern("encoder") + ) + ]) + ) + ) + ] + ) + ) + + desc.members.append( + .commentable( + .doc("Internal reference storage to allow type recursion."), + .variable( + accessModifier: .private, + kind: .var, + left: "storage", + type: .generic( + wrapper: .init(TypeName.box), + wrapped: .member("Storage") + ) + ) + ) + ) + desc.members.append(.struct(storageDesc)) + + return desc + } + + /// Boxes the provided enum description. + /// - Parameter desc: The enum description to box. + /// - Returns: A boxed variant of the provided enum description. + private func boxedEnum(_ desc: EnumDescription) -> EnumDescription { + // Just mark it as indirect, done. + var desc = desc + desc.isIndirect = true + return desc + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift index 1279e037..63f2bf8c 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift @@ -62,13 +62,13 @@ extension TypesFileTranslator { let decls: [Declaration] = try schemas.flatMap { key, value in try translateSchema(componentKey: key, schema: value) } - + let declsWithBoxingApplied = try boxRecursiveTypes(decls) let componentsSchemasEnum = Declaration.commentable( JSONSchema.sectionComment(), .enum( accessModifier: config.access, name: Constants.Components.Schemas.namespace, - members: decls + members: declsWithBoxingApplied ) ) return componentsSchemasEnum diff --git a/Sources/swift-openapi-generator/Documentation.docc/Development/Documentation-for-maintainers.md b/Sources/swift-openapi-generator/Documentation.docc/Development/Documentation-for-maintainers.md index df5826aa..13ef674e 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Development/Documentation-for-maintainers.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Development/Documentation-for-maintainers.md @@ -13,3 +13,4 @@ Use the resources below if you'd like to learn more about how the generator work - - - +- diff --git a/Sources/swift-openapi-generator/Documentation.docc/Development/Supporting-recursive-types.md b/Sources/swift-openapi-generator/Documentation.docc/Development/Supporting-recursive-types.md new file mode 100644 index 00000000..8b55d59a --- /dev/null +++ b/Sources/swift-openapi-generator/Documentation.docc/Development/Supporting-recursive-types.md @@ -0,0 +1,130 @@ +# Supporting recursive types + +Learn how the generator supports recursive types. + +## Overview + +In some applications, the most expressive way to represent arbitrarily nested data is using a type that holds another value of itself, either directly, or through another type. We refer to such types as _recursive types_. + +By default, structs and enums do not support recursion in Swift, so the generator needs to detect recursion in the OpenAPI document and emit a different internal representation for the Swift types involved in recursion. + +This article discusses the details of what boxing is, and how the generator chooses the types to box. + +### Examples of recursive types + +One example of a recursive type would be a file system item, representing a tree. The `FileItem` node contains more `FileItem` nodes in an array. + +```yaml +FileItem: + type: object + properties: + name: + type: string + isDirectory: + type: boolean + contents: + type: array + items: + $ref: '#/components/schemas/FileItem' + required: + - name +``` + +Another example would be a `Person` type, that can have a `partner` property of type `Person`. + +```yaml +Person: + type: object + properties: + name: + type: string + partner: + $ref: '#/components/schemas/Person' + required: + - name +``` + +### Recursive types in Swift + +In Swift, the generator emits structs or enums for JSON schemas that support recursion (enums for `oneOf`, structs for `object`, `allOf`, and `anyOf`). Both structs and enums require that their size is known at compile time, however for arbitrarily nested values, such as a file system hierarchy, it cannot be known at compile time how deep the nesting goes. If such types were generated naively, they would not compile. + +To allow recursion, a _reference_ Swift type must be involved in the reference cycle (as opposed to only _value_ types). We call this technique of using a reference type for storage inside a value type "boxing" and it allows for the outer type to keep its original API, including value semantics, but at the same time be used as a recursive type. + +### Boxing different Swift types + +- Enums can be boxed by adding the `indirect` keyword to the declaration, for example by changing: + +```swift +public enum Directory {} +``` + +to: + +```swift +public indirect enum Directory { ... } +``` + +When an enum type needs to be boxed, the generator simply includes the `indirect` keyword in the generated type. + +- Structs require more work, including: + - Moving the stored properties into a private `final class Storage` type. + - Adding an explicit setter and getter for each property that calls into the storage. + - Adjusting the initializer to forward the initial values to the storage. + - Using a copy-on-write wrapper for the storage to avoid creating copies unless multiple references exist to the value and it's being modified. + +For example, the original struct: +```swift +public struct Person { + public var partner: Person? + public init(partner: Person? = nil) { + self.partner = partner + } +} +``` + +Would look like this when boxed: +```swift +public struct Person { + public var partner: Person? { + get { storage.value.partner } + _modify { yield &storage.value.partner } + } + public init(partner: Person? = nil) { + self.storage = .init(Storage(partner: partner)) + } + private var storage: CopyOnWriteBox + private final class Storage { + var partner: Person? + public init(partner: Person? = nil) { + self.partner = partner + } + } +} +``` + +> Note: The above is an illustrative, simplified example. See the file-based reference tests for the exact shape of generated boxed types. + +The details of the copy-on-write wrapper can be found in the runtime library, where it's defined. + +- Arrays and dictionaries are reference types under the hood (but retain value semantics) and can already be considered boxed. For that reason, the first example that showed a `FileItem` type actually would compile successfully, because the `contents` array is already boxed. That means the `FileItem` type itself does not require boxing. + +- Pure reference schemas can contribute to reference cycles, but cannot be boxed, because they are represented as a `typealias` in Swift. For that reason, the algorithm never chooses a `$ref` type for boxing, and instead boxes the next eligible type in the cycle. + +### Computing which types need boxing + +Since a boxed type requires an internal reference type, and can be less performant than a non-recursive value type, the generator implements an algorithm that _minimizes_ the number of boxed types required to make all the reference cycles still build successfully. + +The algorithm outputs a list of type names that require boxing. + +It iterates over the types defined in `#/components/schemas`, in the order defined in the OpenAPI document, and for each type walks all of its references. + +Once it detects a reference cycle, it checks whether any of the types involved in the current cycle are already in the list, and if so, considers this cycle to already be addressed. + +If no type in the current cycle is found in the list, it adds the first type in the cycle, in other words the one to which the last reference closed the cycle. + +For example, walking the following: +``` +A -> B -> C -> B +``` + +The algorithm would choose type "B" for boxing. diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_RecursionDetector_Generic.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_RecursionDetector_Generic.swift new file mode 100644 index 00000000..fa331e5c --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_RecursionDetector_Generic.swift @@ -0,0 +1,351 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +import OpenAPIKit +@testable import _OpenAPIGeneratorCore + +class Test_RecursionDetector_Generic: Test_Core { + + func testEmpty() throws { + try _test( + rootNodes: [], + putIntoContainer: [], + expected: [] + ) + } + + func testSingleNode() throws { + try _test( + rootNodes: [ + "A" + ], + putIntoContainer: [ + "A ->" + ], + expected: [] + ) + } + + func testMultipleNodesNoEdges() throws { + try _test( + rootNodes: [ + "A", + "B", + "C", + ], + putIntoContainer: [ + "A ->", + "B ->", + "C ->", + ], + expected: [] + ) + } + + func testNoCycle() throws { + try _test( + rootNodes: [ + "A", + "B", + "C", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> D", + "D ->", + ], + expected: [] + ) + } + + func testNoCycleAndDoubleEdge() throws { + try _test( + rootNodes: [ + "A", + "B", + "C", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> C,D", + "C -> D", + "D ->", + ], + expected: [] + ) + } + + func testSelfLoop() throws { + try _test( + rootNodes: [ + "A" + ], + putIntoContainer: [ + "A -> A" + ], + expected: [ + "A" + ] + ) + } + + func testSimpleCycle() throws { + try _test( + rootNodes: [ + "A", + "B", + ], + putIntoContainer: [ + "A -> B", + "B -> A", + ], + expected: [ + "A" + ] + ) + } + + func testLongerCycleStartA() throws { + try _test( + rootNodes: [ + "A", + "C", + "B", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> A", + ], + expected: [ + "A" + ] + ) + } + + func testLongerCycleStartC() throws { + try _test( + rootNodes: [ + "C", + "A", + "B", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> A", + ], + expected: [ + "C" + ] + ) + } + + func testLongerCycleStartAButNotBoxable() throws { + try _test( + rootNodes: [ + "A", + "C", + "B", + ], + putIntoContainer: [ + "A! -> B", + "B -> C", + "C -> A", + ], + expected: [ + "B" + ] + ) + } + + func testMultipleCycles() throws { + try _test( + rootNodes: [ + "A", + "C", + "B", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> A", + "C -> D", + "D -> C", + ], + expected: [ + "A", + "C", + ] + ) + } + + func testMultipleCyclesOverlapping() throws { + try _test( + rootNodes: [ + "C", + "A", + "B", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> A,D", + "D -> C", + ], + expected: [ + "C" + ] + ) + } + + func testNested() throws { + try _test( + rootNodes: [ + "A", + "C", + "B", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> B,D", + "D -> C", + ], + expected: [ + "B", + "C", + ] + ) + } + + func testDisconnected() throws { + try _test( + rootNodes: [ + "A", + "C", + "B", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> A", + "C -> D", + "D ->", + ], + expected: [ + "A" + ] + ) + } + + func testCycleWithLeadingNode() throws { + try _test( + rootNodes: [ + "A", + "B", + "C", + "D", + ], + putIntoContainer: [ + "A -> B", + "B -> C", + "C -> D", + "D -> B", + ], + expected: ["B"] + ) + } + + // MARK: - Private + + private func _test( + rootNodes: [String], + putIntoContainer nodesForContainer: [TestNode], + expected expectedRecursed: Set, + file: StaticString = #file, + line: UInt = #line + ) throws { + precondition(Set(rootNodes).count == nodesForContainer.count, "Not all nodes are mentioned in rootNodes") + let container = TestContainer( + nodes: Dictionary( + uniqueKeysWithValues: nodesForContainer.map { ($0.name, $0) } + ) + ) + let recursedNodes = try RecursionDetector.computeBoxedTypes( + rootNodes: rootNodes.map { try container.lookup($0) }, + container: container + ) + XCTAssertEqual(recursedNodes, expectedRecursed, file: file, line: line) + } +} + +private struct TestNode: TypeNode, ExpressibleByStringLiteral { + typealias NameType = String + var name: String + var isBoxable: Bool + var edges: [String] + + init(name: String, isBoxable: Bool, edges: [String]) { + self.name = name + self.isBoxable = isBoxable + self.edges = edges + } + + init(stringLiteral value: StringLiteralType) { + // A -> B,C,D for boxable + // A! -> B,C,D for unboxable + let comps = + value + .split(separator: "->", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + precondition(comps.count == 2, "Invalid syntax") + let edges = comps[1] + .split( + separator: "," + ) + .map(String.init) + let nameComp = comps[0] + let isBoxable = !nameComp.hasSuffix("!") + let name: String + if isBoxable { + name = String(nameComp) + } else { + name = String(nameComp.dropLast()) + } + self.init(name: name, isBoxable: isBoxable, edges: edges) + } +} + +private struct TestContainer: TypeNodeContainer { + typealias Node = TestNode + + struct MissingNodeError: Error { + var name: String + } + + var nodes: [String: TestNode] + + func lookup(_ name: String) throws -> TestNode { + guard let node = nodes[name] else { + throw MissingNodeError(name: name) + } + return node + } +} diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift index a8832c11..8ddb53fb 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift @@ -261,8 +261,13 @@ final class Test_TypeMatcher: Test_Core { ] func testKeyValuePairTypes() { for schema in Self.keyValuePairTypes { + var referenceStack = ReferenceStack.empty XCTAssertTrue( - try TypeMatcher.isKeyValuePair(schema, components: components), + try TypeMatcher.isKeyValuePair( + schema, + referenceStack: &referenceStack, + components: components + ), "Type is expected to be a key-value pair schema: \(schema)" ) } diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_isSchemaSupported.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_isSchemaSupported.swift index d930d888..d1169a52 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_isSchemaSupported.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_isSchemaSupported.swift @@ -121,8 +121,12 @@ class Test_isSchemaSupported: XCTestCase { func testSupportedTypes() throws { let translator = self.translator for schema in Self.supportedTypes { + var referenceStack = ReferenceStack.empty XCTAssertTrue( - try translator.isSchemaSupported(schema) == .supported, + try translator.isSchemaSupported( + schema, + referenceStack: &referenceStack + ) == .supported, "Expected schema to be supported: \(schema)" ) } @@ -144,7 +148,13 @@ class Test_isSchemaSupported: XCTestCase { func testUnsupportedTypes() throws { let translator = self.translator for (schema, expectedReason) in Self.unsupportedTypes { - guard case let .unsupported(reason, _) = try translator.isSchemaSupported(schema) else { + var referenceStack = ReferenceStack.empty + guard + case let .unsupported(reason, _) = try translator.isSchemaSupported( + schema, + referenceStack: &referenceStack + ) + else { XCTFail("Expected schema to be unsupported: \(schema)") return } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml b/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml index e1d7d6b4..ec86e169 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml @@ -445,6 +445,63 @@ components: type: integer required: - count + RecursivePet: + type: object + properties: + name: + type: string + parent: + $ref: '#/components/schemas/RecursivePet' + required: + - name + RecursivePetNested: + type: object + properties: + name: + type: string + parent: + type: object + properties: + nested: + $ref: '#/components/schemas/RecursivePetNested' + required: + - nested + required: + - name + RecursivePetOneOfFirst: + allOf: + - $ref: '#/components/schemas/RecursivePetOneOf' + - type: object + properties: + type: + type: string + required: + - type + RecursivePetOneOfSecond: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + type: + type: string + required: + - type + RecursivePetOneOf: + oneOf: + - $ref: '#/components/schemas/RecursivePetOneOfFirst' + - $ref: '#/components/schemas/RecursivePetOneOfSecond' + discriminator: + propertyName: type + RecursivePetAnyOf: + anyOf: + - $ref: '#/components/schemas/RecursivePetAnyOf' + - type: string + RecursivePetAllOf: + allOf: + - type: object + properties: + parent: + $ref: '#/components/schemas/RecursivePetAllOf' responses: ErrorBadRequest: description: Bad request diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift index 25dcee63..caa9fbb2 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift @@ -695,6 +695,337 @@ public enum Components { public init(count: Swift.Int) { self.count = count } public enum CodingKeys: String, CodingKey { case count } } + /// - Remark: Generated from `#/components/schemas/RecursivePet`. + public struct RecursivePet: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePet/name`. + public var name: Swift.String { + get { storage.value.name } + _modify { yield &storage.value.name } + } + /// - Remark: Generated from `#/components/schemas/RecursivePet/parent`. + public var parent: Components.Schemas.RecursivePet? { + get { storage.value.parent } + _modify { yield &storage.value.parent } + } + /// Creates a new `RecursivePet`. + /// + /// - Parameters: + /// - name: + /// - parent: + public init(name: Swift.String, parent: Components.Schemas.RecursivePet? = nil) { + storage = .init(value: .init(name: name, parent: parent)) + } + public enum CodingKeys: String, CodingKey { + case name + case parent + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePet/name`. + var name: Swift.String + /// - Remark: Generated from `#/components/schemas/RecursivePet/parent`. + var parent: Components.Schemas.RecursivePet? + init(name: Swift.String, parent: Components.Schemas.RecursivePet? = nil) { + self.name = name + self.parent = parent + } + typealias CodingKeys = Components.Schemas.RecursivePet.CodingKeys + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetNested`. + public struct RecursivePetNested: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/name`. + public var name: Swift.String { + get { storage.value.name } + _modify { yield &storage.value.name } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent`. + public struct parentPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent/nested`. + public var nested: Components.Schemas.RecursivePetNested + /// Creates a new `parentPayload`. + /// + /// - Parameters: + /// - nested: + public init(nested: Components.Schemas.RecursivePetNested) { self.nested = nested } + public enum CodingKeys: String, CodingKey { case nested } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent`. + public var parent: Components.Schemas.RecursivePetNested.parentPayload? { + get { storage.value.parent } + _modify { yield &storage.value.parent } + } + /// Creates a new `RecursivePetNested`. + /// + /// - Parameters: + /// - name: + /// - parent: + public init(name: Swift.String, parent: Components.Schemas.RecursivePetNested.parentPayload? = nil) { + storage = .init(value: .init(name: name, parent: parent)) + } + public enum CodingKeys: String, CodingKey { + case name + case parent + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/name`. + var name: Swift.String + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent`. + struct parentPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent/nested`. + public var nested: Components.Schemas.RecursivePetNested + /// Creates a new `parentPayload`. + /// + /// - Parameters: + /// - nested: + public init(nested: Components.Schemas.RecursivePetNested) { self.nested = nested } + public enum CodingKeys: String, CodingKey { case nested } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetNested/parent`. + var parent: Components.Schemas.RecursivePetNested.parentPayload? + init(name: Swift.String, parent: Components.Schemas.RecursivePetNested.parentPayload? = nil) { + self.name = name + self.parent = parent + } + typealias CodingKeys = Components.Schemas.RecursivePetNested.CodingKeys + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst`. + public struct RecursivePetOneOfFirst: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value1`. + public var value1: Components.Schemas.RecursivePetOneOf { + get { storage.value.value1 } + _modify { yield &storage.value.value1 } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2`. + public struct Value2Payload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2/type`. + public var _type: Swift.String + /// Creates a new `Value2Payload`. + /// + /// - Parameters: + /// - _type: + public init(_type: Swift.String) { self._type = _type } + public enum CodingKeys: String, CodingKey { case _type = "type" } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2`. + public var value2: Components.Schemas.RecursivePetOneOfFirst.Value2Payload { + get { storage.value.value2 } + _modify { yield &storage.value.value2 } + } + /// Creates a new `RecursivePetOneOfFirst`. + /// + /// - Parameters: + /// - value1: + /// - value2: + public init( + value1: Components.Schemas.RecursivePetOneOf, + value2: Components.Schemas.RecursivePetOneOfFirst.Value2Payload + ) { storage = .init(value: .init(value1: value1, value2: value2)) } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value1`. + var value1: Components.Schemas.RecursivePetOneOf + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2`. + struct Value2Payload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2/type`. + public var _type: Swift.String + /// Creates a new `Value2Payload`. + /// + /// - Parameters: + /// - _type: + public init(_type: Swift.String) { self._type = _type } + public enum CodingKeys: String, CodingKey { case _type = "type" } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfFirst/value2`. + var value2: Components.Schemas.RecursivePetOneOfFirst.Value2Payload + init( + value1: Components.Schemas.RecursivePetOneOf, + value2: Components.Schemas.RecursivePetOneOfFirst.Value2Payload + ) { + self.value1 = value1 + self.value2 = value2 + } + init(from decoder: any Decoder) throws { + value1 = try .init(from: decoder) + value2 = try .init(from: decoder) + } + func encode(to encoder: any Encoder) throws { + try value1.encode(to: encoder) + try value2.encode(to: encoder) + } + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfSecond`. + public struct RecursivePetOneOfSecond: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfSecond/value1`. + public var value1: Components.Schemas.Pet + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfSecond/value2`. + public struct Value2Payload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfSecond/value2/type`. + public var _type: Swift.String + /// Creates a new `Value2Payload`. + /// + /// - Parameters: + /// - _type: + public init(_type: Swift.String) { self._type = _type } + public enum CodingKeys: String, CodingKey { case _type = "type" } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOfSecond/value2`. + public var value2: Components.Schemas.RecursivePetOneOfSecond.Value2Payload + /// Creates a new `RecursivePetOneOfSecond`. + /// + /// - Parameters: + /// - value1: + /// - value2: + public init( + value1: Components.Schemas.Pet, + value2: Components.Schemas.RecursivePetOneOfSecond.Value2Payload + ) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + value1 = try .init(from: decoder) + value2 = try .init(from: decoder) + } + public func encode(to encoder: any Encoder) throws { + try value1.encode(to: encoder) + try value2.encode(to: encoder) + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOf`. + @frozen public enum RecursivePetOneOf: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOf/RecursivePetOneOfFirst`. + case RecursivePetOneOfFirst(Components.Schemas.RecursivePetOneOfFirst) + /// - Remark: Generated from `#/components/schemas/RecursivePetOneOf/RecursivePetOneOfSecond`. + case RecursivePetOneOfSecond(Components.Schemas.RecursivePetOneOfSecond) + public enum CodingKeys: String, CodingKey { case _type = "type" } + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(Swift.String.self, forKey: ._type) + switch discriminator { + case "RecursivePetOneOfFirst", "#/components/schemas/RecursivePetOneOfFirst": + self = .RecursivePetOneOfFirst(try .init(from: decoder)) + case "RecursivePetOneOfSecond", "#/components/schemas/RecursivePetOneOfSecond": + self = .RecursivePetOneOfSecond(try .init(from: decoder)) + default: + throw Swift.DecodingError.failedToDecodeOneOfSchema(type: Self.self, codingPath: decoder.codingPath) + } + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .RecursivePetOneOfFirst(value): try value.encode(to: encoder) + case let .RecursivePetOneOfSecond(value): try value.encode(to: encoder) + } + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetAnyOf`. + public struct RecursivePetAnyOf: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAnyOf/value1`. + public var value1: Components.Schemas.RecursivePetAnyOf? { + get { storage.value.value1 } + _modify { yield &storage.value.value1 } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetAnyOf/value2`. + public var value2: Swift.String? { + get { storage.value.value2 } + _modify { yield &storage.value.value2 } + } + /// Creates a new `RecursivePetAnyOf`. + /// + /// - Parameters: + /// - value1: + /// - value2: + public init(value1: Components.Schemas.RecursivePetAnyOf? = nil, value2: Swift.String? = nil) { + storage = .init(value: .init(value1: value1, value2: value2)) + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAnyOf/value1`. + var value1: Components.Schemas.RecursivePetAnyOf? + /// - Remark: Generated from `#/components/schemas/RecursivePetAnyOf/value2`. + var value2: Swift.String? + init(value1: Components.Schemas.RecursivePetAnyOf? = nil, value2: Swift.String? = nil) { + self.value1 = value1 + self.value2 = value2 + } + init(from decoder: any Decoder) throws { + value1 = try? .init(from: decoder) + value2 = try? decoder.decodeFromSingleValueContainer() + try Swift.DecodingError.verifyAtLeastOneSchemaIsNotNil( + [value1, value2], + type: Self.self, + codingPath: decoder.codingPath + ) + } + func encode(to encoder: any Encoder) throws { + try encoder.encodeFirstNonNilValueToSingleValueContainer([value2]) + try value1?.encode(to: encoder) + } + } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf`. + public struct RecursivePetAllOf: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1`. + public struct Value1Payload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1/parent`. + public var parent: Components.Schemas.RecursivePetAllOf? + /// Creates a new `Value1Payload`. + /// + /// - Parameters: + /// - parent: + public init(parent: Components.Schemas.RecursivePetAllOf? = nil) { self.parent = parent } + public enum CodingKeys: String, CodingKey { case parent } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1`. + public var value1: Components.Schemas.RecursivePetAllOf.Value1Payload { + get { storage.value.value1 } + _modify { yield &storage.value.value1 } + } + /// Creates a new `RecursivePetAllOf`. + /// + /// - Parameters: + /// - value1: + public init(value1: Components.Schemas.RecursivePetAllOf.Value1Payload) { + storage = .init(value: .init(value1: value1)) + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1`. + struct Value1Payload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1/parent`. + public var parent: Components.Schemas.RecursivePetAllOf? + /// Creates a new `Value1Payload`. + /// + /// - Parameters: + /// - parent: + public init(parent: Components.Schemas.RecursivePetAllOf? = nil) { self.parent = parent } + public enum CodingKeys: String, CodingKey { case parent } + } + /// - Remark: Generated from `#/components/schemas/RecursivePetAllOf/value1`. + var value1: Components.Schemas.RecursivePetAllOf.Value1Payload + init(value1: Components.Schemas.RecursivePetAllOf.Value1Payload) { self.value1 = value1 } + init(from decoder: any Decoder) throws { value1 = try .init(from: decoder) } + func encode(to encoder: any Encoder) throws { try value1.encode(to: encoder) } + } + } } /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters { diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index f088aa9c..7ed5a268 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -1002,6 +1002,238 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsSchemasRecursive_object() throws { + try self.assertSchemasTranslation( + """ + schemas: + Node: + type: object + properties: + parent: + $ref: '#/components/schemas/Node' + """, + """ + public enum Schemas { + public struct Node: Codable, Hashable, Sendable { + public var parent: Components.Schemas.Node? { + get { storage.value.parent } + _modify { yield &storage.value.parent } + } + public init(parent: Components.Schemas.Node? = nil) { storage = .init(value: .init(parent: parent)) } + public enum CodingKeys: String, CodingKey { case parent } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + var parent: Components.Schemas.Node? + init(parent: Components.Schemas.Node? = nil) { self.parent = parent } + typealias CodingKeys = Components.Schemas.Node.CodingKeys + } + } + } + """ + ) + } + + func testComponentsSchemasRecursive_objectNested() throws { + try self.assertSchemasTranslation( + """ + schemas: + Node: + type: object + properties: + name: + type: string + parent: + type: object + properties: + nested: + $ref: '#/components/schemas/Node' + required: + - nested + required: + - name + """, + """ + public enum Schemas { + public struct Node: Codable, Hashable, Sendable { + public var name: Swift.String { + get { storage.value.name } + _modify { yield &storage.value.name } + } + public struct parentPayload: Codable, Hashable, Sendable { + public var nested: Components.Schemas.Node + public init(nested: Components.Schemas.Node) { self.nested = nested } + public enum CodingKeys: String, CodingKey { case nested } + } + public var parent: Components.Schemas.Node.parentPayload? { + get { storage.value.parent } + _modify { yield &storage.value.parent } + } + public init(name: Swift.String, parent: Components.Schemas.Node.parentPayload? = nil) { + storage = .init(value: .init(name: name, parent: parent)) + } + public enum CodingKeys: String, CodingKey { + case name + case parent + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + var name: Swift.String + struct parentPayload: Codable, Hashable, Sendable { + public var nested: Components.Schemas.Node + public init(nested: Components.Schemas.Node) { self.nested = nested } + public enum CodingKeys: String, CodingKey { case nested } + } + var parent: Components.Schemas.Node.parentPayload? + init(name: Swift.String, parent: Components.Schemas.Node.parentPayload? = nil) { + self.name = name + self.parent = parent + } + typealias CodingKeys = Components.Schemas.Node.CodingKeys + } + } + } + """ + ) + } + + func testComponentsSchemasRecursive_allOf() throws { + try self.assertSchemasTranslation( + """ + schemas: + Node: + allOf: + - type: object + properties: + parent: + $ref: '#/components/schemas/Node' + """, + """ + public enum Schemas { + public struct Node: Codable, Hashable, Sendable { + public struct Value1Payload: Codable, Hashable, Sendable { + public var parent: Components.Schemas.Node? + public init(parent: Components.Schemas.Node? = nil) { self.parent = parent } + public enum CodingKeys: String, CodingKey { case parent } + } + public var value1: Components.Schemas.Node.Value1Payload { + get { storage.value.value1 } + _modify { yield &storage.value.value1 } + } + public init(value1: Components.Schemas.Node.Value1Payload) { storage = .init(value: .init(value1: value1)) } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + struct Value1Payload: Codable, Hashable, Sendable { + public var parent: Components.Schemas.Node? + public init(parent: Components.Schemas.Node? = nil) { self.parent = parent } + public enum CodingKeys: String, CodingKey { case parent } + } + var value1: Components.Schemas.Node.Value1Payload + init(value1: Components.Schemas.Node.Value1Payload) { self.value1 = value1 } + init(from decoder: any Decoder) throws { value1 = try .init(from: decoder) } + func encode(to encoder: any Encoder) throws { try value1.encode(to: encoder) } + } + } + } + """ + ) + } + + func testComponentsSchemasRecursive_anyOf() throws { + try self.assertSchemasTranslation( + """ + schemas: + Node: + anyOf: + - $ref: '#/components/schemas/Node' + - type: string + """, + """ + public enum Schemas { + public struct Node: Codable, Hashable, Sendable { + public var value1: Components.Schemas.Node? { + get { storage.value.value1 } + _modify { yield &storage.value.value1 } + } + public var value2: Swift.String? { + get { storage.value.value2 } + _modify { yield &storage.value.value2 } + } + public init(value1: Components.Schemas.Node? = nil, value2: Swift.String? = nil) { + storage = .init(value: .init(value1: value1, value2: value2)) + } + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + var value1: Components.Schemas.Node? + var value2: Swift.String? + init(value1: Components.Schemas.Node? = nil, value2: Swift.String? = nil) { + self.value1 = value1 + self.value2 = value2 + } + init(from decoder: any Decoder) throws { + value1 = try? .init(from: decoder) + value2 = try? decoder.decodeFromSingleValueContainer() + try Swift.DecodingError.verifyAtLeastOneSchemaIsNotNil( + [value1, value2], + type: Self.self, + codingPath: decoder.codingPath + ) + } + func encode(to encoder: any Encoder) throws { + try encoder.encodeFirstNonNilValueToSingleValueContainer([value2]) + try value1?.encode(to: encoder) + } + } + } + } + """ + ) + } + + func testComponentsSchemasRecursive_oneOf() throws { + try self.assertSchemasTranslation( + """ + schemas: + Node: + oneOf: + - $ref: '#/components/schemas/Node' + - type: string + """, + """ + public enum Schemas { + @frozen public indirect enum Node: Codable, Hashable, Sendable { + case Node(Components.Schemas.Node) + case case2(Swift.String) + public init(from decoder: any Decoder) throws { + do { + self = .Node(try .init(from: decoder)) + return + } catch {} + do { + self = .case2(try decoder.decodeFromSingleValueContainer()) + return + } catch {} + throw Swift.DecodingError.failedToDecodeOneOfSchema(type: Self.self, codingPath: decoder.codingPath) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .Node(value): try value.encode(to: encoder) + case let .case2(value): try encoder.encodeToSingleValueContainer(value) + } + } + } + } + """ + ) + } + func testComponentsResponsesResponseNoBody() throws { try self.assertResponsesTranslation( """ diff --git a/Tests/PetstoreConsumerTests/Test_Types.swift b/Tests/PetstoreConsumerTests/Test_Types.swift index 7f2fde95..db46aa8a 100644 --- a/Tests/PetstoreConsumerTests/Test_Types.swift +++ b/Tests/PetstoreConsumerTests/Test_Types.swift @@ -55,13 +55,16 @@ final class Test_Types: XCTestCase { return decoder } - func roundtrip(_ value: T) throws -> T { + func roundtrip(_ value: T, verifyingJSON: String? = nil) throws -> T { let data = try testEncoder.encode(value) + if let verifyingJSON { + XCTAssertEqual(String(decoding: data, as: UTF8.self), verifyingJSON) + } return try testDecoder.decode(T.self, from: data) } - func _testRoundtrip(_ value: T) throws { - let decodedValue = try roundtrip(value) + func _testRoundtrip(_ value: T, verifyingJSON: String? = nil) throws { + let decodedValue = try roundtrip(value, verifyingJSON: verifyingJSON) XCTAssertEqual(decodedValue, value) } @@ -316,4 +319,65 @@ final class Test_Types: XCTestCase { } } } + + func testRecursiveType_roundtrip() throws { + try _testRoundtrip( + Components.Schemas.RecursivePet( + name: "C", + parent: .init( + name: "B", + parent: .init(name: "A") + ) + ), + verifyingJSON: #"{"name":"C","parent":{"name":"B","parent":{"name":"A"}}}"# + ) + } + + func testRecursiveType_accessors_3levels() throws { + var c = Components.Schemas.RecursivePet(name: "C", parent: .init(name: "B")) + c.name = "C2" + c.parent!.parent = .init(name: "A") + XCTAssertEqual(c.parent, .init(name: "B", parent: .init(name: "A"))) + XCTAssertEqual( + c, + Components.Schemas.RecursivePet( + name: "C2", + parent: .init( + name: "B", + parent: .init(name: "A") + ) + ) + ) + } + + func testRecursiveType_accessors_2levels() throws { + var b = Components.Schemas.RecursivePet(name: "B") + b.name = "B2" + b.parent = .init(name: "A") + XCTAssertEqual(b.parent, .init(name: "A")) + XCTAssertEqual( + b, + .init( + name: "B2", + parent: .init(name: "A") + ) + ) + } + + func testRecursiveNestedType_roundtrip() throws { + try _testRoundtrip( + Components.Schemas.RecursivePetNested( + name: "C", + parent: .init( + nested: .init( + name: "B", + parent: .init( + nested: .init(name: "A") + ) + ) + ) + ), + verifyingJSON: #"{"name":"C","parent":{"nested":{"name":"B","parent":{"nested":{"name":"A"}}}}}"# + ) + } }