Skip to content

Commit bd2a2a6

Browse files
author
Ryan Branche
committed
Add QuerySpaceNormalizingMiddleware.
Add an opt-in ServerMiddleware that rewrites unencoded "+" characters in the request's query string to "%20" before the request reaches the decoder. This lets servers interoperate with clients that encode query parameter spaces using the application/x-www-form-urlencoded convention (as used by Python requests, Java URLEncoder, Scala sttp, and JavaScript URLSearchParams) without changing the default RFC 3986 behavior of the generated server code. Only the query of the request is modified; the path, fragment, and body are untouched. Literal "+" characters that were correctly percent-encoded as "%2B" by the client are unaffected. A malformed input where "?" appears inside the fragment (e.g. "/a#b?c+d") is left untouched rather than crashing. The rewrite builds the new path with reserveCapacity and character-by-character append to avoid the intermediate string allocations of replacingOccurrences and concatenation. Also add an "Additional middlewares" section to the DocC documentation that lists ErrorHandlingMiddleware alongside the new middleware, so built-in middlewares are discoverable from the top-level docs. Move HTTPResponseConvertible into the existing "Errors" section, where it belongs.
1 parent 865bce0 commit bd2a2a6

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

Sources/OpenAPIRuntime/Documentation.docc/Documentation.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ You can also publish your transport or middleware as a Swift package to allow ot
7878
- ``ClientError``
7979
- ``ServerError``
8080
- ``UndocumentedPayload``
81+
- ``HTTPResponseConvertible``
8182

8283
### HTTP Currency Types
8384
- ``HTTPBody``
@@ -91,5 +92,9 @@ You can also publish your transport or middleware as a Swift package to allow ot
9192
- ``OpenAPIObjectContainer``
9293
- ``OpenAPIArrayContainer``
9394

