Skip to content

Commit ac830c1

Browse files
authored
Support recursive types (#330)
Support recursive types ### Motivation Fixes #70. ### Modifications To start, read the new docc [article](https://github.com/apple/swift-openapi-generator/blob/ceb51fa1a4f1f858590b22da75162c4bf999719b/Sources/swift-openapi-generator/Documentation.docc/Development/Supporting-recursive-types.md) about how reference types are implemented, then most of the PR should make sense. As suggested by @simonjbeaumont, boxing of recursive types happens on the Swift representation, as opposed to my original approach, which tried to do this early in the translation layer. This massively simplified the problem and definitely seems like the better way to do it. Highlights: - In `validateDoc`, removed the dereferencing, which we previously used to catch cycles early and emit descriptive errors. - Introduced an efficient stack type caller `ReferenceStack` that makes checking if an item is present in the stack fast, on top of being a stack (represented as an array). - Helper methods like `isSchemaSupported` and `isKeyValuePair` gained an inout parameter of the stack, to allow it to break infinite recursion. - The actual algorithm for walking the graph, detecting cycles, and deciding which types to box is implemented in `RecursionDetector`, which is a generic algorithm on top of nodes with edges. - Then `DeclarationRecursionDetector` provides concrete types that glue it with our structured Swift representation's `Declaration`. - The algorithm runs in `translateSchemas` where we're iterating over the items in `#/components/schemas`, as those are the only ones that can produce a cycle (as schemas in other parts of the document can refer to items in `#/components/schemas`, but not the other way around: items in `#/components/schemas` cannot refer to schemas outside of it.) ### Result OpenAPI documents with recursive schemas are now supported. ### Test Plan - Added unit tests for the recursion detector. - Adapted other tests, of `isSchemaSupported` and `isKeyValuePair`. - Added examples to `petstore.yaml`, as this one introduces quite a lot of new code that we want to make sure compiles without warnings. - Also added examples to snippet tests, to allow us to expand those later with edge cases we haven't thought about yet. Reviewed by: dnadoba Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (compatibility test) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. #330
1 parent 62b69a9 commit ac830c1

File tree

21 files changed

+2139
-72
lines changed

21 files changed

+2139
-72
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ let package = Package(
8989
// Tests-only: Runtime library linked by generated code, and also
9090
// helps keep the runtime library new enough to work with the generated
9191
// code.
92-
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.2")),
92+
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.3")),
9393

9494
// Build and preview docs
9595
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),

Sources/_OpenAPIGeneratorCore/Parser/validateDoc.swift

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,5 @@ func validateDoc(_ doc: ParsedOpenAPIRepresentation, config: Config) throws -> [
4444
]
4545
)
4646
}
47-
48-
// Validate that the document is dereferenceable, which
49-
// catches reference cycles, which we don't yet support.
50-
_ = try doc.locallyDereferenced()
51-
52-
// Also explicitly dereference the parts of components
53-
// that the generator uses. `locallyDereferenced()` above
54-
// only dereferences paths/operations, but not components.
55-
let components = doc.components
56-
try components.schemas.forEach { schema in
57-
_ = try schema.value.dereferenced(in: components)
58-
}
59-
try components.parameters.forEach { schema in
60-
_ = try schema.value.dereferenced(in: components)
61-
}
62-
try components.headers.forEach { schema in
63-
_ = try schema.value.dereferenced(in: components)
64-
}
65-
try components.requestBodies.forEach { schema in
66-
_ = try schema.value.dereferenced(in: components)
67-
}
68-
try components.responses.forEach { schema in
69-
_ = try schema.value.dereferenced(in: components)
70-
}
7147
return diagnostics
7248
}

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ extension FileTranslator {
8585
associatedDeclarations: associatedDeclarations,
8686
asSwiftSafeName: swiftSafeName
8787
)
88+
var referenceStack = ReferenceStack.empty
8889
let isKeyValuePairSchema = try TypeMatcher.isKeyValuePair(
8990
schema,
91+
referenceStack: &referenceStack,
9092
components: components
9193
)
9294
return (blueprint, isKeyValuePairSchema)
@@ -196,8 +198,10 @@ extension FileTranslator {
196198
} else {
197199
associatedDeclarations = []
198200
}
201+
var referenceStack = ReferenceStack.empty
199202
let isKeyValuePair = try TypeMatcher.isKeyValuePair(
200203
schema,
204+
referenceStack: &referenceStack,
201205
components: components
202206
)
203207
return (caseName, nil, isKeyValuePair, comment, childType, associatedDeclarations)

Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,12 @@ enum Constants {
312312

313313
/// The name of the namespace.
314314
static let namespace: String = "Schemas"
315+
316+
/// The full namespace components.
317+
static let components: [String] = [
318+
Constants.Components.namespace,
319+
Constants.Components.Schemas.namespace,
320+
]
315321
}
316322

