29

This is how I add query params to a base URL:

let baseURL: URL = ...
let queryParams: [AnyHashable: Any] = ...
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
components?.queryItems = queryParams.map { URLQueryItem(name: $0, value: "\($1)") }
let finalURL = components?.url

The problem emerges when one of the values contains a + symbol. For some reason it's not encoded to %2B in the final URL, instead, it stays +. If I do encoding myself and pass %2B, NSURL encodes % and the 'plus' becomes %252B.

The question is how can I have %2B in the instance of NSURL?

P.S. I know, I wouldn't even have this problem if I constructed a query string myself and then simply pass a result to the NSURL's constructor init?(string:).

Artem Stepanenko
  • 3,423
  • 6
  • 29
  • 51

4 Answers4

41

As pointed out in the other answers, the "+" character is valid in a query string, this is also stated in the query​Items documentation:

According to RFC 3986, the plus sign is a valid character within a query, and doesn't need to be percent-encoded. However, according to the W3C recommendations for URI addressing, the plus sign is reserved as shorthand notation for a space within a query string (for example, ?greeting=hello+world).
[...]
Depending on the implementation receiving this URL, you may need to preemptively percent-encode the plus sign character.

And the W3C recommendations for URI addressing state that

Within the query string, the plus sign is reserved as shorthand notation for a space. Therefore, real plus signs must be encoded. This method was used to make query URIs easier to pass in systems which did not allow spaces.

This can be achieved by "manually" building the percent encoded query string, using a custom character set:

let queryParams = ["foo":"a+b", "bar": "a-b", "baz": "a b"]
var components = URLComponents()

var cs = CharacterSet.urlQueryAllowed
cs.remove("+")

components.scheme = "http"
components.host = "www.example.com"
components.path = "/somepath"
components.percentEncodedQuery = queryParams.map {
    $0.addingPercentEncoding(withAllowedCharacters: cs)!
    + "=" + $1.addingPercentEncoding(withAllowedCharacters: cs)!
}.joined(separator: "&")

let finalURL = components.url
// http://www.example.com/somepath?bar=a-b&baz=a%20b&foo=a%2Bb

Another option is to "post-encode" the plus character in the generated percent-encoded query string:

let queryParams = ["foo":"a+b", "bar": "a-b", "baz": "a b"]
var components = URLComponents()
components.scheme = "http"
components.host = "www.example.com"
components.path = "/somepath"
components.queryItems = queryParams.map { URLQueryItem(name: $0, value: $1) }
components.percentEncodedQuery = components.percentEncodedQuery?
    .replacingOccurrences(of: "+", with: "%2B")

