Building a reusable system for complex URL requests with Swift

Deal with query items, HTTP headers, request body and more in an easy, declarative way

Theoretically, building a URLRequest in Swift is easy:

let googleURL = URL(string: "https://google.com")!
var urlRequest = URLRequest(url: googleURL)
urlRequest.httpMethod = "POST"

But at some point, when your requests are complex enough, it starts to get frustrating.

First of all, there will be a lot of repetition — setting all the correct headers (but only in correct places), handling the encoding of the content (if necessary) and so on.

Secondly, there is also an issue that’s incredibly easy for developers to mess up: a URL query.

Most developers just try to work with the query directly, but percent encoding separate query items can be a tricky thing. Foundation has a built-in mechanism for it — URLComponents with its queryItems property, but it can feel like a hassle to dance around with URLComponents instances.

Instead, it would’ve been nice if we could operate with a query in a similar way we operate with, say, HTTP headers.

So in this article we will build a reusable, flexible URL request builder system that will help you out tremendously once you have complex enough requests.

Step 1: Separating the path from the base

Once you start building your networking layer, you’ll quickly notice that you need a system that allows you to use any base URL (for example, representing different servers based on dev environment), not just the one that’s “hardcoded” into the URL.

So we’ll start our system by working on a “path” level, like this:

struct RequestBuilder {
var urlComponents: URLComponents

private init(urlComponents: URLComponents) {
self.urlComponents = urlComponents
}

init(path: String) {
var components = URLComponents()
components.path = path
self.init(urlComponents: components)
}
}

And then one can build a full URLRequest like this:

extension RequestBuilder {
func makeRequest(baseURL: URL) -> URLRequest? {
guard let finalURL = urlComponents.url(relativeTo: baseURL) else {
return nil
}
return URLRequest(url: finalURL)
}
}
//let request = RequestBuilder(endpoint: "users/get")
.makeRequest(baseURL: apiBaseURL)

relativeTo: is a rather unknown feature of Foundation’s URL ecosystem, but one that works here really well.

CAUTION: if you get weird errors, make sure that your base URL does not have a “/” at the end, and that your path does not contain “/” at the start.

Step 2: Adding support for query items

Let’s continue by introducing a simple operator “template” for our new features:

extension RequestBuilder {
func modifyURLComponents(_ modifyURLComponents: @escaping (inout URLComponents) -> Void) -> RequestBuilder {
var copy = self
modifyURLComponents(&copy.urlComponents)
return copy
}
}

This function will modify the stored URLComponents and will return a new instance.

With this template ready, building the query items support is trivial:

extension RequestBuilder {
func queryItems(_ queryItems: [URLQueryItem]) -> RequestBuilder {
modifyURLComponents { urlComponents in
var items = urlComponents.queryItems ?? []
items.append(contentsOf: queryItems)
urlComponents.queryItems = items
}
}
}

From a usage perspective, this will work similarly to how you operate on SwiftUI views or Combine pipelines. And the final use of the whole thing so far is clear:

let request = RequestBuilder(path: "users/search")
.queryItems([
URLQueryItem(name: "city", value: "San Francisco")
])
.makeRequest(baseURL: apiBaseURL)
// https://example.com/users/search?city=San%20Francisco

URLQueryItem API provided by Foundation will perform all the necessary percent encoding for us, so there’s no need for us to worry about that at all.

Now we can even add some sugar to make working with the query even nicer:

extension RequestBuilder {
func queryItems(_ queryItems: [(name: String, value: String)]) -> RequestBuilder {
self.queryItems(queryItems.map { .init(name: $0.name, value: $0.value) })
}

func queryItem(name: String, value: String) -> RequestBuilder {
queryItems([(name: name, value: value)])
}
}
let request = RequestBuilder(path: "users/search")
.queryItem(name: "city", value: "San Francisco")
.queryItem(name: "maxResults", value: 100.description)
.makeRequest(baseURL: apiBaseURL)
// https://example.com/users/search?city=San%20Francisco&maxResults=100

Step 3: Modifying the URL request

Well, so far our system works with creating the URL we need, including the query. But what about all the HTTP request stuff?

Well, the main challenge is that URLRequest and URLComponents both contain a representation of a URL. If we have both the urlRequest and urlComponents in our RequestBuilder, they could end up having “conflicting views” on what the correct URL is.

Since our RequestBuilder operates on a path level, urlComponents should be the main “source of truth” regarding all things URL. But the issue is — we can’t create a URLRequest without a URL. So instead, let’s not store the URLRequest instance itself, but instead store instructions on how we’ll need to modify the request after it’s set up with the correct URL.

It might sound a bit confusing, but the concept is actually quite simple:

struct RequestBuilder {
var buildURLRequest: (inout URLRequest) -> Void
var urlComponents: URLComponents

private init(urlComponents: URLComponents) {
self.urlComponents = urlComponents
self.buildURLRequest = { _ in }
}

init(path: String) {
var components = URLComponents()
components.path = path
self.init(urlComponents: components)
}

func makeRequest(baseURL: URL) -> URLRequest? {
let finalURL = urlComponents.url(relativeTo: baseURL) ?? baseURL

var urlRequest = URLRequest(url: finalURL)
buildURLRequest(&urlRequest)

return urlRequest
}
}

