Skip to content

Commit 11c90d1

Browse files
author
Clément Le Provost
committed
Fix URL encoding of path components and query strings
- Spaces must be properly escaped, otherwise the URL is invalid, and results in a crash (fixes #103). - Query parameters have a different allowed character set and must be escaped accordingly. This was already the case inside the `Query` class, but not for the few query parameters set in the `Client` or `Index` classes. => This behavior is now factorized in the helper `String` extension.
1 parent df9c70c commit 11c90d1

File tree

5 files changed

+62
-34
lines changed

5 files changed

+62
-34
lines changed

Source/Client.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ import Foundation
168168
/// - returns: A cancellable operation.
169169
///
170170
@objc public func deleteIndex(indexName: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
171-
let path = "1/indexes/\(indexName.urlEncode())"
171+
let path = "1/indexes/\(indexName.urlEncodedPathComponent())"
172172
return performHTTPQuery(path, method: .DELETE, body: nil, hostnames: writeHosts, completionHandler: completionHandler)
173173
}
174174

@@ -183,7 +183,7 @@ import Foundation
183183
/// - returns: A cancellable operation.
184184
///
185185
@objc public func moveIndex(srcIndexName: String, to dstIndexName: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
186-
let path = "1/indexes/\(srcIndexName.urlEncode())/operation"
186+
let path = "1/indexes/\(srcIndexName.urlEncodedPathComponent())/operation"
187187
let request = [
188188
"destination": dstIndexName,
189189
"operation": "move"
@@ -203,7 +203,7 @@ import Foundation
203203
/// - returns: A cancellable operation.
204204
///
205205
@objc public func copyIndex(srcIndexName: String, to dstIndexName: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
206-
let path = "1/indexes/\(srcIndexName.urlEncode())/operation"
206+
let path = "1/indexes/\(srcIndexName.urlEncodedPathComponent())/operation"
207207
let request = [
208208
"destination": dstIndexName,
209209
"operation": "copy"
@@ -244,7 +244,7 @@ import Foundation
244244
// IMPLEMENTATION NOTE: Objective-C bridgeable alternative.
245245
var path = "1/indexes/*/queries"
246246
if strategy != nil {
247-
path += "?strategy=\(strategy!.urlEncode())"
247+
path += "?strategy=\(strategy!.urlEncodedQueryParam())"
248248
}
249249
var requests = [[String: AnyObject]]()
250250
requests.reserveCapacity(queries.count)

Source/Helpers.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,43 @@
2323

2424
import Foundation
2525

26+
27+
// MARK: - URL encoding
28+
29+
/// Character set allowed as a component of the path portion of a URL.
30+
/// Basically it's just the default `NSCharacterSet.URLPathAllowedCharacterSet()` minus the slash character.
31+
///
32+
let URLPathComponentAllowedCharacterSet: NSCharacterSet = {
33+
let characterSet = NSMutableCharacterSet()
34+
characterSet.formUnionWithCharacterSet(NSCharacterSet.URLPathAllowedCharacterSet())
35+
characterSet.removeCharactersInString("/")
36+
return characterSet
37+
}()
38+
39+
// Allowed characters taken from [RFC 3986](https://tools.ietf.org/html/rfc3986) (cf. §2 "Characters"):
40+
// - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
41+
// - gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
42+
// - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
43+
//
44+
// ... with these further restrictions:
45+
// - ampersand ('&') and equal sign ('=') removed because they are used as delimiters for the parameters;
46+
// - question mark ('?') and hash ('#') removed because they mark the beginning and the end of the query string.
47+
// - plus ('+') is removed because it is interpreted as a space by Algolia's servers.
48+
//
49+
let URLQueryParamAllowedCharacterSet = NSCharacterSet(charactersInString:
50+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/[]@!$'()*,;"
51+
)
52+
53+
2654
extension String {
27-
/// Return URL encoded version of the string
28-
func urlEncode() -> String {
29-
let customAllowedSet = NSCharacterSet(charactersInString: "!*'();:@&=+$,/?%#[]").invertedSet
30-
return stringByAddingPercentEncodingWithAllowedCharacters(customAllowedSet)!
55+
/// Return an URL-encoded version of the string suitable for use as a component of the path portion of a URL.
56+
func urlEncodedPathComponent() -> String {
57+
return stringByAddingPercentEncodingWithAllowedCharacters(URLPathComponentAllowedCharacterSet)!
58+
}
59+
60+
/// Return an URL-encoded version of the string suitable for use as a query parameter key or value.
61+
func urlEncodedQueryParam() -> String {
62+
return stringByAddingPercentEncodingWithAllowedCharacters(URLQueryParamAllowedCharacterSet)!
3163
}
3264
}
3365

Source/Index.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import Foundation
3737
@objc init(client: Client, indexName: String) {
3838
self.client = client
3939
self.indexName = indexName
40-
urlEncodedIndexName = indexName.urlEncode()
40+
urlEncodedIndexName = indexName.urlEncodedPathComponent()
4141
}
4242

4343
// MARK: - Utils
@@ -70,7 +70,7 @@ import Foundation
7070
/// - returns: A cancellable operation.
7171
///
7272
@objc public func addObject(object: [String: AnyObject], withID objectID: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
73-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())"
73+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())"
7474
return client.performHTTPQuery(path, method: .PUT, body: object, hostnames: client.writeHosts, completionHandler: completionHandler)
7575
}
7676

@@ -100,7 +100,7 @@ import Foundation
100100
/// - returns: A cancellable operation.
101101
///
102102
@objc public func deleteObject(objectID: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
103-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())"
103+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())"
104104
return client.performHTTPQuery(path, method: .DELETE, body: nil, hostnames: client.writeHosts, completionHandler: completionHandler)
105105
}
106106

@@ -130,7 +130,7 @@ import Foundation
130130
/// - returns: A cancellable operation.
131131
///
132132
@objc public func getObject(objectID: String, completionHandler: CompletionHandler) -> NSOperation {
133-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())"
133+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())"
134134
return client.performHTTPQuery(path, method: .GET, body: nil, hostnames: client.readHosts, completionHandler: completionHandler)
135135
}
136136

