Clean & simple Swift networking stack
Full network client is written in Swift without any external dependencies. The base code is around 200 LOC.
The idea was to create an extendable and maintainable client that can be used to quickly create a network layer with minimal boilerplate.
It was inspired by Moya, it just uses URLSession
where Moya
depends on Alamofire
enum Result<T, Error>
response handling- dependancy injection
- endpoint modeling with the
Endpoint
protocol - JSON parsing
- observable class for the network activity
- easy mocking and testing
Base code for the NetworkStack
implementation.
Base types used in the client. Typealias callback with the Result
response and the custom errors thrown by the networking stack.
typealias ResultCallback<T> = (Result<T, NetworkStackError>) -> Void
enum NetworkStackError: Error {
case invalidRequest
case dataMissing
case endpointNotMocked
case mockDataMissing
case responseError(error: Error)
case parserError(error: Error)
}
The WebService
class is used for making web requests. It implements the WebServiceProtocol
which allows easy dependency injection and testing. The request method takes an Endpoint
enum and a ResultCallback
. It automatically toggles the network activity indicator using the NetworkActivty
service and parses the data response using the Parser
service.
protocol WebServiceProtocol {
func request<T: Decodable>(_ endpoint: Endpoint, completition: @escaping ResultCallback<T>)
}
class WebService: WebServiceProtocol {
private let urlSession: URLSession
private let parser: Parser
private let networkActivity: NetworkActivityProtocol
init(urlSession: URLSession = URLSession(configuration: URLSessionConfiguration.default),
parser: Parser = Parser(),
networkActivity: NetworkActivityProtocol = NetworkActivity()) {
self.urlSession = urlSession
self.parser = parser
self.networkActivity = networkActivity
}
func request<T: Decodable>(_ endpoint: Endpoint, completition: @escaping ResultCallback<T>) {
guard let request = endpoint.request else {
OperationQueue.main.addOperation({ completition(.failure(NetworkStackError.invalidRequest)) })
return
}
networkActivity.increment()
let task = urlSession.dataTask(with: request) { [unowned self] (data, response, error) in
self.networkActivity.decrement()
if let error = error {
OperationQueue.main.addOperation({ completition(.failure(.responseError(error: error))) })
return
}
guard let data = data else {
OperationQueue.main.addOperation({ completition(.failure(NetworkStackError.dataMissing)) })
return
}
self.parser.json(data: data, completition: completition)
}
task.resume()
}
}
The MockWebService
implements the same WebServiceProtocol
. It skips making the actual web request and returns JSON data directly from a .json
file included with the project. It is useful for running tests or returning mocked responses until the backend endpoint is ready.
class MockWebService: WebServiceProtocol {
private let parser: Parser
init(parser: Parser = Parser()) {
self.parser = parser
}
func request<T: Decodable>(_ endpoint: Endpoint, completition: @escaping ResultCallback<T>) {
guard let endpoint = endpoint as? MockEndpoint else {
OperationQueue.main.addOperation({ completition(.failure(NetworkStackError.endpointNotMocked)) })
return
}
guard let data = endpoint.mockData() else {
OperationQueue.main.addOperation({ completition(.failure(NetworkStackError.mockDataMissing)) })
return
}
parser.json(data: data, completition: completition)
}
}
Service that handles the network activity indicator. It implements the observer pattern using closures. An observing class can subscribe to state updates using the observe
method and can toggle the network activity indicator.
enum NetworkActivityState {
case show
case hide
}
protocol NetworkActivityProtocol {
func increment()
func decrement()
func observe(using closure: @escaping (NetworkActivityState) -> Void)
}
class NetworkActivity: NetworkActivityProtocol {
private var observations = [(NetworkActivityState) -> Void]()
private var activityCount: Int = 0 {
didSet {
if (activityCount < 0) {
activityCount = 0
}
if (oldValue > 0 && activityCount > 0) {
return
}
stateDidChange()
}
}
private func stateDidChange() {
let state = activityCount > 0 ? NetworkActivityState.show : NetworkActivityState.hide
observations.forEach { closure in
OperationQueue.main.addOperation({ closure(state) })
}
}
func increment() {
self.activityCount += 1
}
func decrement() {
self.activityCount -= 1
}
func observe(using closure: @escaping (NetworkActivityState) -> Void) {
observations.append(closure)
}
}
Called from the Webservice
, parses the Data
response and calls the result callback with initialized data structs.
protocol ParserProtocol {
func json<T: Decodable>(data: Data, completition: @escaping ResultCallback<T>)
}
struct Parser {
let jsonDecoder = JSONDecoder()
func json<T: Decodable>(data: Data, completition: @escaping ResultCallback<T>) {
do {
let result: T = try jsonDecoder.decode(T.self, from: data)
OperationQueue.main.addOperation { completition(.success(result)) }
} catch let error {
OperationQueue.main.addOperation { completition(.failure(.parserError(error: error))) }
}
}
}
The base protocol that defines the data for a specific endpoint. An enum that implements the Endpoint
protocol is passed to the WebService
when creating a request.
protocol Endpoint {
var request: URLRequest? { get }
var httpMethod: String { get }
var httpHeaders: [String : String]? { get }
var queryItems: [URLQueryItem]? { get }
var scheme: String { get }
var host: String { get }
}
The protocol extension defines the request method that is used for creating an URLRequest
from the Endpoint
enum.
extension Endpoint {
func request(forEndpoint endpoint: String) -> URLRequest? {
var urlComponents = URLComponents()
urlComponents.scheme = scheme
urlComponents.host = host
urlComponents.path = endpoint
urlComponents.queryItems = queryItems
guard let url = urlComponents.url else { return nil }
var request = URLRequest(url: url)
request.httpMethod = httpMethod
if let httpHeaders = httpHeaders {
for (key, value) in httpHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
}
return request
}
}
The MockEndpoint
protocol inherits the Endpoint protocol and defines the data required for returning mocked responses.
protocol MockEndpoint: Endpoint {
var mockFilename: String? { get }
var mockExtension: String? { get }
}
The first extension defines the mockData
method that will load the .json
file for that endpoint and return it as a Data
object.
extension MockEndpoint {
func mockData() -> Data? {
guard let mockFileUrl = Bundle.main.url(forResource: mockFilename, withExtension: mockExtension),
let mockData = try? Data(contentsOf: mockFileUrl) else {
return nil
}
return mockData
}
}
The second extension has the default values for the mockExtension
.
extension MockEndpoint {
var mockExtension: String? {
return "json"
}
}
An example implementation of a single endpoint for fetching user data with two methods.
To set shared values between all the endpoints extend the base Endpoint
enum. In this example, we are setting the scheme and host for all endpoints.
extension Endpoint {
var scheme: String {
return "https"
}
var host: String {
return "jsonplaceholder.typicode.com"
}
}
Create the UserEndpoint
for describing the users' endpoint. The enum has one case for each endpoint method. .all
fetches all users and get(userId: Int)
is used to fetch a user with a specific id.
enum UserEndpoint {
case all
case get(userId: Int)
}
The extension of the UserEndpoint
defines the values that will be used when converting the UserEndpoint enum case into a URLRequest
. The request
property defines the URL, we also define the httpMethod
, queryItems
and httpHeaders
.
extension UserEndpoint: Endpoint {
var request: URLRequest? {
switch self {
case .all:
return request(forEndpoint: "/users")
case .get(let userId):
return request(forEndpoint: "/users/\(userId)")
}
}
var httpMethod: String {
switch self {
case .all:
return "GET"
case .get( _):
return "GET"
}
}
var queryItems: [URLQueryItem]? {
switch self {
case .all:
return nil
case .get(let userId):
return [URLQueryItem(name: "userId", value: String(userId))]
}
}
var httpHeaders: [String: String]? {
let headers: [String: String] = ["headerField" : "headerValue"]
switch self {
case .all, .get( _):
return headers
}
}
}
Create a User struct that represents the model that will be created by the Parser
service. It needs to conform to the Codable protocol.
struct User: Codable {
let id: Int
let username: String
let email: String
}
Create a WebService
object, call its request method and pass it an Endpoint enum. Its also needed to specify the type of the result callback so that the Parser
service knows how to create the model structs.
let webService = WebService()
webService.request(UserEndpoint.all) { (result: Result<[User], NetworkStackError>) in
switch result {
case .failure(let error):
dump(error)
case .success(let users):
dump(users)
}
}
webService.request(UserEndpoint.get(userId: 10)) { (result: Result<User, NetworkStackError>) in
switch result {
case .failure(let error):
dump(error)
case .success(let users):
dump(users)
}
}
Use the observe
method on the NetworkActivity
service to subscribe to network activity changes and toggle the network activity indicator
let networkActivity = NetworkActivity()
let webService = WebService(networkActivity: networkActivity)
networkActivity.observe { state in
switch state {
case .show:
print("Network activity indicator: SHOW")
case .hide:
print("Network activity indicator: HIDE")
}
}
Create two .json
files with the responses we want to return and add them to the project. Also, extend the UserEndpoint
with the MockEndpoint
protocol and set the filenames for the JSON response files.
extension UserEndpoint: MockEndpoint {
var mockFilename: String? {
switch self {
case .all:
return "users"
case .get( _):
return "user"
}
}
}
Create a MockWebService
instance and call the request method exactly the same way as for a normal WebService
.
let mockWebService = MockWebService()
mockWebService.request(UserEndpoint.get(userId: 10)) { (result: Result<User, NetworkStackError>) in
switch result {
case .failure(let error):
dump(error)
case .success(let users):
dump(users)
}
}
mockWebService.request(UserEndpoint.all) { (result: Result<[User], NetworkStackError>) in
switch result {
case .failure(let error):
dump(error)
case .success(let users):
dump(users)
}
}