From 6fbbf0a59f7d18ed78ebb05c5bafa28cf1132259 Mon Sep 17 00:00:00 2001 From: Nandan Varma Date: Wed, 7 Oct 2026 14:25:19 -0700 Subject: [PATCH 1/2] Decode + as a space in query items OpenAPI 3.2 says an unencoded + in a query string means a space. The server now decodes it that way. %2B still decodes to +, and %20 still decodes to a space. Path parameters and headers are not changed. Fixes apple/swift-openapi-generator#965 --- .../Conversion/Converter+Server.swift | 4 +- .../Conversion/CurrencyExtensions.swift | 9 +++- .../Conversion/Test_Converter+Server.swift | 45 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index 80c033a8..2bfa25b1 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -139,7 +139,7 @@ extension Converter { configuration: .init( style: .init(style), explode: explode, - spaceEscapingCharacter: .percentEncoded, + spaceEscapingCharacter: .plus, dateTranscoder: configuration.dateTranscoder ) ) @@ -177,7 +177,7 @@ extension Converter { configuration: .init( style: .init(style), explode: explode, - spaceEscapingCharacter: .percentEncoded, + spaceEscapingCharacter: .plus, dateTranscoder: configuration.dateTranscoder ) ) diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index b4fd93b8..36e363a7 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -296,7 +296,14 @@ extension Converter { /// - Returns: The decoded value, or `nil` if the query item isn't present. /// - Throws: An error if decoding the query item or the JSON value fails. func convertJSONQueryItemToCodable(_ query: Substring, name: String) throws -> T? { - let uriDecoder = URIDecoder(configuration: uriCoderConfiguration(style: .form, explode: true, inBody: false)) + let uriDecoder = URIDecoder( + configuration: .init( + style: .form, + explode: true, + spaceEscapingCharacter: .plus, + dateTranscoder: configuration.dateTranscoder + ) + ) guard let json = try uriDecoder.decodeIfPresent(String.self, forKey: name, from: query) else { return nil } return try decoder.decode(T.self, from: Data(json.utf8)) } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index e53fb94c..5bad2a4a 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -186,6 +186,28 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, "foo") } + func test_getOptionalQueryItemAsURI_string_plusAsSpace() throws { + let value: String? = try converter.getOptionalQueryItemAsURI( + in: "search=foo+bar%20baz%2Bqux", + style: nil, + explode: nil, + name: "search", + as: String.self + ) + XCTAssertEqual(value, "foo bar baz+qux") + } + + func test_getRequiredQueryItemAsURI_string_plusAsSpace() throws { + let value: String = try converter.getRequiredQueryItemAsURI( + in: "search=foo+bar%20baz%2Bqux", + style: nil, + explode: nil, + name: "search", + as: String.self + ) + XCTAssertEqual(value, "foo bar baz+qux") + } + func test_getOptionalQueryItemAsURI_arrayOfStrings() throws { let query: Substring = "search=foo&search=bar" let value: [String]? = try converter.getOptionalQueryItemAsURI( @@ -234,6 +256,18 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, ["foo", "bar"]) } + func test_getRequiredQueryItemAsURI_arrayOfStrings_plusAsSpace() throws { + let query: Substring = "search=foo+bar&search=baz%2Bqux" + let value: [String] = try converter.getRequiredQueryItemAsURI( + in: query, + style: nil, + explode: nil, + name: "search", + as: [String].self + ) + XCTAssertEqual(value, ["foo bar", "baz+qux"]) + } + func test_getRequiredQueryItemAsURI_arrayOfStrings_unexploded() throws { let query: Substring = "search=foo,bar" let value: [String] = try converter.getRequiredQueryItemAsURI( @@ -298,6 +332,17 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, [Filter(id: 1, name: "a/b")]) } + func test_getRequiredQueryItemAsJSON_plusAsSpace() throws { + let value = try converter.getRequiredQueryItemAsJSON( + in: "filter=%22foo+bar%2Bbaz%22", + style: nil, + explode: nil, + name: "filter", + as: String.self + ) + XCTAssertEqual(value, "foo bar+baz") + } + func test_getOptionalQueryItemAsJSON_absent() throws { let value = try converter.getOptionalQueryItemAsJSON( in: "existing=1", From 5aff590d04c28a505847266b88bd3da038906305 Mon Sep 17 00:00:00 2001 From: Nandan Varma Date: Wed, 7 Oct 2026 14:27:23 -0700 Subject: [PATCH 2/2] Deprecate QuerySpaceNormalizingMiddleware The server now decodes + as a space in query items, so this middleware is not needed anymore. It still works, but it now shows a deprecation warning. --- .../Interface/QuerySpaceNormalizingMiddleware.swift | 10 +++++++--- .../Test_QuerySpaceNormalizingMiddleware.swift | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift index f23526d1..5f623f73 100644 --- a/Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift +++ b/Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift @@ -18,13 +18,16 @@ public import HTTPTypes /// query string to `%20`, so that requests from clients that use /// `application/x-www-form-urlencoded` conventions decode correctly. /// +/// - Important: Deprecated. The server now decodes `+` as a space in query +/// items, so this middleware is no longer needed and can be removed. +/// /// The OpenAPI specification follows RFC 3986, which requires spaces in query /// strings to be percent-encoded as `%20`. However, many widely-used HTTP /// client libraries — including Python's `requests`, Java's `URLEncoder`, /// Scala's `sttp`, and JavaScript's `URLSearchParams` — encode spaces as `+`, -/// following the `application/x-www-form-urlencoded` convention. Without this -/// middleware, query parameters sent from such clients arrive at handlers with -/// literal `+` characters instead of spaces. +/// following the `application/x-www-form-urlencoded` convention. Previously, +/// query parameters sent from such clients arrived at handlers with literal +/// `+` characters instead of spaces. /// /// This middleware rewrites `+` to `%20` in the query portion of the request /// path before the request is parsed, so both encoding conventions produce the @@ -41,6 +44,7 @@ public import HTTPTypes /// /// - Note: Only the query of the request is modified. The path, fragment, and /// body are not touched. +@available(*, deprecated, message: "`+` in query items is now decoded as a space by default; remove this middleware.") public struct QuerySpaceNormalizingMiddleware: ServerMiddleware { /// Creates a new middleware. public init() {} diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_QuerySpaceNormalizingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_QuerySpaceNormalizingMiddleware.swift index 7bec006b..779d43a3 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_QuerySpaceNormalizingMiddleware.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_QuerySpaceNormalizingMiddleware.swift @@ -17,7 +17,7 @@ import HTTPTypes import XCTest @_spi(Generated) @testable import OpenAPIRuntime -final class Test_QuerySpaceNormalizingMiddleware: XCTestCase { +@available(*, deprecated) final class Test_QuerySpaceNormalizingMiddleware: XCTestCase { static let middleware = QuerySpaceNormalizingMiddleware() func testPlusInQueryIsReplacedWithPercent20() async throws {