Skip to content

Commit bb2d2b3

Browse files
authored
[Multipart] Add converter SPI methods (#78)
[Multipart] Add converter SPI methods ### Motivation Last planned runtime PR for multipart, this adds the remaining SPI methods that allow the generated code to serialize/deserialize multipart bodies, both for client and server. ### Modifications Added SPI methods on `Converter` for multipart. ### Result Client and server generated code can now serialize/deserialize multipart bodies. ### Test Plan Added unit tests for the SPI methods. 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. #78
1 parent 5060bb9 commit bb2d2b3

10 files changed

+488
-11
lines changed

Sources/OpenAPIRuntime/Conversion/Converter+Client.swift

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ extension Converter {
140140
/// - Throws: An error if setting the request body as binary fails.
141141
public func setOptionalRequestBodyAsBinary(_ value: HTTPBody?, headerFields: inout HTTPFields, contentType: String)
142142
throws -> HTTPBody?
143-
{ try setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
143+
{ setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
144144

145145
/// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`.
146146
///
@@ -154,7 +154,7 @@ extension Converter {
154154
/// - Throws: An error if setting the request body as binary fails.
155155
public func setRequiredRequestBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String)
156156
throws -> HTTPBody
157-
{ try setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
157+
{ setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
158158

159159
/// Sets an optional request body as URL-encoded form data in the specified header fields and returns an `HTTPBody`.
160160
///
@@ -202,6 +202,56 @@ extension Converter {
202202
)
203203
}
204204

205+
/// Sets a required request body as multipart and returns the streaming body.
206+
///
207+
/// - Parameters:
208+
/// - value: The multipart body to be set as the request body.
209+
/// - headerFields: The header fields in which to set the content type.
210+
/// - contentType: The content type to be set in the header fields.
211+
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
212+
/// should be pass through. If `false`, encountering an unknown part throws an error
213+
/// whent the returned body sequence iterates it.
214+
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
215+
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
216+
/// - atMostOncePartNames: The list of part names that can appear at most once.
217+
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
218+
/// - encode: A closure that transforms the type-safe part into a raw part.
219+
/// - Returns: A streaming body representing the multipart-encoded request body.
220+
/// - Throws: Currently never, but might in the future.
221+
public func setRequiredRequestBodyAsMultipart<Part: Sendable>(
222+
_ value: MultipartBody<Part>,
223+
headerFields: inout HTTPFields,
224+
contentType: String,
225+
allowsUnknownParts: Bool,
226+
requiredExactlyOncePartNames: Set<String>,
227+
requiredAtLeastOncePartNames: Set<String>,
228+
atMostOncePartNames: Set<String>,
229+
zeroOrMoreTimesPartNames: Set<String>,
230+
encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart
231+
) throws -> HTTPBody {
232+
let boundary = configuration.multipartBoundaryGenerator.makeBoundary()
233+
let contentTypeWithBoundary = contentType + "; boundary=\(boundary)"
234+
return setRequiredRequestBody(
235+
value,
236+
headerFields: &headerFields,
237+
contentType: contentTypeWithBoundary,
238+
convert: { value in
239+
convertMultipartToBytes(
240+
value,
241+
requirements: .init(
242+
allowsUnknownParts: allowsUnknownParts,
243+
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
244+
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
245+
atMostOncePartNames: atMostOncePartNames,
246+
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
247+
),
248+
boundary: boundary,
249+
encode: encode
250+
)
251+
}
252+
)
253+
}
254+
205255
/// Retrieves the response body as JSON and transforms it into a specified type.
206256
///
207257
/// - Parameters:
@@ -244,4 +294,48 @@ extension Converter {
244294
guard let data else { throw RuntimeError.missingRequiredResponseBody }
245295
return try getResponseBody(type, from: data, transforming: transform, convert: { $0 })
246296
}
297+
/// Returns an async sequence of multipart parts parsed from the provided body stream.
298+
///
299+
/// - Parameters:
300+
/// - type: The type representing the type-safe multipart body.
301+
/// - data: The HTTP body data to transform.
302+
/// - transform: A closure that transforms the multipart body into the output type.
303+
/// - boundary: The multipart boundary string.
304+
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
305+
/// should be pass through. If `false`, encountering an unknown part throws an error
306+
/// whent the returned body sequence iterates it.
307+
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
308+
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
309+
/// - atMostOncePartNames: The list of part names that can appear at most once.
310+
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
311+
/// - decoder: A closure that parses a raw part into a type-safe part.
312+
/// - Returns: A value of the output type.
313+
/// - Throws: If the transform closure throws.
314+
public func getResponseBodyAsMultipart<C, Part: Sendable>(
315+
_ type: MultipartBody<Part>.Type,
316+
from data: HTTPBody?,
317+
transforming transform: @escaping @Sendable (MultipartBody<Part>) throws -> C,
318+
boundary: String,
319+
allowsUnknownParts: Bool,
320+
requiredExactlyOncePartNames: Set<String>,
321+
requiredAtLeastOncePartNames: Set<String>,
322+
atMostOncePartNames: Set<String>,
323+
zeroOrMoreTimesPartNames: Set<String>,
324+
decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part
325+
) throws -> C {
326+
guard let data else { throw RuntimeError.missingRequiredResponseBody }
327+
let multipart = convertBytesToMultipart(
328+
data,
329+
boundary: boundary,
330+
requirements: .init(
331+
allowsUnknownParts: allowsUnknownParts,
332+
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
333+
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
334+
atMostOncePartNames: atMostOncePartNames,
335+
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
336+
),
337+
transform: decoder
338+
)
339+
return try transform(multipart)
340+
}
247341
}

Sources/OpenAPIRuntime/Conversion/Converter+Common.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ extension Converter {
6565
return bestContentType
6666
}
6767

68+
/// Verifies the MIME type from the content-type header, if present.
69+
/// - Parameters:
70+
/// - headerFields: The header fields to inspect for the content type header.
71+
/// - match: The content type to verify.
72+
/// - Throws: If the content type is incompatible or malformed.
73+
public func verifyContentTypeIfPresent(in headerFields: HTTPFields, matches match: String) throws {
74+
guard let rawValue = headerFields[.contentType] else { return }
75+
_ = try bestContentType(received: .init(rawValue), options: [match])
76+
}
77+
78+
/// Returns the name and file name parameter values from the `content-disposition` header field, if found.
79+
/// - Parameter headerFields: The header fields to inspect for a `content-disposition` header field.
80+
/// - Returns: A tuple of the name and file name string values.
81+
/// - Throws: Currently doesn't, but might in the future.
82+
public func extractContentDispositionNameAndFilename(in headerFields: HTTPFields) throws -> (
83+
name: String?, filename: String?
84+
) {
85+
guard let rawValue = headerFields[.contentDisposition],
86+
let contentDisposition = ContentDisposition(rawValue: rawValue)
87+
else { return (nil, nil) }
88+
return (contentDisposition.name, contentDisposition.filename)
89+
}
90+
6891
// MARK: - Converter helper methods
6992

7093
/// Sets a header field with an optional value, encoding it as a URI component if not nil.

Sources/OpenAPIRuntime/Conversion/Converter+Server.swift

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,51 @@ extension Converter {
284284
)
285285
}
286286

287+
/// Returns an async sequence of multipart parts parsed from the provided body stream.
288+
///
289+
/// - Parameters:
290+
/// - type: The type representing the type-safe multipart body.
291+
/// - data: The HTTP body data to transform.
292+
/// - transform: A closure that transforms the multipart body into the output type.
293+
/// - boundary: The multipart boundary string.
294+
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
295+
/// should be pass through. If `false`, encountering an unknown part throws an error
296+
/// whent the returned body sequence iterates it.
297+
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
298+
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
299+
/// - atMostOncePartNames: The list of part names that can appear at most once.
300+
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
301+
/// - decoder: A closure that parses a raw part into a type-safe part.
302+
/// - Returns: A value of the output type.
303+
/// - Throws: If the transform closure throws.
304+
public func getRequiredRequestBodyAsMultipart<C, Part: Sendable>(
305+
_ type: MultipartBody<Part>.Type,
306+
from data: HTTPBody?,
307+
transforming transform: @escaping @Sendable (MultipartBody<Part>) throws -> C,
308+
boundary: String,
309+
allowsUnknownParts: Bool,
310+
requiredExactlyOncePartNames: Set<String>,
311+
requiredAtLeastOncePartNames: Set<String>,
312+
atMostOncePartNames: Set<String>,
313+
zeroOrMoreTimesPartNames: Set<String>,
314+
decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part
315+
) throws -> C {
316+
guard let data else { throw RuntimeError.missingRequiredRequestBody }
317+
let multipart = convertBytesToMultipart(
318+
data,
319+
boundary: boundary,
320+
requirements: .init(
321+
allowsUnknownParts: allowsUnknownParts,
322+
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
323+
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
324+
atMostOncePartNames: atMostOncePartNames,
325+
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
326+
),
327+
transform: decoder
328+
)
329+
return try transform(multipart)
330+
}
331+
287332
/// Sets the response body as JSON data, serializing the provided value.
288333
///
289334
/// - Parameters:
@@ -313,5 +358,55 @@ extension Converter {
313358
/// - Throws: An error if there are issues setting the response body or updating the header fields.
314359
public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws
315360
-> HTTPBody
316-
{ try setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
361+
{ setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
362+
363+
/// Sets a response body as multipart and returns the streaming body.
364+
///
365+
/// - Parameters:
366+
/// - value: The multipart body to be set as the response body.
367+
/// - headerFields: The header fields in which to set the content type.
368+
/// - contentType: The content type to be set in the header fields.
369+
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
370+
/// should be pass through. If `false`, encountering an unknown part throws an error
371+
/// whent the returned body sequence iterates it.
372+
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
373+
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
374+
/// - atMostOncePartNames: The list of part names that can appear at most once.
375+
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
376+
/// - encode: A closure that transforms the type-safe part into a raw part.
377+
/// - Returns: A streaming body representing the multipart-encoded response body.
378+
/// - Throws: Currently never, but might in the future.
379+
public func setResponseBodyAsMultipart<Part: Sendable>(
380+
_ value: MultipartBody<Part>,
381+
headerFields: inout HTTPFields,
382+
contentType: String,
383+
allowsUnknownParts: Bool,
384+
requiredExactlyOncePartNames: Set<String>,
385+
requiredAtLeastOncePartNames: Set<String>,
386+
atMostOncePartNames: Set<String>,
387+
zeroOrMoreTimesPartNames: Set<String>,
388+
encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart
389+
) throws -> HTTPBody {
390+
let boundary = configuration.multipartBoundaryGenerator.makeBoundary()
391+
let contentTypeWithBoundary = contentType + "; boundary=\(boundary)"
392+
return setResponseBody(
393+
value,
394+
headerFields: &headerFields,
395+
contentType: contentTypeWithBoundary,
396+
convert: { value in
397+
convertMultipartToBytes(
398+
value,
399+
requirements: .init(
400+
allowsUnknownParts: allowsUnknownParts,
401+
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
402+
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
403+
atMostOncePartNames: atMostOncePartNames,
404+
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
405+
),
406+
boundary: boundary,
407+
encode: encode
408+
)
409+
}
410+
)
411+
}
317412
}

Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,52 @@ extension Converter {
179179
return HTTPBody(encodedString)
180180
}
181181

182+
/// Returns a serialized multipart body stream.
183+
/// - Parameters:
184+
/// - multipart: The multipart body.
185+
/// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence.
186+
/// - boundary: The multipart boundary string.
187+
/// - encode: A closure that converts a typed part into a raw part.
188+
/// - Returns: The serialized body stream.
189+
func convertMultipartToBytes<Part: Sendable>(
190+
_ multipart: MultipartBody<Part>,
191+
requirements: MultipartBodyRequirements,
192+
boundary: String,
193+
encode: @escaping @Sendable (Part) throws -> MultipartRawPart
194+
) -> HTTPBody {
195+
let untyped = multipart.map { part in
196+
var untypedPart = try encode(part)
197+
if case .known(let byteCount) = untypedPart.body.length {
198+
untypedPart.headerFields[.contentLength] = String(byteCount)
199+
}
200+
return untypedPart
201+
}
202+
let validated = MultipartValidationSequence(upstream: untyped, requirements: requirements)
203+
let frames = MultipartRawPartsToFramesSequence(upstream: validated)
204+
let bytes = MultipartFramesToBytesSequence(upstream: frames, boundary: boundary)
205+
return HTTPBody(bytes, length: .unknown, iterationBehavior: multipart.iterationBehavior)
206+
}
207+
208+
/// Returns a parsed multipart body.
209+
/// - Parameters:
210+
/// - bytes: The multipart body byte stream.
211+
/// - boundary: The multipart boundary string.
212+
/// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence.
213+
/// - transform: A closure that converts a raw part into a typed part.
214+
/// - Returns: The typed multipart body stream.
215+
func convertBytesToMultipart<Part: Sendable>(
216+
_ bytes: HTTPBody,
217+
boundary: String,
218+
requirements: MultipartBodyRequirements,
219+
transform: @escaping @Sendable (MultipartRawPart) async throws -> Part
220+
) -> MultipartBody<Part> {
221+
let frames = MultipartBytesToFramesSequence(upstream: bytes, boundary: boundary)
222+
let raw = MultipartFramesToRawPartsSequence(upstream: frames)
223+
let validated = MultipartValidationSequence(upstream: raw, requirements: requirements)
224+
let typed = validated.map(transform)
225+
return .init(typed, iterationBehavior: bytes.iterationBehavior)
226+
}
227+
182228
/// Returns a JSON string for the provided encodable value.
183229
/// - Parameter value: The value to encode.
184230
/// - Returns: A JSON string.
@@ -383,13 +429,12 @@ extension Converter {
383429
/// - contentType: The content type value.
384430
/// - convert: The closure that encodes the value into a raw body.
385431
/// - Returns: The body.
386-
/// - Throws: An error if an issue occurs while encoding the request body or setting the content type.
387432
func setRequiredRequestBody<T>(
388433
_ value: T,
389434
headerFields: inout HTTPFields,
390435
contentType: String,
391436
convert: (T) throws -> HTTPBody
392-
) throws -> HTTPBody {
437+
) rethrows -> HTTPBody {
393438
headerFields[.contentType] = contentType
394439
return try convert(value)
395440
}
@@ -402,13 +447,12 @@ extension Converter {
402447
/// - contentType: The content type value.
403448
/// - convert: The closure that encodes the value into a raw body.
404449
/// - Returns: The body, if value was not nil.
405-
/// - Throws: An error if an issue occurs while encoding the request body or setting the content type.
406450
func setOptionalRequestBody<T>(
407451
_ value: T?,
408452
headerFields: inout HTTPFields,
409453
contentType: String,
410454
convert: (T) throws -> HTTPBody
411-
) throws -> HTTPBody? {
455+
) rethrows -> HTTPBody? {
412456
guard let value else { return nil }
413457
return try setRequiredRequestBody(
414458
value,
@@ -547,13 +591,12 @@ extension Converter {
547591
/// - contentType: The content type value.
548592
/// - convert: The closure that encodes the value into a raw body.
549593
/// - Returns: The body, if value was not nil.
550-
/// - Throws: An error if an issue occurs while encoding the request body.
551594
func setResponseBody<T>(
552595
_ value: T,
553596
headerFields: inout HTTPFields,
554597
contentType: String,
555598
convert: (T) throws -> HTTPBody
556-
) throws -> HTTPBody {
599+
) rethrows -> HTTPBody {
557600
headerFields[.contentType] = contentType
558601
return try convert(value)
559602
}

0 commit comments

Comments
 (0)