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 27da7efa2..56f09df54 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 == "*/*" && hasConcreteWildcardResponseBodies { + associatedType = TypeName.contentTypedBody.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 b6917860e..2be9c01ac 100644 --- a/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift +++ b/Sources/_OpenAPIGeneratorCore/Translator/Responses/translateResponseOutcome.swift @@ -237,19 +237,130 @@ 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 usesConcreteWildcardResponseBodies = contentType.lowercasedTypeAndSubtype == "*/*" + && hasConcreteWildcardResponseBodies + + var caseCodeBlocks: [CodeBlock] = [] + let transformExpr: Expression + + 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: [ + .expression( + .dot(caseName) + .call([ + .init( + label: nil, + expression: .dot("init") + .call([ + .init( + label: "contentType", + expression: .identifierPattern("concreteContentType") + ), + .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 = [] @@ -274,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) @@ -401,44 +513,85 @@ 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 assignBodyExpr: Expression = .assignment( - left: .identifierPattern("body"), - right: .try( - .identifierPattern("converter") - .dot("setResponseBodyAs\(contentType.codingStrategy.runtimeName)") - .call( - [ - .init(label: nil, expression: .identifierPattern("value")), + let isWildcardAnyContentType = contentType.lowercasedTypeAndSubtype == "*/*" + let usesConcreteWildcardResponseBodies = isWildcardAnyContentType + && hasConcreteWildcardResponseBodies + + 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: "headerFields", expression: .inOut(.identifierPattern("response").dot("headerFields")) ), .init( label: "contentType", - expression: .literal(contentType.headerValueForSending) + expression: .identifierPattern("value").dot("contentType").dot("headerValue") ), - ] + extraBodyAssignArgs - ) + ]) + ) ) - ) - caseCodeBlocks.append(.expression(assignBodyExpr)) + caseCodeBlocks.append(.expression(assignBodyExpr)) + } else { + let contentTypeForServer = isWildcardAnyContentType + ? 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 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")) + ), + .init( + label: "contentType", + expression: .literal(contentTypeForServer.headerValueForSending) + ), + ] + extraBodyAssignArgs + ) + ) + ) + caseCodeBlocks.append(.expression(assignBodyExpr)) + } return .init( kind: .case(.dot(context.safeNameGenerator.swiftContentTypeName(for: contentType)), ["value"]), 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 d1e6341c0..9479e87c5 100644 --- a/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift +++ b/Tests/OpenAPIGeneratorReferenceTests/SnippetBasedReferenceTests.swift @@ -3604,6 +3604,264 @@ final class SnippetBasedReferenceTests: XCTestCase { ) } + + func testResponseWildcardContentTypeUsesApplicationOctetStreamInServer() 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): + 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: 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 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) + 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 "*/*": + 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( + contentType: concreteContentType, + body: 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( """ @@ -6399,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, @@ -6412,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,