Skip to content

Commit cb41d40

Browse files
authored
[Generator] Multiple content types support (#146)
[Generator] Multiple content types support Depends on apple/swift-openapi-runtime#29 ### Motivation Up until now, when an OpenAPI document contained more than one content type in either a request or a response body, we would only generate code for now, and ignored the other ones. However, that was a temporary workaround until we could add proper support for multiple content types, which is what this PR is about. Addresses #6 and #7, except for the "Accept header" propagation, which will be addressed separately and have its own PR and even a proposal. That's one of the reasons this feature is landing disabled, hidden behind the `multipleContentTypes` feature flag. A tip for reviewing this change: first check out how the test OpenAPI document and the reference files changed, that shows the details of how we encode and decode multiple content types. I also added comments in the code for easier review. ### Modifications - Main: all supported content types found in a `content` map in request and response bodies are now generated in the `Body` enum. - Added a method `supportedTypedContents` that returns all supported content types, used now instead of the `bestTypedContent` method we used before. What is a "supported" content type? One that has a schema that doesn't return `false` to `isSchemaSupported`. (This is a pre-existing concept.) - Updated the logic for generating request and response bodies to use the new method, both on client and server. - Updated content type -> Swift enum case name logic, but that will go through a full proposal, so please hold feedback for the proposal, this is just a first pass based on some collected data of most commonly used content types. - ### Open Questions - What to do in an operation with multiple request/response content types when _no_ `content-type` header is provided? Previously, with up to 1 content type support, we still went ahead and tried to parse it. But here, we don't know which content to parse it as, so we just try it with the first in the list (we respect the order in the YAML dictionary, so what the user put first will be what we try to use when no `content-type` header is provided). If an invalid `content-type` value is provided, we throw an error (pre-existing behavior). Q: Is choosing the first reasonable? What would be better? Note that, unfortunately, many servers don't send `content-type` today. ### Result When an adopter provides multiple content types in their OpenAPI document, we generate one case in the Body enum for each! However, the new functionality is disabled by default, has to be explicitly requested by enabling the `multipleContentTypes` feature flag either on the CLI or in the config file. ### Test Plan Added `setStats` and `getStats` operations that employ multiple content types, one in a request body, the other in a response body. Added unit tests for it in `PetstoreConsumerTests` to verify it all works correctly at runtime. Deleted `Tests/OpenAPIGeneratorCoreTests/Translator/RequestBody/Test_translateRequestBody.swift` as snippet tests do this job better, and I didn't want to spend the time updating the test. Adds `PetstoreConsumerTests_FF_MultipleContentTypes`, a variant of `PetstoreConsumerTests` for when the `multipleContentTypes` feature flag is enabled, so we test both how the existing logic handles multiple content types (it picks one), and the new logic (it generates code for all supported ones it found). ## TODOs - [x] rename `IfConditionPair` to `IfBranch` - [x] break out the first `if` from the subsequence `else if`s in `IfStatement`, to 3 properties: first if, then an array of else ifs, then an optional else; to avoid creating invalid code - [x] create an SPI type `ContentType` to avoid repeatedly parsing the received content type Reviewed by: gjcairo, simonjbeaumont Builds: ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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. #146
1 parent 01c12f5 commit cb41d40

36 files changed

+5137
-334
lines changed

.swift-format

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"OnlyOneTrailingClosureArgument" : true,
4444
"OrderedImports" : false,
4545
"ReturnVoidInsteadOfEmptyTuple" : true,
46-
"UseEarlyExits" : true,
46+
"UseEarlyExits" : false,
4747
"UseLetInEveryBoundCaseVariable" : false,
4848
"UseShorthandTypeNames" : true,
4949
"UseSingleLinePropertyGetter" : false,

Package.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ let package = Package(
7878
),
7979

8080
// Tests-only: Runtime library linked by generated code
81-
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.6")),
81+
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.7")),
8282

8383
// Build and preview docs
8484
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
@@ -143,6 +143,18 @@ let package = Package(
143143
swiftSettings: swiftSettings
144144
),
145145

146+
// PetstoreConsumerTestsFFMultipleContentTypes
147+
// Builds and tests the reference code from GeneratorReferenceTests
148+
// to ensure it actually works correctly at runtime.
149+
// Enabled feature flag: multipleContentTypes
150+
.testTarget(
151+
name: "PetstoreConsumerTestsFFMultipleContentTypes",
152+
dependencies: [
153+
"PetstoreConsumerTestCore"
154+
],
155+
swiftSettings: swiftSettings
156+
),
157+
146158
// Generator CLI
147159
.executableTarget(
148160
name: "swift-openapi-generator",

Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,41 @@ struct SwitchDescription: Equatable, Codable {
664664
var cases: [SwitchCaseDescription]
665665
}
666666

667+
/// A description of an if branch and the corresponding code block.
668+
///
669+
/// For example: in `if foo { bar }`, the condition pair represents
670+
/// `foo` + `bar`.
671+
struct IfBranch: Equatable, Codable {
672+
673+
/// The expressions evaluated by the if statement and their corresponding
674+
/// body blocks. If more than one is provided, an `else if` branch is added.
675+
///
676+
/// For example, in `if foo { bar }`, `condition` is `foo`.
677+
var condition: Expression
678+
679+
/// The body executed if the `condition` evaluates to true.
680+
///
681+
/// For example, in `if foo { bar }`, `body` is `bar`.
682+
var body: [CodeBlock]
683+
}
684+
685+
/// A description of an if[[/elseif]/else] statement expression.
686+
///
687+
/// For example: `if foo { } else if bar { } else { }`.
688+
struct IfStatementDescription: Equatable, Codable {
689+
690+
/// The primary `if` branch.
691+
var ifBranch: IfBranch
692+
693+
/// Additional `else if` branches.
694+
var elseIfBranches: [IfBranch]
695+
696+
/// The body of an else block.
697+
///
698+
/// No `else` statement is added when `elseBody` is nil.
699+
var elseBody: [CodeBlock]?
700+
}
701+
667702
/// A description of a do statement.
668703
///
669704
/// For example: `do { try foo() } catch { return bar }`.
@@ -709,6 +744,9 @@ enum KeywordKind: Equatable, Codable {
709744

710745
/// The await keyword.
711746
case `await`
747+
748+
/// The throw keyword.
749+
case `throw`
712750
}
713751

714752
/// A description of an expression that places a keyword before an expression.
@@ -751,8 +789,14 @@ enum BinaryOperator: String, Equatable, Codable {
751789
/// The += operator, adds and then assigns another value.
752790
case plusEquals = "+="
753791

792+
/// The == operator, checks equality between two values.
793+
case equals = "=="
794+
754795
/// The ... operator, creates an end-inclusive range between two numbers.
755796
case rangeInclusive = "..."
797+
798+
/// The || operator, used between two Boolean values.
799+
case booleanOr = "||"
756800
}
757801

758802
/// A description of a binary operation expression.
@@ -832,6 +876,11 @@ indirect enum Expression: Equatable, Codable {
832876
/// For example: `switch foo {`.
833877
case `switch`(SwitchDescription)
834878

879+
/// An if statement, with optional else if's and an else statement attached.
880+
///
881+
/// For example: `if foo { bar } else if baz { boo } else { bam }`.
882+
case ifStatement(IfStatementDescription)
883+
835884
/// A do statement.
836885
///
837886
/// For example: `do { try foo() } catch { return bar }`.
@@ -1202,6 +1251,26 @@ extension Expression {
12021251
)
12031252
}
12041253

1254+
/// Returns an if statement, with optional else if's and an else
1255+
/// statement attached.
1256+
/// - Parameters:
1257+
/// - ifBranch: The primary `if` branch.
1258+
/// - elseIfBranches: Additional `else if` branches.
1259+
/// - elseBody: The body of an else block.
1260+
static func ifStatement(
1261+
ifBranch: IfBranch,
1262+
elseIfBranches: [IfBranch] = [],
1263+
elseBody: [CodeBlock]? = nil
1264+
) -> Self {
1265+
.ifStatement(
1266+
.init(
1267+
ifBranch: ifBranch,
1268+
elseIfBranches: elseIfBranches,
1269+
elseBody: elseBody
1270+
)
1271+
)
1272+
}
1273+
12051274
/// Returns a new function call expression.
12061275
///
12071276
/// For example `foo(bar: 42)`.

Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,26 @@ struct TextBasedRenderer: RendererProtocol {
175175
return lines.joinedLines()
176176
}
177177

178+
/// Renders the specified if statement.
179+
func renderedIf(_ ifDesc: IfStatementDescription) -> String {
180+
var lines: [String] = []
181+
let ifBranch = ifDesc.ifBranch
182+
lines.append("if \(renderedExpression(ifBranch.condition)) {")
183+
lines.append(renderedCodeBlocks(ifBranch.body))
184+
lines.append("}")
185+
for branch in ifDesc.elseIfBranches {
186+
lines.append("else if \(renderedExpression(branch.condition)) {")
187+
lines.append(renderedCodeBlocks(branch.body))
188+
lines.append("}")
189+
}
190+
if let elseBody = ifDesc.elseBody {
191+
lines.append("else {")
192+
lines.append(renderedCodeBlocks(elseBody))
193+
lines.append("}")
194+
}
195+
return lines.joinedLines()
196+
}
197+
178198
/// Renders the specified switch expression.
179199
func renderedDoStatement(_ description: DoStatementDescription) -> String {
180200
var lines: [String] = ["do {"]
@@ -201,6 +221,8 @@ struct TextBasedRenderer: RendererProtocol {
201221
return "try\(hasPostfixQuestionMark ? "?" : "")"
202222
case .await:
203223
return "await"
224+
case .throw:
225+
return "throw"
204226
}
205227
}
206228

@@ -268,6 +290,8 @@ struct TextBasedRenderer: RendererProtocol {
268290
return renderedAssignment(assignment)
269291
case .switch(let switchDesc):
270292
return renderedSwitch(switchDesc)
293+
case .ifStatement(let ifDesc):
294+
return renderedIf(ifDesc)
271295
case .doStatement(let doStmt):
272296
return renderedDoStatement(doStmt)
273297
case .valueBinding(let valueBinding):

Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift

Lines changed: 156 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,78 @@ extension FileTranslator {
6161
return .init(content: content, typeUsage: associatedType)
6262
}
6363

64+
/// Extract the supported content types.
65+
/// - Parameters:
66+
/// - map: The content map from the OpenAPI document.
67+
/// - excludeBinary: A Boolean value controlling whether binary content
68+
/// type should be skipped, for example used when encoding headers.
69+
/// - parent: The parent type of the chosen typed schema.
70+
/// - Returns: The supported content type + schema + type names.
71+
func supportedTypedContents(
72+
_ map: OpenAPI.Content.Map,
73+
excludeBinary: Bool = false,
74+
inParent parent: TypeName
75+
) throws -> [TypedSchemaContent] {
76+
let contents = supportedContents(
77+
map,
78+
excludeBinary: excludeBinary,
79+
foundIn: parent.description
80+
)
81+
return try contents.compactMap { content in
82+
guard
83+
try validateSchemaIsSupported(
84+
content.schema,
85+
foundIn: parent.description
86+
)
87+
else {
88+
return nil
89+
}
90+
let identifier = contentSwiftName(content.contentType)
91+
let associatedType = try typeAssigner.typeUsage(
92+
usingNamingHint: identifier,
93+
withSchema: content.schema,
94+
inParent: parent
95+
)
96+
return .init(content: content, typeUsage: associatedType)
97+
}
98+
}
99+
100+
/// Extract the supported content types.
101+
/// - Parameters:
102+
/// - contents: The content map from the OpenAPI document.
103+
/// - excludeBinary: A Boolean value controlling whether binary content
104+
/// type should be skipped, for example used when encoding headers.
105+
/// - foundIn: The location where this content is parsed.
106+
/// - Returns: the detected content type + schema, nil if no supported
107+
/// schema found or if empty.
108+
func supportedContents(
109+
_ contents: OpenAPI.Content.Map,
110+
excludeBinary: Bool = false,
111+
foundIn: String
112+
) -> [SchemaContent] {
113+
guard !contents.isEmpty else {
114+
return []
115+
}
116+
guard config.featureFlags.contains(.multipleContentTypes) else {
117+
return bestSingleContent(
118+
contents,
119+
excludeBinary: excludeBinary,
120+
foundIn: foundIn
121+
)
122+
.flatMap { [$0] } ?? []
123+
}
124+
return
125+
contents
126+
.compactMap { key, value in
127+
parseContentIfSupported(
128+
contentKey: key,
129+
contentValue: value,
130+
excludeBinary: excludeBinary,
131+
foundIn: foundIn + "/\(key.rawValue)"
132+
)
133+
}
134+
}
135+
64136
/// While we only support a single content at a time, choose the best one.
65137
///
66138
/// Priority:
@@ -72,6 +144,7 @@ extension FileTranslator {
72144
/// - map: The content map from the OpenAPI document.
73145
/// - excludeBinary: A Boolean value controlling whether binary content
74146
/// type should be skipped, for example used when encoding headers.
147+
/// - foundIn: The location where this content is parsed.
75148
/// - Returns: the detected content type + schema, nil if no supported
76149
/// schema found or if empty.
77150
func bestSingleContent(
@@ -88,8 +161,81 @@ extension FileTranslator {
88161
foundIn: foundIn
89162
)
90163
}
164+
let chosenContent: (SchemaContent, OpenAPI.Content)?
91165
if let (contentKey, contentValue) = map.first(where: { $0.key.isJSON }),
92166
let contentType = ContentType(contentKey.typeAndSubtype)
167+
{
168+
chosenContent = (
169+
.init(
170+
contentType: contentType,
171+
schema: contentValue.schema
172+
),
173+
contentValue
174+
)
175+
} else if let (contentKey, contentValue) = map.first(where: { $0.key.isText }),
176+
let contentType = ContentType(contentKey.typeAndSubtype)
177+
{
178+
chosenContent = (
179+
.init(
180+
contentType: contentType,
181+
schema: .b(.string)
182+
),
183+
contentValue
184+
)
185+
} else if !excludeBinary,
186+
let (contentKey, contentValue) = map.first(where: { $0.key.isBinary }),
187+
let contentType = ContentType(contentKey.typeAndSubtype)
188+
{
189+
chosenContent = (
190+
.init(
191+
contentType: contentType,
192+
schema: .b(.string(format: .binary))
193+
),
194+
contentValue
195+
)
196+
} else {
197+
diagnostics.emitUnsupported(
198+
"Unsupported content",
199+
foundIn: foundIn
200+
)
201+
chosenContent = nil
202+
}
203+
if let chosenContent {
204+
let rawMIMEType = chosenContent.0.contentType.rawMIMEType
205+
if rawMIMEType.hasPrefix("multipart/") || rawMIMEType.contains("application/x-www-form-urlencoded") {
206+
diagnostics.emitUnsupportedIfNotNil(
207+
chosenContent.1.encoding,
208+
"Custom encoding for JSON content",
209+
foundIn: "\(foundIn), content \(rawMIMEType)"
210+
)
211+
}
212+
}
213+
return chosenContent?.0
214+
}
215+
216+
/// Returns a wrapped version of the provided content if supported, returns
217+
/// nil otherwise.
218+
///
219+
/// Priority of checking for known MIME types:
220+
/// 1. JSON
221+
/// 2. text
222+
/// 3. binary
223+
///
224+
/// - Parameters:
225+
/// - contentKey: The content key from the OpenAPI document.
226+
/// - contentValue: The content value from the OpenAPI document.
227+
/// - excludeBinary: A Boolean value controlling whether binary content
228+
/// type should be skipped, for example used when encoding headers.
229+
/// - foundIn: The location where this content is parsed.
230+
/// - Returns: The detected content type + schema, nil if unsupported.
231+
func parseContentIfSupported(
232+
contentKey: OpenAPI.ContentType,
233+
contentValue: OpenAPI.Content,
234+
excludeBinary: Bool = false,
235+
foundIn: String
236+
) -> SchemaContent? {
237+
if contentKey.isJSON,
238+
let contentType = ContentType(contentKey.typeAndSubtype)
93239
{
94240
diagnostics.emitUnsupportedIfNotNil(
95241
contentValue.encoding,
@@ -100,27 +246,28 @@ extension FileTranslator {
100246
contentType: contentType,
101247
schema: contentValue.schema
102248
)
103-
} else if let (contentKey, _) = map.first(where: { $0.key.isText }),
249+
}
250+
if contentKey.isText,
104251
let contentType = ContentType(contentKey.typeAndSubtype)
105252
{
106253
return .init(
107254
contentType: contentType,
108255
schema: .b(.string)
109256
)
110-
} else if !excludeBinary,
111-
let (contentKey, _) = map.first(where: { $0.key.isBinary }),
257+
}
258+
if !excludeBinary,
259+
contentKey.isBinary,
112260
let contentType = ContentType(contentKey.typeAndSubtype)
113261
{
114262
return .init(
115263
contentType: contentType,
116264
schema: .b(.string(format: .binary))
117265
)
118-
} else {
119-
diagnostics.emitUnsupported(
120-
"Unsupported content",
121-
foundIn: foundIn
122-
)
123-
return nil
124266
}
267+
diagnostics.emitUnsupported(
268+
"Unsupported content",
269+
foundIn: foundIn
270+
)
271+
return nil
125272
}
126273
}

0 commit comments

Comments
 (0)