Swift clip: Managing URLs and endpoints
In this video, we’ll take a look at three different ways to manage the URLs and server endpoints that a Swift app is communicating with, and the sort of pros and cons that come with each approach.
Sample code
When getting started with a project, it’s very common to simply construct all URLs inline, like this:
func loadArticle(withID id: Article.ID,
using session: URLSession = .shared) {
let url = URL(string: "https://api.myapp.com/articles/\(id)")!
let task = session.dataTask(with: url) {
data, response, error in
...
}
task.resume()
}
To make things a bit more organized, we can instead move our URLs into static factory methods and computed properties that we’ll add by extending URL
:
extension URL {
static var recommendations: URL {
URL(string: "https://api.myapp.com/recommendations")!
}
static func article(withID id: Article.ID) -> URL {
URL(string: "https://api.myapp.com/articles/\(id)")!
}
}
The above approach enables us to use this really neat dot-syntax when constructing a URL:
func loadArticle(withID id: Article.ID,
using session: URLSession = .shared) {
let task = session.dataTask(with: .article(withID: id)) {
data, response, error in
...
}
task.resume()
}
To reduce code duplication within our URL
extension, we can also create a private utility method that lets us construct a URL for any given endpoint:
extension URL {
static var recommendations: URL {
makeForEndpoint("recommendations")
}
static func article(withID id: Article.ID) -> URL {
makeForEndpoint("articles/\(id)")
}
}
private extension URL {
static func makeForEndpoint(_ endpoint: String) -> URL {
URL(string: "https://api.myapp.com/\(endpoint)")!
}
}
An arguably even more organized approach would be to define a dedicated Endpoint
enum, with cases for each of the server endpoints that our app calls:
enum Endpoint {
case recommendations
case article(id: Article.ID)
case search(query: String, maxResultCount: Int = 100)
}
We’ll then need to implement a way to convert an instance of the above enum into an actual URL
value, which could be done through a computed property:
extension Endpoint {
var url: URL {
switch self {
case .recommendations:
return .makeForEndpoint("recommendations")
case .article(let id):
return .makeForEndpoint("articles/\(id)")
case .search(let query, let count):
return .makeForEndpoint("search/\(query)?count=\(count)")
}
}
}
When performing our network requests, we’ll now need to first construct an Endpoint
instance, and then convert it into a URL — like this:
func loadArticle(withID id: Article.ID,
using session: URLSession = .shared) {
let endpoint = Endpoint.article(id: id)
let task = session.dataTask(with: endpoint.url) {
data, response, error in
...
}
task.resume()
}
To make the above task a bit easier, we could instead extend URLSession
with a convenience API that accepts an Endpoint
to call, and then auto-starts a URLSessionDataTask
for us:
extension URLSession {
typealias Handler = (Data?, URLResponse?, Error?) -> Void
@discardableResult
func request(
_ endpoint: Endpoint,
then handler: @escaping Handler
) -> URLSessionDataTask {
let task = dataTask(
with: endpoint.url,
completionHandler: handler
)
task.resume()
return task
}
}
One way to further improve the above convenience API would be to make it use a Result type, rather than multiple optionals.
With the above in place, we can now heavily simplify our networking code:
func loadArticle(withID id: Article.ID,
using session: URLSession = .shared) {
session.request(.article(id: id)) {
data, response, error in
...
}
}
However, the enum-based approach requires us to define all of our app’s endpoints in a single place, which might not be practical (especially if we’re looking to extract our networking code into a separate module). So let’s also explore using a struct instead:
struct Endpoint {
var path: String
var queryItems: [URLQueryItem] = []
}
Just like our enum-based Endpoint
type, our struct will also need a computed property for converting an endpoint instance into a URL — only this time we’ll use URLComponents
to do that, and we’ll also add a precondition to get a better error message in case we encountered an invalid URL:
extension Endpoint {
var url: URL {
var components = URLComponents()
components.scheme = "https"
components.host = "api.myapp.com"
components.path = "/" + path
components.queryItems = queryItems
guard let url = components.url else {
preconditionFailure(
"Invalid URL components: \(components)"
)
}
return url
}
}
With the above in place, we can now define each of our endpoints using static methods and properties — which will still let us use the same nice dot-syntax at our call sites, but with the added flexibility that endpoints can now be defined away from the declaration of Endpoint
itself:
extension Endpoint {
static var recommendations: Self {
Endpoint(path: "recommendations")
}
static func article(withID id: Article.ID) -> Self {
Endpoint(path: "articles/\(id)")
}
static func search(for query: String,
maxResultCount: Int = 100) -> Self {
Endpoint(
path: "search/\(query)",
queryItems: [URLQueryItem(
name: "count",
value: String(maxResultCount)
)]
)
}
}