Skip to content

Commit b28ebf1

Browse files
fix: URL percent encoding (#798)
Fixes the issues with special characters encoding in the request URLs. - Percent encodes `/` character in the path components - Updates the url parameters percent encoding using the approach from the previous major version of the client - Percent encodes url parameters names
1 parent b1c2f50 commit b28ebf1

File tree

7 files changed

+64
-33
lines changed

7 files changed

+64
-33
lines changed

Sources/AlgoliaSearchClient/Models/Internal/URL+Convenience.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ extension URL {
2424
static var task = Self(string: "/1/task")!
2525

2626
func appending<R: RawRepresentable>(_ rawRepresentable: R) -> Self where R.RawValue == String {
27-
return appendingPathComponent(rawRepresentable.rawValue, isDirectory: false)
27+
return appendingPathComponent(rawRepresentable.rawValue.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed)!, isDirectory: false)
2828
}
2929

3030
func appending(_ pathComponent: PathComponent) -> Self {
31-
return appendingPathComponent(pathComponent.rawValue, isDirectory: false)
31+
return appendingPathComponent(pathComponent.rawValue.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed)!, isDirectory: false)
3232
}
3333

3434
enum PathComponent: String {

Sources/AlgoliaSearchClient/Models/Search/Query/Query+URLEncodable.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ extension Query {
102102

103103
func encode() -> String? {
104104
return queryItems
105-
.map { "\($0.name)=\($0.value?.addingPercentEncoding(withAllowedCharacters: .urlAllowed) ?? "")" }
105+
.map { "\($0.name.addingPercentEncoding(withAllowedCharacters: .urlParameterAllowed)!)=\($0.value?.addingPercentEncoding(withAllowedCharacters: .urlParameterAllowed) ?? "")" }
106106
.joined(separator: "&")
107107
}
108108

Sources/AlgoliaSearchClient/Transport/URLSession/URLRequest+Convenience.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,24 @@ extension URLRequest: Builder {}
1414

1515
extension CharacterSet {
1616

17-
static let urlAllowed: CharacterSet = .alphanumerics.union(.init(charactersIn: "-._~")) // as per RFC 3986
17+
// Allowed characters taken from [RFC 3986](https://tools.ietf.org/html/rfc3986) (cf. §2 "Characters"):
18+
// - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
19+
// - gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
20+
// - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
21+
//
22+
// ... with these further restrictions:
23+
// - ampersand ('&') and equal sign ('=') removed because they are used as delimiters for the parameters;
24+
// - question mark ('?') and hash ('#') removed because they mark the beginning and the end of the query string.
25+
// - plus ('+') is removed because it is interpreted as a space by Algolia's servers.
26+
//
27+
static let urlParameterAllowed: CharacterSet = .alphanumerics.union(.init(charactersIn: "-._~:/[]@!$'()*,;"))
28+
29+
static let urlPathComponentAllowed: CharacterSet = {
30+
var characterSet = CharacterSet()
31+
characterSet.formUnion(CharacterSet.urlPathAllowed)
32+
characterSet.remove(charactersIn: "/")
33+
return characterSet
34+
}()
1835

1936
}
2037

@@ -36,7 +53,7 @@ extension URLRequest {
3653

3754
var urlComponents = URLComponents()
3855
urlComponents.scheme = "https"
39-
urlComponents.path = command.path.absoluteString.removingPercentEncoding!
56+
urlComponents.percentEncodedPath = command.path.path
4057

4158
if let urlParameters = command.requestOptions?.urlParameters {
4259
urlComponents.queryItems = urlParameters.map { (key, value) in .init(name: key.rawValue, value: value) }

Tests/AlgoliaSearchClientTests/Unit/PathTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import FoundationNetworking
1616
class PathTests: XCTestCase {
1717

1818
func testIndexNameEncoding() {
19-
let path = URL(string: "/1/indexes/")!.appendingPathComponent("Index name with spaces")
19+
let path = URL(string: "/1/indexes/")!.appendingPathComponent("Index name with spaces".addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed)!)
2020
let request = URLRequest(command: Command.Custom(method: .post, callType: .write, path: path, body: nil, requestOptions: nil))
2121
XCTAssertEqual(request.url?.absoluteString, "https:/1/indexes/Index%20name%20with%20spaces")
2222
}

Tests/AlgoliaSearchClientTests/Unit/QueryTests.swift

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,23 @@ class QueryTests: XCTestCase {
9393
"distinct=5",
9494
"getRankingInfo=true",
9595
"explainModules=match.alternatives",
96-
"attributesToRetrieve=attr1%2Cattr2%2Cattr3",
97-
"restrictSearchableAttributes=rattr1%2Crattr2",
98-
"filters=%28color%3Ared%20OR%20color%3Ayellow%29%20AND%20on-sale%20AND%2012%2B%20AND%20%28test%3A%22Hello%20%26%20World%22%29",
99-
"facetFilters=%5B%5B%22color%3Ared%22%2C%22color%3Ablue%22%5D%2C%22size%3AM%22%5D",
100-
"optionalFilters=%5B%5B%22color%3Ared%22%2C%22color%3Ayellow%22%5D%2C%22on-sale%22%5D",
101-
"numericFilters=%5B%5B%22price%3E100%22%2C%22length%3C1000%22%5D%2C%22metrics%3E5%22%5D",
102-
"tagFilters=%5B%5B%22tag1%22%2C%22tag2%22%5D%2C%22tag3%22%5D",
96+
"attributesToRetrieve=attr1,attr2,attr3",
97+
"restrictSearchableAttributes=rattr1,rattr2",
98+
"filters=(color:red%20OR%20color:yellow)%20AND%20on-sale%20AND%2012%2B%20AND%20(test:%22Hello%20%26%20World%22)",
99+
"facetFilters=[[%22color:red%22,%22color:blue%22],%22size:M%22]",
100+
"optionalFilters=[[%22color:red%22,%22color:yellow%22],%22on-sale%22]",
101+
"numericFilters=[[%22price%3E100%22,%22length%3C1000%22],%22metrics%3E5%22]",
102+
"tagFilters=[[%22tag1%22,%22tag2%22],%22tag3%22]",
103103
"sumOrFiltersScores=false",
104-
"facets=facet1%2Cfacet2%2Cfacet3",
104+
"facets=facet1,facet2,facet3",
105105
"maxValuesPerFacet=10",
106106
"facetingAfterDistinct=true",
107107
"sortFacetValuesBy=count",
108108
"maxFacetHits=100",
109-
"attributesToHighlight=hattr1%2Chattr2%2Chattr3",
110-
"attributesToSnippet=sattr1%3A10%2Csattr2",
109+
"attributesToHighlight=hattr1,hattr2,hattr3",
110+
"attributesToSnippet=sattr1:10,sattr2",
111111
"highlightPreTag=%3Chl%3E",
112-
"highlightPostTag=%3C%2Fhl%3E",
112+
"highlightPostTag=%3C/hl%3E",
113113
"snippetEllipsisText=read%20more",
114114
"restrictHighlightAndSnippetArrays=true",
115115
"page=15",
@@ -120,41 +120,41 @@ class QueryTests: XCTestCase {
120120
"minWordSizefor2Typos=4",
121121
"typoTolerance=strict",
122122
"allowTyposOnNumericTokens=false",
123-
"disableTypoToleranceOnAttributes=dtattr1%2Cdtattr2",
124-
"aroundLatLng=79.5%2C10.5",
123+
"disableTypoToleranceOnAttributes=dtattr1,dtattr2",
124+
"aroundLatLng=79.5,10.5",
125125
"aroundLatLngViaIP=true",
126126
"aroundRadius=80",
127-
"aroundPrecision=%5B%7B%22from%22%3A0%2C%22value%22%3A1000%7D%2C%7B%22from%22%3A0%2C%22value%22%3A100000%7D%5D",
127+
"aroundPrecision=[%7B%22from%22:0,%22value%22:1000%7D,%7B%22from%22:0,%22value%22:100000%7D]",
128128
"minimumAroundRadius=40",
129-
"insideBoundingBox=%5B%5B0.0%2C10.0%2C20.0%2C30.0%5D%2C%5B40.0%2C50.0%2C60.0%2C70.0%5D%5D",
130-
"insidePolygon=%5B%5B0.0%2C10.0%2C20.0%2C30.0%2C40.0%2C50.0%5D%2C%5B10.0%2C20.0%2C30.0%2C40.0%2C50.0%2C60.0%5D%5D",
129+
"insideBoundingBox=[[0.0,10.0,20.0,30.0],[40.0,50.0,60.0,70.0]]",
130+
"insidePolygon=[[0.0,10.0,20.0,30.0,40.0,50.0],[10.0,20.0,30.0,40.0,50.0,60.0]]",
131131
"queryType=prefixLast",
132132
"removeWordsIfNoResults=lastWords",
133133
"advancedSyntax=false",
134-
"advancedSyntaxFeatures=exactPhrase%2CexcludeWords",
135-
"optionalWords=optWord1%2CoptWord2",
136-
"removeStopWords=ar%2Cfr",
137-
"disableExactOnAttributes=deAttr1%2CdeAttr2",
134+
"advancedSyntaxFeatures=exactPhrase,excludeWords",
135+
"optionalWords=optWord1,optWord2",
136+
"removeStopWords=ar,fr",
137+
"disableExactOnAttributes=deAttr1,deAttr2",
138138
"exactOnSingleWordQuery=word",
139-
"alternativesAsExact=ignorePlurals%2CsingleWordSynonym",
139+
"alternativesAsExact=ignorePlurals,singleWordSynonym",
140140
"ignorePlurals=false",
141-
"queryLanguages=hi%2Csq",
141+
"queryLanguages=hi,sq",
142142
"decompoundQuery=false",
143143
"enableRules=true",
144-
"ruleContexts=rc1%2Crc2",
144+
"ruleContexts=rc1,rc2",
145145
"enablePersonalization=false",
146146
"personalizationImpact=5",
147147
"userToken=testUserToken",
148148
"analytics=true",
149-
"analyticsTags=at1%2Cat2%2Cat3",
149+
"analyticsTags=at1,at2,at3",
150150
"enableABTest=false",
151151
"clickAnalytics=true",
152152
"synonyms=false",
153153
"replaceSynonymsInHighlight=true",
154154
"minProximity=3",
155-
"responseFields=facets_stats%2Chits",
155+
"responseFields=facets_stats,hits",
156156
"percentileComputation=false",
157-
"naturalLanguages=mi%2Cta",
157+
"naturalLanguages=mi,ta",
158158
"enableReRanking=true",
159159
"custom1=val1",
160160
"custom2=2.0",

Tests/AlgoliaSearchClientTests/Unit/SecuredAPIKeyRestrictionTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class SecuredAPIKeyRestrictionTests: XCTestCase {
2222
validUntil: validUntil,
2323
userToken: "testUserToken")
2424

25-
XCTAssertEqual(restriction.urlEncodedString, "query=testQuery&clickAnalytics=true&restrictIndices=index1%2Cindex2&restrictSources=127.0.0.1%2C127.0.0.2&userToken=testUserToken&validUntil=\(Int(validUntil))")
25+
XCTAssertEqual(restriction.urlEncodedString, "query=testQuery&clickAnalytics=true&restrictIndices=index1,index2&restrictSources=127.0.0.1,127.0.0.2&userToken=testUserToken&validUntil=\(Int(validUntil))")
2626
}
2727

2828
}

Tests/AlgoliaSearchClientTests/Unit/Transport/URLRequestConstructionTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,19 @@ class URLRequestConstructionTests: XCTestCase {
7777
}
7878

7979
}
80+
81+
func testPathPercentEncoding() {
82+
let command = Command.Indexing.GetObject(indexName: "myIndex",
83+
objectID: "gid://shopify/Collection/1122334455",
84+
attributesToRetrieve: [],
85+
requestOptions: nil)
86+
XCTAssertEqual(URLRequest(command: command).url?.absoluteString, "https:/1/indexes/myIndex/gid:%2F%2Fshopify%2FCollection%2F1122334455")
87+
}
88+
89+
func testFiltersEncoding() {
90+
let query = Query("test")
91+
.set(\.filters, to: "\"manufacturer\":\"&Quirky\"")
92+
XCTAssertEqual(query.urlEncodedString, "query=test&filters=%22manufacturer%22:%22%26Quirky%22")
93+
}
8094

8195
}

0 commit comments

Comments
 (0)