Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit 5fba5e8

Browse files
authored
Unify query and url-encoded-form implementation (#694)
2 parents 5673524 + 7e912cd commit 5fba5e8

File tree

3 files changed

+248
-25
lines changed

3 files changed

+248
-25
lines changed

WordPressKit/HTTPRequestBuilder.swift

Lines changed: 117 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import Foundation
22

3+
/// A builder type that appends HTTP request data to a URL.
4+
///
5+
/// Calling this class's url related functions (the ones that changes path, query, etc) does not modify the
6+
/// original URL string. The URL will be perserved in the final result that's returned by the `build` function.
37
final class HTTPRequestBuilder {
48
enum Method: String {
59
case get = "GET"
@@ -13,17 +17,20 @@ final class HTTPRequestBuilder {
1317
}
1418
}
1519

16-
private var urlComponents: URLComponents
20+
private let original: URLComponents
1721
private var method: Method = .get
22+
private var appendedPath: String = ""
1823
private var headers: [String: String] = [:]
24+
private var defaultQuery: [URLQueryItem] = []
25+
private var appendedQuery: [URLQueryItem] = []
1926
private var bodyBuilder: ((inout URLRequest) throws -> Void)?
2027
private(set) var multipartForm: [MultipartFormField]?
2128

2229
init(url: URL) {
2330
assert(url.scheme == "http" || url.scheme == "https")
2431
assert(url.host != nil)
2532

26-
urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
33+
original = URLComponents(url: url, resolvingAgainstBaseURL: true)!
2734
}
2835

2936
func method(_ method: Method) -> Self {
@@ -34,16 +41,7 @@ final class HTTPRequestBuilder {
3441
func append(path: String) -> Self {
3542
assert(!path.contains("?") && !path.contains("#"), "Path should not have query or fragment: \(path)")
3643

37-
var relPath = path
38-
if relPath.hasPrefix("/") {
39-
_ = relPath.removeFirst()
40-
}
41-
42-
if urlComponents.path.hasSuffix("/") {
43-
urlComponents.path = urlComponents.path.appending(relPath)
44-
} else {
45-
urlComponents.path = urlComponents.path.appending("/").appending(relPath)
46-
}
44+
appendedPath = Self.join(appendedPath, path)
4745

4846
return self
4947
}
@@ -53,32 +51,34 @@ final class HTTPRequestBuilder {
5351
return self
5452
}
5553

54+
func query(defaults: [URLQueryItem]) -> Self {
55+
defaultQuery = defaults
56+
return self
57+
}
58+
5659
func query(name: String, value: String?, override: Bool = false) -> Self {
5760
append(query: [URLQueryItem(name: name, value: value)], override: override)
5861
}
5962

60-
func append(query: [URLQueryItem], override: Bool = false) -> Self {
61-
var allQuery = urlComponents.queryItems ?? []
63+
func query(_ parameters: [String: Any]) -> Self {
64+
append(query: parameters.flatten(), override: false)
65+
}
6266

67+
func append(query: [URLQueryItem], override: Bool = false) -> Self {
6368
if override {
6469
let newKeys = Set(query.map { $0.name })
65-
allQuery.removeAll(where: { newKeys.contains($0.name) })
70+
appendedQuery.removeAll(where: { newKeys.contains($0.name) })
6671
}
6772

68-
allQuery.append(contentsOf: query)
69-
70-
urlComponents.queryItems = allQuery
73+
appendedQuery.append(contentsOf: query)
7174

7275
return self
7376
}
7477

75-
func body(form: [String: String]) -> Self {
78+
func body(form: [String: Any]) -> Self {
7679
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"
7780
bodyBuilder = { req in
78-
let content = form.map {
79-
"\(HTTPRequestBuilder.urlEncode($0))=\(HTTPRequestBuilder.urlEncode($1))"
80-
}
81-
.joined(separator: "&")
81+
let content = form.flatten().percentEncoded
8282
req.httpBody = content.data(using: .utf8)
8383
}
8484
return self
@@ -119,7 +119,29 @@ final class HTTPRequestBuilder {
119119
}
120120

121121
func build(encodeMultipartForm: Bool = false) throws -> URLRequest {
122-
guard let url = urlComponents.url else {
122+
var components = original
123+
124+
var newPath = Self.join(components.path, appendedPath)
125+
if !newPath.isEmpty, !newPath.hasPrefix("/") {
126+
newPath = "/\(newPath)"
127+
}
128+
components.path = newPath
129+
130+
// Add default query items if they don't exist in `appendedQuery`.
131+
var newQuery = appendedQuery
132+
if !defaultQuery.isEmpty {
133+
let toBeAdded = defaultQuery.filter { item in
134+
!newQuery.contains(where: { $0.name == item.name})
135+
}
136+
newQuery.append(contentsOf: toBeAdded)
137+
}
138+
139+
// Bypass `URLComponents`'s URL query encoding, use our own implementation instead.
140+
if !newQuery.isEmpty {
141+
components.percentEncodedQuery = Self.join(components.percentEncodedQuery ?? "", newQuery.percentEncoded, separator: "&")
142+
}
143+
144+
guard let url = components.url else {
123145
throw URLError(.badURL)
124146
}
125147

@@ -175,10 +197,80 @@ extension HTTPRequestBuilder {
175197
}
176198
}
177199

178-
private extension HTTPRequestBuilder {
200+
extension HTTPRequestBuilder {
179201
static func urlEncode(_ text: String) -> String {
180202
let specialCharacters = ":#[]@!$&'()*+,;="
181203
let allowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: specialCharacters))
182204
return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
183205
}
206+
207+
/// Join a list of strings using a separator only if neighbour items aren't already separated with the given separator.
208+
static func join(_ aList: String..., separator: String = "/") -> String {
209+
guard !aList.isEmpty else { return "" }
210+
211+
var list = aList
212+
let start = list.removeFirst()
213+
return list.reduce(into: start) { result, path in
214+
guard !path.isEmpty else { return }
215+
216+
guard !result.isEmpty else {
217+
result = path
218+
return
219+
}
220+
221+
switch (result.hasSuffix(separator), path.hasPrefix(separator)) {
222+
case (true, true):
223+
var prefixRemoved = path
224+
prefixRemoved.removePrefix(separator)
225+
result.append(prefixRemoved)
226+
case (true, false), (false, true):
227+
result.append(path)
228+
case (false, false):
229+
result.append("\(separator)\(path)")
230+
}
231+
}
232+
}
233+
}
234+
235+
private extension Dictionary where Key == String, Value == Any {
236+
237+
static func urlEncode(into result: inout [URLQueryItem], name: String, value: Any) {
238+
switch value {
239+
case let array as [Any]:
240+
for value in array {
241+
urlEncode(into: &result, name: "\(name)[]", value: value)
242+
}
243+
case let object as [String: Any]:
244+
for (key, value) in object {
245+
urlEncode(into: &result, name: "\(name)[\(key)]", value: value)
246+
}
247+
case let value as Bool:
248+
urlEncode(into: &result, name: name, value: value ? "1" : "0")
249+
default:
250+
result.append(URLQueryItem(name: name, value: "\(value)"))
251+
}
252+
}
253+
254+
func flatten() -> [URLQueryItem] {
255+
reduce(into: []) { result, entry in
256+
Self.urlEncode(into: &result, name: entry.key, value: entry.value)
257+
}
258+
}
259+
260+
}
261+
262+
extension Array where Element == URLQueryItem {
263+
264+
var percentEncoded: String {
265+
map {
266+
let name = HTTPRequestBuilder.urlEncode($0.name)
267+
guard let value = $0.value else {
268+
return name
269+
}
270+
271+
return "\(name)=\(HTTPRequestBuilder.urlEncode(value))"
272+
}
273+
.joined(separator: "&")
274+
}
275+
184276
}

WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,43 @@ import XCTest
55

66
class HTTPRequestBuilderTests: XCTestCase {
77

8+
static let nestedParameters: [String: Any] =
9+
[
10+
"number": 1,
11+
"nsnumber-true": NSNumber(value: true),
12+
"true": true,
13+
"false": false,
14+
"string": "true",
15+
"dict": ["foo": true, "bar": "string"],
16+
"nested-dict": [
17+
"outer1": [
18+
"inner1": "value1",
19+
"inner2": "value2"
20+
],
21+
"outer2": [
22+
"inner1": "value1",
23+
"inner2": "value2"
24+
]
25+
],
26+
"array": ["true", 1, false]
27+
]
28+
static let nestedParametersEncoded = [
29+
"number=1",
30+
"nsnumber-true=1",
31+
"true=1",
32+
"false=0",
33+
"string=true",
34+
"dict[foo]=1",
35+
"dict[bar]=string",
36+
"nested-dict[outer1][inner1]=value1",
37+
"nested-dict[outer1][inner2]=value2",
38+
"nested-dict[outer2][inner1]=value1",
39+
"nested-dict[outer2][inner2]=value2",
40+
"array[]=true",
41+
"array[]=1",
42+
"array[]=0",
43+
]
44+
845
func testURL() throws {
946
try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).build().url?.absoluteString, "https://wordpress.org")
1047
try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.com")!).build().url?.absoluteString, "https://wordpress.com")
@@ -140,6 +177,33 @@ class HTTPRequestBuilderTests: XCTestCase {
140177
)
141178
}
142179

180+
@available(iOS 16.0, *)
181+
func testSetQueryWithDictionary() throws {
182+
let query = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
183+
.query(HTTPRequestBuilderTests.nestedParameters)
184+
.build()
185+
.url?
186+
.query(percentEncoded: false)?
187+
.split(separator: "&")
188+
.reduce(into: Set()) { $0.insert(String($1)) }
189+
?? []
190+
191+
XCTAssertEqual(query.count, HTTPRequestBuilderTests.nestedParametersEncoded.count)
192+
193+
for item in HTTPRequestBuilderTests.nestedParametersEncoded {
194+
XCTAssertTrue(query.contains(item), "Missing query item: \(item)")
195+
}
196+
}
197+
198+
func testDefaultQuery() throws {
199+
let builder = HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
200+
.query(defaults: [URLQueryItem(name: "locale", value: "en")])
201+
202+
try XCTAssertEqual(builder.build().url?.query, "locale=en")
203+
try XCTAssertEqual(builder.query(name: "locale", value: "zh").build().url?.query, "locale=zh")
204+
try XCTAssertEqual(builder.query(name: "foo", value: "bar").build().url?.query, "locale=zh&foo=bar")
205+
}
206+
143207
func testJSONBody() throws {
144208
var request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)
145209
.method(.post)
@@ -225,6 +289,41 @@ class HTTPRequestBuilderTests: XCTestCase {
225289
XCTAssertEqual(form, decodedForm)
226290
}
227291

292+
func testJoin() throws {
293+
XCTAssertEqual(HTTPRequestBuilder.join("foo", "bar"), "foo/bar")
294+
XCTAssertEqual(HTTPRequestBuilder.join("foo/", "bar"), "foo/bar")
295+
XCTAssertEqual(HTTPRequestBuilder.join("foo", "/bar"), "foo/bar")
296+
XCTAssertEqual(HTTPRequestBuilder.join("foo/", "/bar"), "foo/bar")
297+
XCTAssertEqual(HTTPRequestBuilder.join("foo=1", "bar=2", separator: "&"), "foo=1&bar=2")
298+
XCTAssertEqual(HTTPRequestBuilder.join("foo=1/", "bar=2", separator: "&"), "foo=1/&bar=2")
299+
XCTAssertEqual(HTTPRequestBuilder.join("foo=1/", "&bar=2", separator: "&"), "foo=1/&bar=2")
300+
301+
XCTAssertEqual(HTTPRequestBuilder.join("", "foo"), "foo")
302+
XCTAssertEqual(HTTPRequestBuilder.join("foo", ""), "foo")
303+
XCTAssertEqual(HTTPRequestBuilder.join("foo", "/"), "foo/")
304+
XCTAssertEqual(HTTPRequestBuilder.join("/", "/foo"), "/foo")
305+
XCTAssertEqual(HTTPRequestBuilder.join("", "/foo"), "/foo")
306+
}
307+
308+
func testPreserveOriginalURL() throws {
309+
try XCTAssertEqual(
310+
HTTPRequestBuilder(url: URL(string: "https://wordpress.org/api?locale=en")!)
311+
.query(name: "locale", value: "zh")
312+
.build()
313+
.url?
314+
.query,
315+
"locale=en&locale=zh"
316+
)
317+
try XCTAssertEqual(
318+
HTTPRequestBuilder(url: URL(string: "https://wordpress.org/api?locale=en")!)
319+
.query(name: "foo", value: "bar")
320+
.build()
321+
.url?
322+
.query,
323+
"locale=en&foo=bar"
324+
)
325+
}
326+
228327
func testMultipartForm() throws {
229328
XCTAssertNotNil(
230329
try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!)

WordPressKitTests/WordPressComRestApiTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ class WordPressComRestApiTests: XCTestCase {
6060
}
6161
}
6262

63+
@available(iOS 16.0, *)
64+
func testQuery() {
65+
var requestURL: URL?
66+
stub(condition: isRestAPIRequest()) {
67+
requestURL = $0.url
68+
return HTTPStubsResponse(error: URLError(URLError.Code.networkConnectionLost))
69+
}
70+
71+
let expect = self.expectation(description: "One callback should be invoked")
72+
let api = WordPressComRestApi(oAuthToken: "fakeToken")
73+
api.GET(
74+
wordPressMediaRoutePath,
75+
parameters: HTTPRequestBuilderTests.nestedParameters as [String: AnyObject],
76+
success: { _, _ in expect.fulfill() },
77+
failure: { (_, _) in expect.fulfill() }
78+
)
79+
wait(for: [expect], timeout: 0.1)
80+
81+
let query = requestURL?
82+
.query(percentEncoded: false)?
83+
.split(separator: "&")
84+
.reduce(into: Set()) { $0.insert(String($1)) }
85+
?? []
86+
let expected = HTTPRequestBuilderTests.nestedParametersEncoded + ["locale=en"]
87+
88+
XCTAssertEqual(query.count, expected.count)
89+
90+
for item in expected {
91+
XCTAssertTrue(query.contains(item), "Missing query item: \(item)")
92+
}
93+
}
94+
6395
func testSuccessfullCall() {
6496
stub(condition: isRestAPIRequest()) { _ in
6597
let stubPath = OHPathForFile("WordPressComRestApiMedia.json", type(of: self))

0 commit comments

Comments
 (0)