let finalURL = components.url
print(finalURL!)
// http://www.example.com/somepath?bar=a-b&baz=a%20b&foo=a%2Bb
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • Sorry, didn't see this when I was working on incorporating your answer into mine. My code is astonishingly similar (to your first solution) but I promise you I didn't see it. – matt Mar 27 '17 at 19:15
  • @matt: I would not have thought anything else! – Martin R Mar 27 '17 at 19:25
  • 1
    As far as I can see, "+" in a query is not valid and is explicitly considered to be a reserved character here. Although it can be bypassed, it's just that.. bypassing a standard: https://tools.ietf.org/html/rfc3986#section-2.2. Is there something I'm missing in the RFC docs? – TheCodingArt Feb 19 '21 at 18:04
  • @TheCodingArt: As I understand it, a Query (section 3.4) can contain pchar, "/" and "?". pchar (defined in 3.3) includes sub-delims, which includes "+". That is also what [Apple says](https://developer.apple.com/documentation/foundation/nsurlcomponents/1407752-queryitems): *“According to RFC 3986, the plus sign is a valid character within a query, and doesn't need to be percent-encoded.”* On the other hand, there is a W3C recommendation to encode plus signs (which I quoted above). – Martin R Feb 19 '21 at 18:42
  • (cont.) Apples `URLComponents` class does not encode plus signs in a query by default, therefore I provided exemplary code how that can be done. Please let me know what you would like to see improved or clarified in the answer. – Martin R Feb 19 '21 at 18:42
  • It may be a valid character within a query, but it's NOT a valid character within a query _parameter value_ unless escaped. – timbre timbre Nov 25 '21 at 23:25
  • Because manipulating a query like that is not a good option, as question was specifically around _value_ containing `+`, while query may contain other pluses as separators too. A better way is to encode plus inside the `value` of URLQueryItem, and then use `percentEncodedQueryItems` to assign already encoded query items to components. – timbre timbre Nov 26 '21 at 14:38
  • @KirilS.: So for example in `a+b=c+d` only the plus sign in the value should be escaped, so that it becomes `a+b=c%2Bd` ? – Martin R Nov 26 '21 at 14:45
  • yes, in this case 2 query parameters are provided: one named `a` with no value (aka flag), and one named `b` with value `c+d`. Another scenario (which is what I had to deal with) is exactly what Apple mentions on their page: usage of `+` as separator (replacement of space) and in the same query having something like timezone. So the proper query would look like `address=1+high+st&timezone=%2B00:00`. If we encode `+` in `address` how would the server know that this is a space, and not plus? I would prefer of course `address=1%20high%20st&zone=%2B00:00`, but not my choice. – timbre timbre Nov 26 '21 at 15:19
  • @KirilS.: But `&` is the separator between query parameters, not the plus sign or a space. What am I misunderstanding? – Martin R Nov 26 '21 at 15:24
  • Apple thinks that's the only option, lol. But it's actually part of form encoding (aka `application/x-www-form-urlencoded`) which supposedly is for request body, not the rule about URL. For example: `https://...?a=b+c=d` is invalid form (should be `a=b&c=d`), but is valid URL. Check "the other side" - some server side library (e.g. https://nodejs.org/api/querystring.html), they usually set `&` as default, but allow to customize it. Also same principle: `a=b&c&d=e` - you'd want `&b%26c&...` right? – timbre timbre Nov 26 '21 at 16:35
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/239616/discussion-between-martin-r-and-kiril-s). – Martin R Nov 26 '21 at 20:04
7

URLComponents is behaving correctly: the + is not being percent-encoded because it is legal as it stands. You can force the + to be percent-encoded by using .alphanumerics, as explained already by Forest Kunecke (I got the same result independently but he was well ahead of me in submitting his answer!).

Just a couple of refinements. The OP's value: "\($1)" is unnecessary if this is a string; you can just say value:$1. And, it would be better to form the URL from all its components.

This, therefore, is essentially the same solution as Forest Kunecke, but I think it is more canonical and it is certainly more compact ultimately:

let queryParams = ["hey":"ho+ha"]
var components = URLComponents()
components.scheme = "http"
components.host = "www.example.com"
components.path = "/somepath"
components.queryItems = queryParams.map { 
  URLQueryItem(name: $0, 
    value: $1.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!) 
}
let finalURL = components.url

EDIT Rather better, perhaps, after suggested correction from Martin R: we form the entire query and percent-encode the pieces ourselves, and tell the URLComponents that we have done so:

let queryParams = ["hey":"ho+ha", "yo":"de,ho"]
var components = URLComponents()
components.scheme = "http"
components.host = "www.example.com"
components.path = "/somepath"
var cs = CharacterSet.urlQueryAllowed
cs.remove("+")
components.percentEncodedQuery = queryParams.map {
    $0.addingPercentEncoding(withAllowedCharacters: cs)! + 
    "=" + 
    $1.addingPercentEncoding(withAllowedCharacters: cs)!
}.joined(separator:"&")

// ---- Okay, let's see what we've got ----
components.queryItems
// [{name "hey", {some "ho+ha"}}, {name "yo", {some "de,ho"}}]
components.url
// http://www.example.com/somepath?hey=ho%2Bha&yo=de,ho
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Great solution! Indeed, much more compact. – Forest Kunecke Mar 27 '17 at 18:19
  • Thx @ForestKunecke - I added some more discussion but I've been careful to emphasize that you were way ahead submitting your answer while I was off experimenting! So if the OP goes for this, you should still get the checkmark. – matt Mar 27 '17 at 18:21
  • Other non-alphanumeric characters are encoded twice: `["hey":"ho-ha."]` becomes `hey=ho%252Dha%252E` – Martin R Mar 27 '17 at 18:25
  • @MartinR Cool! :) Well, maybe the real answer is to encode the `+` explicitly and set the `percentEncodedQuery`, if the `+` is the only thing the OP is afraid of. – matt Mar 27 '17 at 18:30
  • 1
    Yes, you can create the percent encoded query part using `var cs = CharacterSet.urlQueryAllowed ; cs.remove("+")` – Martin R Mar 27 '17 at 18:40
  • @MartinR Ooooh, I really like that. – matt Mar 27 '17 at 18:48
  • Thinking about it again, `components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")` might be the simpler solution. Is that what you meant? – Martin R Mar 27 '17 at 19:11
  • @MartinR The problem is that I don't know what the OP wants. `+` is legal and he's never explained what the issue is. Anyway I've incorporated your idea, see edited answer. – matt Mar 27 '17 at 19:14
  • @matt I'm sorry if I didn't make it clear. The purpose (which I failed to achieve) was to make '+' encoded in the query. – Artem Stepanenko Mar 27 '17 at 21:40
  • @ArtemStepanenko Our solutions all did that. But we don't know why, since `+` is legal. – matt Mar 27 '17 at 21:43
  • @matt I hope, I never said '+' was illegal, I just wanted to encode it :) Now I'm trying to figure out how can I accept all three answers... Thank you so much! – Artem Stepanenko Mar 27 '17 at 21:49
  • @matt unfortunately, your first solution (and the Forest's one) doesn't work. It looks like `URLComponents` encodes `queryItems` when it builds `percentEncodedQuery`. Thus, if you put `%2B` as a value in `URLQueryItem `, it becomes `%252B` in `percentEncodedQuery`. (I mentioned this in the question.) – Artem Stepanenko Mar 27 '17 at 22:08
  • That's right, that's why I corrected it to do what Martin said. We discussed it all in the comments. Where were you all this time? – matt Mar 27 '17 at 23:00
  • @matt you're right, I should have paid more attention to comments. Returning to your question, I was on my way home (for me it was already late evening). But don't get me wrong, I appreciate your contribution, that's why I've upvoted it. Thank you again. – Artem Stepanenko Mar 28 '17 at 08:41
5

You can simply encode components.percentEncodedQuery after query items was inserted.

let characterSet = CharacterSet(charactersIn: "/+").inverted
components.percentEncodedQuery = components.percentEncodedQuery?.addingPercentEncoding(withAllowedCharacters: characterSet)
2

Can you try using addingPercentEncoding(withAllowedCharacters: .alphanumerics)?

I just put together a quick playground demonstrating how this works:

//: Playground - noun: a place where people can play

let baseURL: URL = URL(string: "http://example.com")!
let queryParams: [AnyHashable: Any] = ["test": 20, "test2": "+thirty"]
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)

var escapedComponents = [String: String]()
for item in queryParams {
    let key = item.key as! String
    let paramString = "\(item.value)"

    // percent-encode any non-alphanumeric character.  This is NOT something you typically need to do.  User discretion advised.
    let escaped = paramString.addingPercentEncoding(withAllowedCharacters: .alphanumerics)

    print("escaped: \(escaped)")

    // add the newly escaped components to our dictionary
    escapedComponents[key] = escaped
}


components?.queryItems = escapedComponents.map { URLQueryItem(name: ($0), value: "\($1)") }
let finalURL = components?.url
Forest Kunecke
  • 2,160
  • 15
  • 32
  • 3
    Though I endorsed this answer, the code snippet doesn't work as I want. If query params contain `+`, it would appear as a `%252B` in the result query string. I think it happens because `URLComponents` encodes (in its own way) `queryItems` right before making `percentEncodedQuery` out of them. – Artem Stepanenko Mar 27 '17 at 22:16
  • 1
    @ArtemStepanenko you're absolutely right! I did not catch that in my playground testing. I will update my answer shortly – Forest Kunecke Mar 27 '17 at 23:16
  • thank you. I would be happy to see the updated version. – Artem Stepanenko Mar 28 '17 at 08:55