Skip to content

[Multipart] Add converter SPI methods #78

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 24, 2023
98 changes: 96 additions & 2 deletions Sources/OpenAPIRuntime/Conversion/Converter+Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ extension Converter {
/// - Throws: An error if setting the request body as binary fails.
public func setOptionalRequestBodyAsBinary(_ value: HTTPBody?, headerFields: inout HTTPFields, contentType: String)
throws -> HTTPBody?
{ try setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
{ setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }

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

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

/// Sets a required request body as multipart and returns the streaming body.
///
/// - Parameters:
/// - value: The multipart body to be set as the request body.
/// - headerFields: The header fields in which to set the content type.
/// - contentType: The content type to be set in the header fields.
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
/// should be pass through. If `false`, encountering an unknown part throws an error
/// whent the returned body sequence iterates it.
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
/// - atMostOncePartNames: The list of part names that can appear at most once.
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
/// - encode: A closure that transforms the type-safe part into a raw part.
/// - Returns: A streaming body representing the multipart-encoded request body.
/// - Throws: Currently never, but might in the future.
public func setRequiredRequestBodyAsMultipart<Part: Sendable>(
_ value: MultipartBody<Part>,
headerFields: inout HTTPFields,
contentType: String,
allowsUnknownParts: Bool,
requiredExactlyOncePartNames: Set<String>,
requiredAtLeastOncePartNames: Set<String>,
atMostOncePartNames: Set<String>,
zeroOrMoreTimesPartNames: Set<String>,
encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart
) throws -> HTTPBody {
let boundary = configuration.multipartBoundaryGenerator.makeBoundary()
let contentTypeWithBoundary = contentType + "; boundary=\(boundary)"
return setRequiredRequestBody(
value,
headerFields: &headerFields,
contentType: contentTypeWithBoundary,
convert: { value in
convertMultipartToBytes(
value,
requirements: .init(
allowsUnknownParts: allowsUnknownParts,
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
atMostOncePartNames: atMostOncePartNames,
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
),
boundary: boundary,
encode: encode
)
}
)
}

/// Retrieves the response body as JSON and transforms it into a specified type.
///
/// - Parameters:
Expand Down Expand Up @@ -244,4 +294,48 @@ extension Converter {
guard let data else { throw RuntimeError.missingRequiredResponseBody }
return try getResponseBody(type, from: data, transforming: transform, convert: { $0 })
}
/// Returns an async sequence of multipart parts parsed from the provided body stream.
///
/// - Parameters:
/// - type: The type representing the type-safe multipart body.
/// - data: The HTTP body data to transform.
/// - transform: A closure that transforms the multipart body into the output type.
/// - boundary: The multipart boundary string.
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
/// should be pass through. If `false`, encountering an unknown part throws an error
/// whent the returned body sequence iterates it.
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
/// - atMostOncePartNames: The list of part names that can appear at most once.
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
/// - decoder: A closure that parses a raw part into a type-safe part.
/// - Returns: A value of the output type.
/// - Throws: If the transform closure throws.
public func getResponseBodyAsMultipart<C, Part: Sendable>(
_ type: MultipartBody<Part>.Type,
from data: HTTPBody?,
transforming transform: @escaping @Sendable (MultipartBody<Part>) throws -> C,
boundary: String,
allowsUnknownParts: Bool,
requiredExactlyOncePartNames: Set<String>,
requiredAtLeastOncePartNames: Set<String>,
atMostOncePartNames: Set<String>,
zeroOrMoreTimesPartNames: Set<String>,
decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part
) throws -> C {
guard let data else { throw RuntimeError.missingRequiredResponseBody }
let multipart = convertBytesToMultipart(
data,
boundary: boundary,
requirements: .init(
allowsUnknownParts: allowsUnknownParts,
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
atMostOncePartNames: atMostOncePartNames,
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
),
transform: decoder
)
return try transform(multipart)
}
}
23 changes: 23 additions & 0 deletions Sources/OpenAPIRuntime/Conversion/Converter+Common.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ extension Converter {
return bestContentType
}

/// Verifies the MIME type from the content-type header, if present.
/// - Parameters:
/// - headerFields: The header fields to inspect for the content type header.
/// - match: The content type to verify.
/// - Throws: If the content type is incompatible or malformed.
public func verifyContentTypeIfPresent(in headerFields: HTTPFields, matches match: String) throws {
guard let rawValue = headerFields[.contentType] else { return }
_ = try bestContentType(received: .init(rawValue), options: [match])
}

/// Returns the name and file name parameter values from the `content-disposition` header field, if found.
/// - Parameter headerFields: The header fields to inspect for a `content-disposition` header field.
/// - Returns: A tuple of the name and file name string values.
/// - Throws: Currently doesn't, but might in the future.
public func extractContentDispositionNameAndFilename(in headerFields: HTTPFields) throws -> (
name: String?, filename: String?
) {
guard let rawValue = headerFields[.contentDisposition],
let contentDisposition = ContentDisposition(rawValue: rawValue)
else { return (nil, nil) }
return (contentDisposition.name, contentDisposition.filename)
}

// MARK: - Converter helper methods

/// Sets a header field with an optional value, encoding it as a URI component if not nil.
Expand Down
97 changes: 96 additions & 1 deletion Sources/OpenAPIRuntime/Conversion/Converter+Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,51 @@ extension Converter {
)
}

