Swift/UnwrapDiscover about Swift, iOS and architecture patterns

URLComponents encoding

September 15, 2020

Let's say we're working on a phone app and we need to query an API to get data about a phone number. As it is working worldwide we need to use international format +<countryCode><countryNumber>. Thanks to URLComponents our query can built as below:

var builder = URLComponents(string: "https://smsservice.com")!

builder.path = "/status"
builder.queryItems = [URLQueryItem(name: "number", value: "+33601020304")]

let requestURL = builder.url

Pretty simple and easy to read 💪. We now send our request to our server and get... a 400 http response 😱!

The devil is in the detail

The reason is pretty simple actually and lies into URLComponents documentation. It explicitly state that queryItems (the ones storing our phone number) are encoding along RFC 3986 🧨. Never heard about it? Put it simply it is a standard where plus sign is a valid query character meaning it does not need to be encoded.

Servers on the other hands usually follow URIs, URLs, and URNs: Clarifications and Recommendations 💣. And guess what? In this recommendation plus sign is a synonym for spacing in query meaning ?greeting=hello world and ?greeting=hello+world are the same thing.

As a result our server didn't see our number as "+33601020304" but actually as " 33601020304" 💥. To have our plus sign recognised by our server as such we have to manually encode it.

Same player play again

First we can do is defining a custom encoding function on URLQueryItem and use it when adding ou query parameters:

extension URLQueryItem {
    func percentEncoded() -> URLQueryItem {
        /// addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) encode parameters following RFC 3986
        /// which we need to encode other special characters correctly.
        /// We then also encode "+" sign with its HTTP equivalent

        var newQueryItem = self
        newQueryItem.value = value?
            .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)?
            .replacingOccurrences(of: "+", with: "%2B")

        return newQueryItem
    }
}

extension Array where Element == URLQueryItem {
    func percentEncoded() -> Array<Element> {
        return map { $0.percentEncoded() }
    }
}
builder.queryItems = [URLQueryItem(name: "number", value: "+33601020304")].percentEncoded()

print(builder.url)

But that's not enough as queryItems automatically encode each item which is what we usually want. Leading to double encoding special characters. To "disable" default behaviour we actually have to use another attribute:

builder.percentEncodedQueryItems = encodedQueryItems

print(builder.url)

And voila! You now are able to send a valid international number to your server.