Skip to content

Commit ad4060b

Browse files
authored
[Generator] Multipart support (#366)
### Motivation Implement SOAR-0009. Fixes #36. ### Modifications It's a large diff, and adds some complexity, however I tried to avoid needlessly complicating existing code and opted for duplicating parts of the code specifically for multipart, for easier reasoning. The implementation very much maps to the changes described in the proposal, which I won't repeat here. Some notable highlights before you dive in: - Testing is mainly done through snippet tests, and I added one request and one response operation to make sure things work end to end. Maybe review those first. - The logic for generating request and response bodies got slightly extended to detect when we're generating multipart content, and branches off to bespoke code specifically for multipart, all in the new `Multipart` directory. - Made a few related changes, but tried to keep this isolated to multipart as much as reasonable. - Let me know which parts I should elaborate on, I'm happy to explain in more detail. ### Result Multipart content now works, as proposed in SOAR-0009. ### Test Plan Added a bunch of snippet tests and two new file-based reference test operations, including petstore consumer tests to verify that it all works at runtime as well.
1 parent e5ce6ac commit ad4060b

File tree

62 files changed

+5577
-444
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+5577
-444
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ let package = Package(
6464
// Tests-only: Runtime library linked by generated code, and also
6565
// helps keep the runtime library new enough to work with the generated
6666
// code.
67-
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.6")),
67+
.package(url: "https://github.com/apple/swift-openapi-runtime", branch: "main"),
6868

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

Sources/PetstoreConsumerTestCore/Assertions.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,73 @@ public func XCTAssertEqualStringifiedData(
6161
if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() }
6262
XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line)
6363
}
64+
fileprivate extension UInt8 {
65+
var asHex: String {
66+
let original: String
67+
switch self {
68+
case 0x0d: original = "CR"
69+
case 0x0a: original = "LF"
70+
default: original = "\(UnicodeScalar(self)) "
71+
}
72+
return String(format: "%02x \(original)", self)
73+
}
74+
}
75+
/// Asserts that the data matches the expected value.
76+
public func XCTAssertEqualData<C1: Collection, C2: Collection>(
77+
_ expression1: @autoclosure () throws -> C1?,
78+
_ expression2: @autoclosure () throws -> C2,
79+
_ message: @autoclosure () -> String = "Data doesn't match.",
80+
file: StaticString = #filePath,
81+
line: UInt = #line
82+
) where C1.Element == UInt8, C2.Element == UInt8 {
83+
do {
84+
guard let actualBytes = try expression1() else {
85+
XCTFail("First value is nil", file: file, line: line)
86+
return
87+
}
88+
let expectedBytes = try expression2()
89+
if ArraySlice(actualBytes) == ArraySlice(expectedBytes) { return }
90+
let actualCount = actualBytes.count
91+
let expectedCount = expectedBytes.count
92+
let minCount = min(actualCount, expectedCount)
93+
print("Printing both byte sequences, first is the actual value and second is the expected one.")
94+
for (index, byte) in zip(actualBytes.prefix(minCount), expectedBytes.prefix(minCount)).enumerated() {
95+
print("\(String(format: "%04d", index)): \(byte.0 != byte.1 ? "x" : " ") \(byte.0.asHex) | \(byte.1.asHex)")
96+
}
97+
let direction: String
98+
let extraBytes: ArraySlice<UInt8>
99+
if actualCount > expectedCount {
100+
direction = "Actual bytes has extra bytes"
101+
extraBytes = ArraySlice(actualBytes.dropFirst(minCount))
102+
} else if expectedCount > actualCount {
103+
direction = "Actual bytes is missing expected bytes"
104+
extraBytes = ArraySlice(expectedBytes.dropFirst(minCount))
105+
} else {
106+
direction = ""
107+
extraBytes = []
108+
}
109+
if !extraBytes.isEmpty {
110+
print("\(direction):")
111+
for (index, byte) in extraBytes.enumerated() {
112+
print("\(String(format: "%04d", minCount + index)): \(byte.asHex)")
113+
}
114+
}
115+
XCTFail(
116+
"Actual stringified data '\(String(decoding: actualBytes, as: UTF8.self))' doesn't equal to expected stringified data '\(String(decoding: expectedBytes, as: UTF8.self))'. Details: \(message())",
117+
file: file,
118+
line: line
119+
)
120+
} catch { XCTFail(error.localizedDescription, file: file, line: line) }
121+
}
122+
/// Asserts that the data matches the expected value.
123+
public func XCTAssertEqualData<C: Collection>(
124+
_ expression1: @autoclosure () throws -> HTTPBody?,
125+
_ expression2: @autoclosure () throws -> C,
126+
_ message: @autoclosure () -> String = "Data doesn't match.",
127+
file: StaticString = #filePath,
128+
line: UInt = #line
129+
) async throws where C.Element == UInt8 {
130+
let data: Data
131+
if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() }
132+
XCTAssertEqualData(data, try expression2(), message(), file: file, line: line)
133+
}

