Networking Layer with Swift Combine

May 10, 2021

How to build a well-structured and testable networking layer.

In iOS, a networking layer aims to simplify the interaction between an API and an app. It acts as the middleman that gives you objects without worrying about talking to a server.

There are many ways to build a networking layer. It could be as simple as a function that calls URLSession.dataTask(with:) or as complex as having multiple layers of data parsing with caching and future-proofing for new API versions.

Let us look at one approach by using Swift Combine.

Network Configuration

First, we need some file that can help with quick configurations for API calls!

// APIConfig.swift

struct APIConfig {

  static let environment: APIEnvironment = .production
  static var host: String {
    switch environment {
      case .production: return "https://www.applit.io"
      case .staging: return "https://staging.applit.io"
    }
  }

}

enum APIEnvironment {

  case production
  case staging

}

Breakdown the Request

Breaking down a network request, there are several building blocks:

Host (e.g. https://www.applit.io)
Path (e.g. /api/v1/content)
Headers (e.g. Accept, Content-Type)
URL Query (e.g. ?limit=20)
Body (e.g. { "some": "json" })
Method (e.g. GET, POST, PUT, PATCH, DELETE)

With this breakdown in mind, we can have separate entities along with a request builder to join everything together. This will help with both reusability and testability.
We already have the host covered, and we’ll worry about the path later.

// HTTPComponents.swift

typealias BodyParameters = [String: String]
typealias HTTPHeaders = [String: String]
typealias QueryParameters = [String: String]
// HTTPMethod.swift

enum HTTPMethod: String {

  case delete = "DELETE"
  case get = "GET"
  case patch = "PATCH"
  case post = "POST"
  case put = "PUT"

}
// HTTPTask.swift

enum HTTPTask {

  case request(body: BodyParameters? = nil,
               query: QueryParameters? = nil,
               additionalHeaders: HTTPHeaders? = nil)

  // More types of tasks can be added here (i.e. download, file upload, etc...)

}

Now that we have all the small components, these can be all joined together along with the path with an Endpoint object. Since an API can have many endpoints, it would make sense to make a protocol for each individual endpoint to conform to.

// Endpoint.swift

protocol Endpoint {

  /**
   Host of where we want to make the API call.
   */
  var host: String { get }
  /**
   API call path.
   */
  var path: String { get }
  var url: URL? { get }

  /**
   Headers specific to the endpoint.
   */
  var headers: HTTPHeaders? { get }
  /**
   GET, POST, PUT, PATCH, DELETE
   */
  var httpMethod: HTTPMethod { get }
  /**
   Type of network call
   */
  var task: HTTPTask { get }

}

extension Endpoint {

  var host: String { APIConfig.host }
  var url: URL? { URL(string: "\(host)\(path)") }

  var headers: HTTPHeaders? { nil }

}

Handling the Response

Great! We have all the components needed to build a request! Next up, we want to handle the network response and interpret it in a better way for iOS.
Let’s start with interpreting status codes.

// HTTPURLResponse+Status.swift

extension HTTPURLResponse {

  enum Status: String {
    case badRequest = "Bad request"
    case failed = "Network request failed."
    case redirect = "This request has been redirected."
    case success = "Success"
    case unableToDecode = "We could not decode the response."
    case unauthorized = "You need to be authenticated first."
  }

  var status: Status {
    switch statusCode {
    case 200...299: return .success
    case 300...399: return .redirect
    case 401...500: return .unauthorized
    case 400, 501...599: return .badRequest
    default: return .failed
    }
  }

}

We have status codes to report any errors, but we also want to see if the server itself would return certain error messages too!

Assuming our backend returns an error along the lines of:

{
  "message": "Woah! Something totally did not work as expected here."
}

We can easily parse this using handy Decodable objects!

// HTTPURLResponse+Status.swift

extension HTTPURLResponse {

  struct HTTPURLResponseError: Decodable, Error {

    let message: String

    init(message: String) {
      self.message = message
    }

    init(status: Status) {
      self.message = status.rawValue
    }

  }

  ...

}

Putting It All Together

We now have a good way to contain request objects, and we have a fairly simple response handler. We need to create the link between these two, which is to actually make the request.
Here is our plan:

  1. Create a URLRequest object with the Endpoint.
  2. Make the request.
  3. Perform any error handling.
  4. Return the response data as Data.

We will be creating a RequestController to do everything listed.
For testability, let’s create a protocol to start with that RequestController will conform to.

// RequestController.swift

protocol RequestControllerProtocol {

  /**
   Executes the set endpoint.

   - parameter endpoint: API endpoint.
   */
  func execute(_ endpoint: Endpoint) -> AnyPublisher<Data, Error>

}

Keep in mind, there is a chance that a network request could fail at the request building level. Therefore, we should have a good way to handle such errors.

// RequestError.swift

enum RequestError: String, Error {

  case invalidParameters = "Invalid parameters"
  case invalidURL = "Invalid url"

}

1. Build the Request

To build a URLRequest object, we need to have an easy way to transform Endpoint parameters into query and body parameters.

// RequestController.swift

final class RequestController {

  /**
   Retrieve `QueryParameters` and return a list of `URLQueryItems`

   - parameter queries: Query parameters to convert to `[URLQueryItem]`.
   */
  private func queryUrl(
    for request: URLRequest,
    with query: QueryParameters
  ) throws -> URL {
    guard let url = request.url,
          var components = URLComponents(url: url,
                                         resolvingAgainstBaseURL: false) else {
      throw RequestError.invalidURL
    }

    components.queryItems = query.map({ item in
      let name = item.key
      let value =
        item.value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
      return URLQueryItem(name: name, value: value)
    })

    guard let updatedUrl = components.url else {
      throw RequestError.invalidParameters
    }
    return updatedUrl
  }

  /**
   Retrieve `BodyParameters` and encode into a JSON data.

   - parameter parameters: Dictionary of parameters to convert to JSON.
   */
  private func data(for body: BodyParameters) throws -> Data {
    do {
      return try JSONSerialization.data(withJSONObject: body)
    } catch {
      throw RequestError.invalidParameters
    }
  }

}

Let’s make good use of these utility methods and build the request!

// RequestController.swift

final class RequestController {

  /**
   Builds a request by adding the proper header values and attaching parameters.

   - parameter endpoint: API endpoint.
   */
  private func makeRequest(with endpoint: Endpoint) throws -> URLRequest {
    guard let url = endpoint.url else { throw RequestError.invalidURL }
    var request = URLRequest(url: url)

    // Set HTTP method
    request.httpMethod = endpoint.httpMethod.rawValue

    // Add default headers
    request.addValue("application/json", forHTTPHeaderField: "Accept")
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")

    // Add endpoint headers
    endpoint.headers?.forEach({
      request.addValue($0.value, forHTTPHeaderField: $0.key)
    })

    // Encode request parameters
    switch endpoint.task {
    case .request(let body, let query):
      // Add query parameters
      if let query = query {
        request.url = try queryUrl(for: request, with: query)
      }

      // Add body parameters
      if let body = body {
        request.httpBody = try data(for: body)
      }
    }

    return request
  }

  ...

}

2. Make the Request

Here’s the fun part! We can do the last 3 steps or our plan all at once.

Let’s start using Swift Combine to do some network calls! Apple Documentation has a lovely way to start using Combine networking calls with URLSession dataTaskPublisher(for:).

// RequestController.swift

extension RequestController: RequestControllerProtocol {

  /**
   Executes the request, parses the error, and type erases to `AnyPublisher`.

   - parameter request: The request used for the network call.
   */
  private func publisher(for request: URLRequest) -> AnyPublisher<Data, Error> {
    typealias Response = HTTPURLResponse
    typealias ResponseError = HTTPURLResponse.HTTPURLResponseError

    return URLSession.shared.dataTaskPublisher(for: request)
      .tryMap({ result in
        guard let response = result.response as? Response else {
          // Cannot figure out what the response is
          throw ResponseError(status: .unableToDecode)
        }

        if response.status != .success {
          // Try to decode the error from the response
          if let error =
              try? JSONDecoder().decode(ResponseError.self, from: result.data) {
            throw error
          }
          // Throw a regular error based on response status
          throw ResponseError(status: response.status)
        }
        return result.data
      })
      .eraseToAnyPublisher()
  }

}

Now to make use of our publisher utility function and adding it with our execute method declared by RequestControllerProtocol

// RequestController.swift

extension RequestController: RequestControllerProtocol {

  func execute(_ endpoint: Endpoint) -> AnyPublisher<Data, Error> {
    do {
      let request = try makeRequest(with: endpoint)
      return publisher(for: request)
        .eraseToAnyPublisher()
    } catch {
      return Fail(error: error).eraseToAnyPublisher()
    }
  }

  ...

}

Conclusion

There you have it! A functional networking layer built for Swift Combine! For the folks exploring cool things with SwiftUI, this networking layer is built to fit right into anything SwiftUI for simple view updates.

To get a better understanding of this tutorial, here is the GitHub link to the full networking layer implementation.

Let's work together

Find out how we can help you grow.