From e808ba9abb77d3e738dafee65640e34fe0711601 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sat, 17 Jan 2026 20:17:39 -0800 Subject: [PATCH 1/4] =?UTF-8?q?Server:=20don=E2=80=99t=20validate/emit=20*?= =?UTF-8?q?/*=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Responses/translateResponseOutcome.swift | 80 +++++++------ .../SnippetBasedReferenceTests.swift | 109 ++++++++++++++++++ 2 files changed, 155 insertions(+), 34 deletions(-) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift index b6917860e..588cd0c1b 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -401,43 +401,55 @@ extension ServerFileTranslator { var caseCodeBlocks: [CodeBlock] = [] - let contentTypeHeaderValue = typedContent.content.contentType.headerValueForValidation - let validateAcceptHeader: Expression = .try( - .identifierPattern("converter").dot("validateAcceptIfPresent") - .call([ - .init(label: nil, expression: .literal(contentTypeHeaderValue)), - .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), - ]) - ) - caseCodeBlocks.append(.expression(validateAcceptHeader)) - let contentType = typedContent.content.contentType - let extraBodyAssignArgs: [FunctionArgumentDescription] - if contentType.isMultipart { - extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) - } else { - extraBodyAssignArgs = [] + let isWildcardAnyContentType = contentType.lowercasedTypeAndSubtype == "*/*" + + if !isWildcardAnyContentType { + let contentTypeHeaderValue = contentType.headerValueForValidation + let validateAcceptHeader: Expression = .try( + .identifierPattern("converter").dot("validateAcceptIfPresent") + .call([ + .init(label: nil, expression: .literal(contentTypeHeaderValue)), + .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), + ]) + ) + caseCodeBlocks.append(.expression(validateAcceptHeader)) } - 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) - ), - ] + extraBodyAssignArgs - ) + + let assignBodyExpr: Expression + if isWildcardAnyContentType { + assignBodyExpr = .assignment( + left: .identifierPattern("body"), + right: .identifierPattern("value") ) - ) + } else { + let extraBodyAssignArgs: [FunctionArgumentDescription] + if contentType.isMultipart { + extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) + } else { + extraBodyAssignArgs = [] + } + assignBodyExpr = .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) + ), + ] + extraBodyAssignArgs + ) + ) + ) + } caseCodeBlocks.append(.expression(assignBodyExpr)) return .init( diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index d1e6341c0..baeb68a3c 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -3604,6 +3604,115 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + + func testResponseWildcardContentTypeDoesNotValidateAcceptOrEmitWildcardContentType() throws { + try self.assertResponseInTypesClientServerTranslation( + """ + /download: + get: + operationId: download + responses: + '200': + description: ok + content: + '*/*': + schema: + type: string + format: binary + """, + output: """ + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + case any(OpenAPIRuntime.HTTPBody) + public var any: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .any(body): + return body + } + } + } + } + public var body: Operations.download.Output.Ok.Body + public init(body: Operations.download.Output.Ok.Body) { + self.body = body + } + } + case ok(Operations.download.Output.Ok) + public var ok: Operations.download.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 .any(value): + body = 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.download.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "*/*" + ] + ) + switch chosenContentType { + case "*/*": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .any(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + """ + ) + } + func testRequestMultipartBodyReferencedRequestBody() throws { try self.assertRequestInTypesClientServerTranslation( """ From 60d84cb45b4a4bfb6cc65f0dac269b3ee0a95989 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sat, 28 Feb 2026 10:42:56 -0800 Subject: [PATCH 2/4] Server: map */* responses to application/octet-stream --- .../Responses/translateResponseOutcome.swift | 82 +++++++++---------- .../SnippetBasedReferenceTests.swift | 12 ++- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift index 588cd0c1b..547993003 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -402,54 +402,46 @@ extension ServerFileTranslator { var caseCodeBlocks: [CodeBlock] = [] let contentType = typedContent.content.contentType - let isWildcardAnyContentType = contentType.lowercasedTypeAndSubtype == "*/*" - - if !isWildcardAnyContentType { - let contentTypeHeaderValue = contentType.headerValueForValidation - let validateAcceptHeader: Expression = .try( - .identifierPattern("converter").dot("validateAcceptIfPresent") - .call([ - .init(label: nil, expression: .literal(contentTypeHeaderValue)), - .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), - ]) - ) - caseCodeBlocks.append(.expression(validateAcceptHeader)) - } + let contentTypeForServer = contentType.lowercasedTypeAndSubtype == "*/*" + ? ContentType.applicationOctetStream + : contentType - let assignBodyExpr: Expression - if isWildcardAnyContentType { - assignBodyExpr = .assignment( - left: .identifierPattern("body"), - right: .identifierPattern("value") - ) + let contentTypeHeaderValue = contentTypeForServer.headerValueForValidation + let validateAcceptHeader: Expression = .try( + .identifierPattern("converter").dot("validateAcceptIfPresent") + .call([ + .init(label: nil, expression: .literal(contentTypeHeaderValue)), + .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), + ]) + ) + caseCodeBlocks.append(.expression(validateAcceptHeader)) + + let extraBodyAssignArgs: [FunctionArgumentDescription] + if contentTypeForServer.isMultipart { + extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) } else { - let extraBodyAssignArgs: [FunctionArgumentDescription] - if contentType.isMultipart { - extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) - } else { - extraBodyAssignArgs = [] - } - assignBodyExpr = .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) - ), - ] + extraBodyAssignArgs - ) - ) - ) + extraBodyAssignArgs = [] } + let assignBodyExpr: Expression = .assignment( + left: .identifierPattern("body"), + right: .try( + .identifierPattern("converter") + .dot("setResponseBodyAs\(contentTypeForServer.codingStrategy.runtimeName)") + .call( + [ + .init(label: nil, expression: .identifierPattern("value")), + .init( + label: "headerFields", + expression: .inOut(.identifierPattern("response").dot("headerFields")) + ), + .init( + label: "contentType", + expression: .literal(contentTypeForServer.headerValueForSending) + ), + ] + extraBodyAssignArgs + ) + ) + ) caseCodeBlocks.append(.expression(assignBodyExpr)) return .init( diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index baeb68a3c..0879a92d1 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -3605,7 +3605,7 @@ final class SnippetBasedReferenceTests: XCTestCase { } - func testResponseWildcardContentTypeDoesNotValidateAcceptOrEmitWildcardContentType() throws { + func testResponseWildcardContentTypeUsesApplicationOctetStreamInServer() throws { try self.assertResponseInTypesClientServerTranslation( """ /download: @@ -3666,7 +3666,15 @@ final class SnippetBasedReferenceTests: XCTestCase { let body: OpenAPIRuntime.HTTPBody switch value.body { case let .any(value): - body = value + try converter.validateAcceptIfPresent( + "application/octet-stream", + in: request.headerFields + ) + body = try converter.setResponseBodyAsBinary( + value, + headerFields: &response.headerFields, + contentType: "application/octet-stream" + ) } return (response, body) case let .undocumented(statusCode, _): From ac353e33d73502957792bb205ab01602c0a74f67 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sat, 17 Jan 2026 20:53:47 -0800 Subject: [PATCH 3/4] Server: model */* body as UndocumentedPayload --- .../Responses/translateResponse.swift | 7 +- .../Responses/translateResponseOutcome.swift | 160 ++++++++++++------ .../SnippetBasedReferenceTests.swift | 24 +-- 3 files changed, 131 insertions(+), 60 deletions(-) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift index 27da7efa2..ebaba9250 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift @@ -142,7 +142,12 @@ extension TypesFileTranslator { var bodyCases: [Declaration] = [] let contentType = typedContent.content.contentType let identifier = context.safeNameGenerator.swiftContentTypeName(for: contentType) - let associatedType = typedContent.resolvedTypeUsage + let associatedType: TypeUsage + if contentType.lowercasedTypeAndSubtype == "*/*" { + associatedType = TypeName.undocumentedPayload.asUsage + } else { + associatedType = typedContent.resolvedTypeUsage + } let content = typedContent.content let schema = content.schema if typeMatcher.isInlinable(schema) || typeMatcher.isReferenceableMultipart(content) { diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift index 547993003..d7d0a50bf 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -237,19 +237,47 @@ extension ClientFileTranslator { codeBlocks.append(.declaration(chosenContentTypeDecl)) func makeCase(typedContent: TypedSchemaContent) throws -> SwitchCaseDescription { + let contentType = typedContent.content.contentType let contentTypeUsage = typedContent.resolvedTypeUsage - let transformExpr: Expression = .closureInvocation( - argumentNames: ["value"], - body: [ - .expression( - .dot(context.safeNameGenerator.swiftContentTypeName(for: typedContent.content.contentType)) - .call([.init(label: nil, expression: .identifierPattern("value"))]) - ) - ] - ) - let codingStrategy = typedContent.content.contentType.codingStrategy + let codingStrategy = contentType.codingStrategy + + let caseName = context.safeNameGenerator.swiftContentTypeName(for: contentType) + let transformExpr: Expression + if contentType.lowercasedTypeAndSubtype == "*/*" { + transformExpr = .closureInvocation( + argumentNames: ["value"], + body: [ + .expression( + .dot(caseName) + .call([ + .init( + label: nil, + expression: .dot("init") + .call([ + .init( + label: "headerFields", + expression: .identifierPattern("response").dot("headerFields") + ), + .init(label: "body", expression: .identifierPattern("value")), + ]) + ) + ]) + ) + ] + ) + } else { + transformExpr = .closureInvocation( + argumentNames: ["value"], + body: [ + .expression( + .dot(caseName) + .call([.init(label: nil, expression: .identifierPattern("value"))]) + ) + ] + ) + } let extraBodyAssignArgs: [FunctionArgumentDescription] - if typedContent.content.contentType.isMultipart { + if contentType.isMultipart { extraBodyAssignArgs = try translateMultipartDeserializerExtraArgumentsInClient(typedContent) } else { extraBodyAssignArgs = [] @@ -402,47 +430,83 @@ extension ServerFileTranslator { var caseCodeBlocks: [CodeBlock] = [] let contentType = typedContent.content.contentType - let contentTypeForServer = contentType.lowercasedTypeAndSubtype == "*/*" - ? ContentType.applicationOctetStream - : contentType - - let contentTypeHeaderValue = contentTypeForServer.headerValueForValidation - let validateAcceptHeader: Expression = .try( - .identifierPattern("converter").dot("validateAcceptIfPresent") - .call([ - .init(label: nil, expression: .literal(contentTypeHeaderValue)), - .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), - ]) - ) - caseCodeBlocks.append(.expression(validateAcceptHeader)) + let isWildcardAnyContentType = contentType.lowercasedTypeAndSubtype == "*/*" - let extraBodyAssignArgs: [FunctionArgumentDescription] - if contentTypeForServer.isMultipart { - extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) - } else { - extraBodyAssignArgs = [] - } - let assignBodyExpr: Expression = .assignment( - left: .identifierPattern("body"), - right: .try( - .identifierPattern("converter") - .dot("setResponseBodyAs\(contentTypeForServer.codingStrategy.runtimeName)") - .call( - [ - .init(label: nil, expression: .identifierPattern("value")), - .init( - label: "headerFields", - expression: .inOut(.identifierPattern("response").dot("headerFields")) - ), + if isWildcardAnyContentType { + caseCodeBlocks.append( + .expression( + .identifierPattern("response").dot("headerFields").dot("append") + .call([ .init( - label: "contentType", - expression: .literal(contentTypeForServer.headerValueForSending) - ), - ] + extraBodyAssignArgs + label: "contentsOf", + expression: .identifierPattern("value").dot("headerFields") + ) + ]) + ) + ) + caseCodeBlocks.append( + .expression( + .assignment( + left: .identifierPattern("body"), + right: .try( + .identifierPattern("converter").dot("getResponseBodyAsBinary") + .call([ + .init( + label: nil, + expression: .identifierType(TypeName.body.asUsage).dot("self") + ), + .init(label: "from", expression: .identifierPattern("value").dot("body")), + .init( + label: "transforming", + expression: .closureInvocation( + argumentNames: ["value"], + body: [.expression(.identifierPattern("value"))] + ) + ), + ]) + ) ) + ) ) - ) - caseCodeBlocks.append(.expression(assignBodyExpr)) + } else { + let contentTypeHeaderValue = contentType.headerValueForValidation + let validateAcceptHeader: Expression = .try( + .identifierPattern("converter").dot("validateAcceptIfPresent") + .call([ + .init(label: nil, expression: .literal(contentTypeHeaderValue)), + .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), + ]) + ) + caseCodeBlocks.append(.expression(validateAcceptHeader)) + + 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) + ), + ] + extraBodyAssignArgs + ) + ) + ) + caseCodeBlocks.append(.expression(assignBodyExpr)) + } return .init( kind: .case(.dot(context.safeNameGenerator.swiftContentTypeName(for: contentType)), ["value"]), diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index 0879a92d1..ce0987798 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -3624,8 +3624,8 @@ final class SnippetBasedReferenceTests: XCTestCase { @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { @frozen public enum Body: Sendable, Hashable { - case any(OpenAPIRuntime.HTTPBody) - public var any: OpenAPIRuntime.HTTPBody { + case any(OpenAPIRuntime.UndocumentedPayload) + public var any: OpenAPIRuntime.UndocumentedPayload { get throws { switch self { case let .any(body): @@ -3666,14 +3666,13 @@ final class SnippetBasedReferenceTests: XCTestCase { let body: OpenAPIRuntime.HTTPBody switch value.body { case let .any(value): - try converter.validateAcceptIfPresent( - "application/octet-stream", - in: request.headerFields - ) - body = try converter.setResponseBodyAsBinary( - value, - headerFields: &response.headerFields, - contentType: "application/octet-stream" + response.headerFields.append(contentsOf: value.headerFields) + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: value.body, + transforming: { value in + value + } ) } return (response, body) @@ -3700,7 +3699,10 @@ final class SnippetBasedReferenceTests: XCTestCase { OpenAPIRuntime.HTTPBody.self, from: responseBody, transforming: { value in - .any(value) + .any(.init( + headerFields: response.headerFields, + body: value + )) } ) default: From 9941001518360356b77ba56bdb21957b2ea90964 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sat, 28 Feb 2026 11:20:56 -0800 Subject: [PATCH 4/4] Server: gate concrete wildcard response bodies behind a flag --- .../_OpenAPIGeneratorCore/FeatureFlags.swift | 6 + .../FileTranslator+FeatureFlags.swift | 5 +- .../Responses/translateResponse.swift | 4 +- .../Responses/translateResponseOutcome.swift | 163 +++++++++++++----- .../Translator/TypeAssignment/Builtins.swift | 6 + .../SnippetBasedReferenceTests.swift | 154 ++++++++++++++++- 6 files changed, 289 insertions(+), 49 deletions(-) diff --git a/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift b/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift index ca3d56824..160d9e936 100644 --- a/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift +++ b/Sources/_OpenAPIGeneratorCore/FeatureFlags.swift @@ -28,6 +28,12 @@ public enum FeatureFlag: String, Hashable, Codable, CaseIterable, Sendable { // needs to be here for the enum to compile case empty + + /// Represent wildcard response bodies as content-typed payloads. + /// + /// Generates response bodies for `*/*` as `OpenAPIContentTypedBody` + /// instead of plain `HTTPBody`. + case concreteWildcardResponseBodies } /// A set of enabled feature flags. diff --git a/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift b/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift index 4527bbe91..68480d447 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/FileTranslator+FeatureFlags.swift @@ -14,5 +14,8 @@ import OpenAPIKit extension FileTranslator { - // Add helpers for reading feature flags below. + /// Whether wildcard response bodies should use content-typed payloads. + var hasConcreteWildcardResponseBodies: Bool { + config.featureFlags.contains(.concreteWildcardResponseBodies) + } } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift index ebaba9250..56f09df54 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponse.swift @@ -143,8 +143,8 @@ extension TypesFileTranslator { let contentType = typedContent.content.contentType let identifier = context.safeNameGenerator.swiftContentTypeName(for: contentType) let associatedType: TypeUsage - if contentType.lowercasedTypeAndSubtype == "*/*" { - associatedType = TypeName.undocumentedPayload.asUsage + if contentType.lowercasedTypeAndSubtype == "*/*" && hasConcreteWildcardResponseBodies { + associatedType = TypeName.contentTypedBody.asUsage } else { associatedType = typedContent.resolvedTypeUsage } diff --git a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift index d7d0a50bf..2be9c01ac 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -242,8 +242,91 @@ extension ClientFileTranslator { let codingStrategy = contentType.codingStrategy let caseName = context.safeNameGenerator.swiftContentTypeName(for: contentType) + let usesConcreteWildcardResponseBodies = contentType.lowercasedTypeAndSubtype == "*/*" + && hasConcreteWildcardResponseBodies + + var caseCodeBlocks: [CodeBlock] = [] let transformExpr: Expression - if contentType.lowercasedTypeAndSubtype == "*/*" { + + if usesConcreteWildcardResponseBodies { + let concreteContentTypeDecl: Declaration = .variable( + kind: .let, + left: "concreteContentType", + type: .init(TypeName.concreteMIMEType) + ) + caseCodeBlocks.append(.declaration(concreteContentTypeDecl)) + + let defaultContentTypeExpr: Expression = .try( + .dot("init") + .call([ + .init(label: "type", expression: .literal("application")), + .init(label: "subtype", expression: .literal("octet-stream")), + ]) + ) + let parsedContentTypeSwitchExpr: Expression = .switch( + switchedExpression: .identifierPattern("contentType"), + cases: [ + .init( + kind: .case(.dot("some"), ["contentTypeValue"]), + body: [ + .expression( + .switch( + switchedExpression: .identifierPattern("contentTypeValue").dot("kind"), + cases: [ + .init( + kind: .case(.dot("concrete"), ["type", "subtype"]), + body: [ + .expression( + .assignment( + left: .identifierPattern("concreteContentType"), + right: .try( + .dot("init") + .call([ + .init( + label: "type", + expression: .identifierPattern("type") + ), + .init( + label: "subtype", + expression: .identifierPattern("subtype") + ), + ]) + ) + ) + ) + ] + ), + .init( + kind: .default, + body: [ + .expression( + .assignment( + left: .identifierPattern("concreteContentType"), + right: defaultContentTypeExpr + ) + ) + ] + ), + ] + ) + ) + ] + ), + .init( + kind: .default, + body: [ + .expression( + .assignment( + left: .identifierPattern("concreteContentType"), + right: defaultContentTypeExpr + ) + ) + ] + ), + ] + ) + caseCodeBlocks.append(.expression(parsedContentTypeSwitchExpr)) + transformExpr = .closureInvocation( argumentNames: ["value"], body: [ @@ -255,8 +338,8 @@ extension ClientFileTranslator { expression: .dot("init") .call([ .init( - label: "headerFields", - expression: .identifierPattern("response").dot("headerFields") + label: "contentType", + expression: .identifierPattern("concreteContentType") ), .init(label: "body", expression: .identifierPattern("value")), ]) @@ -302,9 +385,10 @@ extension ClientFileTranslator { bodyExpr = .try(converterExpr) } let bodyAssignExpr: Expression = .assignment(left: .identifierPattern("body"), right: bodyExpr) + caseCodeBlocks.append(.expression(bodyAssignExpr)) return .init( kind: .case(.literal(typedContent.content.contentType.headerValueForValidation)), - body: [.expression(bodyAssignExpr)] + body: caseCodeBlocks ) } let cases = try typedContents.map(makeCase) @@ -431,45 +515,46 @@ extension ServerFileTranslator { let contentType = typedContent.content.contentType let isWildcardAnyContentType = contentType.lowercasedTypeAndSubtype == "*/*" + let usesConcreteWildcardResponseBodies = isWildcardAnyContentType + && hasConcreteWildcardResponseBodies - if isWildcardAnyContentType { - caseCodeBlocks.append( - .expression( - .identifierPattern("response").dot("headerFields").dot("append") + if usesConcreteWildcardResponseBodies { + let validateAcceptHeader: Expression = .try( + .identifierPattern("converter").dot("validateAcceptIfPresent") + .call([ + .init( + label: nil, + expression: .identifierPattern("value").dot("contentType").dot("headerValue") + ), + .init(label: "in", expression: .identifierPattern("request").dot("headerFields")), + ]) + ) + caseCodeBlocks.append(.expression(validateAcceptHeader)) + + let assignBodyExpr: Expression = .assignment( + left: .identifierPattern("body"), + right: .try( + .identifierPattern("converter").dot("setResponseBodyAsBinary") .call([ + .init(label: nil, expression: .identifierPattern("value").dot("body")), .init( - label: "contentsOf", - expression: .identifierPattern("value").dot("headerFields") - ) + label: "headerFields", + expression: .inOut(.identifierPattern("response").dot("headerFields")) + ), + .init( + label: "contentType", + expression: .identifierPattern("value").dot("contentType").dot("headerValue") + ), ]) ) ) - caseCodeBlocks.append( - .expression( - .assignment( - left: .identifierPattern("body"), - right: .try( - .identifierPattern("converter").dot("getResponseBodyAsBinary") - .call([ - .init( - label: nil, - expression: .identifierType(TypeName.body.asUsage).dot("self") - ), - .init(label: "from", expression: .identifierPattern("value").dot("body")), - .init( - label: "transforming", - expression: .closureInvocation( - argumentNames: ["value"], - body: [.expression(.identifierPattern("value"))] - ) - ), - ]) - ) - ) - ) - ) + caseCodeBlocks.append(.expression(assignBodyExpr)) } else { - let contentTypeHeaderValue = contentType.headerValueForValidation + let contentTypeForServer = isWildcardAnyContentType + ? ContentType.applicationOctetStream + : contentType + + let contentTypeHeaderValue = contentTypeForServer.headerValueForValidation let validateAcceptHeader: Expression = .try( .identifierPattern("converter").dot("validateAcceptIfPresent") .call([ @@ -480,7 +565,7 @@ extension ServerFileTranslator { caseCodeBlocks.append(.expression(validateAcceptHeader)) let extraBodyAssignArgs: [FunctionArgumentDescription] - if contentType.isMultipart { + if contentTypeForServer.isMultipart { extraBodyAssignArgs = try translateMultipartSerializerExtraArgumentsInServer(typedContent) } else { extraBodyAssignArgs = [] @@ -489,7 +574,7 @@ extension ServerFileTranslator { left: .identifierPattern("body"), right: .try( .identifierPattern("converter") - .dot("setResponseBodyAs\(contentType.codingStrategy.runtimeName)") + .dot("setResponseBodyAs\(contentTypeForServer.codingStrategy.runtimeName)") .call( [ .init(label: nil, expression: .identifierPattern("value")), @@ -499,7 +584,7 @@ extension ServerFileTranslator { ), .init( label: "contentType", - expression: .literal(contentType.headerValueForSending) + expression: .literal(contentTypeForServer.headerValueForSending) ), ] + extraBodyAssignArgs ) diff --git a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift index ee51fbbd0..70184a6fb 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/Builtins.swift @@ -58,6 +58,12 @@ extension TypeName { .runtime(Constants.Operation.Output.undocumentedCaseAssociatedValueTypeName) } + /// Returns the type name for the concrete MIME type. + static var concreteMIMEType: Self { .runtime("OpenAPIConcreteMIMEType") } + + /// Returns the type name for the content-typed body payload. + static var contentTypedBody: Self { .runtime("OpenAPIContentTypedBody") } + /// Returns the type name of generic JSON payload. static var valueContainer: TypeName { .runtime("OpenAPIValueContainer") } diff --git a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift index ce0987798..9479e87c5 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -3624,8 +3624,8 @@ final class SnippetBasedReferenceTests: XCTestCase { @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { @frozen public enum Body: Sendable, Hashable { - case any(OpenAPIRuntime.UndocumentedPayload) - public var any: OpenAPIRuntime.UndocumentedPayload { + case any(OpenAPIRuntime.HTTPBody) + public var any: OpenAPIRuntime.HTTPBody { get throws { switch self { case let .any(body): @@ -3666,13 +3666,131 @@ final class SnippetBasedReferenceTests: XCTestCase { let body: OpenAPIRuntime.HTTPBody switch value.body { case let .any(value): - response.headerFields.append(contentsOf: value.headerFields) + try converter.validateAcceptIfPresent( + "application/octet-stream", + in: request.headerFields + ) + body = try converter.setResponseBodyAsBinary( + value, + headerFields: &response.headerFields, + contentType: "application/octet-stream" + ) + } + 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.download.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "*/*" + ] + ) + switch chosenContentType { + case "*/*": body = try converter.getResponseBodyAsBinary( OpenAPIRuntime.HTTPBody.self, - from: value.body, + from: responseBody, transforming: { value in - value + .any(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + """ + ) + } + + func testResponseWildcardContentTypeUsesContentTypedBodyWhenFeatureEnabled() throws { + try self.assertResponseInTypesClientServerTranslation( + """ + /download: + get: + operationId: download + responses: + '200': + description: ok + content: + '*/*': + schema: + type: string + format: binary + """, + featureFlags: [.concreteWildcardResponseBodies], + output: """ + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { + case any(OpenAPIRuntime.OpenAPIContentTypedBody) + public var any: OpenAPIRuntime.OpenAPIContentTypedBody { + get throws { + switch self { + case let .any(body): + return body + } } + } + } + public var body: Operations.download.Output.Ok.Body + public init(body: Operations.download.Output.Ok.Body) { + self.body = body + } + } + case ok(Operations.download.Output.Ok) + public var ok: Operations.download.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 .any(value): + try converter.validateAcceptIfPresent( + value.contentType.headerValue, + in: request.headerFields + ) + body = try converter.setResponseBodyAsBinary( + value.body, + headerFields: &response.headerFields, + contentType: value.contentType.headerValue ) } return (response, body) @@ -3695,12 +3813,33 @@ final class SnippetBasedReferenceTests: XCTestCase { ) switch chosenContentType { case "*/*": + let concreteContentType: OpenAPIRuntime.OpenAPIConcreteMIMEType + switch contentType { + case let .some(contentTypeValue): + switch contentTypeValue.kind { + case let .concrete(type, subtype): + concreteContentType = try .init( + type: type, + subtype: subtype + ) + default: + concreteContentType = try .init( + type: "application", + subtype: "octet-stream" + ) + } + default: + concreteContentType = try .init( + type: "application", + subtype: "octet-stream" + ) + } body = try converter.getResponseBodyAsBinary( OpenAPIRuntime.HTTPBody.self, from: responseBody, transforming: { value in .any(.init( - headerFields: response.headerFields, + contentType: concreteContentType, body: value )) } @@ -6518,6 +6657,7 @@ extension SnippetBasedReferenceTests { func assertResponseInTypesClientServerTranslation( _ pathsYAML: String, _ componentsYAML: String? = nil, + featureFlags: FeatureFlags = [], output expectedOutputSwift: String, schemas expectedSchemasSwift: String? = nil, responses expectedResponsesSwift: String? = nil, @@ -6531,7 +6671,7 @@ extension SnippetBasedReferenceTests { 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 (types, client, server) = try makeTranslators(components: components, featureFlags: featureFlags) let paths = try YAMLDecoder().decode(OpenAPI.PathItem.Map.self, from: pathsYAML) let document = OpenAPI.Document( openAPIVersion: .v3_1_0,