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:
- Unbox for deserialization: https://github.com/JohnSundell/Unbox
- Alamofire for accessing the network: https://github.com/Alamofire/Alamofire
The steps are:
- Create a protocol called
Fetchablewhich will define classes that can be fetched from the server and parsed - Create a
Routerenum which will conform to Alamofire’sURLRequestConvertible
Let’s assume our endpoints are:
http://www.example.com/api/usersfor a list of usershttp://www.example.com/api/user/<id>for a specific userhttp://www.example.com/api/products?limit=<limit>&offset=<offset>for a list of products wherelimitis an int parameter for the number of elements andoffsetis an int parameter for the starting positionhttp://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.