From 25fe2d416b4670783fe2944be631e98ab40895cf Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sat, 17 Jan 2026 16:59:29 -0800 Subject: [PATCH] Accept: match structured-syntax +json --- .../Conversion/Converter+Server.swift | 40 ++++++++++++++++++- .../Conversion/Test_Converter+Server.swift | 7 ++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index a3088bd3..dd244990 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -44,6 +44,9 @@ extension Converter { /// - substring: Expected content type, for example "application/json". /// - headerFields: Header fields in which to look for "Accept". /// Also supports wildcars, such as "application/\*" and "\*/\*". + /// Additionally, supports matching structured syntax suffixes (RFC 6839), + /// for example `Accept: application/json` is treated as compatible with + /// `Content-Type: application/problem+json`. /// - Throws: An error if the "Accept" header is present but incompatible with the provided content type, /// or if there are issues parsing the header. public func validateAcceptIfPresent(_ substring: String, in headerFields: HTTPFields) throws { @@ -495,8 +498,41 @@ fileprivate extension OpenAPIMIMEType { return acceptType.lowercased() == substringType.lowercased() case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)): // Accept: type/subtype -- The content-type should match the concrete type. - return acceptType.lowercased() == substringType.lowercased() - && acceptSubtype.lowercased() == substringSubtype.lowercased() + let acceptTypeLowercased = acceptType.lowercased() + let substringTypeLowercased = substringType.lowercased() + guard acceptTypeLowercased == substringTypeLowercased else { return false } + + let acceptSubtypeLowercased = acceptSubtype.lowercased() + let substringSubtypeLowercased = substringSubtype.lowercased() + + // Exact match. + if acceptSubtypeLowercased == substringSubtypeLowercased { return true } + + // RFC 6839 structured syntax suffix matching (e.g. application/problem+json). + if let structuredSyntaxSuffix = structuredSyntaxSuffix(of: substringSubtypeLowercased), + structuredSyntaxSuffix == acceptSubtypeLowercased + { return true } + + // Accept: application/*+json matching (and treating it as also matching application/json). + if let structuredSyntaxWildcardSuffix = structuredSyntaxWildcardSuffix(of: acceptSubtypeLowercased) { + return substringSubtypeLowercased == structuredSyntaxWildcardSuffix + || structuredSyntaxSuffix(of: substringSubtypeLowercased) == structuredSyntaxWildcardSuffix + } + return false } } + + private func structuredSyntaxSuffix(of subtype: String) -> String? { + guard let plusIndex = subtype.lastIndex(of: "+") else { return nil } + let suffixStart = subtype.index(after: plusIndex) + guard suffixStart < subtype.endIndex else { return nil } + return String(subtype[suffixStart...]) + } + + private func structuredSyntaxWildcardSuffix(of acceptSubtype: String) -> String? { + guard acceptSubtype.hasPrefix("*+") else { return nil } + let suffixStart = acceptSubtype.index(acceptSubtype.startIndex, offsetBy: 2) + guard suffixStart < acceptSubtype.endIndex else { return nil } + return String(acceptSubtype[suffixStart...]) + } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 3d956bb2..d956f671 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -35,6 +35,8 @@ final class Test_ServerConverterExtensions: Test_Runtime { let wildcard: HTTPFields = [.accept: "*/*"] let partialWildcard: HTTPFields = [.accept: "text/*"] let short: HTTPFields = [.accept: "text/plain"] + let json: HTTPFields = [.accept: "application/json"] + let anyJsonStructuredSyntax: HTTPFields = [.accept: "application/*+json"] let long: HTTPFields = [ .accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8" ] @@ -54,6 +56,11 @@ final class Test_ServerConverterExtensions: Test_Runtime { (short, "text/plain", true), (short, "application/json", false), (short, "application/*", false), (short, "*/*", false), + // RFC 6839 structured syntax suffix matching (common with RFC 7807 Problem Details): + // If response is application/problem+json, treat Accept: application/json as compatible. + (json, "application/problem+json", true), + (anyJsonStructuredSyntax, "application/problem+json", true), + // A bunch of acceptable content types (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), (long, "image/webp", true), (long, "application/json", true),