Skip to content

Commit d50b489

Browse files
authored
[Multipart] Validation sequence (#76)
[Multipart] Validation sequence ### Motivation The OpenAPI document provides information about which parts are required, optional, arrays, and single values, so we need to enforce those semantics for the adopter, just like we enforce (using JSONDecoder) that a received JSON payload follows the documented structure. ### Modifications Since the mutlipart body is not a struct, but an async sequence of parts, it's a little more complicated. We introduce a `MultipartValidationSequence` with a state machine that keeps track of the requirements and which of them have already been fulfilled over time. And it throws an error if any of the requirements are violated. For missing required parts, the error is thrown when `nil` is received from the upstream sequence, indicating that there will be no more parts coming. To implement this, an internal type `ContentDisposition` was also introduced for working with that header's values, and helper accessors on `MultipartRawPart` as well. ### Result Adopters don't have to validate these semantics manually, if they successfully iterate over the parts without an error being thrown, they can be confident that the received (or sent) parts match the requirements from the OpenAPI document. ### Test Plan Unit tests for the sequence, the validator, and the state machine were added. Also added unit tests for the `ContentDisposition` type. Reviewed by: simonjbeaumont 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 (api breakage) - 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. #76
1 parent 304808a commit d50b489

File tree

5 files changed

+856
-0
lines changed

5 files changed

+856
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 parsed representation of the `content-disposition` header described by RFC 6266 containing only
16+
/// the features relevant to OpenAPI multipart bodies.
17+
struct ContentDisposition: Hashable {
18+
19+
/// A `disposition-type` parameter value.
20+
enum DispositionType: Hashable {
21+
22+
/// A form data value.
23+
case formData
24+
25+
/// Any other value.
26+
case other(String)
27+
28+
/// Creates a new disposition type value.
29+
/// - Parameter rawValue: A string representation of the value.
30+
init(rawValue: String) {
31+
switch rawValue.lowercased() {
32+
case "form-data": self = .formData
33+
default: self = .other(rawValue)
34+
}
35+
}
36+
37+
/// A string representation of the value.
38+
var rawValue: String {
39+
switch self {
40+
case .formData: return "form-data"
41+
case .other(let string): return string
42+
}
43+
}
44+
}
45+
46+
/// The disposition type value.
47+
var dispositionType: DispositionType
48+
49+
/// A content disposition parameter name.
50+
enum ParameterName: Hashable {
51+
52+
/// The name parameter.
53+
case name
54+
55+
/// The filename parameter.
56+
case filename
57+
58+
/// Any other parameter.
59+
case other(String)
60+
61+
/// Creates a new parameter name.
62+
/// - Parameter rawValue: A string representation of the name.
63+
init(rawValue: String) {
64+
switch rawValue.lowercased() {
65+
case "name": self = .name
66+
case "filename": self = .filename
67+
default: self = .other(rawValue)
68+
}
69+
}
70+
71+
/// A string representation of the name.
72+
var rawValue: String {
73+
switch self {
74+
case .name: return "name"
75+
case .filename: return "filename"
76+
case .other(let string): return string
77+
}
78+
}
79+
}
80+
81+
/// The parameters of the content disposition value.
82+
var parameters: [ParameterName: String] = [:]
83+
84+
/// The name parameter value.
85+
var name: String? {
86+
get { parameters[.name] }
87+
set { parameters[.name] = newValue }
88+
}
89+
90+
/// The filename parameter value.
91+
var filename: String? {
92+
get { parameters[.filename] }
93+
set { parameters[.filename] = newValue }
94+
}
95+
}
96+
97+
extension ContentDisposition: RawRepresentable {
98+
99+
/// Creates a new instance with the specified raw value.
100+
///
101+
/// https://datatracker.ietf.org/doc/html/rfc6266#section-4.1
102+
/// - Parameter rawValue: The raw value to use for the new instance.
103+
init?(rawValue: String) {
104+
var components = rawValue.split(separator: ";").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
105+
guard !components.isEmpty else { return nil }
106+
self.dispositionType = DispositionType(rawValue: components.removeFirst())
107+
let parameterTuples: [(ParameterName, String)] = components.compactMap { component in
108+
let parameterComponents = component.split(separator: "=", maxSplits: 1)
109+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
110+
guard parameterComponents.count == 2 else { return nil }
111+
let valueWithoutQuotes = parameterComponents[1].trimmingCharacters(in: ["\""])
112+
return (.init(rawValue: parameterComponents[0]), valueWithoutQuotes)
113+
}
114+
self.parameters = Dictionary(parameterTuples, uniquingKeysWith: { a, b in a })
115+
}
116+
117+
/// The corresponding value of the raw type.
118+
var rawValue: String {
119+
var string = ""
120+
string.append(dispositionType.rawValue)
121+
if !parameters.isEmpty {
122+
for (key, value) in parameters.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
123+
string.append("; \(key.rawValue)=\"\(value)\"")
124+
}
125+
}
126+
return string
127+
}
128+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
import Foundation
16+
import HTTPTypes
17+
18+
// MARK: - Extensions
19+
20+
extension MultipartRawPart {
21+
22+
/// Creates a new raw part by injecting the provided name and filename into
23+
/// the `content-disposition` header field.
24+
/// - Parameters:
25+
/// - name: The name of the part.
26+
/// - filename: The file name of the part.
27+
/// - headerFields: The header fields of the part.
28+
/// - body: The body stream of the part.
29+
public init(name: String?, filename: String? = nil, headerFields: HTTPFields, body: HTTPBody) {
30+
var parameters: [ContentDisposition.ParameterName: String] = [:]
31+
if let name { parameters[.name] = name }
32+
if let filename { parameters[.filename] = filename }
33+
let contentDisposition = ContentDisposition(dispositionType: .formData, parameters: parameters)
34+
var headerFields = headerFields
35+
headerFields[.contentDisposition] = contentDisposition.rawValue
36+
self.init(headerFields: headerFields, body: body)
37+
}
38+
39+
/// Returns the parameter value for the provided name.
40+
/// - Parameter name: The parameter name.
41+
/// - Returns: The parameter value. Nil if not found in the content disposition header field.
42+
private func getParameter(_ name: ContentDisposition.ParameterName) -> String? {
43+
guard let contentDispositionString = headerFields[.contentDisposition],
44+
let contentDisposition = ContentDisposition(rawValue: contentDispositionString)
45+
else { return nil }
46+
return contentDisposition.parameters[name]
47+
}
48+
49+
/// Sets the parameter name to the provided value.
50+
/// - Parameters:
51+
/// - name: The parameter name.
52+
/// - value: The value of the parameter.
53+
private mutating func setParameter(_ name: ContentDisposition.ParameterName, _ value: String?) {
54+
guard let contentDispositionString = headerFields[.contentDisposition],
55+
var contentDisposition = ContentDisposition(rawValue: contentDispositionString)
56+
else {
57+
if let value {
58+
headerFields[.contentDisposition] =
59+
ContentDisposition(dispositionType: .formData, parameters: [name: value]).rawValue
60+
}
61+
return
62+
}
63+
contentDisposition.parameters[name] = value
64+
headerFields[.contentDisposition] = contentDisposition.rawValue
65+
}
66+
67+
/// The name of the part stored in the `content-disposition` header field.
68+
public var name: String? {
69+
get { getParameter(.name) }
70+
set { setParameter(.name, newValue) }
71+
}
72+
73+
/// The file name of the part stored in the `content-disposition` header field.
74+
public var filename: String? {
75+
get { getParameter(.filename) }
76+
set { setParameter(.filename, newValue) }
77+
}
78+
}

0 commit comments

Comments
 (0)