Constructing URLs in Swift
Basics article available: NetworkingMost modern apps require some form of networking - which means working with URLs in different shapes and forms. However, constructing URLs - especially dynamic ones based on user input - isn't always straight forward, and can lead to a wide range of bugs and problems if we're not careful.
This week, let's take a look at various techniques for working with URLs in Swift, how to make our URL construction code more robust, and how different kinds of URLs might warrant different approaches. Let's dive right in!
Strings
A common way of looking at URLs is that they're essentially strings. And while that might be true in some regards, URLs have a much stricter set of limitations when it comes to their format and what characters they can contain, compared to many other kinds of strings.
Those limitations can quickly lead to problems when using simple string concatenation to construct URLs, like below where we use a search query to create a URL to request the GitHub search API:
func findRepositories(matching query: String) {
let api = "https://api.github.com"
let endpoint = "/search/repositories?q=\(query)"
let url = URL(string: api + endpoint)
...
}
While the above might work fine for simpler URLs, it's easy to run into two kinds of problems when using that approach:
- As the number of parameters might grow, we'll quickly end up with quite messy code that is hard to read, since all that we're doing is adding up strings using concatenation and interpolation.
- Since
query
is a normal string, it can contain any kind of special characters and emoji that could result in an invalid URL. We could of course encode the query using theaddingPercentEncoding
API, but it'd be much nicer to have the system take care of that for us.
Thankfully, Foundation provides a type that solves both of the above two problems for us - enter URLComponents
.
Components
While URLs might be strings under the hood, they are a lot more structured than simply being a collection of characters - since they have a well-defined format that they must conform to. So rather than dealing with them as concatenated strings - treating them as a sum of their individual components is usually a much better fit, especially as the number of dynamic components grows.
For example, let's say that we wanted to add support for GitHub's sort
parameter, which lets us sort our search results in different ways. To model the available sorting options, we might create an enum for them - like this:
enum Sorting: String {
case numberOfStars = "stars"
case numberOfForks = "forks"
case recency = "updated"
}
Now let's change our findRepositories
function from before to use URLComponents
instead of constructing its URL by manipulating strings. The result is a few more lines of code, but the readability is greatly improved, and we can now easily add multiple query items - including our new sorting option - to our URL in a very structured fashion:
func findRepositories(matching query: String,
sortedBy sorting: Sorting) {
var components = URLComponents()
components.scheme = "https"
components.host = "api.github.com"
components.path = "/search/repositories"
components.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "sort", value: sorting.rawValue)
]
// Getting a URL from our components is as simple as
// accessing the 'url' property.
let url = components.url
...
}
Not only does URLComponents
allow us to construct URLs in a nice and clean way, it also automatically encodes our parameters for us. By "outsourcing" this kind of tasks to the system, our code base no longer has to be aware of all the details associated with URLs, which usually makes things a lot more future-proof.
URLComponents
is also a great example of a built-in type that uses the builder pattern. The power of builders is that we get a dedicated API for building up a complex value, just like how we construct our URL above. For more on the builder pattern - check out "Using the builder pattern in Swift".
Endpoints
Chances are high that our app doesn't only need to request a single endpoint, and retyping all of the URLComponents
code required to construct a URL can become quite repetitive. Let's see if we can generalize our implementation a bit to support requesting any kind of GitHub endpoint.
First, let's define a struct to represent an endpoint. The only thing that we're expecting to change between endpoints is what path
we're requesting, as well as what queryItems
we want to attach as parameters - giving us a struct that looks like this:
struct Endpoint {
let path: String
let queryItems: [URLQueryItem]
}
Using the power of extensions, we can now easily define static factory methods for common endpoints, such as the search one we were using before:
extension Endpoint {
static func search(matching query: String,
sortedBy sorting: Sorting = .recency) -> Endpoint {
return Endpoint(
path: "/search/repositories",
queryItems: [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "sort", value: sorting.rawValue)
]
)
}
}
Finally, we can define another extension that uses the path
and queryItems
for any given endpoint to easily create a URL for it, using URLComponents
:
extension Endpoint {
// We still have to keep 'url' as an optional, since we're
// dealing with dynamic components that could be invalid.
var url: URL? {
var components = URLComponents()
components.scheme = "https"
components.host = "api.github.com"
components.path = path
components.queryItems = queryItems
return components.url
}
}
With the above in place, we can now easily pass endpoints around in our code base, rather than having to deal with URLs directly. For example, we could create a DataLoader
type that lets us pass an endpoint to load data from, like this:
class DataLoader {
func request(_ endpoint: Endpoint,
then handler: @escaping (Result<Data>) -> Void) {
guard let url = endpoint.url else {
return handler(.failure(Error.invalidURL))
}
let task = urlSession.dataTask(with: url) {
data, _, error in
let result = data.map(Result.success) ??
.failure(Error.network(error))
handler(result)
}
task.resume()
}
}
With the above in place, we now get a really nice syntax for loading data, since we can call static factory methods using dot-syntax when the type can be inferred:
dataLoader.request(.search(matching: query)) { result in
...
}
Pretty sweet! 🎉 By introducing simple abstractions, and using URLComponents
under the hood, we can quickly make big improvements to our URL handling code - especially compared to the string-based approach that we originally used.
Static URLs
So far we've been dealing with URLs that were dynamically constructed based on user input or other data that only becomes known at runtime. However, not all URLs have to be dynamic, and many times when we're performing requests to things like analytics or configuration endpoints - the complete URL is known at compile time.
As we saw when dealing with dynamic URLs, even when using dedicated types like URLComponents
, we have to work with a lot of optionals. We simply can't make a guarantee that all dynamic components will be valid, so to avoid crashes and unpredictable behavior, we're forced to add code paths that handle nil
cases for invalid URLs.
However, that's not the case for static URLs. With static URLs, we've either correctly defined the URL in our code, or our code is actually incorrect. For that kind of URLs, having to deal with optionals all over our code base is a bit unnecessary, so let's take a look at how we can add a separate way of constructing those URLs - using Swift's StaticString
type.
StaticString
is the lesser-known "cousin" of the main String
type. The main difference between the two is that StaticString
can't be the result of any dynamic expression - such as string interpolation or concatenation - the whole string needs to be defined as an inline literal. Internally, Swift uses this type for things like collecting file names for assertions and preconditions, but we can also use this type to create a URL
initializer for completely static URLs - like this:
extension URL {
init(staticString string: StaticString) {
guard let url = URL(string: "\(string)") else {
preconditionFailure("Invalid static URL string: \(string)")
}
self = url
}
}
At first, doing something like the above might seem to go against Swift's idea of runtime safety, but there's a good reason why we want to cause a crash here rather than dealing with optionals.
Like we took a look at in "Picking the right way of failing in Swift", what kind of error handling that's appropriate for any given case depends a lot on whether the error is caused by a programmer mistake or an execution error. Since defining an invalid static URL is definitely a programmer mistake, using a preconditionFailure
is most likely the best fit for the problem. With handling like that in place, we'll get a clear indication of what went wrong, and since we're now using a dedicated API for static URLs, we can even add linting and static checks to make things even more safe.
With the above in place, we can now easily define non-optional URLs using a static string literal:
let url = URL(staticString: "https://myapp.com/faq.html")
Fewer optionals, means fewer code paths to maintain and test, which is usually a good thing đź‘Ť.
Conclusion
At first, working with URLs might seem like a trivial problem, but once we start taking edge cases and user input into account, things can become quite complex. By leveraging APIs such as URLComponents
, we can offload much of the logic needed to correctly handle URLs to the system - and by defining domain-specific types for things like endpoints, we can vastly improve the ergonomics of working with URLs in our code base.
While we've touched a bit on networking code in this article, we'll dive much deeper into networking and the complexities that come with it in a future one. To stay up to date with all my articles - I recommend subscribing to the Swift by Sundell monthly newsletter.
What do you think? How do you usually work with URLs in your code base, do you already use some of the techniques from this article, or will you try some of them out? Let me know - along with any questions, comments or feedback that you might have - on Twitter @johnsundell.
Thanks for reading! 🚀