NetworkStack
NetworkStack copied to clipboard
Clean & simple Swift networking stack playground
NetworkStack 
Clean & simple Swift networking stack
About
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
Features
enum Result<T, Error>response handling- dependancy injection
- endpoint modeling with the
Endpointprotocol - JSON parsing
- observable class for the network activity
- easy mocking and testing
Base code
Base code for the NetworkStack implementation.
Types
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)
}
WebService
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()
}
}
MockWebService
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)
}
}
Network Activity
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)
}
}
Parser
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))) }
}
}
}
Endpoint
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"
}
}
Example
An example implementation of a single endpoint for fetching user data with two methods.
Shared values
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"
}
}
UserEndpoint
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
}
}
}
User
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
}
Use
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)
}
}
Network activity
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")
}
}
Mocking
Setup
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"
}
}
}
Use
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)
}
}