/// Returns an async sequence of multipart parts parsed from the provided body stream.
///
/// - Parameters:
/// - type: The type representing the type-safe multipart body.
/// - data: The HTTP body data to transform.
/// - transform: A closure that transforms the multipart body into the output type.
/// - boundary: The multipart boundary string.
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
/// should be pass through. If `false`, encountering an unknown part throws an error
/// whent the returned body sequence iterates it.
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
/// - atMostOncePartNames: The list of part names that can appear at most once.
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
/// - decoder: A closure that parses a raw part into a type-safe part.
/// - Returns: A value of the output type.
/// - Throws: If the transform closure throws.
public func getRequiredRequestBodyAsMultipart<C, Part: Sendable>(
_ type: MultipartBody<Part>.Type,
from data: HTTPBody?,
transforming transform: @escaping @Sendable (MultipartBody<Part>) throws -> C,
boundary: String,
allowsUnknownParts: Bool,
requiredExactlyOncePartNames: Set<String>,
requiredAtLeastOncePartNames: Set<String>,
atMostOncePartNames: Set<String>,
zeroOrMoreTimesPartNames: Set<String>,
decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part
) throws -> C {
guard let data else { throw RuntimeError.missingRequiredRequestBody }
let multipart = convertBytesToMultipart(
data,
boundary: boundary,
requirements: .init(
allowsUnknownParts: allowsUnknownParts,
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
atMostOncePartNames: atMostOncePartNames,
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
),
transform: decoder
)
return try transform(multipart)
}

/// Sets the response body as JSON data, serializing the provided value.
///
/// - Parameters:
Expand Down Expand Up @@ -313,5 +358,55 @@ extension Converter {
/// - Throws: An error if there are issues setting the response body or updating the header fields.
public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws
-> HTTPBody
{ try setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }
{ setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) }

/// Sets a response body as multipart and returns the streaming body.
///
/// - Parameters:
/// - value: The multipart body to be set as the response body.
/// - headerFields: The header fields in which to set the content type.
/// - contentType: The content type to be set in the header fields.
/// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names
/// should be pass through. If `false`, encountering an unknown part throws an error
/// whent the returned body sequence iterates it.
/// - requiredExactlyOncePartNames: The list of part names that are required exactly once.
/// - requiredAtLeastOncePartNames: The list of part names that are required at least once.
/// - atMostOncePartNames: The list of part names that can appear at most once.
/// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times.
/// - encode: A closure that transforms the type-safe part into a raw part.
/// - Returns: A streaming body representing the multipart-encoded response body.
/// - Throws: Currently never, but might in the future.
public func setResponseBodyAsMultipart<Part: Sendable>(
_ value: MultipartBody<Part>,
headerFields: inout HTTPFields,
contentType: String,
allowsUnknownParts: Bool,
requiredExactlyOncePartNames: Set<String>,
requiredAtLeastOncePartNames: Set<String>,
atMostOncePartNames: Set<String>,
zeroOrMoreTimesPartNames: Set<String>,
encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart
) throws -> HTTPBody {
let boundary = configuration.multipartBoundaryGenerator.makeBoundary()
let contentTypeWithBoundary = contentType + "; boundary=\(boundary)"
return setResponseBody(
value,
headerFields: &headerFields,
contentType: contentTypeWithBoundary,
convert: { value in
convertMultipartToBytes(
value,
requirements: .init(
allowsUnknownParts: allowsUnknownParts,
requiredExactlyOncePartNames: requiredExactlyOncePartNames,
requiredAtLeastOncePartNames: requiredAtLeastOncePartNames,
atMostOncePartNames: atMostOncePartNames,
zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames
),
boundary: boundary,
encode: encode
)
}
)
}
}
55 changes: 49 additions & 6 deletions Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,52 @@ extension Converter {
return HTTPBody(encodedString)
}

