diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 4b723cac..ea575002 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -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`. /// @@ -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`. /// @@ -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( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + 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: @@ -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( + _ type: MultipartBody.Type, + from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + 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) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index a7f8e979..dc908e75 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -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. diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index c354d4aa..e8f36306 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -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( + _ type: MultipartBody.Type, + from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + 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: @@ -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( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + 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 + ) + } + ) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 55765921..df6caf04 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -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( + _ multipart: MultipartBody, + 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( + _ bytes: HTTPBody, + boundary: String, + requirements: MultipartBodyRequirements, + transform: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) -> MultipartBody { + 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. @@ -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( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } @@ -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( _ 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, @@ -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( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index ffb39ab7..150b804c 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -39,6 +39,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case unexpectedContentTypeHeader(String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) + case missingOrMalformedContentDispositionName // Path case missingRequiredPathParameter(String) @@ -51,6 +52,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case missingRequiredRequestBody case missingRequiredResponseBody + // Multipart + case missingRequiredMultipartFormDataContentType + case missingMultipartBoundaryContentTypeParameter + // Transport/Handler case transportFailed(any Error) case middlewareFailed(middlewareType: Any.Type, any Error) @@ -90,11 +95,16 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" + case .missingOrMalformedContentDispositionName: + return "Missing or malformed Content-Disposition header or it's missing a name." case .missingRequiredPathParameter(let name): return "Missing required path parameter named: \(name)" case .pathUnset: return "Path was not set on the request." case .missingRequiredQueryParameter(let name): return "Missing required query parameter named: \(name)" case .missingRequiredRequestBody: return "Missing required request body" case .missingRequiredResponseBody: return "Missing required response body" + case .missingRequiredMultipartFormDataContentType: return "Expected a 'multipart/form-data' content type." + case .missingMultipartBoundaryContentTypeParameter: + return "Missing 'boundary' parameter in the 'multipart/form-data' content type." case .transportFailed: return "Transport threw an error." case .middlewareFailed(middlewareType: let type, _): return "Middleware of type '\(type)' threw an error." case .handlerFailed: return "User handler threw an error." diff --git a/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift new file mode 100644 index 00000000..4d8b2f25 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +@_spi(Generated) extension Optional where Wrapped == OpenAPIMIMEType { + + /// Unwraps the boundary parameter from the parsed MIME type. + /// - Returns: The boundary value. + /// - Throws: If self is nil, or if the MIME type isn't a `multipart/form-data` + /// with a boundary parameter. + public func requiredBoundary() throws -> String { + guard let self else { throw RuntimeError.missingRequiredMultipartFormDataContentType } + guard case .concrete(type: "multipart", subtype: "form-data") = self.kind else { + throw RuntimeError.missingRequiredMultipartFormDataContentType + } + guard let boundary = self.parameters["boundary"] else { + throw RuntimeError.missingMultipartBoundaryContentTypeParameter + } + return boundary + } +} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 135bdf46..57c11580 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -175,6 +175,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) } + // | client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | + func test_setRequiredRequestBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setRequiredRequestBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } + // | client | get | response body | JSON | required | getResponseBodyAsJSON | func test_getResponseBodyAsJSON_codable() async throws { let value: TestPet = try await converter.getResponseBodyAsJSON( @@ -194,6 +216,24 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) try await XCTAssertEqualStringifiedData(value, testString) } + // | client | get | response body | multipart | required | getResponseBodyAsMultipart | + func test_getResponseBodyAsMultipart() async throws { + let value = try converter.getResponseBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + transforming: { $0 }, + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } } /// Asserts that the string representation of binary data is equal to an expected string. diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index da68208f..bca29837 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -107,6 +107,37 @@ final class Test_CommonConverterExtensions: Test_Runtime { ) } + func testVerifyContentTypeIfPresent() throws { + func testCase(received: String?, match: String, file: StaticString = #file, line: UInt = #line) throws { + let headerFields: HTTPFields + if let received { headerFields = [.contentType: received] } else { headerFields = [:] } + try converter.verifyContentTypeIfPresent(in: headerFields, matches: match) + } + try testCase(received: nil, match: "application/json") + try testCase(received: "application/json", match: "application/json") + try testCase(received: "application/json", match: "application/*") + try testCase(received: "application/json", match: "*/*") + } + + func testExtractContentDispositionNameAndFilename() throws { + func testCase(value: String?, name: String?, filename: String?, file: StaticString = #file, line: UInt = #line) + throws + { + let headerFields: HTTPFields + if let value { headerFields = [.contentDisposition: value] } else { headerFields = [:] } + let (actualName, actualFilename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + XCTAssertEqual(actualName, name, file: file, line: line) + XCTAssertEqual(actualFilename, filename, file: file, line: line) + } + try testCase(value: nil, name: nil, filename: nil) + try testCase(value: "form-data", name: nil, filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=\"Foo and Bar\"", name: "Foo and Bar", filename: nil) + try testCase(value: "form-data; filename=foo.txt", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=Foo", name: "Foo", filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"; name=\"Foo\"", name: "Foo", filename: "foo.txt") + } + // MARK: Converter helper methods // | common | set | header field | URI | both | setHeaderFieldAsURI | diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 91525af4..d70a58d7 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -288,6 +288,25 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(body, testString) } + // | server | get | request body | multipart | required | getRequiredRequestBodyAsMultipart | + func test_getRequiredRequestBodyAsMultipart() async throws { + let value = try converter.getRequiredRequestBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + transforming: { $0 }, + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } + // | server | set | response body | JSON | required | setResponseBodyAsJSON | func test_setResponseBodyAsJSON_codable() async throws { var headers: HTTPFields = [:] @@ -311,4 +330,26 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(data, testString) XCTAssertEqual(headers, [.contentType: "application/octet-stream"]) } + + // | server | set | response body | multipart | required | setResponseBodyAsMultipart | + func test_setResponseBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setResponseBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 2e6d386e..0d7d108e 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@_spi(Generated) @testable import OpenAPIRuntime import HTTPTypes class Test_Runtime: XCTestCase { @@ -26,7 +26,7 @@ class Test_Runtime: XCTestCase { var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { .init() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant) } var converter: Converter { .init(configuration: configuration) } @@ -52,6 +52,34 @@ class Test_Runtime: XCTestCase { var testStringData: Data { Data(testString.utf8) } + var testMultipartString: String { "hello" } + + var testMultipartStringBytes: ArraySlice { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="foo.txt"; name="hello""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "hello".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="bar.txt"; name="world""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "world".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + return ArraySlice(bytes) + } + var testQuotedString: String { "\"hello\"" } var testQuotedStringData: Data { Data(testQuotedString.utf8) } @@ -196,6 +224,31 @@ enum TestHabitat: String, Codable, Equatable { case air } +enum MultipartTestPart: Hashable { + case hello(payload: String, filename: String?) + case world(payload: String, filename: String?) + var rawPart: MultipartRawPart { + switch self { + case .hello(let payload, let filename): + return .init(name: "hello", filename: filename, headerFields: [:], body: .init(payload)) + case .world(let payload, let filename): + return .init(name: "world", filename: filename, headerFields: [:], body: .init(payload)) + } + } + init(_ rawPart: MultipartRawPart) async throws { + switch rawPart.name { + case "hello": + self = .hello(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + case "world": + self = .world(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + default: preconditionFailure("Unexpected part: \(rawPart.name ?? "")") + } + } + static var all: [MultipartTestPart] { + [.hello(payload: "hello", filename: "foo.txt"), .world(payload: "world", filename: "bar.txt")] + } +} + /// Injects an authentication header to every request. struct AuthenticationMiddleware: ClientMiddleware { @@ -292,6 +345,7 @@ fileprivate extension UInt8 { return String(format: "%02x \(original)", self) } } + /// Asserts that the data matches the expected value. public func XCTAssertEqualData( _ expression1: @autoclosure () throws -> C1?, @@ -339,3 +393,19 @@ public func XCTAssertEqualData( ) } catch { XCTFail(error.localizedDescription, file: file, line: line) } } + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> C, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) async throws where C.Element == UInt8 { + guard let actualBytesBody = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let actualBytes = try await [UInt8](collecting: actualBytesBody, upTo: .max) + XCTAssertEqualData(actualBytes, try expression2(), file: file, line: line) +}