This video has been archived, as it was published several years ago, so some of its information might now be outdated. For more recent articles, please visit the main article feed.
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)
)]
)
}
}