And the “template” operator is implemented like this:

extension RequestBuilder {
func modifyURLRequest(_ modifyURLRequest: @escaping (inout URLRequest) -> Void) -> RequestBuilder {
var copy = self
let existing = buildURLRequest
copy.buildURLRequest = { request in
existing(&request)
modifyURLRequest(&request)

}
return copy
}
}
//let request = RequestBuilder(path: "users/submit")
.modifyURLRequest({ request in
request.httpMethod = "POST"
})
.makeRequest(baseURL: apiBaseURL)

So far, we already have a system that:

  • Separates the path from the base URL
  • Allows working on a URLComponents level, which gives easy control of the query
  • Allows us to modify the URLRequest instance

Step 4: Creating operators for URL request

Now we can add an infinite number of useful operators that modify our URLRequest instance.

For example, HTTP method:

extension RequestBuilder {
enum HTTPRequestMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case head = "HEAD"
case delete = "DELETE"
case patch = "PATCH"
case options = "OPTIONS"
case connect = "CONNECT"
case trace = "TRACE"
}

func httpMethod(_ method: HTTPRequestMethod) -> RequestBuilder {
modifyURLRequest { $0.httpMethod = method.rawValue }
}
}
//let request = RequestBuilder(path: "users/submit")
.httpMethod(.post)
.makeRequest(baseURL: apiBaseURL)

Or HTTP headers:

extension RequestBuilder {
func httpHeader(name: String, value: String) -> RequestBuilder {
modifyURLRequest { $0.addValue(value, forHTTPHeaderField: name) }
}
}
//let request = RequestBuilder(path: "users/submit")
.httpMethod(.post)
.httpHeader(name: "Content-Type", value: "application/json")
.makeRequest(baseURL: apiBaseURL)

HTTP body:

extension RequestBuilder {
func httpBody(_ body: Data) -> RequestBuilder {
modifyURLRequest { $0.httpBody = body }
}

static let jsonEncoder = JSONEncoder()

func httpJSONBody<Content: Encodable>(_ body: Content, encoder: JSONEncoder = RequestBuilder.jsonEncoder) throws -> RequestBuilder {
let body = try encoder.encode(body)
return httpBody(body)
}
}
//let request = RequestBuilder(path: "users/submit")
.httpMethod(.post)
.httpHeader(name: "Content-Type", value: "application/json")
.httpJSONBody(userForm)
.makeRequest(baseURL: apiBaseURL)

Timeout:

extension RequestBuilder {
func timeout(seconds timeout: TimeInterval) -> RequestBuilder {
modifyURLRequest { $0.timeoutInterval = timeout }
}
}
let request = RequestBuilder(path: "users/submit")
.httpMethod(.post)
.httpHeader(name: "Content-Type", value: "application/json")
.timeout(seconds: 10)
.makeRequest(baseURL: apiBaseURL)

As you can see, we can have as many of these operators as we want. The system is really flexible, and you can always create operators that are specific to your own app.

What we also found helpful is the concept of “factories” — easy “starting points” for your requests. For example, these are the one we use often:

extension RequestBuilder {
// MARK: - Factories

static func get(path: String) -> RequestBuilder {
RequestBuilder(path: path)
.httpMethod(.get)
}

static func post(path: String) -> RequestBuilder {
RequestBuilder(path: path)
.httpMethod(.post)
}

// MARK: - JSON Factories

static func jsonGet(path: String) -> RequestBuilder {
.get(path: path)
.httpHeader(name: "Content-Type", value: "application/json")
}

static func jsonPost(path: String, jsonData: Data) -> RequestBuilder {
.post(path: path)
.httpHeader(name: "Content-Type", value: "application/json")
.httpBody(jsonData)
}

static func jsonPost<Content: Encodable>(
path: String,
jsonObject: Content,
encoder: JSONEncoder = RequestBuilder.jsonEncoder
) throws -> EndpointRequest {
try .post(path: path)
.httpHeader(name: "Content-Type", value: "application/json")
.httpJSONBody(jsonObject, encoder: encoder)
}
}

//

let request = RequestBuilder.jsonPost(path: "users/submit", jsonObject: userForm)
.timeout(seconds: 10)
.makeRequest(baseURL: apiBaseURL)

The system can really be extended and adapted to your every need.

Conclusion

Hopefully, you find this guide useful. For us, the biggest benefit of this approach is that we can operate on query items and HTTP stuff in the same centralised space, without messing with URLComponents directly. It simplified a lot of the code we had — code was not only bulky and sometimes hard to grasp, but also very repetitive and error-prone.

Of course, there are still many improvements that can be made here. Let us know in the comments if there’s anything you’d want to add to this system!

We also published an improved version of this system to our GitHub account, feel free to check it out here:

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Oleg Dreyman

Oleg Dreyman

797 Followers

iOS development know-it-all. Talk to me about Swift, coffee, photography & motorsports.