/// Returns a serialized multipart body stream.
/// - Parameters:
/// - multipart: The multipart body.
/// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence.
/// - boundary: The multipart boundary string.
/// - encode: A closure that converts a typed part into a raw part.
/// - Returns: The serialized body stream.
func convertMultipartToBytes<Part: Sendable>(
_ multipart: MultipartBody<Part>,
requirements: MultipartBodyRequirements,
boundary: String,
encode: @escaping @Sendable (Part) throws -> MultipartRawPart
) -> HTTPBody {
let untyped = multipart.map { part in
var untypedPart = try encode(part)
if case .known(let byteCount) = untypedPart.body.length {
untypedPart.headerFields[.contentLength] = String(byteCount)
}
return untypedPart
}
let validated = MultipartValidationSequence(upstream: untyped, requirements: requirements)
let frames = MultipartRawPartsToFramesSequence(upstream: validated)
let bytes = MultipartFramesToBytesSequence(upstream: frames, boundary: boundary)
return HTTPBody(bytes, length: .unknown, iterationBehavior: multipart.iterationBehavior)
}

/// Returns a parsed multipart body.
/// - Parameters:
/// - bytes: The multipart body byte stream.
/// - boundary: The multipart boundary string.
/// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence.
/// - transform: A closure that converts a raw part into a typed part.
/// - Returns: The typed multipart body stream.
func convertBytesToMultipart<Part: Sendable>(
_ bytes: HTTPBody,
boundary: String,
requirements: MultipartBodyRequirements,
transform: @escaping @Sendable (MultipartRawPart) async throws -> Part
) -> MultipartBody<Part> {
let frames = MultipartBytesToFramesSequence(upstream: bytes, boundary: boundary)
let raw = MultipartFramesToRawPartsSequence(upstream: frames)
let validated = MultipartValidationSequence(upstream: raw, requirements: requirements)
let typed = validated.map(transform)
return .init(typed, iterationBehavior: bytes.iterationBehavior)
}

/// Returns a JSON string for the provided encodable value.
/// - Parameter value: The value to encode.
/// - Returns: A JSON string.
Expand Down Expand Up @@ -383,13 +429,12 @@ extension Converter {
/// - contentType: The content type value.
/// - convert: The closure that encodes the value into a raw body.
/// - Returns: The body.
/// - Throws: An error if an issue occurs while encoding the request body or setting the content type.
func setRequiredRequestBody<T>(
_ value: T,
headerFields: inout HTTPFields,
contentType: String,
convert: (T) throws -> HTTPBody
) throws -> HTTPBody {
) rethrows -> HTTPBody {
headerFields[.contentType] = contentType
return try convert(value)
}
Expand All @@ -402,13 +447,12 @@ extension Converter {
/// - contentType: The content type value.
/// - convert: The closure that encodes the value into a raw body.
/// - Returns: The body, if value was not nil.
/// - Throws: An error if an issue occurs while encoding the request body or setting the content type.
func setOptionalRequestBody<T>(
_ value: T?,
headerFields: inout HTTPFields,
contentType: String,
convert: (T) throws -> HTTPBody
) throws -> HTTPBody? {
) rethrows -> HTTPBody? {
guard let value else { return nil }
return try setRequiredRequestBody(
value,
Expand Down Expand Up @@ -547,13 +591,12 @@ extension Converter {
/// - contentType: The content type value.
/// - convert: The closure that encodes the value into a raw body.
/// - Returns: The body, if value was not nil.
/// - Throws: An error if an issue occurs while encoding the request body.
func setResponseBody<T>(
_ value: T,
headerFields: inout HTTPFields,
contentType: String,
convert: (T) throws -> HTTPBody
) throws -> HTTPBody {
) rethrows -> HTTPBody {
headerFields[.contentType] = contentType
return try convert(value)
}
Expand Down
Loading