@@ -144,7 +144,7 @@ import Foundation
144144
@objc public func getObject(objectID: String, attributesToRetrieve attributes: [String], completionHandler: CompletionHandler) -> NSOperation {
145145
let query = Query()
146146
query.attributesToRetrieve = attributes
147-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())?\(query.build())"
147+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())?\(query.build())"
148148
return client.performHTTPQuery(path, method: .GET, body: nil, hostnames: client.readHosts, completionHandler: completionHandler)
149149
}
150150

@@ -175,7 +175,7 @@ import Foundation
175175
/// - returns: A cancellable operation.
176176
///
177177
@objc public func partialUpdateObject(partialObject: [String: AnyObject], objectID: String, completionHandler: CompletionHandler? = nil) -> NSOperation {
178-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())/partial"
178+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())/partial"
179179
return client.performHTTPQuery(path, method: .POST, body: partialObject, hostnames: client.writeHosts, completionHandler: completionHandler)
180180
}
181181

@@ -210,7 +210,7 @@ import Foundation
210210
///
211211
@objc public func saveObject(object: [String: AnyObject], completionHandler: CompletionHandler? = nil) -> NSOperation {
212212
let objectID = object["objectID"] as! String
213-
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncode())"
213+
let path = "1/indexes/\(urlEncodedIndexName)/\(objectID.urlEncodedPathComponent())"
214214
return client.performHTTPQuery(path, method: .PUT, body: object, hostnames: client.writeHosts, completionHandler: completionHandler)
215215
}
216216

@@ -359,7 +359,7 @@ import Foundation
359359
/// - returns: A cancellable operation.
360360
///
361361
@objc public func browseFrom(cursor: String, completionHandler: CompletionHandler) -> NSOperation {
362-
let path = "1/indexes/\(urlEncodedIndexName)/browse?cursor=\(cursor.urlEncode())"
362+
let path = "1/indexes/\(urlEncodedIndexName)/browse?cursor=\(cursor.urlEncodedQueryParam())"
363363
return client.performHTTPQuery(path, method: .GET, body: nil, hostnames: client.readHosts, completionHandler: completionHandler)
364364
}
365365

Source/Query.swift

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -886,30 +886,15 @@ public func ==(lhs: LatLng, rhs: LatLng) -> Bool {
886886

887887
// MARK: Serialization & parsing
888888

889-
// Allowed characters taken from [RFC 3986](https://tools.ietf.org/html/rfc3986) (cf. §2 "Characters"):
890-
// - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
891-
// - gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
892-
// - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
893-
//
894-
// ... with these further restrictions:
895-
// - ampersand ('&') and equal sign ('=') removed because they are used as delimiters for the parameters;
896-
// - question mark ('?') and hash ('#') removed because they mark the beginning and the end of the query string.
897-
// - plus ('+') is removed because it is interpreted as a space by Algolia's servers.
898-
//
899-
static let queryParamAllowedCharacterSet = NSCharacterSet(charactersInString:
900-
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/[]@!$'()*,;"
901-
)
902-
903889
/// Return the final query string used in URL.
904890
@objc public func build() -> String {
905891
var components = [String]()
906892
// Sort parameters by name to get predictable output.
907893
let sortedParameters = parameters.sort { $0.0 < $1.0 }
908894
for (key, value) in sortedParameters {
909-
if let escapedKey = key.stringByAddingPercentEncodingWithAllowedCharacters(Query.queryParamAllowedCharacterSet),
910-
let escapedValue = value.stringByAddingPercentEncodingWithAllowedCharacters(Query.queryParamAllowedCharacterSet) {
911-
components.append(escapedKey + "=" + escapedValue)
912-
}
895+
let escapedKey = key.urlEncodedQueryParam()
896+
let escapedValue = value.urlEncodedQueryParam()
897+
components.append(escapedKey + "=" + escapedValue)
913898
}
914899
return components.joinWithSeparator("&")
915900
}

Tests/ClientTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,15 @@ class ClientTests: XCTestCase {
336336
}
337337
waitForExpectationsWithTimeout(expectationTimeout, handler: nil)
338338
}
339+
340+
func testIndexNameWithSpace() {
341+
let expectation = expectationWithDescription(#function)
342+
client.deleteIndex("Index with spaces", completionHandler: { (content, error) -> Void in
343+
if error != nil {
344+
XCTFail(error!.localizedDescription)
345+
}
346+
expectation.fulfill()
347+
})
348+
waitForExpectationsWithTimeout(expectationTimeout, handler: nil)
349+
}
339350
}

0 commit comments

Comments
 (0)