diff --git a/Package.swift b/Package.swift index 251bfe6e..21c01d71 100644 --- a/Package.swift +++ b/Package.swift @@ -64,7 +64,7 @@ let package = Package( // Tests-only: Runtime library linked by generated code, and also // helps keep the runtime library new enough to work with the generated // code. - .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.6")), + .package(url: "https://github.com/apple/swift-openapi-runtime", branch: "main"), // Build and preview docs .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), diff --git a/Sources/PetstoreConsumerTestCore/Assertions.swift b/Sources/PetstoreConsumerTestCore/Assertions.swift index 86b4bd2d..d68514a6 100644 --- a/Sources/PetstoreConsumerTestCore/Assertions.swift +++ b/Sources/PetstoreConsumerTestCore/Assertions.swift @@ -61,3 +61,73 @@ public func XCTAssertEqualStringifiedData( if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() } XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line) } +fileprivate extension UInt8 { + var asHex: String { + let original: String + switch self { + case 0x0d: original = "CR" + case 0x0a: original = "LF" + default: original = "\(UnicodeScalar(self)) " + } + return String(format: "%02x \(original)", self) + } +} +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> C1?, + _ expression2: @autoclosure () throws -> C2, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) where C1.Element == UInt8, C2.Element == UInt8 { + do { + guard let actualBytes = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let expectedBytes = try expression2() + if ArraySlice(actualBytes) == ArraySlice(expectedBytes) { return } + let actualCount = actualBytes.count + let expectedCount = expectedBytes.count + let minCount = min(actualCount, expectedCount) + print("Printing both byte sequences, first is the actual value and second is the expected one.") + for (index, byte) in zip(actualBytes.prefix(minCount), expectedBytes.prefix(minCount)).enumerated() { + print("\(String(format: "%04d", index)): \(byte.0 != byte.1 ? "x" : " ") \(byte.0.asHex) | \(byte.1.asHex)") + } + let direction: String + let extraBytes: ArraySlice + if actualCount > expectedCount { + direction = "Actual bytes has extra bytes" + extraBytes = ArraySlice(actualBytes.dropFirst(minCount)) + } else if expectedCount > actualCount { + direction = "Actual bytes is missing expected bytes" + extraBytes = ArraySlice(expectedBytes.dropFirst(minCount)) + } else { + direction = "" + extraBytes = [] + } + if !extraBytes.isEmpty { + print("\(direction):") + for (index, byte) in extraBytes.enumerated() { + print("\(String(format: "%04d", minCount + index)): \(byte.asHex)") + } + } + XCTFail( + "Actual stringified data '\(String(decoding: actualBytes, as: UTF8.self))' doesn't equal to expected stringified data '\(String(decoding: expectedBytes, as: UTF8.self))'. Details: \(message())", + file: file, + line: line + ) + } 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 { + let data: Data + if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() } + XCTAssertEqualData(data, try expression2(), message(), file: file, line: line) +} diff --git a/Sources/PetstoreConsumerTestCore/Common.swift b/Sources/PetstoreConsumerTestCore/Common.swift index 0e8c439d..828e140f 100644 --- a/Sources/PetstoreConsumerTestCore/Common.swift +++ b/Sources/PetstoreConsumerTestCore/Common.swift @@ -74,6 +74,103 @@ public extension Data { static var quotedEfghString: String { #""efgh""# } static var efgh: Data { Data(efghString.utf8) } + + static let crlf: ArraySlice = [0xd, 0xa] + + static var multipartBodyString: String { String(decoding: multipartBodyAsSlice, as: UTF8.self) } + + static var multipartBodyAsSlice: [UInt8] { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="efficiency""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "4.2".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="name""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 21"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "Vitamin C and friends".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + return bytes + } + + static var multipartBody: Data { Data(multipartBodyAsSlice) } + + static var multipartTypedBodyAsSlice: [UInt8] { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="process.log"; name="log""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 35"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"x-log-type: unstructured"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "here be logs!\nand more lines\nwheee\n".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="fun.stuff"; name="keyword""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "fun".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="barfoo.txt"; name="foobar""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 0"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="metadata""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 42"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: application/json; charset=utf-8"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "{\n \"createdAt\" : \"2023-01-18T10:04:11Z\"\n}".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="keyword""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "joy".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: "--".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + return bytes + } } public extension HTTPRequest { diff --git a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift index 0ca39e9f..509e2b02 100644 --- a/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift +++ b/Sources/_OpenAPIGeneratorCore/Layers/StructuredSwiftRepresentation.swift @@ -236,7 +236,7 @@ struct VariableDescription: Equatable, Codable { /// The name of the variable. /// /// For example, in `let foo = 42`, `left` is `foo`. - var left: String + var left: Expression /// The type of the variable. /// @@ -1106,6 +1106,49 @@ extension Declaration { setter: [CodeBlock]? = nil, modify: [CodeBlock]? = nil + ) -> Self { + .variable( + accessModifier: accessModifier, + isStatic: isStatic, + kind: kind, + left: .identifierPattern(left), + type: type, + right: right, + getter: getter, + getterEffects: getterEffects, + setter: setter, + modify: modify + ) + } + + /// A variable declaration. + /// + /// For example: `let foo = 42`. + /// - Parameters: + /// - accessModifier: An access modifier. + /// - isStatic: A Boolean value that indicates whether the variable + /// is static. + /// - kind: The variable binding kind. + /// - left: The name of the variable. + /// - type: The type of the variable. + /// - right: The expression to be assigned to the variable. + /// - getter: Body code for the getter of the variable. + /// - getterEffects: Effects of the getter. + /// - setter: Body code for the setter of the variable. + /// - modify: Body code for the `_modify` accessor. + /// - Returns: Variable declaration. + static func variable( + accessModifier: AccessModifier? = nil, + isStatic: Bool = false, + kind: BindingKind, + left: Expression, + type: ExistingTypeDescription? = nil, + right: Expression? = nil, + getter: [CodeBlock]? = nil, + getterEffects: [FunctionKeyword] = [], + setter: [CodeBlock]? = nil, + modify: [CodeBlock]? = nil + ) -> Self { .variable( .init( @@ -1521,14 +1564,6 @@ extension MemberAccessDescription { static func dot(_ member: String) -> Self { .init(right: member) } } -extension Expression: ExpressibleByStringLiteral, ExpressibleByNilLiteral, ExpressibleByArrayLiteral { - init(arrayLiteral elements: Expression...) { self = .literal(.array(elements)) } - - init(stringLiteral value: String) { self = .literal(.string(value)) } - - init(nilLiteral: ()) { self = .literal(.nil) } -} - extension LiteralDescription: ExpressibleByStringLiteral, ExpressibleByNilLiteral, ExpressibleByArrayLiteral { init(arrayLiteral elements: Expression...) { self = .array(elements) } @@ -1544,14 +1579,14 @@ extension VariableDescription { /// For example `var foo = 42`. /// - Parameter name: The name of the variable. /// - Returns: A new mutable variable declaration. - static func `var`(_ name: String) -> Self { Self.init(kind: .var, left: name) } + static func `var`(_ name: String) -> Self { Self.init(kind: .var, left: .identifierPattern(name)) } /// Returns a new immutable variable declaration. /// /// For example `let foo = 42`. /// - Parameter name: The name of the variable. /// - Returns: A new immutable variable declaration. - static func `let`(_ name: String) -> Self { Self.init(kind: .let, left: name) } + static func `let`(_ name: String) -> Self { Self.init(kind: .let, left: .identifierPattern(name)) } } extension Expression { @@ -1563,10 +1598,6 @@ extension Expression { func equals(_ rhs: Expression) -> AssignmentDescription { .init(left: self, right: rhs) } } -extension FunctionArgumentDescription: ExpressibleByStringLiteral { - init(stringLiteral value: String) { self = .init(expression: .literal(.string(value))) } -} - extension FunctionSignatureDescription { /// Returns a new function signature description that has the access /// modifier updated to the specified one. diff --git a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift index f2ec5aa1..4deff6a9 100644 --- a/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift +++ b/Sources/_OpenAPIGeneratorCore/Renderer/TextBasedRenderer.swift @@ -543,18 +543,21 @@ struct TextBasedRenderer: RendererProtocol { /// Renders the specified variable declaration. func renderVariable(_ variable: VariableDescription) { do { - var words: [String] = [] - if let accessModifier = variable.accessModifier { words.append(renderedAccessModifier(accessModifier)) } - if variable.isStatic { words.append("static") } - words.append(renderedBindingKind(variable.kind)) - let labelWithOptionalType: String + if let accessModifier = variable.accessModifier { + writer.writeLine(renderedAccessModifier(accessModifier) + " ") + writer.nextLineAppendsToLastLine() + } + if variable.isStatic { + writer.writeLine("static ") + writer.nextLineAppendsToLastLine() + } + writer.writeLine(renderedBindingKind(variable.kind) + " ") + writer.nextLineAppendsToLastLine() + renderExpression(variable.left) if let type = variable.type { - labelWithOptionalType = "\(variable.left): \(renderedExistingTypeDescription(type))" - } else { - labelWithOptionalType = variable.left + writer.nextLineAppendsToLastLine() + writer.writeLine(": \(renderedExistingTypeDescription(type))") } - words.append(labelWithOptionalType) - writer.writeLine(words.joinedWords()) } if let right = variable.right { @@ -883,3 +886,15 @@ fileprivate extension String { /// - Returns: A new string where each line has been transformed using the given closure. func transformingLines(_ work: (String) -> String) -> [String] { asLines().map(work) } } + +extension TextBasedRenderer { + + /// Returns the provided expression rendered as a string. + /// - Parameter expression: The expression. + /// - Returns: The string representation of the expression. + static func renderedExpressionAsString(_ expression: Expression) -> String { + let renderer = TextBasedRenderer.default + renderer.renderExpression(expression) + return renderer.renderedContents() + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/translateClientMethod.swift b/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/translateClientMethod.swift index e5ad7643..14b9211e 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/translateClientMethod.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/ClientTranslator/translateClientMethod.swift @@ -81,7 +81,7 @@ extension ClientFileTranslator { ) requestBlocks.append(.expression(requestBodyExpr)) } else { - requestBodyReturnExpr = nil + requestBodyReturnExpr = .literal(nil) } let returnRequestExpr: Expression = .return(.tuple([.identifierPattern("request"), requestBodyReturnExpr])) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift index a23a6a33..ad7a1b63 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateAllAnyOneOf.swift @@ -23,7 +23,7 @@ enum AllOrAnyOf { case anyOf } -extension FileTranslator { +extension TypesFileTranslator { /// Returns a declaration for an allOf or anyOf schema. /// diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift index 64de23c6..c9958420 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateArray.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import OpenAPIKit -extension FileTranslator { +extension TypesFileTranslator { /// Returns a list of declarations for an array schema. /// diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift index 90eb6eb2..b1283566 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateCodable.swift @@ -52,7 +52,7 @@ extension FileTranslator { trailingCodeBlocks: [ .expression( .assignment( - left: .identifierPattern("additionalProperties"), + left: .identifierPattern(Constants.AdditionalProperties.variableName), right: .try( .identifierPattern("decoder").dot("decodeAdditionalProperties") .call([knownKeysFunctionArg]) @@ -86,7 +86,12 @@ extension FileTranslator { .expression( .try( .identifierPattern("encoder").dot("encodeAdditionalProperties") - .call([.init(label: nil, expression: .identifierPattern("additionalProperties"))]) + .call([ + .init( + label: nil, + expression: .identifierPattern(Constants.AdditionalProperties.variableName) + ) + ]) ) ) ] diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateObjectStruct.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateObjectStruct.swift index b03bef25..a55517b4 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateObjectStruct.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateObjectStruct.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import OpenAPIKit -extension FileTranslator { +extension TypesFileTranslator { /// Returns a declaration of an object schema. /// @@ -149,7 +149,7 @@ extension FileTranslator { associatedDeclarations = [] case .b(let schema): let valueTypeUsage = try typeAssigner.typeUsage( - forObjectPropertyNamed: "additionalProperties", + forObjectPropertyNamed: Constants.AdditionalProperties.variableName, withSchema: schema, components: components, inParent: parent @@ -171,7 +171,7 @@ extension FileTranslator { let extraProperty = PropertyBlueprint( comment: .doc("A container of undocumented properties."), - originalName: "additionalProperties", + originalName: Constants.AdditionalProperties.variableName, typeUsage: typeUsage, default: .emptyInit, isSerializedInTopLevelDictionary: false, diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawRepresentableEnum.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawRepresentableEnum.swift index ab0a44d1..4e4ee3c1 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawRepresentableEnum.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateRawRepresentableEnum.swift @@ -109,7 +109,7 @@ extension FileTranslator { body: [.expression(.return(.identifierPattern("string")))] ) - let variableDescription = VariableDescription( + rawValueGetter = .variable( accessModifier: config.access, kind: .var, left: "rawValue", @@ -120,8 +120,6 @@ extension FileTranslator { ) ] ) - - rawValueGetter = .variable(variableDescription) } let allCasesGetter: Declaration diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift index acdc7e1c..f7668e4f 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateSchema.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import OpenAPIKit -extension FileTranslator { +extension TypesFileTranslator { /// Returns a list of declarations for the specified schema. /// @@ -61,10 +61,17 @@ extension FileTranslator { /// - schema: The JSON schema representing the type. /// - overrides: A structure with the properties that should be overridden /// instead of extracted from the schema. + /// - isMultipartContent: A Boolean value indicating whether the schema defines multipart parts. /// - Throws: An error if there is an issue during translation. /// - Returns: A list of declarations representing the translated schema. - func translateSchema(typeName: TypeName, schema: JSONSchema, overrides: SchemaOverrides) throws -> [Declaration] { + func translateSchema( + typeName: TypeName, + schema: JSONSchema, + overrides: SchemaOverrides, + isMultipartContent: Bool = false + ) throws -> [Declaration] { + if isMultipartContent { return try translateMultipartBody(typeName: typeName, schema: schema) } let value = schema.value // Attach any warnings from the parsed schema as a diagnostic. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStructBlueprint.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStructBlueprint.swift index 4513058f..9f0bbef2 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStructBlueprint.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTranslations/translateStructBlueprint.swift @@ -106,12 +106,10 @@ extension FileTranslator { let propertyDecl: Declaration = .commentable( property.comment, .variable( - .init( - accessModifier: config.access, - kind: .var, - left: property.swiftSafeName, - type: .init(property.typeUsage) - ) + accessModifier: config.access, + kind: .var, + left: property.swiftSafeName, + type: .init(property.typeUsage) ) .deprecate(if: property.isDeprecated) ) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Annotations.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Annotations.swift index a03220e1..44b06912 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Annotations.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Annotations.swift @@ -15,8 +15,7 @@ extension VariableDescription { /// Returns an expression that suppresses mutability warnings. var suppressMutabilityWarningExpr: Expression { - .identifierPattern("suppressMutabilityWarning") - .call([.init(label: nil, expression: .inOut(.identifierPattern(left)))]) + .identifierPattern("suppressMutabilityWarning").call([.init(label: nil, expression: .inOut(left))]) } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift index cc8ec2e3..e72337ee 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/CommonTypes/Constants.swift @@ -345,6 +345,9 @@ enum Constants { /// The substring used in method names for the url encoded form coding strategy. static let urlEncodedForm: String = "URLEncodedForm" + + /// The substring used in method names for the multipart coding strategy. + static let multipart: String = "Multipart" } /// Constants related to types used in many components. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Content/CodingStrategy.swift b/Sources/_OpenAPIGeneratorCore/Translator/Content/CodingStrategy.swift index 6b99de7c..cf90f76b 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Content/CodingStrategy.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Content/CodingStrategy.swift @@ -27,6 +27,9 @@ enum CodingStrategy: String, Hashable, Sendable { /// A strategy using x-www-form-urlencoded. case urlEncodedForm + /// A strategy using multipart/form-data. + case multipart + /// The name of the coding strategy in the runtime library. var runtimeName: String { switch self { @@ -34,6 +37,7 @@ enum CodingStrategy: String, Hashable, Sendable { case .uri: return Constants.CodingStrategy.uri case .binary: return Constants.CodingStrategy.binary case .urlEncodedForm: return Constants.CodingStrategy.urlEncodedForm + case .multipart: return Constants.CodingStrategy.multipart } } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift b/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift index d9a529d4..b8df47b5 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentInspector.swift @@ -39,13 +39,7 @@ extension FileTranslator { return nil } guard try validateSchemaIsSupported(content.schema, foundIn: parent.description) else { return nil } - let identifier = contentSwiftName(content.contentType) - let associatedType = try typeAssigner.typeUsage( - usingNamingHint: identifier, - withSchema: content.schema, - components: components, - inParent: parent - ) + let associatedType = try typeAssigner.typeUsage(withContent: content, components: components, inParent: parent) return .init(content: content, typeUsage: associatedType) } @@ -54,20 +48,27 @@ extension FileTranslator { /// - map: The content map from the OpenAPI document. /// - excludeBinary: A Boolean value controlling whether binary content /// type should be skipped, for example used when encoding headers. + /// - isRequired: Whether the contents are in a required container. /// - parent: The parent type of the chosen typed schema. /// - Returns: The supported content type + schema + type names. /// - Throws: An error if there's a problem while extracting or validating the supported /// content types or assigning the associated types. - func supportedTypedContents(_ map: OpenAPI.Content.Map, excludeBinary: Bool = false, inParent parent: TypeName) - throws -> [TypedSchemaContent] - { - let contents = try supportedContents(map, excludeBinary: excludeBinary, foundIn: parent.description) + func supportedTypedContents( + _ map: OpenAPI.Content.Map, + excludeBinary: Bool = false, + isRequired: Bool, + inParent parent: TypeName + ) throws -> [TypedSchemaContent] { + let contents = try supportedContents( + map, + excludeBinary: excludeBinary, + isRequired: isRequired, + foundIn: parent.description + ) return try contents.compactMap { content in - guard try validateSchemaIsSupported(content.schema, foundIn: parent.description) else { return nil } - let identifier = contentSwiftName(content.contentType) + guard try validateContentIsSupported(content, foundIn: parent.description) else { return nil } let associatedType = try typeAssigner.typeUsage( - usingNamingHint: identifier, - withSchema: content.schema, + withContent: content, components: components, inParent: parent ) @@ -80,19 +81,24 @@ extension FileTranslator { /// - contents: The content map from the OpenAPI document. /// - excludeBinary: A Boolean value controlling whether binary content /// type should be skipped, for example used when encoding headers. + /// - isRequired: Whether the contents are in a required container. /// - foundIn: The location where this content is parsed. /// - Returns: the detected content type + schema, nil if no supported /// schema found or if empty. /// - Throws: If parsing of any of the contents throws. - func supportedContents(_ contents: OpenAPI.Content.Map, excludeBinary: Bool = false, foundIn: String) throws - -> [SchemaContent] - { + func supportedContents( + _ contents: OpenAPI.Content.Map, + excludeBinary: Bool = false, + isRequired: Bool, + foundIn: String + ) throws -> [SchemaContent] { guard !contents.isEmpty else { return [] } return try contents.compactMap { key, value in try parseContentIfSupported( contentKey: key, contentValue: value, excludeBinary: excludeBinary, + isRequired: isRequired, foundIn: foundIn + "/\(key.rawValue)" ) } @@ -162,6 +168,7 @@ extension FileTranslator { /// - contentValue: The content value from the OpenAPI document. /// - excludeBinary: A Boolean value controlling whether binary content /// type should be skipped, for example used when encoding headers. + /// - isRequired: Whether the contents are in a required container. /// - foundIn: The location where this content is parsed. /// - Returns: The detected content type + schema, nil if unsupported. /// - Throws: If a malformed content type string is encountered. @@ -169,22 +176,32 @@ extension FileTranslator { contentKey: OpenAPI.ContentType, contentValue: OpenAPI.Content, excludeBinary: Bool = false, + isRequired: Bool, foundIn: String ) throws -> SchemaContent? { let contentType = try contentKey.asGeneratorContentType - if contentType.isJSON { - if contentType.lowercasedType == "multipart" - || contentType.lowercasedTypeAndSubtype.contains("application/x-www-form-urlencoded") - { - diagnostics.emitUnsupportedIfNotNil( - contentValue.encoding, - "Custom encoding for multipart/formEncoded content", - foundIn: "\(foundIn), content \(contentType.originallyCasedTypeAndSubtype)" + if contentType.lowercasedTypeAndSubtype.contains("application/x-www-form-urlencoded") { + diagnostics.emitUnsupportedIfNotNil( + contentValue.encoding, + "Custom encoding for formEncoded content", + foundIn: "\(foundIn), content \(contentType.originallyCasedTypeAndSubtype)" + ) + } + if contentType.isJSON { return .init(contentType: contentType, schema: contentValue.schema) } + if contentType.isUrlEncodedForm { return .init(contentType: contentType, schema: contentValue.schema) } + if contentType.isMultipart { + guard isRequired else { + diagnostics.emit( + .warning( + message: + "Multipart request bodies must always be required, but found an optional one - skipping. Mark as `required: true` to get this body generated.", + context: ["foundIn": foundIn] + ) ) + return nil } - return .init(contentType: contentType, schema: contentValue.schema) + return .init(contentType: contentType, schema: contentValue.schema, encoding: contentValue.encoding) } - if contentType.isUrlEncodedForm { return .init(contentType: contentType, schema: contentValue.schema) } if !excludeBinary, contentType.isBinary { return .init(contentType: contentType, schema: .b(.string(contentEncoding: .binary))) } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentSwiftName.swift b/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentSwiftName.swift deleted file mode 100644 index c84aee32..00000000 --- a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentSwiftName.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// -import OpenAPIKit - -extension FileTranslator { - - /// Returns a Swift-safe identifier used as the name of the content - /// enum case. - /// - /// - Parameter contentType: The content type for which to compute the name. - /// - Returns: A Swift-safe identifier representing the name of the content enum case. - func contentSwiftName(_ contentType: ContentType) -> String { - let rawContentType = contentType.lowercasedTypeSubtypeAndParameters - switch rawContentType { - case "application/json": return "json" - case "application/x-www-form-urlencoded": return "urlEncodedForm" - case "multipart/form-data": return "multipartForm" - case "text/plain": return "plainText" - case "*/*": return "any" - case "application/xml": return "xml" - case "application/octet-stream": return "binary" - case "text/html": return "html" - case "application/yaml": return "yaml" - case "text/csv": return "csv" - case "image/png": return "png" - case "application/pdf": return "pdf" - case "image/jpeg": return "jpeg" - default: - let safedType = swiftSafeName(for: contentType.originallyCasedType) - let safedSubtype = swiftSafeName(for: contentType.originallyCasedSubtype) - let prefix = "\(safedType)_\(safedSubtype)" - let params = contentType.lowercasedParameterPairs - guard !params.isEmpty else { return prefix } - let safedParams = - params.map { pair in - pair.split(separator: "=").map { swiftSafeName(for: String($0)) }.joined(separator: "_") - } - .joined(separator: "_") - return prefix + "_" + safedParams - } - } -} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift b/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift index 12a22f9a..20a94573 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Content/ContentType.swift @@ -51,6 +51,11 @@ struct ContentType: Hashable { /// The type is encoded as a binary UTF-8 data packet. case urlEncodedForm + /// A content type for multipart/form-data. + /// + /// The type is encoded as an async sequence of parts. + case multipart + /// Creates a category from the provided type and subtype. /// /// First checks if the provided content type is a JSON, then text, @@ -65,6 +70,8 @@ struct ContentType: Hashable { self = .json } else if lowercasedType == "application" && lowercasedSubtype == "x-www-form-urlencoded" { self = .urlEncodedForm + } else if lowercasedType == "multipart" && lowercasedSubtype == "form-data" { + self = .multipart } else { self = .binary } @@ -76,6 +83,7 @@ struct ContentType: Hashable { case .json: return .json case .binary: return .binary case .urlEncodedForm: return .urlEncodedForm + case .multipart: return .multipart } } } @@ -199,8 +207,23 @@ struct ContentType: Hashable { /// is just binary data. var isBinary: Bool { category == .binary } + /// A Boolean value that indicates whether the content type + /// is a URL-encoded form. var isUrlEncodedForm: Bool { category == .urlEncodedForm } + /// A Boolean value that indicates whether the content type + /// is a multipart form. + var isMultipart: Bool { category == .multipart } + + /// The content type `text/plain`. + static var textPlain: Self { try! .init(string: "text/plain") } + + /// The content type `application/json`. + static var applicationJSON: Self { try! .init(string: "application/json") } + + /// The content type `application/octet-stream`. + static var applicationOctetStream: Self { try! .init(string: "application/octet-stream") } + static func == (lhs: Self, rhs: Self) -> Bool { // MIME type equality is case-insensitive. lhs.lowercasedTypeAndSubtype == rhs.lowercasedTypeAndSubtype diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Content/SchemaContent.swift b/Sources/_OpenAPIGeneratorCore/Translator/Content/SchemaContent.swift index d6638878..3d098c8f 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Content/SchemaContent.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Content/SchemaContent.swift @@ -25,6 +25,11 @@ struct SchemaContent { /// Can be nil for unstructured JSON payloads, or for unstructured /// content types such as binary data. var schema: UnresolvedSchema? + + /// The optional encoding mapping for each of the properties in the object schema. + /// + /// Only used in multipart object schemas, ignored otherwise, as per the OpenAPI specification. + var encoding: OrderedDictionary? } /// A type grouping schema content and its computed Swift type usage. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartAdditionalProperties.swift b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartAdditionalProperties.swift new file mode 100644 index 00000000..69089414 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartAdditionalProperties.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit + +/// The strategy for handling the additional properties key in a multipart schema. +enum MultipartAdditionalPropertiesStrategy: Equatable { + + /// A strategy where additional properties are explicitly disallowed. + case disallowed + + /// A strategy where additional properties are implicitly allowed. + case allowed + + /// A strategy where all additional properties must conform to the given schema. + case typed(JSONSchema) + + /// A strategy where additional properties are explicitly allowed, and are freeform. + case any +} + +extension MultipartAdditionalPropertiesStrategy { + + /// Creates the additional properties strategy given the schema's additional properties value. + /// - Parameter additionalProperties: The schema's additional properties value. + init(_ additionalProperties: Either?) { + switch additionalProperties { + case .none: self = .allowed + case .a(let bool): self = bool ? .any : .disallowed + case .b(let schema): self = .typed(schema) + } + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContent.swift b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContent.swift new file mode 100644 index 00000000..881e967b --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContent.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit + +/// The top level container of multipart parts. +struct MultipartContent { + + /// The type name of the enclosing enum. + var typeName: TypeName + + /// The multipart parts. + var parts: [MultipartSchemaTypedContent] + + /// The strategy for handling additional properties. + var additionalPropertiesStrategy: MultipartAdditionalPropertiesStrategy + + /// The requirements enforced by the validation sequence. + var requirements: MultipartRequirements +} + +/// A container of information about an individual multipart part. +enum MultipartSchemaTypedContent { + + /// The associated data with the `documentedTyped` case. + struct DocumentedTypeInfo { + + /// The original name of the case from the OpenAPI document. + var originalName: String + + /// The type name of the part wrapper. + var typeName: TypeName + + /// Information about the kind of the part. + var partInfo: MultipartPartInfo + + /// The value schema of the part defined in the OpenAPI document. + var schema: JSONSchema + + /// The headers defined for the part in the OpenAPI document. + var headers: OpenAPI.Header.Map? + } + /// A documented part with a name specified in the OpenAPI document. + case documentedTyped(DocumentedTypeInfo) + + /// The associated data with the `otherDynamicallyNamed` case. + struct OtherDynamicallyNamedInfo { + + /// The type name of the part wrapper. + var typeName: TypeName + + /// Information about the kind of the part. + var partInfo: MultipartPartInfo + + /// The value schema of the part defined in the OpenAPI document. + var schema: JSONSchema + } + /// A part representing additional properties with a schema constraint. + case otherDynamicallyNamed(OtherDynamicallyNamedInfo) + + /// A part representing explicitly allowed, freeform additional properties. + case otherRaw + + /// A part representing an undocumented value. + case undocumented +} + +extension MultipartSchemaTypedContent { + + /// The type usage of the part type wrapper. + /// + /// For example, for a documented part, the generated type is wrapped in `OpenAPIRuntime.MultipartPart<...>`. + var wrapperTypeUsage: TypeUsage { + switch self { + case .documentedTyped(let info): return info.typeName.asUsage.asWrapped(in: .multipartPart) + case .otherDynamicallyNamed(let info): + return info.typeName.asUsage.asWrapped(in: .multipartDynamicallyNamedPart) + case .otherRaw, .undocumented: return TypeName.multipartRawPart.asUsage + } + } +} + +extension SchemaContent { + /// Returns a Boolean value whether the schema is a multipart content type and is referenceable. + var isReferenceableMultipart: Bool { + guard contentType.isMultipart else { return false } + let ref = TypeMatcher.multipartElementTypeReferenceIfReferenceable(schema: schema, encoding: encoding) + return ref == nil + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContentInspector.swift b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContentInspector.swift new file mode 100644 index 00000000..1b4bdb2e --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/MultipartContentInspector.swift @@ -0,0 +1,359 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit + +/// Information about the kind of the part. +struct MultipartPartInfo: Hashable { + + /// The serialization strategy used by this part, derived from the schema and content type. + enum SerializationStrategy: Hashable { + + /// A primitive strategy, for example used for raw strings. + case primitive + + /// A complex strategy, for example used for JSON objects. + case complex + + /// A binary strategy, used for raw byte payloads. + case binary + + /// The content type most appropriate for the serialization strategy. + var contentType: ContentType { + // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#special-considerations-for-multipart-content + // > If the property is a primitive, or an array of primitive values, the default Content-Type is text/plain + // > If the property is complex, or an array of complex values, the default Content-Type is application/json + // > If the property is a type: string with a contentEncoding, the default Content-Type is application/octet-stream + switch self { + case .primitive: return .textPlain + case .complex: return .applicationJSON + case .binary: return .applicationOctetStream + } + } + } + + /// The repetition kind of the part, whether it only appears once or multiple times. + enum RepetitionKind: Hashable { + + /// A single kind, cannot be repeated. + case single + + /// An array kind, allows the part name to appear more than once. + case array + } + + /// The source of the content type information. + enum ContentTypeSource: Hashable { + + /// An explicit source, where the OpenAPI document contains a content type in the encoding map. + case explicit(ContentType) + + /// An implicit source, where the content type is inferred from the serialization strategy. + case infer(SerializationStrategy) + + /// The content type computed from the source. + var contentType: ContentType { + switch self { + case .explicit(let contentType): return contentType + case .infer(let serializationStrategy): return serializationStrategy.contentType + } + } + } + + /// The repetition kind of the part. + var repetition: RepetitionKind + + /// The source of content type information. + var contentTypeSource: ContentTypeSource + + /// The content type used by this part. + var contentType: ContentType { contentTypeSource.contentType } +} + +/// The requirements derived from the OpenAPI document. +struct MultipartRequirements { + + /// A Boolean value indicating whether unknown part names are allowed. + var allowsUnknownParts: Bool + + /// A set of known part names that must appear exactly once. + var requiredExactlyOncePartNames: Set + + /// A set of known part names that must appear at least once. + var requiredAtLeastOncePartNames: Set + + /// A set of known part names that can appear at most once. + var atMostOncePartNames: Set + + /// A set of known part names that can appear any number of times. + var zeroOrMoreTimesPartNames: Set +} + +/// Utilities for asking questions about multipart content. +extension FileTranslator { + + /// Parses multipart content information from the provided schema. + /// - Parameters: + /// - typeName: The name of the multipart type. + /// - schema: The top level schema of the multipart content. + /// - encoding: The encoding mapping refining the information from the schema. + /// - Returns: A multipart content value, or nil if the provided schema is not valid multipart content. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func parseMultipartContent( + typeName: TypeName, + schema: UnresolvedSchema?, + encoding: OrderedDictionary? + ) throws -> MultipartContent? { + var referenceStack: ReferenceStack = .empty + guard let topLevelObject = try flattenedTopLevelMultipartObject(schema, referenceStack: &referenceStack) else { + return nil + } + var parts: [MultipartSchemaTypedContent] = try topLevelObject.properties.compactMap { + (key, value) -> MultipartSchemaTypedContent? in + let swiftSafeName = swiftSafeName(for: key) + let typeName = typeName.appending( + swiftComponent: swiftSafeName + Constants.Global.inlineTypeSuffix, + jsonComponent: key + ) + let partEncoding = encoding?[key] + guard + let (info, resolvedSchema) = try parseMultipartPartInfo( + schema: value, + encoding: partEncoding, + foundIn: typeName.description + ) + else { return nil } + return .documentedTyped( + .init( + originalName: key, + typeName: typeName, + partInfo: info, + schema: resolvedSchema, + headers: partEncoding?.headers + ) + ) + } + let additionalPropertiesStrategy = MultipartAdditionalPropertiesStrategy(topLevelObject.additionalProperties) + switch additionalPropertiesStrategy { + case .disallowed: break + case .allowed: parts.append(.undocumented) + case .typed(let schema): + let typeUsage = try typeAssigner.typeUsage( + usingNamingHint: Constants.AdditionalProperties.variableName, + withSchema: .b(schema), + components: components, + inParent: typeName + )! + // The unwrap is safe, the method only returns nil when the input schema is nil. + let typeName = typeUsage.typeName + guard + let (info, resolvedSchema) = try parseMultipartPartInfo( + schema: schema, + encoding: nil, + foundIn: typeName.description + ) + else { + throw GenericError( + message: "Failed to parse multipart info for additionalProperties in \(typeName.description)." + ) + } + parts.append(.otherDynamicallyNamed(.init(typeName: typeName, partInfo: info, schema: resolvedSchema))) + case .any: parts.append(.otherRaw) + } + let requirements = try parseMultipartRequirements( + parts: parts, + additionalPropertiesStrategy: additionalPropertiesStrategy + ) + return .init( + typeName: typeName, + parts: parts, + additionalPropertiesStrategy: additionalPropertiesStrategy, + requirements: requirements + ) + } + + /// Parses multipart content information from the provided schema. + /// - Parameter content: The schema content from which to parse multipart information. + /// - Returns: A multipart content value, or nil if the provided schema is not valid multipart content. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func parseMultipartContent(_ content: TypedSchemaContent) throws -> MultipartContent? { + let schemaContent = content.content + precondition(schemaContent.contentType.isMultipart, "Unexpected content type passed to translateMultipartBody") + // Safe - we never produce nil for multipart. + let typeUsage = content.typeUsage! + let typeName = typeUsage.typeName + let schema = schemaContent.schema + let encoding = schemaContent.encoding + return try parseMultipartContent(typeName: typeName, schema: schema, encoding: encoding) + } + + /// Computes the requirements for the provided parts and additional properties strategy. + /// - Parameters: + /// - parts: The multipart parts. + /// - additionalPropertiesStrategy: The strategy used for handling additional properties. + /// - Returns: The multipart requirements. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func parseMultipartRequirements( + parts: [MultipartSchemaTypedContent], + additionalPropertiesStrategy: MultipartAdditionalPropertiesStrategy + ) throws -> MultipartRequirements { + var requiredExactlyOncePartNames: Set = [] + var requiredAtLeastOncePartNames: Set = [] + var atMostOncePartNames: Set = [] + var zeroOrMoreTimesPartNames: Set = [] + for part in parts { + switch part { + case .documentedTyped(let part): + let name = part.originalName + let schema = part.schema + let isRequired = try !typeMatcher.isOptional(schema, components: components) + switch (part.partInfo.repetition, isRequired) { + case (.single, true): requiredExactlyOncePartNames.insert(name) + case (.single, false): atMostOncePartNames.insert(name) + case (.array, true): requiredAtLeastOncePartNames.insert(name) + case (.array, false): zeroOrMoreTimesPartNames.insert(name) + } + case .otherDynamicallyNamed, .otherRaw, .undocumented: break + } + } + return .init( + allowsUnknownParts: additionalPropertiesStrategy != .disallowed, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ) + } + + /// Parses information about an individual part's schema. + /// + /// The returned schema is the schema of the part element, so the top arrays are stripped here, and + /// are allowed to be repeated. + /// - Parameters: + /// - schema: The schema of the part. + /// - encoding: The encoding information for the schema. + /// - foundIn: The location where this part is parsed. + /// - Returns: A tuple of the part info and resolved schema, or nil if the schema is not a valid part schema. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func parseMultipartPartInfo(schema: JSONSchema, encoding: OpenAPI.Content.Encoding?, foundIn: String) throws -> ( + MultipartPartInfo, JSONSchema + )? { + func inferStringContent(_ context: JSONSchema.StringContext) throws -> MultipartPartInfo.ContentTypeSource { + if let contentMediaType = context.contentMediaType { + return try .explicit(contentMediaType.asGeneratorContentType) + } + switch context.contentEncoding { + case .binary: return .infer(.binary) + default: return .infer(.primitive) + } + } + let repetitionKind: MultipartPartInfo.RepetitionKind + let candidateSource: MultipartPartInfo.ContentTypeSource + switch try schema.dereferenced(in: components) { + case .null, .not: return nil + case .boolean, .number, .integer: + repetitionKind = .single + candidateSource = .infer(.primitive) + case .string(_, let context): + repetitionKind = .single + candidateSource = try inferStringContent(context) + case .object, .all, .one, .any, .fragment: + repetitionKind = .single + candidateSource = .infer(.complex) + case .array(_, let context): + repetitionKind = .array + if let items = context.items { + switch items { + case .null, .not: return nil + case .boolean, .number, .integer: candidateSource = .infer(.primitive) + case .string(_, let context): candidateSource = try inferStringContent(context) + case .object, .all, .one, .any, .fragment, .array: candidateSource = .infer(.complex) + } + } else { + candidateSource = .infer(.complex) + } + } + let finalContentTypeSource: MultipartPartInfo.ContentTypeSource + if let encoding, let contentType = encoding.contentType { + finalContentTypeSource = try .explicit(contentType.asGeneratorContentType) + } else { + finalContentTypeSource = candidateSource + } + let contentType = finalContentTypeSource.contentType + if finalContentTypeSource.contentType.isMultipart { + diagnostics.emitUnsupported("Multipart part cannot nest another multipart content.", foundIn: foundIn) + return nil + } + let info = MultipartPartInfo(repetition: repetitionKind, contentTypeSource: finalContentTypeSource) + if contentType.isBinary { + let isOptional = try typeMatcher.isOptional(schema, components: components) + let baseSchema: JSONSchema = .string(contentEncoding: .binary) + let resolvedSchema: JSONSchema + if isOptional { resolvedSchema = baseSchema.optionalSchemaObject() } else { resolvedSchema = baseSchema } + return (info, resolvedSchema) + } + return (info, schema) + } + /// Parses the names of component schemas used by multipart request and response bodies. + /// + /// The result is used to inform how a schema is generated. + /// - Parameters: + /// - paths: The paths section of the OpenAPI document. + /// - components: The components section of the OpenAPI document. + /// - Returns: A set of component keys of the schemas used by multipart content. + /// - Throws: An error if a reference cannot be followed. + func parseSchemaNamesUsedInMultipart(paths: OpenAPI.PathItem.Map, components: OpenAPI.Components) throws -> Set< + OpenAPI.ComponentKey + > { + var refs: Set = [] + func visitContentMap(_ contentMap: OpenAPI.Content.Map) throws { + for (key, value) in contentMap { + guard try key.asGeneratorContentType.isMultipart else { continue } + guard let schema = value.schema, case let .a(ref) = schema, let name = ref.name, + let componentKey = OpenAPI.ComponentKey(rawValue: name) + else { continue } + refs.insert(componentKey) + } + } + func visitPath(_ path: OpenAPI.PathItem) throws { + for endpoint in path.endpoints { + let operation = endpoint.operation + if let requestBodyEither = operation.requestBody { + let requestBody: OpenAPI.Request + switch requestBodyEither { + case .a(let ref): requestBody = try components.lookup(ref) + case .b(let value): requestBody = value + } + try visitContentMap(requestBody.content) + } + for responseOutcome in operation.responseOutcomes { + let response: OpenAPI.Response + switch responseOutcome.response { + case .a(let ref): response = try components.lookup(ref) + case .b(let value): response = value + } + try visitContentMap(response.content) + } + } + } + for (_, value) in paths { + let pathItem: OpenAPI.PathItem + switch value { + case .a(let ref): pathItem = try components.lookup(ref) + case .b(let value): pathItem = value + } + try visitPath(pathItem) + } + return refs + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Multipart/translateMultipart.swift b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/translateMultipart.swift new file mode 100644 index 00000000..186cfda8 --- /dev/null +++ b/Sources/_OpenAPIGeneratorCore/Translator/Multipart/translateMultipart.swift @@ -0,0 +1,656 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +import OpenAPIKit + +extension TypesFileTranslator { + + /// Returns declarations representing the provided multipart content. + /// - Parameter content: The multipart content. + /// - Returns: A list of declarations, or empty if not valid multipart content. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartBody(_ content: TypedSchemaContent) throws -> [Declaration] { + guard let multipart = try parseMultipartContent(content) else { return [] } + let decl = try translateMultipartBody(multipart) + return [decl] + } + + /// Returns a declaration of a multipart part's associated type, containing headers (if defined) and body. + /// - Parameters: + /// - typeName: The type name of the part's type. + /// - headerMap: The headers for the part. + /// - contentType: The content type of the part. + /// - schema: The schema of the part's body. + /// - Returns: A declaration of the type containing headers and body. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func translateMultipartPartContent( + typeName: TypeName, + headers headerMap: OpenAPI.Header.Map?, + contentType: ContentType, + schema: JSONSchema + ) throws -> Declaration { + let headersTypeName = typeName.appending( + swiftComponent: Constants.Operation.Output.Payload.Headers.typeName, + jsonComponent: "headers" + ) + let headers = try typedResponseHeaders(from: headerMap, inParent: headersTypeName) + let headersProperty: PropertyBlueprint? + if !headers.isEmpty { + let headerProperties: [PropertyBlueprint] = try headers.map { header in + try parseResponseHeaderAsProperty(for: header, parent: headersTypeName) + } + let headerStructComment: Comment? = headersTypeName.docCommentWithUserDescription(nil) + let headersStructBlueprint: StructBlueprint = .init( + comment: headerStructComment, + access: config.access, + typeName: headersTypeName, + conformances: Constants.Operation.Output.Payload.Headers.conformances, + properties: headerProperties + ) + let headersStructDecl = translateStructBlueprint(headersStructBlueprint) + headersProperty = PropertyBlueprint( + comment: .doc("Received HTTP response headers"), + originalName: Constants.Operation.Output.Payload.Headers.variableName, + typeUsage: headersTypeName.asUsage, + default: headersStructBlueprint.hasEmptyInit ? .emptyInit : nil, + associatedDeclarations: [headersStructDecl], + asSwiftSafeName: swiftSafeName + ) + } else { + headersProperty = nil + } + let bodyTypeUsage = try typeAssigner.typeUsage( + forObjectPropertyNamed: Constants.Operation.Body.variableName, + withSchema: schema.requiredSchemaObject(), + components: components, + inParent: typeName.appending(swiftComponent: nil, jsonComponent: "content") + ) + let associatedDeclarations: [Declaration] + if TypeMatcher.isInlinable(schema) { + associatedDeclarations = try translateSchema( + typeName: bodyTypeUsage.typeName, + schema: schema, + overrides: .none + ) + } else { + associatedDeclarations = [] + } + let bodyProperty = PropertyBlueprint( + comment: nil, + originalName: Constants.Operation.Body.variableName, + typeUsage: bodyTypeUsage, + associatedDeclarations: associatedDeclarations, + asSwiftSafeName: swiftSafeName + ) + let structDecl = translateStructBlueprint( + .init( + comment: nil, + access: config.access, + typeName: typeName, + conformances: Constants.Operation.Output.Payload.conformances, + properties: [headersProperty, bodyProperty].compactMap { $0 } + ) + ) + return .commentable(typeName.docCommentWithUserDescription(nil), structDecl) + } + + /// Returns the associated declarations of a dynamically named part. + /// - Parameters: + /// - typeName: The type name of the part. + /// - contentType: The cotent type of the part. + /// - schema: The schema of the part. + /// - Returns: The associated declarations, or empty if the type is referenced. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func translateMultipartPartContentAdditionalPropertiesWithSchemaAssociatedDeclarations( + typeName: TypeName, + contentType: ContentType, + schema: JSONSchema + ) throws -> [Declaration] { + let associatedDeclarations: [Declaration] + if TypeMatcher.isInlinable(schema) { + associatedDeclarations = try translateSchema(typeName: typeName, schema: schema, overrides: .none) + } else { + associatedDeclarations = [] + } + return associatedDeclarations + } + + /// Returns the declaration for the provided root multipart content. + /// - Parameter multipart: The multipart content. + /// - Returns: The declaration of the multipart container enum type. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartBody(_ multipart: MultipartContent) throws -> Declaration { + let parts = multipart.parts + let multipartBodyTypeName = multipart.typeName + + let partDecls: [Declaration] = try parts.flatMap { part -> [Declaration] in + switch part { + case .documentedTyped(let documentedPart): + let caseDecl: Declaration = .enumCase( + name: swiftSafeName(for: documentedPart.originalName), + kind: .nameWithAssociatedValues([.init(type: .init(part.wrapperTypeUsage))]) + ) + let decl = try translateMultipartPartContent( + typeName: documentedPart.typeName, + headers: documentedPart.headers, + contentType: documentedPart.partInfo.contentType, + schema: documentedPart.schema + ) + return [decl, caseDecl] + case .otherDynamicallyNamed(let dynamicallyNamedPart): + let caseDecl: Declaration = .enumCase( + name: Constants.AdditionalProperties.variableName, + kind: .nameWithAssociatedValues([.init(type: .init(part.wrapperTypeUsage))]) + ) + let associatedDecls = + try translateMultipartPartContentAdditionalPropertiesWithSchemaAssociatedDeclarations( + typeName: dynamicallyNamedPart.typeName, + contentType: dynamicallyNamedPart.partInfo.contentType, + schema: dynamicallyNamedPart.schema + ) + return associatedDecls + [caseDecl] + case .otherRaw: + return [ + .enumCase(name: "other", kind: .nameWithAssociatedValues([.init(type: .init(.multipartRawPart))])) + ] + case .undocumented: + return [ + .enumCase( + name: "undocumented", + kind: .nameWithAssociatedValues([.init(type: .init(.multipartRawPart))]) + ) + ] + } + } + let enumDescription = EnumDescription( + isFrozen: true, + accessModifier: config.access, + name: multipartBodyTypeName.shortSwiftName, + conformances: Constants.Operation.Body.conformances, + members: partDecls + ) + let comment: Comment? = multipartBodyTypeName.docCommentWithUserDescription(nil) + return .commentable(comment, .enum(enumDescription)) + } + + /// Returns the declaration for the provided root multipart content. + /// - Parameters: + /// - typeName: The type name of the body. + /// - schema: The root schema of the body. + /// - Returns: The declaration of the multipart container enum type. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func translateMultipartBody(typeName: TypeName, schema: JSONSchema) throws -> [Declaration] { + guard let multipart = try parseMultipartContent(typeName: typeName, schema: .b(schema), encoding: nil) else { + return [] + } + let decl = try translateMultipartBody(multipart) + return [decl] + } +} + +extension ClientFileTranslator { + + /// Returns the extra function arguments used for multipart serializers (request) in the client code. + /// - Parameter content: The multipart content. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartSerializerExtraArgumentsInClient(_ content: TypedSchemaContent) throws + -> [FunctionArgumentDescription] + { try translateMultipartSerializerExtraArguments(content, setBodyMethodPrefix: "setRequiredRequestBody") } + + /// Returns the extra function arguments used for multipart deserializers (response) in the client code. + /// - Parameter content: The multipart content. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartDeserializerExtraArgumentsInClient(_ content: TypedSchemaContent) throws + -> [FunctionArgumentDescription] + { try translateMultipartDeserializerExtraArguments(content, getBodyMethodPrefix: "getResponseBody") } +} + +extension ServerFileTranslator { + + /// Returns the extra function arguments used for multipart deserializers (request) in the server code. + /// - Parameter content: The multipart content. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartDeserializerExtraArgumentsInServer(_ content: TypedSchemaContent) throws + -> [FunctionArgumentDescription] + { try translateMultipartDeserializerExtraArguments(content, getBodyMethodPrefix: "getRequiredRequestBody") } + + /// Returns the extra function arguments used for multipart serializers (response) in the server code. + /// - Parameter content: The multipart content. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartSerializerExtraArgumentsInServer(_ content: TypedSchemaContent) throws + -> [FunctionArgumentDescription] + { try translateMultipartSerializerExtraArguments(content, setBodyMethodPrefix: "setResponseBody") } +} + +extension FileTranslator { + + /// Returns the requirements-related extra function arguments used for multipart serializers and deserializers. + /// - Parameter requirements: The requirements to generate arguments for. + /// - Returns: The list of arguments. + func translateMultipartRequirementsExtraArguments(_ requirements: MultipartRequirements) + -> [FunctionArgumentDescription] + { + func sortedStringSetLiteral(_ set: Set) -> Expression { + .literal(.array(set.sorted().map { .literal($0) })) + } + let requirementsArgs: [FunctionArgumentDescription] = [ + .init(label: "allowsUnknownParts", expression: .literal(.bool(requirements.allowsUnknownParts))), + .init( + label: "requiredExactlyOncePartNames", + expression: sortedStringSetLiteral(requirements.requiredExactlyOncePartNames) + ), + .init( + label: "requiredAtLeastOncePartNames", + expression: sortedStringSetLiteral(requirements.requiredAtLeastOncePartNames) + ), + .init(label: "atMostOncePartNames", expression: sortedStringSetLiteral(requirements.atMostOncePartNames)), + .init( + label: "zeroOrMoreTimesPartNames", + expression: sortedStringSetLiteral(requirements.zeroOrMoreTimesPartNames) + ), + ] + return requirementsArgs + } + + /// Returns the extra function arguments used for multipart deserializers. + /// - Parameters: + /// - content: The multipart content. + /// - getBodyMethodPrefix: The string prefix of the "get body" methods. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartDeserializerExtraArguments(_ content: TypedSchemaContent, getBodyMethodPrefix: String) throws + -> [FunctionArgumentDescription] + { + guard let multipart = try parseMultipartContent(content) else { return [] } + let boundaryArg: FunctionArgumentDescription = .init( + label: "boundary", + expression: .identifierPattern("contentType").dot("requiredBoundary").call([]) + ) + let requirementsArgs = translateMultipartRequirementsExtraArguments(multipart.requirements) + let decoding: FunctionArgumentDescription = .init( + label: "decoding", + expression: .closureInvocation( + argumentNames: ["part"], + body: try translateMultipartDecodingClosure(multipart, getBodyMethodPrefix: getBodyMethodPrefix) + ) + ) + return [boundaryArg] + requirementsArgs + [decoding] + } + + /// Returns the description of the switch case for the provided individual multipart part. + /// - Parameters: + /// - caseName: The name of the case. + /// - caseKind: The kind of the case. + /// - isDynamicallyNamed: A Boolean value indicating whether the part is dynamically named (in other words, if + /// this is using `additionalProperties: `. + /// - isPayloadBodyTypeNested: A Boolean value indicating whether the payload body type is nested. If `false`, + /// then the body type is assumed to be the part's type. + /// - getBodyMethodPrefix: The string prefix of the "get body" methods. + /// - contentType: The content type of the part. + /// - partTypeName: The part's type name. + /// - schema: The schema of the part. + /// - payloadExpr: The expression of the payload in the corresponding wrapper type. + /// - headerDecls: A list of declarations of the part headers, can be empty. + /// - headersVarArgs: A list of arguments for headers on the part's type, can be empty. + /// - Returns: The switch case description, or nil if not valid or supported schema. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func translateMultipartDecodingClosureTypedPart( + caseName: String, + caseKind: SwitchCaseKind, + isDynamicallyNamed: Bool, + isPayloadBodyTypeNested: Bool, + getBodyMethodPrefix: String, + contentType: ContentType, + partTypeName: TypeName, + schema: JSONSchema, + payloadExpr: Expression, + headerDecls: [Declaration], + headersVarArgs: [FunctionArgumentDescription] + ) throws -> SwitchCaseDescription? { + let contentTypeHeaderValue = contentType.headerValueForValidation + let codingStrategy = contentType.codingStrategy + guard try validateSchemaIsSupported(schema, foundIn: partTypeName.description) else { return nil } + let contentTypeUsage: TypeUsage + if isPayloadBodyTypeNested { + contentTypeUsage = try typeAssigner.typeUsage( + forObjectPropertyNamed: Constants.Operation.Body.variableName, + withSchema: schema.requiredSchemaObject(), + components: components, + inParent: partTypeName.appending(swiftComponent: nil, jsonComponent: "content") + ) + } else { + contentTypeUsage = partTypeName.asUsage + } + + let verifyContentTypeExpr: Expression = .try( + .identifierPattern("converter").dot("verifyContentTypeIfPresent") + .call([ + .init(label: "in", expression: .identifierPattern("headerFields")), + .init(label: "matches", expression: .literal(contentTypeHeaderValue)), + ]) + ) + let transformExpr: Expression = .closureInvocation(body: [.expression(.identifierPattern("$0"))]) + let converterExpr: Expression = .identifierPattern("converter") + .dot("\(getBodyMethodPrefix)As\(codingStrategy.runtimeName)") + .call([ + .init(label: nil, expression: .identifierType(contentTypeUsage.withOptional(false)).dot("self")), + .init(label: "from", expression: .identifierPattern("part").dot("body")), + .init(label: "transforming", expression: transformExpr), + ]) + let bodyExpr: Expression + switch codingStrategy { + case .json, .uri, .urlEncodedForm: + // Buffering. + bodyExpr = .try(.await(converterExpr)) + case .binary, .multipart: + // Streaming. + bodyExpr = .try(converterExpr) + } + let bodyDecl: Declaration = .variable(kind: .let, left: "body", right: bodyExpr) + + let extraNameArgs: [FunctionArgumentDescription] + if isDynamicallyNamed { + extraNameArgs = [.init(label: "name", expression: .identifierPattern("name"))] + } else { + extraNameArgs = [] + } + let returnExpr: Expression = .return( + .dot(caseName) + .call([ + .init( + expression: .dot("init") + .call( + [ + .init(label: "payload", expression: payloadExpr), + .init(label: "filename", expression: .identifierPattern("filename")), + ] + extraNameArgs + ) + ) + ]) + ) + return .init( + kind: caseKind, + body: headerDecls.map { .declaration($0) } + [ + .expression(verifyContentTypeExpr), .declaration(bodyDecl), .expression(returnExpr), + ] + ) + } + + /// Returns the code blocks representing the body of the multipart deserializer's decoding closure, parsing the + /// raw parts into typed parts. + /// - Parameters: + /// - multipart: The multipart content. + /// - getBodyMethodPrefix: The string prefix of the "get body" methods. + /// - Returns: The body code blocks of the decoding closure. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartDecodingClosure(_ multipart: MultipartContent, getBodyMethodPrefix: String) throws + -> [CodeBlock] + { + var cases: [SwitchCaseDescription] = try multipart.parts.compactMap { (part) -> SwitchCaseDescription? in + switch part { + case .documentedTyped(let part): + let originalName = part.originalName + let identifier = swiftSafeName(for: originalName) + let contentType = part.partInfo.contentType + let partTypeName = part.typeName + let schema = part.schema + let headersTypeName = part.typeName.appending( + swiftComponent: Constants.Operation.Output.Payload.Headers.typeName, + jsonComponent: "headers" + ) + let headers = try typedResponseHeaders(from: part.headers, inParent: headersTypeName) + let headerDecls: [Declaration] + let headersVarArgs: [FunctionArgumentDescription] + if !headers.isEmpty { + let headerExprs: [FunctionArgumentDescription] = headers.map { header in + translateMultipartIncomingHeader(header) + } + let headersDecl: Declaration = .variable( + kind: .let, + left: "headers", + type: .init(headersTypeName), + right: .dot("init").call(headerExprs) + ) + headerDecls = [headersDecl] + headersVarArgs = [.init(label: "headers", expression: .identifierPattern("headers"))] + } else { + headerDecls = [] + headersVarArgs = [] + } + let payloadExpr: Expression = .dot("init") + .call(headersVarArgs + [.init(label: "body", expression: .identifierPattern("body"))]) + return try translateMultipartDecodingClosureTypedPart( + caseName: identifier, + caseKind: .case(.literal(originalName)), + isDynamicallyNamed: false, + isPayloadBodyTypeNested: true, + getBodyMethodPrefix: getBodyMethodPrefix, + contentType: contentType, + partTypeName: partTypeName, + schema: schema, + payloadExpr: payloadExpr, + headerDecls: headerDecls, + headersVarArgs: headersVarArgs + ) + case .otherDynamicallyNamed(let part): + let contentType = part.partInfo.contentType + let partTypeName = part.typeName + let schema = part.schema + let payloadExpr: Expression = .identifierPattern("body") + return try translateMultipartDecodingClosureTypedPart( + caseName: Constants.AdditionalProperties.variableName, + caseKind: .default, + isDynamicallyNamed: true, + isPayloadBodyTypeNested: false, + getBodyMethodPrefix: getBodyMethodPrefix, + contentType: contentType, + partTypeName: partTypeName, + schema: schema, + payloadExpr: payloadExpr, + headerDecls: [], + headersVarArgs: [] + ) + case .undocumented: + return .init( + kind: .default, + body: [ + .expression(.return(.dot("undocumented").call([.init(expression: .identifierPattern("part"))]))) + ] + ) + case .otherRaw: + return .init( + kind: .default, + body: [.expression(.return(.dot("other").call([.init(expression: .identifierPattern("part"))])))] + ) + } + } + if multipart.additionalPropertiesStrategy == .disallowed { + cases.append( + .init( + kind: .default, + body: [ + .expression( + .identifierPattern("preconditionFailure") + .call([ + .init( + expression: .literal("Unknown part should be rejected by multipart validation.") + ) + ]) + ) + ] + ) + ) + } + let hasAtLeastOneTypedPart = multipart.parts.contains { part in + switch part { + case .documentedTyped, .otherDynamicallyNamed: return true + case .otherRaw, .undocumented: return false + } + } + return [ + .declaration( + .variable(kind: .let, left: "headerFields", right: .identifierPattern("part").dot("headerFields")) + ), + .declaration( + .variable( + kind: .let, + left: .tuple([ + .identifierPattern("name"), .identifierPattern(hasAtLeastOneTypedPart ? "filename" : "_"), + ]), + right: .try( + .identifierPattern("converter").dot("extractContentDispositionNameAndFilename") + .call([.init(label: "in", expression: .identifierPattern("headerFields"))]) + ) + ) + ), .expression(.switch(switchedExpression: .identifierPattern("name"), cases: cases)), + ] + } + + /// Returns the extra function arguments used for multipart serializers. + /// - Parameters: + /// - content: The multipart content. + /// - setBodyMethodPrefix: The string prefix of the "set body" methods. + /// - Returns: The extra function arguments. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartSerializerExtraArguments(_ content: TypedSchemaContent, setBodyMethodPrefix: String) throws + -> [FunctionArgumentDescription] + { + guard let multipart = try parseMultipartContent(content) else { return [] } + let requirementsArgs = translateMultipartRequirementsExtraArguments(multipart.requirements) + let encoding: FunctionArgumentDescription = .init( + label: "encoding", + expression: .closureInvocation( + argumentNames: ["part"], + body: try translateMultipartEncodingClosure(multipart, setBodyMethodPrefix: setBodyMethodPrefix) + ) + ) + return requirementsArgs + [encoding] + } + + /// Returns the description of the switch case for the provided individual multipart part. + /// - Parameters: + /// - caseName: The name of the case. + /// - nameExpr: The expression for the part's name. + /// - bodyExpr: The expression for the part's body. + /// - setBodyMethodPrefix: The string prefix of the "set body" methods. + /// - contentType: The content type of the part. + /// - headerExprs: A list of expressions of the part headers, can be empty. + /// - Returns: The switch case description. + func translateMultipartEncodingClosureTypedPart( + caseName: String, + nameExpr: Expression, + bodyExpr: Expression, + setBodyMethodPrefix: String, + contentType: ContentType, + headerExprs: [Expression] + ) -> SwitchCaseDescription { + let contentTypeHeaderValue = contentType.headerValueForSending + let headersDecl: Declaration = .variable( + kind: .var, + left: "headerFields", + type: .init(.httpFields), + right: .dot("init").call([]) + ) + let valueDecl: Declaration = .variable( + kind: .let, + left: "value", + right: .identifierPattern("wrapped").dot("payload") + ) + let bodyDecl: Declaration = .variable( + kind: .let, + left: "body", + right: .try( + .identifierPattern("converter").dot("\(setBodyMethodPrefix)As\(contentType.codingStrategy.runtimeName)") + .call([ + .init(label: nil, expression: bodyExpr), + .init(label: "headerFields", expression: .inOut(.identifierPattern("headerFields"))), + .init(label: "contentType", expression: .literal(contentTypeHeaderValue)), + ]) + ) + ) + let returnExpr: Expression = .return( + .dot("init") + .call([ + .init(label: "name", expression: nameExpr), + .init(label: "filename", expression: .identifierPattern("wrapped").dot("filename")), + .init(label: "headerFields", expression: .identifierPattern("headerFields")), + .init(label: "body", expression: .identifierPattern("body")), + ]) + ) + return .init( + kind: .case(.dot(caseName), ["wrapped"]), + body: [.declaration(headersDecl), .declaration(valueDecl)] + headerExprs.map { .expression($0) } + [ + .declaration(bodyDecl), .expression(returnExpr), + ] + ) + } + + /// Returns the code blocks representing the body of the multipart serializer's encoding closure, serializing the + /// typed parts into raw parts. + /// - Parameters: + /// - multipart: The multipart content. + /// - setBodyMethodPrefix: The string prefix of the "set body" methods. + /// - Returns: The body code blocks of the encoding closure. + /// - Throws: An error if the content is malformed or a reference cannot be followed. + func translateMultipartEncodingClosure(_ multipart: MultipartContent, setBodyMethodPrefix: String) throws + -> [CodeBlock] + { + let cases: [SwitchCaseDescription] = try multipart.parts.compactMap { part in + switch part { + case .documentedTyped(let part): + let originalName = part.originalName + let identifier = swiftSafeName(for: originalName) + let contentType = part.partInfo.contentType + let headersTypeName = part.typeName.appending( + swiftComponent: Constants.Operation.Output.Payload.Headers.typeName, + jsonComponent: "headers" + ) + let headers = try typedResponseHeaders(from: part.headers, inParent: headersTypeName) + let headerExprs: [Expression] = headers.map { header in translateMultipartOutgoingHeader(header) } + return translateMultipartEncodingClosureTypedPart( + caseName: identifier, + nameExpr: .literal(originalName), + bodyExpr: .identifierPattern("value").dot("body"), + setBodyMethodPrefix: setBodyMethodPrefix, + contentType: contentType, + headerExprs: headerExprs + ) + case .otherDynamicallyNamed(let part): + let contentType = part.partInfo.contentType + return translateMultipartEncodingClosureTypedPart( + caseName: Constants.AdditionalProperties.variableName, + nameExpr: .identifierPattern("wrapped").dot("name"), + bodyExpr: .identifierPattern("value"), + setBodyMethodPrefix: setBodyMethodPrefix, + contentType: contentType, + headerExprs: [] + ) + case .undocumented: + return .init( + kind: .case(.dot("undocumented"), ["value"]), + body: [.expression(.return(.identifierPattern("value")))] + ) + case .otherRaw: + return .init( + kind: .case(.dot("other"), ["value"]), + body: [.expression(.return(.identifierPattern("value")))] + ) + } + } + return [.expression(.switch(switchedExpression: .identifierPattern("part"), cases: cases))] + } +} diff --git a/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/TypedRequestBody.swift b/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/TypedRequestBody.swift index 2c2a9b82..2279bd1a 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/TypedRequestBody.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/TypedRequestBody.swift @@ -80,7 +80,7 @@ extension FileTranslator { isInlined = true } - let contents = try supportedTypedContents(request.content, inParent: typeName) + let contents = try supportedTypedContents(request.content, isRequired: request.required, inParent: typeName) if contents.isEmpty { return nil } let usage = typeName.asUsage.withOptional(!request.required) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/translateRequestBody.swift b/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/translateRequestBody.swift index 06c183b2..76c0c6f7 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/translateRequestBody.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/RequestBody/translateRequestBody.swift @@ -22,12 +22,13 @@ extension TypesFileTranslator { /// unsupported. /// - Throws: An error if there is an issue translating and declaring the schema content. func translateRequestBodyContentInTypes(_ content: TypedSchemaContent) throws -> [Declaration] { - let decl = try translateSchema( + if content.content.contentType.isMultipart { return try translateMultipartBody(content) } + let decls = try translateSchema( typeName: content.resolvedTypeUsage.typeName, schema: content.content.schema, overrides: .none ) - return decl + return decls } /// Returns a list of declarations for the specified request body wrapped @@ -45,12 +46,12 @@ extension TypesFileTranslator { let contentTypeName = typeName.appending(jsonComponent: "content") let contents = requestBody.contents for content in contents { - if TypeMatcher.isInlinable(content.content.schema) { + if TypeMatcher.isInlinable(content.content.schema) || content.content.isReferenceableMultipart { let inlineTypeDecls = try translateRequestBodyContentInTypes(content) bodyMembers.append(contentsOf: inlineTypeDecls) } let contentType = content.content.contentType - let identifier = contentSwiftName(contentType) + let identifier = typeAssigner.contentSwiftName(contentType) let associatedType = content.resolvedTypeUsage.withOptional(false) let contentCase: Declaration = .commentable( contentType.docComment(typeName: contentTypeName), @@ -143,12 +144,18 @@ extension ClientFileTranslator { inputVariableName: String ) throws -> Expression { let contents = requestBody.contents - var cases: [SwitchCaseDescription] = contents.map { typedContent in + var cases: [SwitchCaseDescription] = try contents.map { typedContent in let content = typedContent.content let contentType = content.contentType - let contentTypeIdentifier = contentSwiftName(contentType) + let contentTypeIdentifier = typeAssigner.contentSwiftName(contentType) let contentTypeHeaderValue = contentType.headerValueForSending + let extraBodyAssignArgs: [FunctionArgumentDescription] + if contentType.isMultipart { + extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInClient(typedContent) + } else { + extraBodyAssignArgs = [] + } let bodyAssignExpr: Expression = .assignment( left: .identifierPattern(bodyVariableName), right: .try( @@ -156,13 +163,15 @@ extension ClientFileTranslator { .dot( "set\(requestBody.request.required ? "Required" : "Optional")RequestBodyAs\(contentType.codingStrategy.runtimeName)" ) - .call([ - .init(label: nil, expression: .identifierPattern("value")), - .init( - label: "headerFields", - expression: .inOut(.identifierPattern(requestVariableName).dot("headerFields")) - ), .init(label: "contentType", expression: .literal(contentTypeHeaderValue)), - ]) + .call( + [ + .init(label: nil, expression: .identifierPattern("value")), + .init( + label: "headerFields", + expression: .inOut(.identifierPattern(requestVariableName).dot("headerFields")) + ), .init(label: "contentType", expression: .literal(contentTypeHeaderValue)), + ] + extraBodyAssignArgs + ) ) ) let caseDesc = SwitchCaseDescription( @@ -231,7 +240,7 @@ extension ServerFileTranslator { ) codeBlocks.append(.declaration(chosenContentTypeDecl)) - func makeCase(typedContent: TypedSchemaContent) -> SwitchCaseDescription { + func makeCase(typedContent: TypedSchemaContent) throws -> SwitchCaseDescription { let contentTypeUsage = typedContent.resolvedTypeUsage let content = typedContent.content let contentType = content.contentType @@ -241,23 +250,36 @@ extension ServerFileTranslator { argumentNames: ["value"], body: [ .expression( - .dot(contentSwiftName(typedContent.content.contentType)) + .dot(typeAssigner.contentSwiftName(typedContent.content.contentType)) .call([.init(label: nil, expression: .identifierPattern("value"))]) ) ] ) + let extraBodyAssignArgs: [FunctionArgumentDescription] + if contentType.isMultipart { + extraBodyAssignArgs = try translateMultipartDeserializerExtraArgumentsInServer(typedContent) + } else { + extraBodyAssignArgs = [] + } let converterExpr: Expression = .identifierPattern("converter") .dot("get\(isOptional ? "Optional" : "Required")RequestBodyAs\(codingStrategyName)") - .call([ - .init(label: nil, expression: .identifierType(contentTypeUsage.withOptional(false)).dot("self")), - .init(label: "from", expression: .identifierPattern("requestBody")), - .init(label: "transforming", expression: transformExpr), - ]) + .call( + [ + .init( + label: nil, + expression: .identifierType(contentTypeUsage.withOptional(false)).dot("self") + ), .init(label: "from", expression: .identifierPattern("requestBody")), + .init(label: "transforming", expression: transformExpr), + ] + extraBodyAssignArgs + ) let bodyExpr: Expression - if codingStrategy == .binary { - bodyExpr = .try(converterExpr) - } else { + switch codingStrategy { + case .json, .uri, .urlEncodedForm: + // Buffering. bodyExpr = .try(.await(converterExpr)) + case .binary, .multipart: + // Streaming. + bodyExpr = .try(converterExpr) } let bodyAssignExpr: Expression = .assignment(left: .identifierPattern("body"), right: bodyExpr) return .init( @@ -266,7 +288,7 @@ extension ServerFileTranslator { ) } - let cases = typedContents.map(makeCase) + let cases = try typedContents.map(makeCase) let switchExpr: Expression = .switch( switchedExpression: .identifierPattern("chosenContentType"), cases: cases + [ diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/TypedResponseHeader.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/TypedResponseHeader.swift index 3980dbfe..4cf5a85a 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/TypedResponseHeader.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/TypedResponseHeader.swift @@ -66,8 +66,23 @@ extension FileTranslator { /// headers, such as unsupported header types or invalid definitions. func typedResponseHeaders(from response: OpenAPI.Response, inParent parent: TypeName) throws -> [TypedResponseHeader] + { try typedResponseHeaders(from: response.headers, inParent: parent) } + + /// Returns the response headers declared by the specified response. + /// + /// Skips any unsupported response headers. + /// - Parameters: + /// - headers: The OpenAPI headers. + /// - parent: The Swift type name of the parent type of the headers. + /// - Returns: A list of response headers; can be empty if no response + /// headers are specified in the OpenAPI document, or if all headers are + /// unsupported. + /// - Throws: An error if there's an issue processing or generating typed response + /// headers, such as unsupported header types or invalid definitions. + func typedResponseHeaders(from headers: OpenAPI.Header.Map?, inParent parent: TypeName) throws + -> [TypedResponseHeader] { - guard let headers = response.headers else { return [] } + guard let headers else { return [] } return try headers.compactMap { name, header in try typedResponseHeader(from: header, named: name, inParent: parent) } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/acceptHeaderContentTypes.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/acceptHeaderContentTypes.swift index a7bcf13c..62490bc7 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/acceptHeaderContentTypes.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/acceptHeaderContentTypes.swift @@ -27,7 +27,7 @@ extension FileTranslator { let contentTypes = try description.responseOutcomes .flatMap { outcome in let response = try outcome.response.resolve(in: components) - return try supportedContents(response.content, foundIn: description.operationID) + return try supportedContents(response.content, isRequired: true, foundIn: description.operationID) } .map { content in content.contentType } return Array(contentTypes.uniqued()) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift index 4a03782f..13a44064 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift @@ -60,80 +60,18 @@ extension TypesFileTranslator { swiftComponent: Constants.Operation.Body.typeName, jsonComponent: "content" ) - let typedContents = try supportedTypedContents(response.content, inParent: bodyTypeName) + let typedContents = try supportedTypedContents(response.content, isRequired: true, inParent: bodyTypeName) let bodyProperty: PropertyBlueprint? if !typedContents.isEmpty { var bodyCases: [Declaration] = [] for typedContent in typedContents { - let contentType = typedContent.content.contentType - let identifier = contentSwiftName(contentType) - let associatedType = typedContent.resolvedTypeUsage - if TypeMatcher.isInlinable(typedContent.content.schema), let inlineType = typedContent.typeUsage { - let inlineTypeDecls = try translateSchema( - typeName: inlineType.typeName, - schema: typedContent.content.schema, - overrides: .none - ) - bodyCases.append(contentsOf: inlineTypeDecls) - } - - let bodyCase: Declaration = .commentable( - contentType.docComment(typeName: bodyTypeName), - .enumCase(name: identifier, kind: .nameWithAssociatedValues([.init(type: .init(associatedType))])) - ) - bodyCases.append(bodyCase) - - var throwingGetterSwitchCases = [ - SwitchCaseDescription( - kind: .case(.dot(identifier), ["body"]), - body: [.expression(.return(.identifierPattern("body")))] - ) - ] - // We only generate the default branch if there is more than one case to prevent - // a warning when compiling the generated code. - if typedContents.count > 1 { - throwingGetterSwitchCases.append( - SwitchCaseDescription( - kind: .default, - body: [ - .expression( - .try( - .identifierPattern("throwUnexpectedResponseBody") - .call([ - .init( - label: "expectedContent", - expression: .literal(.string(contentType.headerValueForValidation)) - ), .init(label: "body", expression: .identifierPattern("self")), - ]) - ) - ) - ] - ) - ) - } - let throwingGetter = VariableDescription( - accessModifier: config.access, - isStatic: false, - kind: .var, - left: identifier, - type: .init(associatedType), - getter: [ - .expression( - .switch(switchedExpression: .identifierPattern("self"), cases: throwingGetterSwitchCases) - ) - ], - getterEffects: [.throws] + let newBodyCases = try translateResponseBodyContentInTypes( + typedContent, + bodyTypeName: bodyTypeName, + hasMultipleContentTypes: typedContents.count > 1 ) - let throwingGetterComment = Comment.doc( - """ - The associated value of the enum case if `self` is `.\(identifier)`. - - - Throws: An error if `self` is not `.\(identifier)`. - - SeeAlso: `.\(identifier)`. - """ - ) - bodyCases.append(.commentable(throwingGetterComment, .variable(throwingGetter))) + bodyCases.append(contentsOf: newBodyCases) } let hasNoContent: Bool = bodyCases.isEmpty let contentEnumDecl: Declaration = .commentable( @@ -188,4 +126,93 @@ extension TypesFileTranslator { let typeName = typeAssigner.typeName(for: componentKey, of: OpenAPI.Response.self) return try translateResponseInTypes(typeName: typeName, response: response) } + + /// Returns a list of declarations for the specified content to be generated in the provided body namespace. + /// - Parameters: + /// - typedContent: The content to generated. + /// - bodyTypeName: The parent body type name. + /// - hasMultipleContentTypes: A Boolean value indicating whether there are more than one content types. + /// - Returns: A list of declarations. + /// - Throws: If the translation of underlying schemas fails. + func translateResponseBodyContentInTypes( + _ typedContent: TypedSchemaContent, + bodyTypeName: TypeName, + hasMultipleContentTypes: Bool + ) throws -> [Declaration] { + var bodyCases: [Declaration] = [] + let contentType = typedContent.content.contentType + let identifier = typeAssigner.contentSwiftName(contentType) + let associatedType = typedContent.resolvedTypeUsage + let content = typedContent.content + let schema = content.schema + if TypeMatcher.isInlinable(schema) || content.isReferenceableMultipart { + let decls: [Declaration] + if contentType.isMultipart { + decls = try translateMultipartBody(typedContent) + } else { + decls = try translateSchema( + typeName: typedContent.resolvedTypeUsage.typeName, + schema: typedContent.content.schema, + overrides: .none + ) + } + bodyCases.append(contentsOf: decls) + } + + let bodyCase: Declaration = .commentable( + contentType.docComment(typeName: bodyTypeName), + .enumCase(name: identifier, kind: .nameWithAssociatedValues([.init(type: .init(associatedType))])) + ) + bodyCases.append(bodyCase) + + var throwingGetterSwitchCases = [ + SwitchCaseDescription( + kind: .case(.dot(identifier), ["body"]), + body: [.expression(.return(.identifierPattern("body")))] + ) + ] + // We only generate the default branch if there is more than one case to prevent + // a warning when compiling the generated code. + if hasMultipleContentTypes { + throwingGetterSwitchCases.append( + SwitchCaseDescription( + kind: .default, + body: [ + .expression( + .try( + .identifierPattern("throwUnexpectedResponseBody") + .call([ + .init( + label: "expectedContent", + expression: .literal(.string(contentType.headerValueForValidation)) + ), .init(label: "body", expression: .identifierPattern("self")), + ]) + ) + ) + ] + ) + ) + } + let throwingGetter = VariableDescription( + accessModifier: config.access, + isStatic: false, + kind: .var, + left: .identifierPattern(identifier), + type: .init(associatedType), + getter: [ + .expression(.switch(switchedExpression: .identifierPattern("self"), cases: throwingGetterSwitchCases)) + ], + getterEffects: [.throws] + ) + let throwingGetterComment = Comment.doc( + """ + The associated value of the enum case if `self` is `.\(identifier)`. + + - Throws: An error if `self` is not `.\(identifier)`. + - SeeAlso: `.\(identifier)`. + """ + ) + bodyCases.append(.commentable(throwingGetterComment, .variable(throwingGetter))) + return bodyCases + } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseHeader.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseHeader.swift index 4412a7be..229d575a 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseHeader.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseHeader.swift @@ -13,6 +13,45 @@ //===----------------------------------------------------------------------===// import OpenAPIKit +extension FileTranslator { + + /// Returns an expression representing the call to converter to set the provided header into + /// a header fields container. + /// - Parameter header: The header to set. + /// - Returns: An expression. + func translateMultipartOutgoingHeader(_ header: TypedResponseHeader) -> Expression { + .try( + .identifierPattern("converter").dot("setHeaderFieldAs\(header.codingStrategy.runtimeName)") + .call([ + .init(label: "in", expression: .inOut(.identifierPattern("headerFields"))), + .init(label: "name", expression: .literal(header.name)), + .init( + label: "value", + expression: .identifierPattern("value").dot("headers").dot(header.variableName) + ), + ]) + ) + } + + /// Returns an expression representing the call to converter to get the provided header from + /// a header fields container. + /// - Parameter header: The header to get. + /// - Returns: A function argument description. + func translateMultipartIncomingHeader(_ header: TypedResponseHeader) -> FunctionArgumentDescription { + let methodName = + "get\(header.isOptional ? "Optional" : "Required")HeaderFieldAs\(header.codingStrategy.runtimeName)" + let convertExpr: Expression = .try( + .identifierPattern("converter").dot(methodName) + .call([ + .init(label: "in", expression: .identifierPattern("headerFields")), + .init(label: "name", expression: .literal(header.name)), + .init(label: "as", expression: .identifierType(header.typeUsage.withOptional(false)).dot("self")), + ]) + ) + return .init(label: header.variableName, expression: convertExpr) + } +} + extension TypesFileTranslator { /// Returns the specified response header extracted into a property diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift index 264257af..a5187b4d 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -67,7 +67,7 @@ extension TypesFileTranslator { let throwingGetterDesc = VariableDescription( accessModifier: config.access, kind: .var, - left: enumCaseName, + left: .identifierPattern(enumCaseName), type: .init(responseStructTypeName), getter: [ .expression( @@ -176,7 +176,11 @@ extension ClientFileTranslator { headersVarExpr = nil } - let typedContents = try supportedTypedContents(typedResponse.response.content, inParent: bodyTypeName) + let typedContents = try supportedTypedContents( + typedResponse.response.content, + isRequired: true, + inParent: bodyTypeName + ) let bodyVarExpr: Expression? if !typedContents.isEmpty { @@ -210,30 +214,42 @@ extension ClientFileTranslator { ) codeBlocks.append(.declaration(chosenContentTypeDecl)) - func makeCase(typedContent: TypedSchemaContent) -> SwitchCaseDescription { + func makeCase(typedContent: TypedSchemaContent) throws -> SwitchCaseDescription { let contentTypeUsage = typedContent.resolvedTypeUsage let transformExpr: Expression = .closureInvocation( argumentNames: ["value"], body: [ .expression( - .dot(contentSwiftName(typedContent.content.contentType)) + .dot(typeAssigner.contentSwiftName(typedContent.content.contentType)) .call([.init(label: nil, expression: .identifierPattern("value"))]) ) ] ) let codingStrategy = typedContent.content.contentType.codingStrategy + let extraBodyAssignArgs: [FunctionArgumentDescription] + if typedContent.content.contentType.isMultipart { + extraBodyAssignArgs = try translateMultipartDeserializerExtraArgumentsInClient(typedContent) + } else { + extraBodyAssignArgs = [] + } + let converterExpr: Expression = .identifierPattern("converter") .dot("getResponseBodyAs\(codingStrategy.runtimeName)") - .call([ - .init(label: nil, expression: .identifierType(contentTypeUsage).dot("self")), - .init(label: "from", expression: .identifierPattern("responseBody")), - .init(label: "transforming", expression: transformExpr), - ]) + .call( + [ + .init(label: nil, expression: .identifierType(contentTypeUsage).dot("self")), + .init(label: "from", expression: .identifierPattern("responseBody")), + .init(label: "transforming", expression: transformExpr), + ] + extraBodyAssignArgs + ) let bodyExpr: Expression - if codingStrategy == .binary { - bodyExpr = .try(converterExpr) - } else { + switch codingStrategy { + case .json, .uri, .urlEncodedForm: + // Buffering. bodyExpr = .try(.await(converterExpr)) + case .binary, .multipart: + // Streaming. + bodyExpr = .try(converterExpr) } let bodyAssignExpr: Expression = .assignment(left: .identifierPattern("body"), right: bodyExpr) return .init( @@ -241,7 +257,7 @@ extension ClientFileTranslator { body: [.expression(bodyAssignExpr)] ) } - let cases = typedContents.map(makeCase) + let cases = try typedContents.map(makeCase) let switchExpr: Expression = .switch( switchedExpression: .identifierPattern("chosenContentType"), cases: cases + [ @@ -352,10 +368,14 @@ extension ServerFileTranslator { codeBlocks.append(contentsOf: headerExprs.map { .expression($0) }) let bodyReturnExpr: Expression - let typedContents = try supportedTypedContents(typedResponse.response.content, inParent: bodyTypeName) + let typedContents = try supportedTypedContents( + typedResponse.response.content, + isRequired: true, + inParent: bodyTypeName + ) if !typedContents.isEmpty { codeBlocks.append(.declaration(.variable(kind: .let, left: "body", type: .init(TypeName.body)))) - let switchContentCases: [SwitchCaseDescription] = typedContents.map { typedContent in + let switchContentCases: [SwitchCaseDescription] = try typedContents.map { typedContent in var caseCodeBlocks: [CodeBlock] = [] @@ -370,23 +390,38 @@ extension ServerFileTranslator { caseCodeBlocks.append(.expression(validateAcceptHeader)) let contentType = typedContent.content.contentType + let extraBodyAssignArgs: [FunctionArgumentDescription] + if contentType.isMultipart { + extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) + } else { + extraBodyAssignArgs = [] + } let assignBodyExpr: Expression = .assignment( left: .identifierPattern("body"), right: .try( .identifierPattern("converter") .dot("setResponseBodyAs\(contentType.codingStrategy.runtimeName)") - .call([ - .init(label: nil, expression: .identifierPattern("value")), - .init( - label: "headerFields", - expression: .inOut(.identifierPattern("response").dot("headerFields")) - ), .init(label: "contentType", expression: .literal(contentType.headerValueForSending)), - ]) + .call( + [ + .init(label: nil, expression: .identifierPattern("value")), + .init( + label: "headerFields", + expression: .inOut(.identifierPattern("response").dot("headerFields")) + ), + .init( + label: "contentType", + expression: .literal(contentType.headerValueForSending) + ), + ] + extraBodyAssignArgs + ) ) ) caseCodeBlocks.append(.expression(assignBodyExpr)) - return .init(kind: .case(.dot(contentSwiftName(contentType)), ["value"]), body: caseCodeBlocks) + return .init( + kind: .case(.dot(typeAssigner.contentSwiftName(contentType)), ["value"]), + body: caseCodeBlocks + ) } codeBlocks.append( @@ -397,7 +432,7 @@ extension ServerFileTranslator { bodyReturnExpr = .identifierPattern("body") } else { - bodyReturnExpr = nil + bodyReturnExpr = .literal(nil) } let returnExpr: Expression = .return(.tuple([.identifierPattern("response"), bodyReturnExpr])) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/translateServerMethod.swift b/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/translateServerMethod.swift index a8e85b5f..ca8aef41 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/translateServerMethod.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/ServerTranslator/translateServerMethod.swift @@ -103,7 +103,7 @@ extension ServerFileTranslator { let undocumentedExpr: Expression = .return( .tuple([ .dot("init").call([.init(label: "soar_statusCode", expression: .identifierPattern("statusCode"))]), - nil, + .literal(nil), ]) ) cases.append( diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift index 1621468c..90dca3be 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift @@ -73,9 +73,24 @@ extension TypeName { /// Returns the type name for the response type. static var response: TypeName { .httpTypes("HTTPResponse") } + /// Returns the type name for the HTTP fields type. + static var httpFields: TypeName { .httpTypes("HTTPFields") } + /// Returns the type name for the body type. static var body: TypeName { .runtime("HTTPBody") } + /// Returns the type name for the body type. + static var multipartBody: TypeName { .runtime("MultipartBody") } + + /// Returns the type name for the multipart typed part type. + static var multipartPart: TypeName { .runtime("MultipartPart") } + + /// Returns the type name for the multipart dynamically typed part type. + static var multipartDynamicallyNamedPart: TypeName { .runtime("MultipartDynamicallyNamedPart") } + + /// Returns the type name for the multipart raw part type. + static var multipartRawPart: TypeName { .runtime("MultipartRawPart") } + /// Returns the type name for the server request metadata type. static var serverRequestMetadata: TypeName { .runtime("ServerRequestMetadata") } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeAssigner.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeAssigner.swift index bc563c18..093caeaf 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeAssigner.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeAssigner.swift @@ -109,6 +109,65 @@ struct TypeAssigner { return associatedType } + /// Returns a type usage for an unresolved multipart schema. + /// - Parameters: + /// - hint: A hint string used when computing a name for an inline type. + /// - schema: The OpenAPI schema. + /// - encoding: The encoding mapping refining the schema. + /// - components: The components in which to look up references. + /// - parent: The parent type in which to name the type. + /// - Returns: A type usage. + /// - Throws: An error if there's an issue while computing the type usage, such as when resolving a type name or checking compatibility. + func typeUsage( + usingNamingHint hint: String, + withMultipartSchema schema: UnresolvedSchema?, + encoding: OrderedDictionary?, + components: OpenAPI.Components, + inParent parent: TypeName + ) throws -> TypeUsage { + let multipartBodyElementTypeName: TypeName + if let ref = TypeMatcher.multipartElementTypeReferenceIfReferenceable(schema: schema, encoding: encoding) { + multipartBodyElementTypeName = try typeName(for: ref) + } else { + let swiftSafeName = asSwiftSafeName(hint) + multipartBodyElementTypeName = parent.appending( + swiftComponent: swiftSafeName + Constants.Global.inlineTypeSuffix, + jsonComponent: hint + ) + } + let bodyUsage = multipartBodyElementTypeName.asUsage.asWrapped(in: .multipartBody) + return bodyUsage + } + + /// Returns a type usage for an unresolved schema. + /// - Parameters: + /// - content: The OpenAPI content. + /// - components: The components in which to look up references. + /// - parent: The parent type in which to name the type. + /// - Returns: A type usage; or nil if the schema is nil or unsupported. + /// - Throws: An error if there's an issue while computing the type usage, such as when resolving a type name or checking compatibility. + func typeUsage(withContent content: SchemaContent, components: OpenAPI.Components, inParent parent: TypeName) throws + -> TypeUsage? + { + let identifier = contentSwiftName(content.contentType) + if content.contentType.isMultipart { + return try typeUsage( + usingNamingHint: identifier, + withMultipartSchema: content.schema, + encoding: content.encoding, + components: components, + inParent: parent + ) + } else { + return try typeUsage( + usingNamingHint: identifier, + withSchema: content.schema, + components: components, + inParent: parent + ) + } + } + /// Returns a type usage for a property. /// - Parameters: /// - originalName: The name of the property in the OpenAPI document. @@ -450,6 +509,43 @@ struct TypeAssigner { func typeNameForComponents() -> TypeName { TypeName(components: [.root, .init(swift: Constants.Components.namespace, json: "components")]) } + + /// Returns a Swift-safe identifier used as the name of the content + /// enum case. + /// + /// - Parameter contentType: The content type for which to compute the name. + /// - Returns: A Swift-safe identifier representing the name of the content enum case. + func contentSwiftName(_ contentType: ContentType) -> String { + let rawContentType = contentType.lowercasedTypeSubtypeAndParameters + switch rawContentType { + case "application/json": return "json" + case "application/x-www-form-urlencoded": return "urlEncodedForm" + case "multipart/form-data": return "multipartForm" + case "text/plain": return "plainText" + case "*/*": return "any" + case "application/xml": return "xml" + case "application/octet-stream": return "binary" + case "text/html": return "html" + case "application/yaml": return "yaml" + case "text/csv": return "csv" + case "image/png": return "png" + case "application/pdf": return "pdf" + case "image/jpeg": return "jpeg" + default: + let safedType = asSwiftSafeName(contentType.originallyCasedType) + let safedSubtype = asSwiftSafeName(contentType.originallyCasedSubtype) + let prefix = "\(safedType)_\(safedSubtype)" + let params = contentType.lowercasedParameterPairs + guard !params.isEmpty else { return prefix } + let safedParams = + params.map { pair in + pair.split(separator: "=").map { asSwiftSafeName(String($0)) }.joined(separator: "_") + } + .joined(separator: "_") + return prefix + "_" + safedParams + } + } + } extension FileTranslator { diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift index 0cefe261..e4116293 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift @@ -149,6 +149,21 @@ struct TypeMatcher { /// - Returns: `true` if the schema is inlinable; `false` otherwise. static func isInlinable(_ schema: UnresolvedSchema?) -> Bool { !isReferenceable(schema) } + /// Return a reference to a multipart element type if the provided schema is referenceable. + /// - Parameters: + /// - schema: The schema to try to reference. + /// - encoding: The associated encoding. + /// - Returns: A reference if the schema is referenceable, nil otherwise. + static func multipartElementTypeReferenceIfReferenceable( + schema: UnresolvedSchema?, + encoding: OrderedDictionary? + ) -> OpenAPI.Reference? { + // If the schema is a ref AND no encoding is provided, we can reference the type. + // Otherwise, we must inline. + guard case .a(let ref) = schema, encoding == nil || encoding!.isEmpty else { return nil } + return ref + } + /// Returns a Boolean value that indicates whether the schema /// is a key-value pair schema, for example an object. /// diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift index e0929f1e..e4735b0b 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/isSchemaSupported.swift @@ -87,6 +87,40 @@ extension FileTranslator { } } + /// Validates that the multipart schema is supported by the generator. + /// + /// Also emits a diagnostic into the collector if the schema is unsupported. + /// - Parameters: + /// - schema: The schema to validate. + /// - foundIn: A description of the schema's context. + /// - Returns: `true` if the schema is supported; `false` otherwise. + /// - Throws: An error if there's an issue during the validation process. + func validateMultipartSchemaIsSupported(_ schema: UnresolvedSchema?, foundIn: String) throws -> Bool { + var referenceStack = ReferenceStack.empty + switch try isObjectOrRefToObjectSchemaAndSupported(schema, referenceStack: &referenceStack) { + case .supported: return true + case .unsupported(reason: let reason, schema: let schema): + diagnostics.emitUnsupportedSchema(reason: reason.description, schema: schema, foundIn: foundIn) + return false + } + } + + /// Validates that the content is supported by the generator. + /// + /// Also emits a diagnostic into the collector if the content is unsupported. + /// - Parameters: + /// - content: The content to validate. + /// - foundIn: A description of the content's context. + /// - Returns: `true` if the content is supported; `false` otherwise. + /// - Throws: An error if there's an issue during the validation process. + func validateContentIsSupported(_ content: SchemaContent, foundIn: String) throws -> Bool { + if content.contentType.isMultipart { + return try validateMultipartSchemaIsSupported(content.schema, foundIn: foundIn) + } else { + return try validateSchemaIsSupported(content.schema, foundIn: foundIn) + } + } + /// Returns whether the schema is supported. /// /// If a schema is not supported, no references to it should be emitted. @@ -188,7 +222,7 @@ extension FileTranslator { /// Returns a result indicating whether the provided schema /// is an reference, object, or allOf (object-ish) schema and is supported. /// - Parameters: - /// - schema: A schemas to check. + /// - schema: A schema to check. /// - referenceStack: A stack of reference names that lead to this schema. /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is /// supported or not. @@ -205,6 +239,29 @@ extension FileTranslator { } } + /// Returns a result indicating whether the provided schema + /// is an reference, object, or allOf (object-ish) schema and is supported. + /// - Parameters: + /// - schema: A schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. + /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is + /// supported or not. + /// - Throws: An error if there's an issue during the validation process. + func isObjectishSchemaAndSupported(_ schema: UnresolvedSchema?, referenceStack: inout ReferenceStack) throws + -> IsSchemaSupportedResult + { + guard let schema else { + // fragment type is supported + return .supported + } + switch schema { + case .a: + // references are supported + return .supported + case let .b(schema): return try isObjectishSchemaAndSupported(schema, referenceStack: &referenceStack) + } + } + /// Returns a result indicating whether the provided schemas /// are object-ish schemas and supported. /// - Parameters: @@ -265,4 +322,98 @@ extension FileTranslator { default: return .unsupported(reason: .notRef, schema: schema) } } + + /// Returns a result indicating whether the provided schema + /// is an object or a reference to an object, or a fragment, and is supported. + /// - Parameters: + /// - schema: A schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. + /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is + /// supported or not. + /// - Throws: An error if there's an issue during the validation process. + func isObjectOrRefToObjectSchemaAndSupported(_ schema: UnresolvedSchema?, referenceStack: inout ReferenceStack) + throws -> IsSchemaSupportedResult + { + guard let schema else { + // fragment type is supported + return .supported + } + switch schema { + case .a: + // references are supported + return .supported + case let .b(schema): return try isObjectOrRefToObjectSchemaAndSupported(schema, referenceStack: &referenceStack) + } + } + + /// Returns a result indicating whether the provided schema + /// is an object or a reference to an object, or a fragment, and is supported. + /// - Parameters: + /// - schema: A schema to check. + /// - referenceStack: A stack of reference names that lead to this schema. + /// - Returns: An `IsSchemaSupportedResult` indicating whether the schema is + /// supported or not. + /// - Throws: An error if there's an issue during the validation process. + func isObjectOrRefToObjectSchemaAndSupported(_ schema: JSONSchema, referenceStack: inout ReferenceStack) throws + -> IsSchemaSupportedResult + { + switch schema.value { + case .object, .fragment: return .supported + case let .reference(ref, _): + if try referenceStack.contains(ref) { + // Encountered a cycle, but that's okay - return supported. + return .supported + } + // reference is supported iff the existing type is supported + let referencedSchema = try components.lookup(ref) + try referenceStack.push(ref) + defer { referenceStack.pop() } + return try isObjectOrRefToObjectSchemaAndSupported(referencedSchema, referenceStack: &referenceStack) + default: return .unsupported(reason: .notRef, schema: schema) + } + } + + /// Resolves references and returns the object context for the provided object schema. + /// - Parameters: + /// - schema: The schema to resolve. + /// - referenceStack: The reference stack. + /// - Returns: An object context, or nil if the first concrete type is not an object schema. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func flattenedTopLevelMultipartObject(_ schema: JSONSchema, referenceStack: inout ReferenceStack) throws + -> JSONSchema.ObjectContext? + { + switch schema.value { + case .null, .boolean, .number, .integer, .string, .not, .array, .one, .all, .any: return nil + case .object(_, let context): return context + case .reference(let ref, _): + if try referenceStack.contains(ref) { + // Encountered a cycle, that's not supported for top level multipart objects. + return nil + } + // reference is supported iff the existing type is supported + let referencedSchema = try components.lookup(ref) + try referenceStack.push(ref) + defer { referenceStack.pop() } + return try flattenedTopLevelMultipartObject(referencedSchema, referenceStack: &referenceStack) + case .fragment: return .init(properties: [:], additionalProperties: nil) + } + } + + /// Resolves references and returns the object context for the provided object schema. + /// - Parameters: + /// - schema: The schema to resolve. + /// - referenceStack: The reference stack. + /// - Returns: An object context, or nil if the first concrete type is not an object schema. + /// - Throws: An error if the schema is malformed or a reference cannot be followed. + func flattenedTopLevelMultipartObject(_ schema: UnresolvedSchema?, referenceStack: inout ReferenceStack) throws + -> JSONSchema.ObjectContext? + { + let resolvedSchema: JSONSchema + switch schema { + case .none: resolvedSchema = .fragment + case .a(let ref): resolvedSchema = .reference(ref.jsonReference) + case .b(let schema): resolvedSchema = schema + } + return try flattenedTopLevelMultipartObject(resolvedSchema, referenceStack: &referenceStack) + } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift index 4df12bf5..fb0cc056 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/TypesFileTranslator.swift @@ -42,7 +42,8 @@ struct TypesFileTranslator: FileTranslator { let serversDecl = translateServers(doc.servers) - let components = try translateComponents(doc.components) + let multipartSchemaNames = try parseSchemaNamesUsedInMultipart(paths: doc.paths, components: doc.components) + let components = try translateComponents(doc.components, multipartSchemaNames: multipartSchemaNames) let operationDescriptions = try OperationDescription.all( from: doc.paths, diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift index 89ba293f..02921018 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateBoxedTypes.swift @@ -106,7 +106,7 @@ extension TypesFileTranslator { guard case .commentable(let comment, let commented) = member, case .variable(var variableDescription) = commented else { return member } - let name = variableDescription.left + let name = TextBasedRenderer.renderedExpressionAsString(variableDescription.left) variableDescription.getter = [.expression(.identifierPattern("storage").dot("value").dot(name))] variableDescription.modify = [ .expression(.yield(.inOut(.identifierPattern("storage").dot("value").dot(name)))) @@ -122,7 +122,7 @@ extension TypesFileTranslator { else { return member } let propertyNames: [String] = desc.members.compactMap { member in guard case .variable(let variableDescription) = member.strippingTopComment else { return nil } - return variableDescription.left + return TextBasedRenderer.renderedExpressionAsString(variableDescription.left) } funcDesc.body = [ .expression( diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponentRequestBodies.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponentRequestBodies.swift index 1edcc1fc..bafb5ef8 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponentRequestBodies.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponentRequestBodies.swift @@ -21,7 +21,6 @@ extension TypesFileTranslator { /// - Returns: An enum declaration representing the requestBodies namespace. /// - Throws: An error if there's an issue during translation or request body processing. func translateComponentRequestBodies(_ items: OpenAPI.ComponentDictionary) throws -> Declaration { - let typedItems: [TypedRequestBody] = try items.compactMap { key, item in let typeName = typeAssigner.typeName(for: key, of: OpenAPI.Request.self) return try typedRequestBody(typeName: typeName, from: .b(item)) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponents.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponents.swift index bc9e44e0..54a1b6b9 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponents.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateComponents.swift @@ -18,13 +18,17 @@ extension TypesFileTranslator { /// Returns a declaration of a code block containing the components /// namespace, which contains all the reusable component namespaces, such /// as for schemas, parameters, and response headers. - /// - Parameter components: The components defined in the OpenAPI document. + /// - Parameters: + /// - components: The components defined in the OpenAPI document. + /// - multipartSchemaNames: The names of schemas used as root multipart content. /// - Returns: A code block with the enum representing the components /// namespace. /// - Throws: An error if there's an issue during translation of components. - func translateComponents(_ components: OpenAPI.Components) throws -> CodeBlock { + func translateComponents(_ components: OpenAPI.Components, multipartSchemaNames: Set) throws + -> CodeBlock + { - let schemas = try translateSchemas(components.schemas) + let schemas = try translateSchemas(components.schemas, multipartSchemaNames: multipartSchemaNames) let parameters = try translateComponentParameters(components.parameters) let requestBodies = try translateComponentRequestBodies(components.requestBodies) let responses = try translateComponentResponses(components.responses) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateOperations.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateOperations.swift index 305d1df7..df3576ac 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateOperations.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateOperations.swift @@ -191,7 +191,7 @@ extension TypesFileTranslator { let contentTypes = try acceptHeaderContentTypes(for: description) guard !contentTypes.isEmpty else { return nil } let cases: [(caseName: String, rawExpr: LiteralDescription)] = contentTypes.map { contentType in - (contentSwiftName(contentType), .string(contentType.lowercasedTypeAndSubtype)) + (typeAssigner.contentSwiftName(contentType), .string(contentType.lowercasedTypeAndSubtype)) } return try translateRawRepresentableEnum( typeName: acceptableContentTypeName, @@ -218,14 +218,12 @@ extension TypesFileTranslator { func translateOperation(_ operation: OperationDescription) throws -> Declaration { let idPropertyDecl: Declaration = .variable( - .init( - accessModifier: config.access, - isStatic: true, - kind: .let, - left: "id", - type: .init(TypeName.string), - right: .literal(operation.operationID) - ) + accessModifier: config.access, + isStatic: true, + kind: .let, + left: "id", + type: .init(TypeName.string), + right: .literal(operation.operationID) ) let inputDecl: Declaration = try translateOperationInput(operation) @@ -236,11 +234,9 @@ extension TypesFileTranslator { let operationEnumDecl = Declaration.commentable( operation.comment, .enum( - .init( - accessModifier: config.access, - name: operationNamespace.shortSwiftName, - members: [idPropertyDecl, inputDecl, outputDecl] + (acceptDecl.flatMap { [$0] } ?? []) - ) + accessModifier: config.access, + name: operationNamespace.shortSwiftName, + members: [idPropertyDecl, inputDecl, outputDecl] + (acceptDecl.flatMap { [$0] } ?? []) ) ) return operationEnumDecl diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift index ccc74d14..5935ad80 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypesTranslator/translateSchemas.swift @@ -23,30 +23,47 @@ extension TypesFileTranslator { /// - componentKey: The key for the schema, specified in the OpenAPI /// document. /// - schema: The schema to translate to a Swift type. + /// - isMultipartContent: A Boolean value indicating whether the schema defines multipart parts. /// - Returns: A list of declarations. Returns a single element in the list /// if only the type for the schema needs to be declared. Returns an empty /// list if the specified schema is unsupported. Returns multiple elements /// if the specified schema contains unnamed types that need to be declared /// inline. /// - Throws: An error if there is an issue during the matching process. - func translateSchema(componentKey: OpenAPI.ComponentKey, schema: JSONSchema) throws -> [Declaration] { + func translateSchema(componentKey: OpenAPI.ComponentKey, schema: JSONSchema, isMultipartContent: Bool) throws + -> [Declaration] + { guard try validateSchemaIsSupported(schema, foundIn: "#/components/schemas/\(componentKey.rawValue)") else { return [] } let typeName = typeAssigner.typeName(for: (componentKey, schema)) - return try translateSchema(typeName: typeName, schema: schema, overrides: .none) + return try translateSchema( + typeName: typeName, + schema: schema, + overrides: .none, + isMultipartContent: isMultipartContent + ) } /// Returns a declaration of the namespace that contains all the reusable /// schema definitions. - /// - Parameter schemas: The schemas from the OpenAPI document. + /// - Parameters: + /// - schemas: The schemas from the OpenAPI document. + /// - multipartSchemaNames: The names of schemas used as root multipart content. /// - Returns: A declaration of the schemas namespace in the parent /// components namespace. /// - Throws: An error if there is an issue during schema translation. - func translateSchemas(_ schemas: OpenAPI.ComponentDictionary) throws -> Declaration { + func translateSchemas( + _ schemas: OpenAPI.ComponentDictionary, + multipartSchemaNames: Set + ) throws -> Declaration { let decls: [Declaration] = try schemas.flatMap { key, value in - try translateSchema(componentKey: key, schema: value) + try translateSchema( + componentKey: key, + schema: value, + isMultipartContent: multipartSchemaNames.contains(key) + ) } let declsWithBoxingApplied = try boxRecursiveTypes(decls) let componentsSchemasEnum = Declaration.commentable( diff --git a/Sources/swift-openapi-generator/Documentation.docc/Development/Converting-between-data-and-Swift-types.md b/Sources/swift-openapi-generator/Documentation.docc/Development/Converting-between-data-and-Swift-types.md index 1d01d37b..608522f4 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Development/Converting-between-data-and-Swift-types.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Development/Converting-between-data-and-Swift-types.md @@ -56,6 +56,9 @@ Below is a list of the "dimensions" across which the helper methods differ: - `urlEncodedForm` - example: request body with the `application/x-www-form-urlencoded` content type - `greeting=Hello+world` + - `multipart` + - example: request body with the `multipart/form-data` content type + - part 1: `{"color": "red", "power": 24}`, part 2: `greeting=Hello+world` - `binary` - example: `application/octet-stream` - serves as the fallback for content types that don't have more specific handling @@ -94,8 +97,10 @@ method parameters: value or type of value | client | set | request body | binary | required | setRequiredRequestBodyAsBinary | | client | set | request body | urlEncodedForm | optional | setOptionalRequestBodyAsURLEncodedForm | | client | set | request body | urlEncodedForm | required | setRequiredRequestBodyAsURLEncodedForm | +| client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | | client | get | response body | JSON | required | getResponseBodyAsJSON | | client | get | response body | binary | required | getResponseBodyAsBinary | +| client | get | response body | multipart | required | getResponseBodyAsMultipart | | server | get | request path | URI | required | getPathParameterAsURI | | server | get | request query | URI | optional | getOptionalQueryItemAsURI | | server | get | request query | URI | required | getRequiredQueryItemAsURI | @@ -105,5 +110,7 @@ method parameters: value or type of value | server | get | request body | binary | required | getRequiredRequestBodyAsBinary | | server | get | request body | urlEncodedForm | optional | getOptionalRequestBodyAsURLEncodedForm | | server | get | request body | urlEncodedForm | required | getRequiredRequestBodyAsURLEncodedForm | +| server | get | request body | multipart | required | getRequiredRequestBodyAsMultipart | | server | set | response body | JSON | required | setResponseBodyAsJSON | | server | set | response body | binary | required | setResponseBodyAsBinary | +| server | set | response body | multipart | required | setResponseBodyAsMultipart | diff --git a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0009.md b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0009.md index adcef02c..4481f639 100644 --- a/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0009.md +++ b/Sources/swift-openapi-generator/Documentation.docc/Proposals/SOAR-0009.md @@ -6,12 +6,14 @@ Provide a type-safe streaming API to produce and consume multipart bodies. - Proposal: SOAR-0009 - Author(s): [Honza Dvorsky](https://github.com/czechboy0) -- Status: **In Review** +- Status: **Accepted, available since 1.0.0-alpha.1** + - Review period: 2023-11-08 – 2023-11-15 + - [Swift Forums post](https://forums.swift.org/t/proposal-soar-0009-type-safe-streaming-multipart-support/68331) - Issue: [apple/swift-openapi-generator#36](https://github.com/apple/swift-openapi-generator/issues/36) - Implementation: - [apple/swift-openapi-runtime#69](https://github.com/apple/swift-openapi-runtime/pull/69) - [apple/swift-openapi-generator#366](https://github.com/apple/swift-openapi-generator/pull/366) -- Feature flag: `multipart` +- Feature flag: none - Affected components: - generator - runtime diff --git a/Tests/OpenAPIGeneratorCoreTests/Renderer/Test_TextBasedRenderer.swift b/Tests/OpenAPIGeneratorCoreTests/Renderer/Test_TextBasedRenderer.swift index c50ce589..79d23804 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Renderer/Test_TextBasedRenderer.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Renderer/Test_TextBasedRenderer.swift @@ -235,7 +235,7 @@ final class Test_TextBasedRenderer: XCTestCase { func testDeclaration() throws { try _test( - .variable(.init(kind: .let, left: "foo")), + .variable(kind: .let, left: "foo"), renderedBy: TextBasedRenderer.renderDeclaration, rendersAs: #""" let foo @@ -506,7 +506,7 @@ final class Test_TextBasedRenderer: XCTestCase { .init( accessModifier: .public, onType: "Info", - declarations: [.variable(.init(kind: .let, left: "foo", type: .member("Int")))] + declarations: [.variable(kind: .let, left: "foo", type: .member("Int"))] ), renderedBy: TextBasedRenderer.renderExtension, rendersAs: #""" @@ -571,7 +571,7 @@ final class Test_TextBasedRenderer: XCTestCase { accessModifier: .public, isStatic: true, kind: .let, - left: "foo", + left: .identifierPattern("foo"), type: .init(TypeName.string), right: .literal(.string("bar")) ), @@ -581,7 +581,14 @@ final class Test_TextBasedRenderer: XCTestCase { """# ) try _test( - .init(accessModifier: .internal, isStatic: false, kind: .var, left: "foo", type: nil, right: nil), + .init( + accessModifier: .internal, + isStatic: false, + kind: .var, + left: .identifierPattern("foo"), + type: nil, + right: nil + ), renderedBy: TextBasedRenderer.renderVariable, rendersAs: #""" internal var foo @@ -590,7 +597,7 @@ final class Test_TextBasedRenderer: XCTestCase { try _test( .init( kind: .var, - left: "foo", + left: .identifierPattern("foo"), type: .init(TypeName.int), getter: [CodeBlock.expression(.literal(.int(42)))] ), @@ -604,7 +611,7 @@ final class Test_TextBasedRenderer: XCTestCase { try _test( .init( kind: .var, - left: "foo", + left: .identifierPattern("foo"), type: .init(TypeName.int), getter: [CodeBlock.expression(.literal(.int(42)))], getterEffects: [.throws] @@ -652,7 +659,7 @@ final class Test_TextBasedRenderer: XCTestCase { func testCodeBlockItem() throws { try _test( - .declaration(.variable(.init(kind: .let, left: "foo"))), + .declaration(.variable(kind: .let, left: "foo")), renderedBy: TextBasedRenderer.renderCodeBlockItem, rendersAs: #""" let foo @@ -669,7 +676,7 @@ final class Test_TextBasedRenderer: XCTestCase { func testCodeBlock() throws { try _test( - .init(comment: .inline("- MARK: Section"), item: .declaration(.variable(.init(kind: .let, left: "foo")))), + .init(comment: .inline("- MARK: Section"), item: .declaration(.variable(kind: .let, left: "foo"))), renderedBy: TextBasedRenderer.renderCodeBlock, rendersAs: #""" // - MARK: Section @@ -677,7 +684,7 @@ final class Test_TextBasedRenderer: XCTestCase { """# ) try _test( - .init(comment: nil, item: .declaration(.variable(.init(kind: .let, left: "foo")))), + .init(comment: nil, item: .declaration(.variable(kind: .let, left: "foo"))), renderedBy: TextBasedRenderer.renderCodeBlock, rendersAs: #""" let foo diff --git a/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift b/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift index 6e2704ba..c25697b7 100644 --- a/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift +++ b/Tests/OpenAPIGeneratorCoreTests/StructureHelpers.swift @@ -101,7 +101,8 @@ extension Declaration { var info: DeclInfo { switch strippingTopComment { case .deprecated: return .init(kind: .deprecated) - case let .variable(description): return .init(name: description.left, kind: .variable) + case let .variable(description): + return .init(name: TextBasedRenderer.renderedExpressionAsString(description.left), kind: .variable) case let .`extension`(description): return .init(name: description.onType, kind: .`extension`) case let .`struct`(description): return .init(name: description.name, kind: .`struct`) case let .`enum`(description): return .init(name: description.name, kind: .`enum`) diff --git a/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift b/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift index 4d0f0074..ce1d11ab 100644 --- a/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift +++ b/Tests/OpenAPIGeneratorCoreTests/TestUtilities.swift @@ -29,7 +29,7 @@ class Test_Core: XCTestCase { components: OpenAPI.Components = .noComponents, diagnostics: any DiagnosticCollector = PrintingDiagnosticCollector(), featureFlags: FeatureFlags = [] - ) -> any FileTranslator { + ) -> TypesFileTranslator { makeTypesTranslator(components: components, diagnostics: diagnostics, featureFlags: featureFlags) } diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentSwiftName.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentSwiftName.swift deleted file mode 100644 index a97ae038..00000000 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentSwiftName.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// -import XCTest -import OpenAPIKit -@testable import _OpenAPIGeneratorCore - -final class Test_ContentSwiftName: Test_Core { - - func test() throws { - let nameMaker = makeTranslator().contentSwiftName - let cases: [(String, String)] = [ - - // Short names. - ("application/json", "json"), ("application/x-www-form-urlencoded", "urlEncodedForm"), - ("multipart/form-data", "multipartForm"), ("text/plain", "plainText"), ("*/*", "any"), - ("application/xml", "xml"), ("application/octet-stream", "binary"), ("text/html", "html"), - ("application/yaml", "yaml"), ("text/csv", "csv"), ("image/png", "png"), ("application/pdf", "pdf"), - ("image/jpeg", "jpeg"), - - // Generic names. - ("application/myformat+json", "application_myformat_plus_json"), ("foo/bar", "foo_bar"), - - // Names with a parameter. - ("application/foo", "application_foo"), - ("application/foo; bar=baz; boo=foo", "application_foo_bar_baz_boo_foo"), - ("application/foo; bar = baz", "application_foo_bar_baz"), - ] - for (string, name) in cases { - let contentType = try XCTUnwrap(ContentType(string: string)) - XCTAssertEqual(nameMaker(contentType), name, "Case \(string) failed") - } - } -} diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentType.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentType.swift index df709a6e..0da8df65 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentType.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/Content/Test_ContentType.swift @@ -41,7 +41,7 @@ final class Test_ContentType: Test_Core { "application/x-www-form-urlencoded" ), ( - "multipart/form-data", .binary, "multipart", "form-data", "", "multipart/form-data", + "multipart/form-data", .multipart, "multipart", "form-data", "", "multipart/form-data", "multipart/form-data", "multipart/form-data" ), ("text/plain", .binary, "text", "plain", "", "text/plain", "text/plain", "text/plain"), ("*/*", .binary, "*", "*", "", "*/*", "*/*", "*/*"), diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/Multipart/Test_MultipartAdditionalProperties.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/Multipart/Test_MultipartAdditionalProperties.swift new file mode 100644 index 00000000..92e23817 --- /dev/null +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/Multipart/Test_MultipartAdditionalProperties.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +import XCTest +import OpenAPIKit +@testable import _OpenAPIGeneratorCore + +class Test_MultipartAdditionalProperties: XCTestCase { + + static let cases: [(Either?, MultipartAdditionalPropertiesStrategy)] = [ + (nil, .allowed), (.a(true), .any), (.a(false), .disallowed), (.b(.string), .typed(.string)), + ] + func test() throws { + for (additionalProperties, expectedStrategy) in Self.cases { + XCTAssertEqual(MultipartAdditionalPropertiesStrategy(additionalProperties), expectedStrategy) + } + } +} diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeAssigner.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeAssigner.swift index e7b0dfa0..c76ec4c5 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeAssigner.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeAssigner.swift @@ -103,4 +103,29 @@ class Test_TypeAssigner: Test_Core { ) } } + + func testContentSwiftName() throws { + let nameMaker = makeTranslator().typeAssigner.contentSwiftName + let cases: [(String, String)] = [ + + // Short names. + ("application/json", "json"), ("application/x-www-form-urlencoded", "urlEncodedForm"), + ("multipart/form-data", "multipartForm"), ("text/plain", "plainText"), ("*/*", "any"), + ("application/xml", "xml"), ("application/octet-stream", "binary"), ("text/html", "html"), + ("application/yaml", "yaml"), ("text/csv", "csv"), ("image/png", "png"), ("application/pdf", "pdf"), + ("image/jpeg", "jpeg"), + + // Generic names. + ("application/myformat+json", "application_myformat_plus_json"), ("foo/bar", "foo_bar"), + + // Names with a parameter. + ("application/foo", "application_foo"), + ("application/foo; bar=baz; boo=foo", "application_foo_bar_baz_boo_foo"), + ("application/foo; bar = baz", "application_foo_bar_baz"), + ] + for (string, name) in cases { + let contentType = try XCTUnwrap(ContentType(string: string)) + XCTAssertEqual(nameMaker(contentType), name, "Case \(string) failed") + } + } } diff --git a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift index 8b8ddd1c..a7e0b8e3 100644 --- a/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift +++ b/Tests/OpenAPIGeneratorCoreTests/Translator/TypeAssignment/Test_TypeMatcher.swift @@ -242,4 +242,20 @@ final class Test_TypeMatcher: Test_Core { } } + static let multipartElementTypeReferenceIfReferenceableTypes: + [(UnresolvedSchema?, OrderedDictionary?, String?)] = [ + (nil, nil, nil), (.b(.string), nil, nil), (.a(.component(named: "Foo")), nil, "Foo"), + (.a(.component(named: "Foo")), ["foo": .init(contentType: .json)], nil), + ] + func testMultipartElementTypeReferenceIfReferenceableTypes() throws { + for (schema, encoding, name) in Self.multipartElementTypeReferenceIfReferenceableTypes { + let actualName = TypeMatcher.multipartElementTypeReferenceIfReferenceable( + schema: schema, + encoding: encoding + )? + .name + XCTAssertEqual(actualName, name) + } + } + } diff --git a/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift b/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift index 4521ba37..3603247d 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/CompatabilityTest.swift @@ -57,7 +57,9 @@ final class CompatibilityTest: XCTestCase { try await _test( "https://raw.githubusercontent.com/box/box-openapi/5955d651f0cd273c0968e3855c1d873c7ae3523e/openapi.json", license: .apache, - expectedDiagnostics: [], + expectedDiagnostics: [ + "Multipart request bodies must always be required, but found an optional one - skipping. Mark as `required: true` to get this body generated." + ], skipBuild: compatibilityTestSkipBuild ) } @@ -85,7 +87,8 @@ final class CompatibilityTest: XCTestCase { "https://raw.githubusercontent.com/discourse/discourse_api_docs/aa152ea188c7b07bbf809681154cc311ec178acf/openapi.yml", license: .apache, expectedDiagnostics: [ - "Validation warning: Inconsistency encountered when parsing `OpenAPI Schema`: Found nothing but unsupported attributes.." + "Validation warning: Inconsistency encountered when parsing `OpenAPI Schema`: Found nothing but unsupported attributes..", + "Multipart request bodies must always be required, but found an optional one - skipping. Mark as `required: true` to get this body generated.", ], skipBuild: compatibilityTestSkipBuild ) @@ -104,7 +107,9 @@ final class CompatibilityTest: XCTestCase { try await _test( "https://raw.githubusercontent.com/github/rest-api-description/13c873cb3b15ffd5bcd88c6d6270a963ef4518f6/descriptions/ghes-3.5/ghes-3.5.yaml", license: .mit, - expectedDiagnostics: [], + expectedDiagnostics: [ + "Multipart request bodies must always be required, but found an optional one - skipping. Mark as `required: true` to get this body generated." + ], skipBuild: true ) } @@ -131,9 +136,7 @@ final class CompatibilityTest: XCTestCase { try await _test( "https://raw.githubusercontent.com/openai/openai-openapi/ec0b3953bfa08a92782bdccf34c1931b13402f56/openapi.yaml", license: .mit, - expectedDiagnostics: [ - "Schema \"string (binary)\" is not supported, reason: \"Binary properties in object schemas.\", skipping" - ], + expectedDiagnostics: [], skipBuild: compatibilityTestSkipBuild ) } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml b/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml index b881e7e6..3d17469d 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/Docs/petstore.yaml @@ -241,6 +241,19 @@ paths: text/plain: schema: type: string + /pets/multipart-typed: + post: + operationId: multipartUploadTyped + requestBody: + $ref: '#/components/requestBodies/MultipartUploadTypedRequest' + responses: + '202': + description: Successfully accepted the data. + get: + operationId: multipartDownloadTyped + responses: + '200': + $ref: '#/components/responses/MultipartDownloadTypedResponse' components: headers: TracingHeader: @@ -535,6 +548,39 @@ components: type: integer required: - code + MultipartDownloadTypedResponse: + description: A typed multipart response. + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + metadata: + type: object + properties: + createdAt: + type: string + format: date-time + required: + - createdAt + keyword: + type: array + items: + type: string + required: + - log + encoding: + log: + headers: + x-log-type: + description: The type of the log. + schema: + type: string + enum: + - structured + - unstructured requestBodies: UpdatePetRequest: required: false @@ -550,3 +596,36 @@ components: $ref: '#/components/schemas/PetKind' tag: type: string + MultipartUploadTypedRequest: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + metadata: + type: object + properties: + createdAt: + type: string + format: date-time + required: + - createdAt + keyword: + type: array + items: + type: string + required: + - log + encoding: + log: + headers: + x-log-type: + description: The type of the log. + schema: + type: string + enum: + - structured + - unstructured diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift index 7d445daf..ee9fc11c 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Client.swift @@ -662,4 +662,239 @@ public struct Client: APIProtocol { } ) } + /// - Remark: HTTP `GET /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)`. + public func multipartDownloadTyped(_ input: Operations.multipartDownloadTyped.Input) async throws -> Operations.multipartDownloadTyped.Output { + try await client.send( + input: input, + forOperation: Operations.multipartDownloadTyped.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/pets/multipart-typed", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.MultipartDownloadTypedResponse.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getResponseBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: responseBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "metadata" + ], + zeroOrMoreTimesPartNames: [ + "keyword" + ], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + let headers: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers = .init(x_hyphen_log_hyphen_type: try converter.getOptionalHeaderFieldAsURI( + in: headerFields, + name: "x-log-type", + as: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload.self + )) + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init( + headers: headers, + body: body + ), + filename: filename + )) + case "metadata": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "application/json" + ) + let body = try await converter.getResponseBodyAsJSON( + Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.metadataPayload.bodyPayload.self, + from: part.body, + transforming: { + $0 + } + ) + return .metadata(.init( + payload: .init(body: body), + filename: filename + )) + case "keyword": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .keyword(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init() + ) + } + } + ) + } + /// - Remark: HTTP `POST /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)`. + public func multipartUploadTyped(_ input: Operations.multipartUploadTyped.Input) async throws -> Operations.multipartUploadTyped.Output { + try await client.send( + input: input, + forOperation: Operations.multipartUploadTyped.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/pets/multipart-typed", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "metadata" + ], + zeroOrMoreTimesPartNames: [ + "keyword" + ], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + try converter.setHeaderFieldAsURI( + in: &headerFields, + name: "x-log-type", + value: value.headers.x_hyphen_log_hyphen_type + ) + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .metadata(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsJSON( + value.body, + headerFields: &headerFields, + contentType: "application/json; charset=utf-8" + ) + return .init( + name: "metadata", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .keyword(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "keyword", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 202: + return .accepted(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init() + ) + } + } + ) + } } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift index 143304b0..80d642b3 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Server.swift @@ -118,6 +118,28 @@ extension APIProtocol { method: .put, path: server.apiPathComponentsWithServerPrefix("/pets/{petId}/avatar") ) + try transport.register( + { + try await server.multipartDownloadTyped( + request: $0, + body: $1, + metadata: $2 + ) + }, + method: .get, + path: server.apiPathComponentsWithServerPrefix("/pets/multipart-typed") + ) + try transport.register( + { + try await server.multipartUploadTyped( + request: $0, + body: $1, + metadata: $2 + ) + }, + method: .post, + path: server.apiPathComponentsWithServerPrefix("/pets/multipart-typed") + ) } } @@ -754,4 +776,240 @@ fileprivate extension UniversalServer where APIHandler: APIProtocol { } ) } + /// - Remark: HTTP `GET /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)`. + func multipartDownloadTyped( + request: HTTPTypes.HTTPRequest, + body: OpenAPIRuntime.HTTPBody?, + metadata: OpenAPIRuntime.ServerRequestMetadata + ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { + try await handle( + request: request, + requestBody: body, + metadata: metadata, + forOperation: Operations.multipartDownloadTyped.id, + using: { + APIHandler.multipartDownloadTyped($0) + }, + deserializer: { request, requestBody, metadata in + let headers: Operations.multipartDownloadTyped.Input.Headers = .init(accept: try converter.extractAcceptHeaderIfPresent(in: request.headerFields)) + return Operations.multipartDownloadTyped.Input(headers: headers) + }, + serializer: { output, request in + switch output { + case let .ok(value): + suppressUnusedWarning(value) + var response = HTTPTypes.HTTPResponse(soar_statusCode: 200) + suppressMutabilityWarning(&response) + let body: OpenAPIRuntime.HTTPBody + switch value.body { + case let .multipartForm(value): + try converter.validateAcceptIfPresent( + "multipart/form-data", + in: request.headerFields + ) + body = try converter.setResponseBodyAsMultipart( + value, + headerFields: &response.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "metadata" + ], + zeroOrMoreTimesPartNames: [ + "keyword" + ], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + try converter.setHeaderFieldAsURI( + in: &headerFields, + name: "x-log-type", + value: value.headers.x_hyphen_log_hyphen_type + ) + let body = try converter.setResponseBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .metadata(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setResponseBodyAsJSON( + value.body, + headerFields: &headerFields, + contentType: "application/json; charset=utf-8" + ) + return .init( + name: "metadata", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .keyword(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setResponseBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "keyword", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (response, body) + case let .undocumented(statusCode, _): + return (.init(soar_statusCode: statusCode), nil) + } + } + ) + } + /// - Remark: HTTP `POST /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)`. + func multipartUploadTyped( + request: HTTPTypes.HTTPRequest, + body: OpenAPIRuntime.HTTPBody?, + metadata: OpenAPIRuntime.ServerRequestMetadata + ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { + try await handle( + request: request, + requestBody: body, + metadata: metadata, + forOperation: Operations.multipartUploadTyped.id, + using: { + APIHandler.multipartUploadTyped($0) + }, + deserializer: { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Components.RequestBodies.MultipartUploadTypedRequest + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "metadata" + ], + zeroOrMoreTimesPartNames: [ + "keyword" + ], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + let headers: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers = .init(x_hyphen_log_hyphen_type: try converter.getOptionalHeaderFieldAsURI( + in: headerFields, + name: "x-log-type", + as: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload.self + )) + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init( + headers: headers, + body: body + ), + filename: filename + )) + case "metadata": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "application/json" + ) + let body = try await converter.getRequiredRequestBodyAsJSON( + Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.metadataPayload.bodyPayload.self, + from: part.body, + transforming: { + $0 + } + ) + return .metadata(.init( + payload: .init(body: body), + filename: filename + )) + case "keyword": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .keyword(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.multipartUploadTyped.Input(body: body) + }, + serializer: { output, request in + switch output { + case let .accepted(value): + suppressUnusedWarning(value) + var response = HTTPTypes.HTTPResponse(soar_statusCode: 202) + suppressMutabilityWarning(&response) + return (response, nil) + case let .undocumented(statusCode, _): + return (.init(soar_statusCode: statusCode), nil) + } + } + ) + } } diff --git a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift index 34f60b55..66b5e4c9 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/Resources/ReferenceSources/Petstore/Types.swift @@ -48,6 +48,12 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `PUT /pets/{petId}/avatar`. /// - Remark: Generated from `#/paths//pets/{petId}/avatar/put(uploadAvatarForPet)`. func uploadAvatarForPet(_ input: Operations.uploadAvatarForPet.Input) async throws -> Operations.uploadAvatarForPet.Output + /// - Remark: HTTP `GET /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)`. + func multipartDownloadTyped(_ input: Operations.multipartDownloadTyped.Input) async throws -> Operations.multipartDownloadTyped.Output + /// - Remark: HTTP `POST /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)`. + func multipartUploadTyped(_ input: Operations.multipartUploadTyped.Input) async throws -> Operations.multipartUploadTyped.Output } /// Convenience overloads for operation inputs. @@ -133,6 +139,16 @@ extension APIProtocol { body: body )) } + /// - Remark: HTTP `GET /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)`. + public func multipartDownloadTyped(headers: Operations.multipartDownloadTyped.Input.Headers = .init()) async throws -> Operations.multipartDownloadTyped.Output { + try await multipartDownloadTyped(Operations.multipartDownloadTyped.Input(headers: headers)) + } + /// - Remark: HTTP `POST /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)`. + public func multipartUploadTyped(body: Components.RequestBodies.MultipartUploadTypedRequest) async throws -> Operations.multipartUploadTyped.Output { + try await multipartUploadTyped(Operations.multipartUploadTyped.Input(body: body)) + } } /// Server URLs defined in the OpenAPI document. @@ -1451,6 +1467,92 @@ public enum Components { /// - Remark: Generated from `#/components/requestBodies/UpdatePetRequest/content/application\/json`. case json(Components.RequestBodies.UpdatePetRequest.jsonPayload) } + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest`. + @frozen public enum MultipartUploadTypedRequest: Sendable, Hashable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm`. + @frozen public enum multipartFormPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/log`. + public struct logPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/log/headers`. + public struct Headers: Sendable, Hashable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/log/headers/x-log-type`. + @frozen public enum x_hyphen_log_hyphen_typePayload: String, Codable, Hashable, Sendable { + case structured = "structured" + case unstructured = "unstructured" + } + /// The type of the log. + /// + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/log/headers/x-log-type`. + public var x_hyphen_log_hyphen_type: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - x_hyphen_log_hyphen_type: The type of the log. + public init(x_hyphen_log_hyphen_type: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? = nil) { + self.x_hyphen_log_hyphen_type = x_hyphen_log_hyphen_type + } + } + /// Received HTTP response headers + public var headers: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers + public var body: OpenAPIRuntime.HTTPBody + /// Creates a new `logPayload`. + /// + /// - Parameters: + /// - headers: Received HTTP response headers + /// - body: + public init( + headers: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers = .init(), + body: OpenAPIRuntime.HTTPBody + ) { + self.headers = headers + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/metadata`. + public struct metadataPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/metadata/content/body`. + public struct bodyPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/metadata/content/body/createdAt`. + public var createdAt: Foundation.Date + /// Creates a new `bodyPayload`. + /// + /// - Parameters: + /// - createdAt: + public init(createdAt: Foundation.Date) { + self.createdAt = createdAt + } + public enum CodingKeys: String, CodingKey { + case createdAt + } + } + public var body: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.metadataPayload.bodyPayload + /// Creates a new `metadataPayload`. + /// + /// - Parameters: + /// - body: + public init(body: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.metadataPayload.bodyPayload) { + self.body = body + } + } + case metadata(OpenAPIRuntime.MultipartPart) + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/multipartForm/keyword`. + public struct keywordPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + /// Creates a new `keywordPayload`. + /// + /// - Parameters: + /// - body: + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case keyword(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + /// - Remark: Generated from `#/components/requestBodies/MultipartUploadTypedRequest/content/multipart\/form-data`. + case multipartForm(OpenAPIRuntime.MultipartBody) + } } /// Types generated from the `#/components/responses` section of the OpenAPI document. public enum Responses { @@ -1518,6 +1620,115 @@ public enum Components { self.body = body } } + public struct MultipartDownloadTypedResponse: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm`. + @frozen public enum multipartFormPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/log`. + public struct logPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/log/headers`. + public struct Headers: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/log/headers/x-log-type`. + @frozen public enum x_hyphen_log_hyphen_typePayload: String, Codable, Hashable, Sendable { + case structured = "structured" + case unstructured = "unstructured" + } + /// The type of the log. + /// + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/log/headers/x-log-type`. + public var x_hyphen_log_hyphen_type: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - x_hyphen_log_hyphen_type: The type of the log. + public init(x_hyphen_log_hyphen_type: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? = nil) { + self.x_hyphen_log_hyphen_type = x_hyphen_log_hyphen_type + } + } + /// Received HTTP response headers + public var headers: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers + public var body: OpenAPIRuntime.HTTPBody + /// Creates a new `logPayload`. + /// + /// - Parameters: + /// - headers: Received HTTP response headers + /// - body: + public init( + headers: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.logPayload.Headers = .init(), + body: OpenAPIRuntime.HTTPBody + ) { + self.headers = headers + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/metadata`. + public struct metadataPayload: Sendable, Hashable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/metadata/content/body`. + public struct bodyPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/metadata/content/body/createdAt`. + public var createdAt: Foundation.Date + /// Creates a new `bodyPayload`. + /// + /// - Parameters: + /// - createdAt: + public init(createdAt: Foundation.Date) { + self.createdAt = createdAt + } + public enum CodingKeys: String, CodingKey { + case createdAt + } + } + public var body: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.metadataPayload.bodyPayload + /// Creates a new `metadataPayload`. + /// + /// - Parameters: + /// - body: + public init(body: Components.Responses.MultipartDownloadTypedResponse.Body.multipartFormPayload.metadataPayload.bodyPayload) { + self.body = body + } + } + case metadata(OpenAPIRuntime.MultipartPart) + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipartForm/keyword`. + public struct keywordPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + /// Creates a new `keywordPayload`. + /// + /// - Parameters: + /// - body: + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case keyword(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + /// - Remark: Generated from `#/components/responses/MultipartDownloadTypedResponse/content/multipart\/form-data`. + case multipartForm(OpenAPIRuntime.MultipartBody) + /// The associated value of the enum case if `self` is `.multipartForm`. + /// + /// - Throws: An error if `self` is not `.multipartForm`. + /// - SeeAlso: `.multipartForm`. + public var multipartForm: OpenAPIRuntime.MultipartBody { + get throws { + switch self { + case let .multipartForm(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Components.Responses.MultipartDownloadTypedResponse.Body + /// Creates a new `MultipartDownloadTypedResponse`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Components.Responses.MultipartDownloadTypedResponse.Body) { + self.body = body + } + } } /// Types generated from the `#/components/headers` section of the OpenAPI document. public enum Headers { @@ -2703,4 +2914,132 @@ public enum Operations { } } } + /// - Remark: HTTP `GET /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)`. + public enum multipartDownloadTyped { + public static let id: Swift.String = "multipartDownloadTyped" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/pets/multipart-typed/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.multipartDownloadTyped.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.multipartDownloadTyped.Input.Headers = .init()) { + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + /// A typed multipart response. + /// + /// - Remark: Generated from `#/paths//pets/multipart-typed/get(multipartDownloadTyped)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Components.Responses.MultipartDownloadTypedResponse) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Components.Responses.MultipartDownloadTypedResponse { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case multipartForm + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "multipart/form-data": + self = .multipartForm + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .multipartForm: + return "multipart/form-data" + } + } + public static var allCases: [Self] { + [ + .multipartForm + ] + } + } + } + /// - Remark: HTTP `POST /pets/multipart-typed`. + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)`. + public enum multipartUploadTyped { + public static let id: Swift.String = "multipartUploadTyped" + public struct Input: Sendable, Hashable { + public var body: Components.RequestBodies.MultipartUploadTypedRequest + /// Creates a new `Input`. + /// + /// - Parameters: + /// - body: + public init(body: Components.RequestBodies.MultipartUploadTypedRequest) { + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Accepted: Sendable, Hashable { + /// Creates a new `Accepted`. + public init() {} + } + /// Successfully accepted the data. + /// + /// - Remark: Generated from `#/paths//pets/multipart-typed/post(multipartUploadTyped)/responses/202`. + /// + /// HTTP response code: `202 accepted`. + case accepted(Operations.multipartUploadTyped.Output.Accepted) + /// The associated value of the enum case if `self` is `.accepted`. + /// + /// - Throws: An error if `self` is not `.accepted`. + /// - SeeAlso: `.accepted`. + public var accepted: Operations.multipartUploadTyped.Output.Accepted { + get throws { + switch self { + case let .accepted(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "accepted", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } } diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index 6600891e..75724e10 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -1868,6 +1868,102 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + func testComponentsRequestBodiesInline_multipart() throws { + try self.assertRequestBodiesTranslation( + """ + requestBodies: + MultipartUploadTypedRequest: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + metadata: + type: object + properties: + createdAt: + type: string + format: date-time + required: + - createdAt + keyword: + type: array + items: + type: string + required: + - log + encoding: + log: + headers: + x-log-type: + description: The type of the log. + schema: + type: string + enum: + - structured + - unstructured + """, + #""" + public enum RequestBodies { + @frozen public enum MultipartUploadTypedRequest: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public struct Headers: Sendable, Hashable { + @frozen public enum x_hyphen_log_hyphen_typePayload: String, Codable, Hashable, Sendable { + case structured = "structured" + case unstructured = "unstructured" + } + public var x_hyphen_log_hyphen_type: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? + public init(x_hyphen_log_hyphen_type: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers.x_hyphen_log_hyphen_typePayload? = nil) { + self.x_hyphen_log_hyphen_type = x_hyphen_log_hyphen_type + } + } + public var headers: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers + public var body: OpenAPIRuntime.HTTPBody + public init( + headers: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.logPayload.Headers = .init(), + body: OpenAPIRuntime.HTTPBody + ) { + self.headers = headers + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + public struct metadataPayload: Sendable, Hashable { + public struct bodyPayload: Codable, Hashable, Sendable { + public var createdAt: Foundation.Date + public init(createdAt: Foundation.Date) { + self.createdAt = createdAt + } + public enum CodingKeys: String, CodingKey { + case createdAt + } + } + public var body: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.metadataPayload.bodyPayload + public init(body: Components.RequestBodies.MultipartUploadTypedRequest.multipartFormPayload.metadataPayload.bodyPayload) { + self.body = body + } + } + case metadata(OpenAPIRuntime.MultipartPart) + public struct keywordPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case keyword(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + } + """# + ) + } + func testPaths() throws { let paths = """ /healthOld: @@ -2582,113 +2678,2047 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } - func testResponseWithExampleWithOnlyValueByte() throws { - try self.assertResponsesTranslation( - featureFlags: [.base64DataEncodingDecoding], + func testRequestMultipartBodyReferencedRequestBody() throws { + try self.assertRequestInTypesClientServerTranslation( """ - responses: - MyResponse: - description: Some response + /foo: + post: + requestBody: + $ref: '#/components/requestBodies/MultipartRequest' + responses: + default: + description: Response + """, + """ + requestBodies: + MultipartRequest: + required: true content: - application/json: + multipart/form-data: schema: - type: string - contentEncoding: base64 - examples: - application/json: - summary: "a hello response" + type: object + properties: + log: + type: string """, - """ - public enum Responses { - public struct MyResponse: Sendable, Hashable { - @frozen public enum Body: Sendable, Hashable { - case json(OpenAPIRuntime.Base64EncodedData) - public var json: OpenAPIRuntime.Base64EncodedData { - get throws { - switch self { - case let .json(body): - return body + types: """ + public struct Input: Sendable, Hashable { + public var body: Components.RequestBodies.MultipartRequest + public init(body: Components.RequestBodies.MultipartRequest) { + self.body = body + } + } + """, + requestBodies: """ + public enum RequestBodies { + @frozen public enum MultipartRequest: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body } } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) } + case multipartForm(OpenAPIRuntime.MultipartBody) } - public var body: Components.Responses.MyResponse.Body - public init(body: Components.Responses.MyResponse.Body) { - self.body = body + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) } + return (request, body) } - } - """ - ) - } - -} - -extension SnippetBasedReferenceTests { - func makeTypesTranslator(openAPIDocumentYAML: String) throws -> TypesFileTranslator { - let document = try YAMLDecoder().decode(OpenAPI.Document.self, from: openAPIDocumentYAML) - return TypesFileTranslator( - config: Config(mode: .types), - diagnostics: XCTestDiagnosticCollector(test: self), - components: document.components - ) - } - - func makeTypesTranslator( - featureFlags: FeatureFlags = [], - ignoredDiagnosticMessages: Set = [], - componentsYAML: String - ) throws -> TypesFileTranslator { - let components = try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) - return TypesFileTranslator( - config: Config(mode: .types, featureFlags: featureFlags), - diagnostics: XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages), - components: components + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Components.RequestBodies.MultipartRequest + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ ) } - func makeTranslators( - components: OpenAPI.Components = .noComponents, - featureFlags: FeatureFlags = [], - ignoredDiagnosticMessages: Set = [] - ) throws -> (TypesFileTranslator, ClientFileTranslator, ServerFileTranslator) { - let collector = XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages) - return ( - TypesFileTranslator( - config: Config(mode: .types, featureFlags: featureFlags), - diagnostics: collector, - components: components - ), - ClientFileTranslator( - config: Config(mode: .client, featureFlags: featureFlags), - diagnostics: collector, - components: components - ), - ServerFileTranslator( - config: Config(mode: .server, featureFlags: featureFlags), - diagnostics: collector, - components: components - ) + func testRequestMultipartBodyInlineRequestBody() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ ) } - func assertHeadersTranslation( - _ componentsYAML: String, - _ expectedSwift: String, - file: StaticString = #filePath, - line: UInt = #line - ) throws { - let translator = try makeTypesTranslator(componentsYAML: componentsYAML) - let translation = try translator.translateComponentHeaders(translator.components.headers) - try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) - } - - func assertParametersTranslation( - _ componentsYAML: String, - _ expectedSwift: String, - file: StaticString = #filePath, - line: UInt = #line - ) throws { + func testRequestMultipartBodyInlineRequestBodyReferencedPartSchema() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + info: + $ref: '#/components/schemas/Info' + responses: + default: + description: Response + """, + """ + schemas: + Info: + type: object + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct infoPayload: Sendable, Hashable { + public var body: Components.Schemas.Info + public init(body: Components.Schemas.Info) { + self.body = body + } + } + case info(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "info" + ], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .info(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsJSON( + value.body, + headerFields: &headerFields, + contentType: "application/json; charset=utf-8" + ) + return .init( + name: "info", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "info" + ], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "info": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "application/json" + ) + let body = try await converter.getRequiredRequestBodyAsJSON( + Components.Schemas.Info.self, + from: part.body, + transforming: { + $0 + } + ) + return .info(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyReferencedSchema() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Multipet' + responses: + default: + description: Response + """, + """ + schemas: + Multipet: + type: object + properties: + log: + type: string + required: + - log + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + schemas: """ + public enum Schemas { + @frozen public enum Multipet: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyReferencedSchemaWithEncoding() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Multipet' + encoding: + log: + headers: + x-log-type: + schema: + type: string + responses: + default: + description: Response + """, + """ + schemas: + Multipet: + type: object + properties: + log: + type: string + required: + - log + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public struct Headers: Sendable, Hashable { + public var x_hyphen_log_hyphen_type: Swift.String? + public init(x_hyphen_log_hyphen_type: Swift.String? = nil) { + self.x_hyphen_log_hyphen_type = x_hyphen_log_hyphen_type + } + } + public var headers: Operations.post_sol_foo.Input.Body.multipartFormPayload.logPayload.Headers + public var body: OpenAPIRuntime.HTTPBody + public init( + headers: Operations.post_sol_foo.Input.Body.multipartFormPayload.logPayload.Headers = .init(), + body: OpenAPIRuntime.HTTPBody + ) { + self.headers = headers + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + schemas: """ + public enum Schemas { + @frozen public enum Multipet: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + try converter.setHeaderFieldAsURI( + in: &headerFields, + name: "x-log-type", + value: value.headers.x_hyphen_log_hyphen_type + ) + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + let headers: Operations.post_sol_foo.Input.Body.multipartFormPayload.logPayload.Headers = .init(x_hyphen_log_hyphen_type: try converter.getOptionalHeaderFieldAsURI( + in: headerFields, + name: "x-log-type", + as: Swift.String.self + )) + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init( + headers: headers, + body: body + ), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyFragment() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: {} + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .undocumented(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, _) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyAdditionalPropertiesTrue() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + additionalProperties: true + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + case other(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .other(value): + return value + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, _) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + default: + return .other(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyAdditionalPropertiesFalse() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + required: + - log + additionalProperties: false + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: false, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: false, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + preconditionFailure("Unknown part should be rejected by multipart validation.") + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyAdditionalPropertiesSchemaInline() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + required: + - log + additionalProperties: + type: object + properties: + foo: + type: string + responses: + default: + description: Response + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + public struct additionalPropertiesPayload: Codable, Hashable, Sendable { + public var foo: Swift.String? + public init(foo: Swift.String? = nil) { + self.foo = foo + } + public enum CodingKeys: String, CodingKey { + case foo + } + } + case additionalProperties(OpenAPIRuntime.MultipartDynamicallyNamedPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .additionalProperties(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &headerFields, + contentType: "application/json; charset=utf-8" + ) + return .init( + name: wrapped.name, + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "application/json" + ) + let body = try await converter.getRequiredRequestBodyAsJSON( + Operations.post_sol_foo.Input.Body.multipartFormPayload.additionalPropertiesPayload.self, + from: part.body, + transforming: { + $0 + } + ) + return .additionalProperties(.init( + payload: body, + filename: filename, + name: name + )) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testRequestMultipartBodyAdditionalPropertiesSchemaReferenced() throws { + try self.assertRequestInTypesClientServerTranslation( + """ + /foo: + post: + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + required: + - log + additionalProperties: + $ref: '#/components/schemas/AssociatedValue' + responses: + default: + description: Response + """, + """ + schemas: + AssociatedValue: + type: object + properties: + foo: + type: string + """, + types: """ + public struct Input: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case additionalProperties(OpenAPIRuntime.MultipartDynamicallyNamedPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + } + public var body: Operations.post_sol_foo.Input.Body + public init(body: Operations.post_sol_foo.Input.Body) { + self.body = body + } + } + """, + client: """ + { input in + let path = try converter.renderedPath( + template: "/foo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .multipartForm(value): + body = try converter.setRequiredRequestBodyAsMultipart( + value, + headerFields: &request.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .additionalProperties(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &headerFields, + contentType: "application/json; charset=utf-8" + ) + return .init( + name: wrapped.name, + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + } + } + ) + } + return (request, body) + } + """, + server: """ + { request, requestBody, metadata in + let contentType = converter.extractContentTypeIfPresent(in: request.headerFields) + let body: Operations.post_sol_foo.Input.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getRequiredRequestBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: requestBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [ + "log" + ], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getRequiredRequestBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "application/json" + ) + let body = try await converter.getRequiredRequestBodyAsJSON( + Components.Schemas.AssociatedValue.self, + from: part.body, + transforming: { + $0 + } + ) + return .additionalProperties(.init( + payload: body, + filename: filename, + name: name + )) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return Operations.post_sol_foo.Input(body: body) + } + """ + ) + } + + func testResponseMultipartReferencedResponse() throws { + try self.assertResponseInTypesClientServerTranslation( + """ + /foo: + get: + responses: + '200': + $ref: '#/components/responses/MultipartResponse' + """, + """ + responses: + MultipartResponse: + description: Multipart + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + """, + types: """ + @frozen public enum Output: Sendable, Hashable { + case ok(Components.Responses.MultipartResponse) + public var ok: Components.Responses.MultipartResponse { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + """, + responses: """ + public enum Responses { + public struct MultipartResponse: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + public var multipartForm: OpenAPIRuntime.MultipartBody { + get throws { + switch self { + case let .multipartForm(body): + return body + } + } + } + } + public var body: Components.Responses.MultipartResponse.Body + public init(body: Components.Responses.MultipartResponse.Body) { + self.body = body + } + } + } + """, + server: """ + { output, request in + switch output { + case let .ok(value): + suppressUnusedWarning(value) + var response = HTTPTypes.HTTPResponse(soar_statusCode: 200) + suppressMutabilityWarning(&response) + let body: OpenAPIRuntime.HTTPBody + switch value.body { + case let .multipartForm(value): + try converter.validateAcceptIfPresent( + "multipart/form-data", + in: request.headerFields + ) + body = try converter.setResponseBodyAsMultipart( + value, + headerFields: &response.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setResponseBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (response, body) + case let .undocumented(statusCode, _): + return (.init(soar_statusCode: statusCode), nil) + } + } + """, + client: """ + { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.MultipartResponse.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getResponseBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: responseBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init() + ) + } + } + """ + ) + } + + func testResponseMultipartInlineResponse() throws { + try self.assertResponseInTypesClientServerTranslation( + """ + /foo: + get: + responses: + '200': + description: Multipart + content: + multipart/form-data: + schema: + type: object + properties: + log: + type: string + """, + types: """ + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + @frozen public enum multipartFormPayload: Sendable, Hashable { + public struct logPayload: Sendable, Hashable { + public var body: OpenAPIRuntime.HTTPBody + public init(body: OpenAPIRuntime.HTTPBody) { + self.body = body + } + } + case log(OpenAPIRuntime.MultipartPart) + case undocumented(OpenAPIRuntime.MultipartRawPart) + } + case multipartForm(OpenAPIRuntime.MultipartBody) + public var multipartForm: OpenAPIRuntime.MultipartBody { + get throws { + switch self { + case let .multipartForm(body): + return body + } + } + } + } + public var body: Operations.get_sol_foo.Output.Ok.Body + public init(body: Operations.get_sol_foo.Output.Ok.Body) { + self.body = body + } + } + case ok(Operations.get_sol_foo.Output.Ok) + public var ok: Operations.get_sol_foo.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + """, + server: """ + { output, request in + switch output { + case let .ok(value): + suppressUnusedWarning(value) + var response = HTTPTypes.HTTPResponse(soar_statusCode: 200) + suppressMutabilityWarning(&response) + let body: OpenAPIRuntime.HTTPBody + switch value.body { + case let .multipartForm(value): + try converter.validateAcceptIfPresent( + "multipart/form-data", + in: request.headerFields + ) + body = try converter.setResponseBodyAsMultipart( + value, + headerFields: &response.headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + encoding: { part in + switch part { + case let .log(wrapped): + var headerFields: HTTPTypes.HTTPFields = .init() + let value = wrapped.payload + let body = try converter.setResponseBodyAsBinary( + value.body, + headerFields: &headerFields, + contentType: "text/plain" + ) + return .init( + name: "log", + filename: wrapped.filename, + headerFields: headerFields, + body: body + ) + case let .undocumented(value): + return value + } + } + ) + } + return (response, body) + case let .undocumented(statusCode, _): + return (.init(soar_statusCode: statusCode), nil) + } + } + """, + client: """ + { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.get_sol_foo.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "multipart/form-data" + ] + ) + switch chosenContentType { + case "multipart/form-data": + body = try converter.getResponseBodyAsMultipart( + OpenAPIRuntime.MultipartBody.self, + from: responseBody, + transforming: { value in + .multipartForm(value) + }, + boundary: contentType.requiredBoundary(), + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [ + "log" + ], + zeroOrMoreTimesPartNames: [], + decoding: { part in + let headerFields = part.headerFields + let (name, filename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + switch name { + case "log": + try converter.verifyContentTypeIfPresent( + in: headerFields, + matches: "text/plain" + ) + let body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: part.body, + transforming: { + $0 + } + ) + return .log(.init( + payload: .init(body: body), + filename: filename + )) + default: + return .undocumented(part) + } + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init() + ) + } + } + """ + ) + } + + func testResponseWithExampleWithOnlyValueByte() throws { + try self.assertResponsesTranslation( + featureFlags: [.base64DataEncodingDecoding], + """ + responses: + MyResponse: + description: Some response + content: + application/json: + schema: + type: string + contentEncoding: base64 + examples: + application/json: + summary: "a hello response" + """, + """ + public enum Responses { + public struct MyResponse: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + case json(OpenAPIRuntime.Base64EncodedData) + public var json: OpenAPIRuntime.Base64EncodedData { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + public var body: Components.Responses.MyResponse.Body + public init(body: Components.Responses.MyResponse.Body) { + self.body = body + } + } + } + """ + ) + } + +} + +extension SnippetBasedReferenceTests { + func makeTypesTranslator(openAPIDocumentYAML: String) throws -> TypesFileTranslator { + let document = try YAMLDecoder().decode(OpenAPI.Document.self, from: openAPIDocumentYAML) + return TypesFileTranslator( + config: Config(mode: .types), + diagnostics: XCTestDiagnosticCollector(test: self), + components: document.components + ) + } + + func makeTypesTranslator( + featureFlags: FeatureFlags = [], + ignoredDiagnosticMessages: Set = [], + componentsYAML: String + ) throws -> TypesFileTranslator { + let components = try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) + return TypesFileTranslator( + config: Config(mode: .types, featureFlags: featureFlags), + diagnostics: XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages), + components: components + ) + } + + func makeTranslators( + components: OpenAPI.Components = .noComponents, + featureFlags: FeatureFlags = [], + ignoredDiagnosticMessages: Set = [] + ) throws -> (TypesFileTranslator, ClientFileTranslator, ServerFileTranslator) { + let collector = XCTestDiagnosticCollector(test: self, ignoredDiagnosticMessages: ignoredDiagnosticMessages) + return ( + TypesFileTranslator( + config: Config(mode: .types, featureFlags: featureFlags), + diagnostics: collector, + components: components + ), + ClientFileTranslator( + config: Config(mode: .client, featureFlags: featureFlags), + diagnostics: collector, + components: components + ), + ServerFileTranslator( + config: Config(mode: .server, featureFlags: featureFlags), + diagnostics: collector, + components: components + ) + ) + } + + func assertHeadersTranslation( + _ componentsYAML: String, + _ expectedSwift: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let translator = try makeTypesTranslator(componentsYAML: componentsYAML) + let translation = try translator.translateComponentHeaders(translator.components.headers) + try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) + } + + func assertParametersTranslation( + _ componentsYAML: String, + _ expectedSwift: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { let translator = try makeTypesTranslator(componentsYAML: componentsYAML) let translation = try translator.translateComponentParameters(translator.components.parameters) try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) @@ -2698,17 +4728,19 @@ extension SnippetBasedReferenceTests { _ pathsYAML: String, _ componentsYAML: String? = nil, types expectedTypesSwift: String, + schemas expectedSchemasSwift: String? = nil, + requestBodies expectedRequestBodiesSwift: String? = nil, client expectedClientSwift: String, server expectedServerSwift: String, file: StaticString = #filePath, line: UInt = #line ) throws { continueAfterFailure = false - let (types, client, server) = try makeTranslators() let components = try componentsYAML.flatMap { componentsYAML in try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) } ?? OpenAPI.Components.noComponents + let (types, client, server) = try makeTranslators(components: components) let paths = try YAMLDecoder().decode(OpenAPI.PathItem.Map.self, from: pathsYAML) let document = OpenAPI.Document( openAPIVersion: .v3_1_0, @@ -2717,6 +4749,7 @@ extension SnippetBasedReferenceTests { paths: paths, components: components ) + let multipartSchemaNames = try types.parseSchemaNamesUsedInMultipart(paths: paths, components: components) let operationDescriptions = try OperationDescription.all( from: document.paths, in: document.components, @@ -2725,7 +4758,24 @@ extension SnippetBasedReferenceTests { let operation = try XCTUnwrap(operationDescriptions.first) let generatedTypesStructuredSwift = try types.translateOperationInput(operation) try XCTAssertSwiftEquivalent(generatedTypesStructuredSwift, expectedTypesSwift, file: file, line: line) - + if let expectedSchemasSwift { + let generatedSchemasStructuredSwift = try types.translateSchemas( + document.components.schemas, + multipartSchemaNames: multipartSchemaNames + ) + try XCTAssertSwiftEquivalent(generatedSchemasStructuredSwift, expectedSchemasSwift, file: file, line: line) + } + if let expectedRequestBodiesSwift { + let generatedRequestBodiesStructuredSwift = try types.translateComponentRequestBodies( + document.components.requestBodies + ) + try XCTAssertSwiftEquivalent( + generatedRequestBodiesStructuredSwift, + expectedRequestBodiesSwift, + file: file, + line: line + ) + } let generatedClientStructuredSwift = try client.translateClientSerializer(operation) try XCTAssertSwiftEquivalent(generatedClientStructuredSwift, expectedClientSwift, file: file, line: line) @@ -2733,6 +4783,65 @@ extension SnippetBasedReferenceTests { try XCTAssertSwiftEquivalent(generatedServerStructuredSwift, expectedServerSwift, file: file, line: line) } + func assertResponseInTypesClientServerTranslation( + _ pathsYAML: String, + _ componentsYAML: String? = nil, + types expectedTypesSwift: String, + schemas expectedSchemasSwift: String? = nil, + responses expectedResponsesSwift: String? = nil, + server expectedServerSwift: String, + client expectedClientSwift: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + continueAfterFailure = false + let components = + try componentsYAML.flatMap { componentsYAML in + try YAMLDecoder().decode(OpenAPI.Components.self, from: componentsYAML) + } ?? OpenAPI.Components.noComponents + let (types, client, server) = try makeTranslators(components: components) + let paths = try YAMLDecoder().decode(OpenAPI.PathItem.Map.self, from: pathsYAML) + let document = OpenAPI.Document( + openAPIVersion: .v3_1_0, + info: .init(title: "Test", version: "1.0.0"), + servers: [], + paths: paths, + components: components + ) + let multipartSchemaNames = try types.parseSchemaNamesUsedInMultipart(paths: paths, components: components) + let operationDescriptions = try OperationDescription.all( + from: document.paths, + in: document.components, + asSwiftSafeName: types.swiftSafeName + ) + let operation = try XCTUnwrap(operationDescriptions.first) + let generatedTypesStructuredSwift = try types.translateOperationOutput(operation) + try XCTAssertSwiftEquivalent(generatedTypesStructuredSwift, expectedTypesSwift, file: file, line: line) + if let expectedSchemasSwift { + let generatedSchemasStructuredSwift = try types.translateSchemas( + document.components.schemas, + multipartSchemaNames: multipartSchemaNames + ) + try XCTAssertSwiftEquivalent(generatedSchemasStructuredSwift, expectedSchemasSwift, file: file, line: line) + } + if let expectedResponsesSwift { + let generatedRequestBodiesStructuredSwift = try types.translateComponentResponses( + document.components.responses + ) + try XCTAssertSwiftEquivalent( + generatedRequestBodiesStructuredSwift, + expectedResponsesSwift, + file: file, + line: line + ) + } + let generatedServerStructuredSwift = try server.translateServerSerializer(operation) + try XCTAssertSwiftEquivalent(generatedServerStructuredSwift, expectedServerSwift, file: file, line: line) + + let generatedClientStructuredSwift = try client.translateClientDeserializer(operation) + try XCTAssertSwiftEquivalent(generatedClientStructuredSwift, expectedClientSwift, file: file, line: line) + } + func assertSchemasTranslation( featureFlags: FeatureFlags = [], ignoredDiagnosticMessages: Set = [], @@ -2746,7 +4855,12 @@ extension SnippetBasedReferenceTests { ignoredDiagnosticMessages: ignoredDiagnosticMessages, componentsYAML: componentsYAML ) - let translation = try translator.translateSchemas(translator.components.schemas) + let components = translator.components + let multipartSchemaNames = try translator.parseSchemaNamesUsedInMultipart(paths: [:], components: components) + let translation = try translator.translateSchemas( + components.schemas, + multipartSchemaNames: multipartSchemaNames + ) try XCTAssertSwiftEquivalent(translation, expectedSwift, file: file, line: line) } diff --git a/Tests/PetstoreConsumerTests/TestClient.swift b/Tests/PetstoreConsumerTests/TestClient.swift index 0bc23cfa..b08e72b5 100644 --- a/Tests/PetstoreConsumerTests/TestClient.swift +++ b/Tests/PetstoreConsumerTests/TestClient.swift @@ -76,6 +76,24 @@ struct TestClient: APIProtocol { guard let block = uploadAvatarForPetBlock else { throw UnspecifiedBlockError() } return try await block(input) } + typealias MultipartDownloadTypedSignature = @Sendable (Operations.multipartDownloadTyped.Input) async throws -> + Operations.multipartDownloadTyped.Output + var multipartDownloadTypedBlock: MultipartDownloadTypedSignature? + func multipartDownloadTyped(_ input: Operations.multipartDownloadTyped.Input) async throws + -> Operations.multipartDownloadTyped.Output + { + guard let block = multipartDownloadTypedBlock else { throw UnspecifiedBlockError() } + return try await block(input) + } + typealias MultipartUploadTypedSignature = @Sendable (Operations.multipartUploadTyped.Input) async throws -> + Operations.multipartUploadTyped.Output + var multipartUploadTypedBlock: MultipartUploadTypedSignature? + func multipartUploadTyped(_ input: Operations.multipartUploadTyped.Input) async throws + -> Operations.multipartUploadTyped.Output + { + guard let block = multipartUploadTypedBlock else { throw UnspecifiedBlockError() } + return try await block(input) + } } struct UnspecifiedBlockError: Swift.Error, LocalizedError, CustomStringConvertible { diff --git a/Tests/PetstoreConsumerTests/TestServer.swift b/Tests/PetstoreConsumerTests/TestServer.swift index 10bca779..f538d5c8 100644 --- a/Tests/PetstoreConsumerTests/TestServer.swift +++ b/Tests/PetstoreConsumerTests/TestServer.swift @@ -19,7 +19,11 @@ import PetstoreConsumerTestCore extension APIProtocol { func configuredServer(for serverURLString: String = "/api") throws -> TestServerTransport { let transport = TestServerTransport() - try registerHandlers(on: transport, serverURL: try URL(validatingOpenAPIServerURL: serverURLString)) + try registerHandlers( + on: transport, + serverURL: try URL(validatingOpenAPIServerURL: serverURLString), + configuration: .init(multipartBoundaryGenerator: .constant) + ) return transport } } @@ -52,4 +56,12 @@ extension TestServerTransport { var probe: Handler { get throws { try findHandler(method: .post, path: "/api/probe/") } } var uploadAvatarForPet: Handler { get throws { try findHandler(method: .put, path: "/api/pets/{petId}/avatar") } } + + var multipartUploadTyped: Handler { + get throws { try findHandler(method: .post, path: "/api/pets/multipart-typed") } + } + + var multipartDownloadTyped: Handler { + get throws { try findHandler(method: .get, path: "/api/pets/multipart-typed") } + } } diff --git a/Tests/PetstoreConsumerTests/Test_Client.swift b/Tests/PetstoreConsumerTests/Test_Client.swift index 7156cb25..873fe4a4 100644 --- a/Tests/PetstoreConsumerTests/Test_Client.swift +++ b/Tests/PetstoreConsumerTests/Test_Client.swift @@ -20,7 +20,13 @@ final class Test_Client: XCTestCase { var transport: TestClientTransport! var client: Client { - get throws { .init(serverURL: try URL(validatingOpenAPIServerURL: "/api"), transport: transport) } + get throws { + .init( + serverURL: try URL(validatingOpenAPIServerURL: "/api"), + configuration: .init(multipartBoundaryGenerator: .constant), + transport: transport + ) + } } /// Setup method called before the invocation of each test method in the class. @@ -296,9 +302,7 @@ final class Test_Client: XCTestCase { body: .json( .init( name: "Fluffz", - genome: Base64EncodedData( - data: ArraySlice(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) - ) + genome: Base64EncodedData(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) ) ) ) @@ -315,9 +319,7 @@ final class Test_Client: XCTestCase { .init( id: 1, name: "Fluffz", - genome: Base64EncodedData( - data: ArraySlice(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) - ) + genome: Base64EncodedData(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) ) ) } @@ -688,4 +690,119 @@ final class Test_Client: XCTestCase { case .plainText(let text): try await XCTAssertEqualStringifiedData(text, Data.efghString) } } + + func testMultipartUploadTyped_202() async throws { + transport = .init { request, requestBody, baseURL, operationID in + XCTAssertEqual(operationID, "multipartUploadTyped") + XCTAssertEqual(request.path, "/pets/multipart-typed") + XCTAssertEqual(baseURL.absoluteString, "/api") + XCTAssertEqual(request.method, .post) + XCTAssertEqual( + request.headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + try await XCTAssertEqualData(requestBody, Data.multipartTypedBodyAsSlice) + return (.init(status: .accepted), nil) + } + let parts: MultipartBody = [ + .log( + .init( + payload: .init( + headers: .init(x_hyphen_log_hyphen_type: .unstructured), + body: .init("here be logs!\nand more lines\nwheee\n") + ), + filename: "process.log" + ) + ), .keyword(.init(payload: .init(body: "fun"), filename: "fun.stuff")), + .undocumented(.init(name: "foobar", filename: "barfoo.txt", headerFields: .init(), body: .init())), + .metadata(.init(payload: .init(body: .init(createdAt: Date.test)))), + .keyword(.init(payload: .init(body: "joy"))), + ] + let response = try await client.multipartUploadTyped(.init(body: .multipartForm(parts))) + guard case .accepted = response else { + XCTFail("Unexpected response: \(response)") + return + } + } + + func testMultipartDownloadTyped_200() async throws { + transport = .init(callHandler: { request, requestBody, baseURL, operationID in + XCTAssertEqual(operationID, "multipartDownloadTyped") + XCTAssertEqual(request.path, "/pets/multipart-typed") + XCTAssertEqual(baseURL.absoluteString, "/api") + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.headerFields, [.accept: "multipart/form-data"]) + let stream = AsyncStream> { continuation in + let bytes = Data.multipartTypedBodyAsSlice + continuation.yield(ArraySlice(bytes)) + continuation.finish() + } + let body: HTTPBody = .init(stream, length: .unknown) + return ( + .init( + status: .ok, + headerFields: [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ), body + ) + }) + let response = try await client.multipartDownloadTyped() + let responseMultipart = try response.ok.body.multipartForm + + var iterator = responseMultipart.makeAsyncIterator() + do { + let part = try await iterator.next()! + guard case .log(let log) = part else { + XCTFail("Unexpected part") + return + } + XCTAssertEqual(log.filename, "process.log") + XCTAssertEqual(log.payload.headers, .init(x_hyphen_log_hyphen_type: .unstructured)) + try await XCTAssertEqualData(log.payload.body, "here be logs!\nand more lines\nwheee\n".utf8) + } + do { + let part = try await iterator.next()! + guard case .keyword(let keyword) = part else { + XCTFail("Unexpected part") + return + } + XCTAssertEqual(keyword.filename, "fun.stuff") + try await XCTAssertEqualData(keyword.payload.body, "fun".utf8) + } + do { + let part = try await iterator.next()! + guard case .undocumented(let undocumented) = part else { + XCTFail("Unexpected part") + return + } + XCTAssertEqual( + undocumented.headerFields, + [.contentDisposition: #"form-data; filename="barfoo.txt"; name="foobar""#, .contentLength: "0"] + ) + XCTAssertEqual(undocumented.name, "foobar") + XCTAssertEqual(undocumented.filename, "barfoo.txt") + try await XCTAssertEqualData(undocumented.body, []) + } + do { + let part = try await iterator.next()! + guard case .metadata(let metadata) = part else { + XCTFail("Unexpected part") + return + } + XCTAssertNil(metadata.filename) + XCTAssertEqual(metadata.payload.body, .init(createdAt: .test)) + } + do { + let part = try await iterator.next()! + guard case .keyword(let keyword) = part else { + XCTFail("Unexpected part") + return + } + XCTAssertNil(keyword.filename) + try await XCTAssertEqualData(keyword.payload.body, "joy".utf8) + } + do { + let part = try await iterator.next() + XCTAssertNil(part) + } + } } diff --git a/Tests/PetstoreConsumerTests/Test_Server.swift b/Tests/PetstoreConsumerTests/Test_Server.swift index 0eb86525..02713e99 100644 --- a/Tests/PetstoreConsumerTests/Test_Server.swift +++ b/Tests/PetstoreConsumerTests/Test_Server.swift @@ -238,9 +238,7 @@ final class Test_Server: XCTestCase { createPet, .init( name: "Fluffz", - genome: Base64EncodedData( - data: ArraySlice(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) - ) + genome: Base64EncodedData(#""GACTATTCATAGAGTTTCACCTCAGGAGAGAGAAGTAAGCATTAGCAGCTGC""#.utf8) ) ) return .created( @@ -764,4 +762,113 @@ final class Test_Server: XCTestCase { XCTAssertEqual(response.headerFields, [.contentType: "text/plain"]) try await XCTAssertEqualStringifiedData(responseBody, Data.efghString) } + + func testMultipartDownloadTyped_202() async throws { + client = .init(multipartDownloadTypedBlock: { input in + let parts: MultipartBody = [ + .log( + .init( + payload: .init( + headers: .init(x_hyphen_log_hyphen_type: .unstructured), + body: .init("here be logs!\nand more lines\nwheee\n") + ), + filename: "process.log" + ) + ), .keyword(.init(payload: .init(body: "fun"), filename: "fun.stuff")), + .undocumented(.init(name: "foobar", filename: "barfoo.txt", headerFields: .init(), body: .init())), + .metadata(.init(payload: .init(body: .init(createdAt: Date.test)))), + .keyword(.init(payload: .init(body: "joy"))), + ] + return .ok(.init(body: .multipartForm(parts))) + }) + let (response, responseBody) = try await server.multipartDownloadTyped( + .init(soar_path: "/api/pets/multipart-typed", method: .get, headerFields: [.accept: "multipart/form-data"]), + .init(Data.multipartTypedBodyAsSlice), + .init() + ) + XCTAssertEqual(response.status.code, 200) + XCTAssertEqual( + response.headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + try await XCTAssertEqualData(responseBody, Data.multipartTypedBodyAsSlice) + } + + func testMultipartUploadTyped_202() async throws { + client = .init(multipartUploadTypedBlock: { input in + let body: MultipartBody + switch input.body { + case .multipartForm(let value): body = value + } + var iterator = body.makeAsyncIterator() + do { + let part = try await iterator.next()! + guard case .log(let log) = part else { + XCTFail("Unexpected part") + return .undocumented(statusCode: 500, .init()) + } + XCTAssertEqual(log.filename, "process.log") + XCTAssertEqual(log.payload.headers, .init(x_hyphen_log_hyphen_type: .unstructured)) + try await XCTAssertEqualData(log.payload.body, "here be logs!\nand more lines\nwheee\n".utf8) + } + do { + let part = try await iterator.next()! + guard case .keyword(let keyword) = part else { + XCTFail("Unexpected part") + return .undocumented(statusCode: 500, .init()) + } + XCTAssertEqual(keyword.filename, "fun.stuff") + try await XCTAssertEqualData(keyword.payload.body, "fun".utf8) + } + do { + let part = try await iterator.next()! + guard case .undocumented(let undocumented) = part else { + XCTFail("Unexpected part") + return .undocumented(statusCode: 500, .init()) + } + XCTAssertEqual( + undocumented.headerFields, + [.contentDisposition: #"form-data; filename="barfoo.txt"; name="foobar""#, .contentLength: "0"] + ) + XCTAssertEqual(undocumented.name, "foobar") + XCTAssertEqual(undocumented.filename, "barfoo.txt") + try await XCTAssertEqualData(undocumented.body, []) + } + do { + let part = try await iterator.next()! + guard case .metadata(let metadata) = part else { + XCTFail("Unexpected part") + return .undocumented(statusCode: 500, .init()) + } + XCTAssertNil(metadata.filename) + XCTAssertEqual(metadata.payload.body, .init(createdAt: .test)) + } + do { + let part = try await iterator.next()! + guard case .keyword(let keyword) = part else { + XCTFail("Unexpected part") + return .undocumented(statusCode: 500, .init()) + } + XCTAssertNil(keyword.filename) + try await XCTAssertEqualData(keyword.payload.body, "joy".utf8) + } + do { + let part = try await iterator.next() + XCTAssertNil(part) + } + return .accepted(.init()) + }) + let (response, responseBody) = try await server.multipartUploadTyped( + .init( + soar_path: "/api/pets/multipart-typed", + method: .post, + headerFields: [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ), + .init(Data.multipartTypedBodyAsSlice), + .init() + ) + XCTAssertEqual(response.status.code, 202) + XCTAssertEqual(response.headerFields, [:]) + XCTAssertNil(responseBody) + } }