Sources/PetstoreConsumerTestCore/Common.swift

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,103 @@ public extension Data {
7474
static var quotedEfghString: String { #""efgh""# }
7575

7676
static var efgh: Data { Data(efghString.utf8) }
77+
78+
static let crlf: ArraySlice<UInt8> = [0xd, 0xa]
79+
80+
static var multipartBodyString: String { String(decoding: multipartBodyAsSlice, as: UTF8.self) }
81+
82+
static var multipartBodyAsSlice: [UInt8] {
83+
var bytes: [UInt8] = []
84+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
85+
bytes.append(contentsOf: crlf)
86+
bytes.append(contentsOf: #"content-disposition: form-data; name="efficiency""#.utf8)
87+
bytes.append(contentsOf: crlf)
88+
bytes.append(contentsOf: #"content-length: 3"#.utf8)
89+
bytes.append(contentsOf: crlf)
90+
bytes.append(contentsOf: crlf)
91+
bytes.append(contentsOf: "4.2".utf8)
92+
bytes.append(contentsOf: crlf)
93+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
94+
bytes.append(contentsOf: crlf)
95+
bytes.append(contentsOf: #"content-disposition: form-data; name="name""#.utf8)
96+
bytes.append(contentsOf: crlf)
97+
bytes.append(contentsOf: #"content-length: 21"#.utf8)
98+
bytes.append(contentsOf: crlf)
99+
bytes.append(contentsOf: crlf)
100+
bytes.append(contentsOf: "Vitamin C and friends".utf8)
101+
bytes.append(contentsOf: crlf)
102+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8)
103+
bytes.append(contentsOf: crlf)
104+
bytes.append(contentsOf: crlf)
105+
return bytes
106+
}
107+
108+
static var multipartBody: Data { Data(multipartBodyAsSlice) }
109+
110+
static var multipartTypedBodyAsSlice: [UInt8] {
111+
var bytes: [UInt8] = []
112+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
113+
bytes.append(contentsOf: crlf)
114+
bytes.append(contentsOf: #"content-disposition: form-data; filename="process.log"; name="log""#.utf8)
115+
bytes.append(contentsOf: crlf)
116+
bytes.append(contentsOf: #"content-length: 35"#.utf8)
117+
bytes.append(contentsOf: crlf)
118+
bytes.append(contentsOf: #"content-type: text/plain"#.utf8)
119+
bytes.append(contentsOf: crlf)
120+
bytes.append(contentsOf: #"x-log-type: unstructured"#.utf8)
121+
bytes.append(contentsOf: crlf)
122+
bytes.append(contentsOf: crlf)
123+
bytes.append(contentsOf: "here be logs!\nand more lines\nwheee\n".utf8)
124+
bytes.append(contentsOf: crlf)
125+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
126+
bytes.append(contentsOf: crlf)
127+
bytes.append(contentsOf: #"content-disposition: form-data; filename="fun.stuff"; name="keyword""#.utf8)
128+
bytes.append(contentsOf: crlf)
129+
bytes.append(contentsOf: #"content-length: 3"#.utf8)
130+
bytes.append(contentsOf: crlf)
131+
bytes.append(contentsOf: #"content-type: text/plain"#.utf8)
132+
bytes.append(contentsOf: crlf)
133+
bytes.append(contentsOf: crlf)
134+
bytes.append(contentsOf: "fun".utf8)
135+
bytes.append(contentsOf: crlf)
136+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
137+
138+
bytes.append(contentsOf: crlf)
139+
bytes.append(contentsOf: #"content-disposition: form-data; filename="barfoo.txt"; name="foobar""#.utf8)
140+
bytes.append(contentsOf: crlf)
141+
bytes.append(contentsOf: #"content-length: 0"#.utf8)
142+
bytes.append(contentsOf: crlf)
143+
bytes.append(contentsOf: crlf)
144+
bytes.append(contentsOf: "".utf8)
145+
bytes.append(contentsOf: crlf)
146+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
147+
bytes.append(contentsOf: crlf)
148+
bytes.append(contentsOf: #"content-disposition: form-data; name="metadata""#.utf8)
149+
bytes.append(contentsOf: crlf)
150+
bytes.append(contentsOf: #"content-length: 42"#.utf8)
151+
bytes.append(contentsOf: crlf)
152+
bytes.append(contentsOf: #"content-type: application/json; charset=utf-8"#.utf8)
153+
bytes.append(contentsOf: crlf)
154+
bytes.append(contentsOf: crlf)
155+
bytes.append(contentsOf: "{\n \"createdAt\" : \"2023-01-18T10:04:11Z\"\n}".utf8)
156+
bytes.append(contentsOf: crlf)
157+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
158+
bytes.append(contentsOf: crlf)
159+
bytes.append(contentsOf: #"content-disposition: form-data; name="keyword""#.utf8)
160+
bytes.append(contentsOf: crlf)
161+
bytes.append(contentsOf: #"content-length: 3"#.utf8)
162+
bytes.append(contentsOf: crlf)
163+
bytes.append(contentsOf: #"content-type: text/plain"#.utf8)
164+
bytes.append(contentsOf: crlf)
165+
bytes.append(contentsOf: crlf)
166+
bytes.append(contentsOf: "joy".utf8)
167+
bytes.append(contentsOf: crlf)
168+
bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8)
169+
bytes.append(contentsOf: "--".utf8)
170+
bytes.append(contentsOf: crlf)
171+
bytes.append(contentsOf: crlf)
172+
return bytes
173+
}
77174
}
78175

79176
public extension HTTPRequest {

Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ struct VariableDescription: Equatable, Codable {
236236
/// The name of the variable.
237237
///
238238
/// For example, in `let foo = 42`, `left` is `foo`.
239-
var left: String
239+
var left: Expression
240240

241241
/// The type of the variable.
242242
///
@@ -1106,6 +1106,49 @@ extension Declaration {
11061106
setter: [CodeBlock]? = nil,
11071107
modify: [CodeBlock]? = nil
11081108

1109+
) -> Self {
1110+
.variable(
1111+
accessModifier: accessModifier,
1112+
isStatic: isStatic,
1113+
kind: kind,
1114+
left: .identifierPattern(left),
1115+
type: type,
1116+
right: right,
1117+
getter: getter,
1118+
getterEffects: getterEffects,
1119+
setter: setter,
1120+
modify: modify
1121+
)
1122+
}
1123+
1124+
/// A variable declaration.
1125+
///
1126+
/// For example: `let foo = 42`.
1127+
/// - Parameters:
1128+
/// - accessModifier: An access modifier.
1129+
/// - isStatic: A Boolean value that indicates whether the variable
1130+
/// is static.
1131+
/// - kind: The variable binding kind.
1132+
/// - left: The name of the variable.
1133+
/// - type: The type of the variable.
1134+
/// - right: The expression to be assigned to the variable.
1135+
/// - getter: Body code for the getter of the variable.
1136+
/// - getterEffects: Effects of the getter.
1137+
/// - setter: Body code for the setter of the variable.
1138+
/// - modify: Body code for the `_modify` accessor.
1139+
/// - Returns: Variable declaration.
1140+
static func variable(
1141+
accessModifier: AccessModifier? = nil,
1142+
isStatic: Bool = false,
1143+
kind: BindingKind,
1144+
left: Expression,
1145+
type: ExistingTypeDescription? = nil,
1146+
right: Expression? = nil,
1147+
getter: [CodeBlock]? = nil,
1148+
getterEffects: [FunctionKeyword] = [],
1149+
setter: [CodeBlock]? = nil,
1150+
modify: [CodeBlock]? = nil
1151+
11091152
) -> Self {
11101153
.variable(
11111154
.init(
@@ -1521,14 +1564,6 @@ extension MemberAccessDescription {
15211564
static func dot(_ member: String) -> Self { .init(right: member) }
15221565
}
15231566

1524-
extension Expression: ExpressibleByStringLiteral, ExpressibleByNilLiteral, ExpressibleByArrayLiteral {
1525-
init(arrayLiteral elements: Expression...) { self = .literal(.array(elements)) }
1526-
1527-
init(stringLiteral value: String) { self = .literal(.string(value)) }
1528-
1529-
init(nilLiteral: ()) { self = .literal(.nil) }
1530-
}
1531-
15321567
extension LiteralDescription: ExpressibleByStringLiteral, ExpressibleByNilLiteral, ExpressibleByArrayLiteral {
15331568
init(arrayLiteral elements: Expression...) { self = .array(elements) }
15341569

@@ -1544,14 +1579,14 @@ extension VariableDescription {
15441579
/// For example `var foo = 42`.
15451580
/// - Parameter name: The name of the variable.
15461581
/// - Returns: A new mutable variable declaration.
1547-
static func `var`(_ name: String) -> Self { Self.init(kind: .var, left: name) }
1582+
static func `var`(_ name: String) -> Self { Self.init(kind: .var, left: .identifierPattern(name)) }
15481583

15491584
/// Returns a new immutable variable declaration.
15501585
///
15511586
/// For example `let foo = 42`.
15521587
/// - Parameter name: The name of the variable.
15531588
/// - Returns: A new immutable variable declaration.
1554-
static func `let`(_ name: String) -> Self { Self.init(kind: .let, left: name) }
1589+
static func `let`(_ name: String) -> Self { Self.init(kind: .let, left: .identifierPattern(name)) }
15551590
}
15561591

15571592
extension Expression {
@@ -1563,10 +1598,6 @@ extension Expression {
15631598
func equals(_ rhs: Expression) -> AssignmentDescription { .init(left: self, right: rhs) }
15641599
}
15651600

1566-
extension FunctionArgumentDescription: ExpressibleByStringLiteral {
1567-
init(stringLiteral value: String) { self = .init(expression: .literal(.string(value))) }
1568-
}
1569-
15701601
extension FunctionSignatureDescription {
15711602
/// Returns a new function signature description that has the access
15721603
/// modifier updated to the specified one.

Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -543,18 +543,21 @@ struct TextBasedRenderer: RendererProtocol {
543543
/// Renders the specified variable declaration.
544544
func renderVariable(_ variable: VariableDescription) {
545545
do {
546-
var words: [String] = []
547-
if let accessModifier = variable.accessModifier { words.append(renderedAccessModifier(accessModifier)) }
548-
if variable.isStatic { words.append("static") }
549-
words.append(renderedBindingKind(variable.kind))
550-
let labelWithOptionalType: String
546+
if let accessModifier = variable.accessModifier {
547+
writer.writeLine(renderedAccessModifier(accessModifier) + " ")
548+
writer.nextLineAppendsToLastLine()
549+
}
550+
if variable.isStatic {
551+
writer.writeLine("static ")
552+
writer.nextLineAppendsToLastLine()
553+
}
554+
writer.writeLine(renderedBindingKind(variable.kind) + " ")
555+
writer.nextLineAppendsToLastLine()
556+
renderExpression(variable.left)
551557
if let type = variable.type {
552-
labelWithOptionalType = "\(variable.left): \(renderedExistingTypeDescription(type))"
553-
} else {
554-
labelWithOptionalType = variable.left
558+
writer.nextLineAppendsToLastLine()
559+
writer.writeLine(": \(renderedExistingTypeDescription(type))")
555560
}
556-
words.append(labelWithOptionalType)
557-
writer.writeLine(words.joinedWords())
558561
}
559562

560563
if let right = variable.right {
@@ -883,3 +886,15 @@ fileprivate extension String {
883886
/// - Returns: A new string where each line has been transformed using the given closure.
884887
func transformingLines(_ work: (String) -> String) -> [String] { asLines().map(work) }
885888
}
889+
890+
extension TextBasedRenderer {
891+
892+
/// Returns the provided expression rendered as a string.
893+
/// - Parameter expression: The expression.
894+
/// - Returns: The string representation of the expression.
895+
static func renderedExpressionAsString(_ expression: Expression) -> String {
896+
let renderer = TextBasedRenderer.default
897+
renderer.renderExpression(expression)
898+
return renderer.renderedContents()
899+
}
900+
}

Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/translateClientMethod.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ extension ClientFileTranslator {
8181
)
8282
requestBlocks.append(.expression(requestBodyExpr))
8383
} else {
84-
requestBodyReturnExpr = nil
84+
requestBodyReturnExpr = .literal(nil)
8585
}
8686

8787
let returnRequestExpr: Expression = .return(.tuple([.identifierPattern("request"), requestBodyReturnExpr]))

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ enum AllOrAnyOf {
2323
case anyOf
2424
}
2525

26-
extension FileTranslator {
26+
extension TypesFileTranslator {
2727

2828
/// Returns a declaration for an allOf or anyOf schema.
2929
///

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//===----------------------------------------------------------------------===//
1414
import OpenAPIKit
1515

16-
extension FileTranslator {
16+
extension TypesFileTranslator {
1717

1818
/// Returns a list of declarations for an array schema.
1919
///

Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ extension FileTranslator {
5252
trailingCodeBlocks: [
5353
.expression(
5454
.assignment(
55-
left: .identifierPattern("additionalProperties"),
55+
left: .identifierPattern(Constants.AdditionalProperties.variableName),
5656
right: .try(
5757
.identifierPattern("decoder").dot("decodeAdditionalProperties")
5858
.call([knownKeysFunctionArg])
@@ -86,7 +86,12 @@ extension FileTranslator {
8686
.expression(
8787
.try(
8888
.identifierPattern("encoder").dot("encodeAdditionalProperties")
89-
.call([.init(label: nil, expression: .identifierPattern("additionalProperties"))])
89+
.call([
90+
.init(
91+
label: nil,
92+
expression: .identifierPattern(Constants.AdditionalProperties.variableName)
93+
)
94+
])
9095
)
9196
)
9297
]

0 commit comments

Comments
 (0)