317323
/// Constants related to the Parameters namespace.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// A set of specialized types for using the recursion detector for
16+
/// declarations.
17+
struct DeclarationRecursionDetector {
18+
19+
/// A node for a pair of a Swift type name and a corresponding declaration.
20+
struct Node: TypeNode, Equatable {
21+
22+
/// The type of the name is a string.
23+
typealias NameType = String
24+
25+
/// The name of the node.
26+
var name: NameType
27+
28+
/// Whether the type can be boxed.
29+
var isBoxable: Bool
30+
31+
/// The names of nodes pointed to by this node.
32+
var edges: [NameType]
33+
34+
/// The declaration represented by this node.
35+
var decl: Declaration
36+
37+
/// Creates a new node.
38+
/// - Parameters:
39+
/// - name: The name of the node.
40+
/// - isBoxable: Whether the type can be boxed.
41+
/// - edges: The names of nodes pointed to by this node.
42+
/// - decl: The declaration represented by this node.
43+
private init(name: NameType, isBoxable: Bool, edges: [NameType], decl: Declaration) {
44+
self.name = name
45+
self.isBoxable = isBoxable
46+
self.edges = edges
47+
self.decl = decl
48+
}
49+
50+
/// Creates a new node from the provided declaration.
51+
///
52+
/// Returns nil when the declaration is missing a name.
53+
/// - Parameter decl: A declaration.
54+
init?(_ decl: Declaration) {
55+
guard let name = decl.name else {
56+
return nil
57+
}
58+
let edges = decl.schemaComponentNamesOfUnbreakableReferences
59+
self.init(
60+
name: name,
61+
isBoxable: decl.isBoxable,
62+
edges: edges,
63+
decl: decl
64+
)
65+
}
66+
}
67+
68+
/// A container for declarations.
69+
struct Container: TypeNodeContainer {
70+
71+
/// The type of the node.
72+
typealias Node = DeclarationRecursionDetector.Node
73+
74+
/// An error thrown by the container.
75+
enum ContainerError: Swift.Error {
76+
77+
/// The node for the provided name was not found.
78+
case nodeNotFound(Node.NameType)
79+
}
80+
81+
/// The lookup map from the name to the node.
82+
var lookupMap: [String: Node]
83+
84+
func lookup(_ name: String) throws -> DeclarationRecursionDetector.Node {
85+
guard let node = lookupMap[name] else {
86+
throw ContainerError.nodeNotFound(name)
87+
}
88+
return node
89+
}
90+
}
91+
}
92+
93+
extension Declaration {
94+
95+
/// A name of the declaration, if it has one.
96+
var name: String? {
97+
switch self {
98+
case .struct(let desc):
99+
return desc.name
100+
case .enum(let desc):
101+
return desc.name
102+
case .typealias(let desc):
103+
return desc.name
104+
case .commentable(_, let decl), .deprecated(_, let decl):
105+
return decl.name
106+
case .variable, .extension, .protocol, .function, .enumCase:
107+
return nil
108+
}
109+
}
110+
111+
/// A Boolean value representing whether this declaration can be boxed.
112+
var isBoxable: Bool {
113+
switch self {
114+
case .struct, .enum:
115+
return true
116+
case .commentable(_, let decl), .deprecated(_, let decl):
117+
return decl.isBoxable
118+
case .typealias, .variable, .extension, .protocol, .function, .enumCase:
119+
return false
120+
}
121+
}
122+
123+
/// An array of names that can be found in `#/components/schemas` in
124+
/// the OpenAPI document that represent references that can cause
125+
/// a reference cycle.
126+
var schemaComponentNamesOfUnbreakableReferences: [String] {
127+
switch self {
128+
case .struct(let desc):
129+
return desc
130+
.members
131+
.compactMap { (member) -> [String]? in
132+
switch member.strippingTopComment {
133+
case .variable, // A reference to a reusable type.
134+
.struct, .enum: // An inline type.
135+
return member.schemaComponentNamesOfUnbreakableReferences
136+
default:
137+
return nil
138+
}
139+
}
140+
.flatMap { $0 }
141+
case .enum(let desc):
142+
return desc
143+
.members
144+
.compactMap { (member) -> [String]? in
145+
guard case .enumCase = member.strippingTopComment else {
146+
return nil
147+
}
148+
return member
149+
.schemaComponentNamesOfUnbreakableReferences
150+
}
151+
.flatMap { $0 }
152+
case .commentable(_, let decl), .deprecated(_, let decl):
153+
return decl
154+
.schemaComponentNamesOfUnbreakableReferences
155+
case .typealias(let desc):
156+
return desc
157+
.existingType
158+
.referencedSchemaComponentName
159+
.map { [$0] } ?? []
160+
case .variable(let desc):
161+
return desc.type?.referencedSchemaComponentName.map { [$0] } ?? []
162+
case .enumCase(let desc):
163+
switch desc.kind {
164+
case .nameWithAssociatedValues(let values):
165+
return values.compactMap { $0.type.referencedSchemaComponentName }
166+
default:
167+
return []
168+
}
169+
case .extension, .protocol, .function:
170+
return []
171+
}
172+
}
173+
}
174+
175+
fileprivate extension Array where Element == String {
176+
177+
/// The name in the `Components.Schemas.` namespace.
178+
var nameIfTopLevelSchemaComponent: String? {
179+
let components = self
180+
guard
181+
components.count == 3,
182+
components.starts(with: Constants.Components.Schemas.components)
183+
else {
184+
return nil
185+
}
186+
return components[2]
187+
}
188+
}
189+
190+
extension ExistingTypeDescription {
191+
192+
/// The name in the `Components.Schemas.` namespace, if the type can appear
193+
/// there. Nil otherwise.
194+
var referencedSchemaComponentName: String? {
195+
switch self {
196+
case .member(let components):
197+
return components.nameIfTopLevelSchemaComponent
198+
case .array(let desc), .dictionaryValue(let desc), .any(let desc), .optional(let desc):
199+
return desc.referencedSchemaComponentName
200+
case .generic:
201+
return nil
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)