95+
### Additional middlewares
96+
- ``ErrorHandlingMiddleware``
97+
- ``QuerySpaceNormalizingMiddleware``
98+
9499
[0]: https://github.com/apple/swift-openapi-generator
95100
[1]: https://swiftpackageindex.com/apple/swift-openapi-generator/documentation
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
public import HTTPTypes
16+
17+
/// An opt-in middleware that normalizes `+`-encoded spaces in the request's
18+
/// query string to `%20`, so that requests from clients that use
19+
/// `application/x-www-form-urlencoded` conventions decode correctly.
20+
///
21+
/// The OpenAPI specification follows RFC 3986, which requires spaces in query
22+
/// strings to be percent-encoded as `%20`. However, many widely-used HTTP
23+
/// client libraries — including Python's `requests`, Java's `URLEncoder`,
24+
/// Scala's `sttp`, and JavaScript's `URLSearchParams` — encode spaces as `+`,
25+
/// following the `application/x-www-form-urlencoded` convention. Without this
26+
/// middleware, query parameters sent from such clients arrive at handlers with
27+
/// literal `+` characters instead of spaces.
28+
///
29+
/// This middleware rewrites `+` to `%20` in the query portion of the request
30+
/// path before the request is parsed, so both encoding conventions produce the
31+
/// same decoded value. A literal `+` in the original input should already be
32+
/// sent as `%2B` by any client; such values are unaffected because this
33+
/// middleware only rewrites unencoded `+` characters.
34+
///
35+
/// ## Example usage
36+
///
37+
/// ```swift
38+
/// let handler = RequestHandler()
39+
/// try handler.registerHandlers(on: transport, middlewares: [QuerySpaceNormalizingMiddleware()])
40+
/// ```
41+
///
42+
/// - Note: Only the query of the request is modified. The path, fragment, and
43+
/// body are not touched.
44+
public struct QuerySpaceNormalizingMiddleware: ServerMiddleware {
45+
/// Creates a new middleware.
46+
public init() {}
47+
48+
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
49+
public func intercept(
50+
_ request: HTTPTypes.HTTPRequest,
51+
body: OpenAPIRuntime.HTTPBody?,
52+
metadata: OpenAPIRuntime.ServerRequestMetadata,
53+
operationID: String,
54+
next:
55+
@Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata)
56+
async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?)
57+
) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
58+
var request = request
59+
if let path = request.path {
60+
let fragmentStart = path.firstIndex(of: "#") ?? path.endIndex
61+
let pathAndQuery = path[..<fragmentStart]
62+
if let queryStart = pathAndQuery.firstIndex(of: "?") {
63+
let queryContentStart = path.index(after: queryStart)
64+
let query = path[queryContentStart..<fragmentStart]
65+
if query.contains("+") {
66+
// Each "+" expands to 3 bytes ("%20"), so the worst-case
67+
// length is path.count + 2 * query.count.
68+
var newPath = ""
69+
newPath.reserveCapacity(path.count + 2 * query.count)
70+
newPath.append(contentsOf: path[..<queryContentStart])
71+
for character in query {
72+
if character == "+" {
73+
newPath.append("%20")
74+
} else {
75+
newPath.append(character)
76+
}
77+
}
78+
newPath.append(contentsOf: path[fragmentStart...])
79+
request.path = newPath
80+
}
81+
}
82+
}
83+
return try await next(request, body, metadata)
84+
}
85+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
17+
import XCTest
18+
@_spi(Generated) @testable import OpenAPIRuntime
19+
20+
final class Test_QuerySpaceNormalizingMiddleware: XCTestCase {
21+
static let middleware = QuerySpaceNormalizingMiddleware()
22+
23+
func testPlusInQueryIsReplacedWithPercent20() async throws {
24+
try await assertNormalizedPath(input: "/search?q=hello+world", expected: "/search?q=hello%20world")
25+
}
26+
27+
func testMultiplePlusesAreReplaced() async throws {
28+
try await assertNormalizedPath(
29+
input: "/search?a=one+two&b=three+four+five",
30+
expected: "/search?a=one%20two&b=three%20four%20five"
31+
)
32+
}
33+
34+
func testPercent20InQueryIsUntouched() async throws {
35+
try await assertNormalizedPath(input: "/search?q=hello%20world", expected: "/search?q=hello%20world")
36+
}
37+
38+
func testLiteralPlusEncodedAsPercent2BIsUntouched() async throws {
39+
try await assertNormalizedPath(input: "/search?q=hello%2Bworld", expected: "/search?q=hello%2Bworld")
40+
}
41+
42+
func testPlusInPathIsNotReplaced() async throws {
43+
try await assertNormalizedPath(input: "/a+b/c+d?q=e+f", expected: "/a+b/c+d?q=e%20f")
44+
}
45+
46+
func testPlusInFragmentIsNotReplaced() async throws {
47+
try await assertNormalizedPath(input: "/search?q=a+b#c+d", expected: "/search?q=a%20b#c+d")
48+
}
49+
50+
func testQuestionMarkInsideFragmentDoesNotCrash() async throws {
51+
try await assertNormalizedPath(input: "/a#b?c+d", expected: "/a#b?c+d")
52+
}
53+
54+
func testQueryBeforeFragmentContainingQuestionMark() async throws {
55+
try await assertNormalizedPath(input: "/a?b+c#d?e+f", expected: "/a?b%20c#d?e+f")
56+
}
57+
58+
func testPathWithoutQueryIsUntouched() async throws {
59+
try await assertNormalizedPath(input: "/search", expected: "/search")
60+
}
61+
62+
func testQueryWithoutPlusIsUntouched() async throws {
63+
try await assertNormalizedPath(input: "/search?q=hello", expected: "/search?q=hello")
64+
}
65+
66+
func testEmptyQueryIsUntouched() async throws {
67+
try await assertNormalizedPath(input: "/search?", expected: "/search?")
68+
}
69+
70+
func testResponseIsForwardedUnchanged() async throws {
71+
let request = HTTPRequest(soar_path: "/search?q=hello+world", method: .get)
72+
let (response, responseBody) = try await Test_QuerySpaceNormalizingMiddleware.middleware.intercept(
73+
request,
74+
body: nil,
75+
metadata: .init(),
76+
operationID: "testop",
77+
next: { _, _, _ in (HTTPResponse(status: .accepted), HTTPBody("ok")) }
78+
)
79+
XCTAssertEqual(response.status, .accepted)
80+
let bodyBytes = try await String(collecting: responseBody!, upTo: .max)
81+
XCTAssertEqual(bodyBytes, "ok")
82+
}
83+
84+
private func assertNormalizedPath(
85+
input: String,
86+
expected: String,
87+
file: StaticString = #filePath,
88+
line: UInt = #line
89+
) async throws {
90+
let request = HTTPRequest(soar_path: input, method: .get)
91+
_ = try await Test_QuerySpaceNormalizingMiddleware.middleware.intercept(
92+
request,
93+
body: nil,
94+
metadata: .init(),
95+
operationID: "testop",
96+
next: { forwarded, _, _ in
97+
XCTAssertEqual(forwarded.path, expected, file: file, line: line)
98+
return (HTTPResponse(status: .ok), nil)
99+
}
100+
)
101+
}
102+
}

0 commit comments

Comments
 (0)