Protocol-Oriented Networking Layer in Swift

intermediate

swiftnetworkingiosprotocol-oriented-programming

Protocol-Oriented Programming

Protocol-oriented programming is a modern approach to developing applications in Swift. It’s not a new paradigm, nor was it introduced by Swift—it’s simply a new name for a concept that can be very useful in abstracting away the complexities of our apps. It also allows us to avoid certain object-oriented pitfalls such as the fragile base class issue and god class issue when dealing with inheritance.

Networking Layer with Protocol-Oriented Programming

End Result

The end result of this approach will be that we’ll be able to call our service by doing the following:

UserModel.fetch(Router.getUser(id: 1200), onSuccess { result in
    if case let .asSelf(model) = result {
        print(model.name)
    }
}, onError: { error in
    print(error)
})

Setup

I found this paradigm to be really useful in creating an expressive networking layer for my latest application.

For this you’ll need:

The steps are:

  1. Create a protocol called Fetchable which will define classes that can be fetched from the server and parsed
  2. Create a Router enum which will conform to Alamofire’s URLRequestConvertible

Let’s assume our endpoints are:

  • http://www.example.com/api/users for a list of users
  • http://www.example.com/api/user/<id> for a specific user
  • http://www.example.com/api/products?limit=<limit>&offset=<offset> for a list of products where limit is an int parameter for the number of elements and offset is an int parameter for the starting position
  • http://www.example.com/api/product/<id> for a single product

Fetchable Protocol

First, we’ll create a helper enum so we can support root arrays and dictionaries:

enum MappingResult<T> {
    case asSelf(T)
    case asDictionary([String: T])
    case asArray([T])
    case raw(Data)
}

The unboxer will try to deserialize the result in the same order that it was defined in the enum.

We’ll define Fetchable as an empty protocol:

protocol Fetchable {}

And implement the fetch(...) function in a protocol extension.

In order to do that, we need to implement a protocol extension with a Self constraint. In this context, Self refers to a struct, class, or enum implementing the Fetchable protocol.

We constrain Self to be Unboxable (Unboxable is a protocol in the Unbox library—you could just as easily constrain Self to Mappable if you were using ObjectMapper or any other protocol).

So our protocol extension signature looks like this:

extension Fetchable where Self: Unboxable { ... }

This means that when we use Self as a type in the extension, we can access all the methods defined in the Unboxable protocol.

So let’s create the fetch(...) function. First, we’ll define some helpful type aliases:

typealias ErrorHandler = (Error) -> Void
typealias SuccessHandler<T> = (MappingResult<T>) -> Void where T: Unboxable

These are used just for convenience.

The fetch function will look like this:

static func fetch(
    with request: URLRequestConvertible,
    onSuccess: @escaping SuccessHandler<Self>,
    onError: @escaping ErrorHandler
) {
    Alamofire.request(request).responseJSON { response in
        if let errorData = response.result.error {
            onError(errorData)
            return
        }
        if let data = response.data {
            do {
                let mapped: Self = try unbox(data: data)
                onSuccess(.asSelf(mapped))
            } catch {
                do {
                    let json = try JSONSerialization.jsonObject(
                        with: data,
                        options: []
                    ) as? [String: [String: Any]]
                    var mappedDictionary = [String: Self]()
                    try json?.forEach { key, value in
                        let data: Self = try unbox(dictionary: value)
                        mappedDictionary[key] = data
                    }
                    onSuccess(.asDictionary(mappedDictionary))
                } catch {
                    do {
                        let mapped: [Self] = try unbox(data: data)
                        onSuccess(.asArray(mapped))
                    } catch {
                        do {
                            onSuccess(.raw(data))
                        } catch {
                            onError(error)
                        }
                    }
                }
            }
        }
    }
}

Defining the Models

Now let’s define our models:

struct ListItemModel: Fetchable, Unboxable {
    let id: Int
    let name: String

    init(unboxer: Unboxer) throws {
        id = try unboxer.unbox(key: "id")
        name = try unboxer.unbox(key: "name")
    }
}

struct UserModel: Fetchable, Unboxable {
    let firstName: String
    let lastName: String

    init(unboxer: Unboxer) throws {
        firstName = try unboxer.unbox(key: "first_name")
        lastName = try unboxer.unbox(key: "last_name")
    }
}

struct ProductModel: Fetchable, Unboxable {
    let sku: String
    let weight: Int

    init(unboxer: Unboxer) throws {
        sku = try unboxer.unbox(key: "sku")
        weight = try unboxer.unbox(key: "weight")
    }
}

The Router is defined as a classic Swift enum router. There are plenty of other tutorials that cover that pattern.

Usage

And voilà—you can now call the endpoints with the super-expressive:

UserModel.fetch(Router.getUser(id: 150),
onSuccess: { result in
    // Handle success
}, onError: { error in
    // Handle error
})

And the responses are completely parsed and ready to use.