“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” — Martin Fowler
In my iOS projects, I’ve come to realize that crafting a functional and efficient Network Layer is an ongoing adventure. It’s like a constantly evolving puzzle that keeps me on my toes. With each new project, I strive to make improvements to the Network Layer, always aiming for a smoother experience than before. However, I’ve also learned that sometimes, in the pursuit of perfection, I can get caught up in overthinking and unnecessary complexity.
Recently, I took a step back and approached things differently. I decided to focus on creating a clean and straightforward class to handle network calls in my iOS app. By simplifying and streamlining the process, I hope to achieve a more sensible and enjoyable development experience. Join me on this journey as we unravel the mysteries of the Network Layer and discover the beauty of simplicity in handling our network requests.
Let’s Start Then!
Before we get to the Network Layer, I think we can create a simple ResponseWrapper. Usually, an API response will have the following structure
{
"message": "success",
"status": 200,
"data": {
"id": 123,
"name": "John Doe",
"email": "johndoe@example.com",
"age": 30,
"city": "New York"
}
}
Bearing this format in mind, let’s proceed to make a Class to handle the response.
struct ResponseWrapper<T: Decodable>: Decodable {
let status: Int?
let message: String?
let data: T?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
status = try container.decodeIfPresent(Int.self, forKey: .status)
message = try container.decodeIfPresent(String.self, forKey: .message)
data = try container.decodeIfPresent(T.self, forKey: .data)
}
private enum CodingKeys: String, CodingKey {
case status
case message
case data
}
}
Having the status
, message
, and data
as optional parameters provides me with peace of mind when deploying the code to production. I prefer handling guarded unwrapping over encountering a fatalError
.
Enabling the decoder
through CodingKeys
enhances the flexibility and reusability of the Wrapper, allowing it to adapt to new developer and backend naming conventions.
The Network Layer
- Error Handling
I decided to stick to an enum
based approach, here. So let’s start with some simple error handling-
enum APIError: Error {
case invalidURL
case requestFailed(String)
case decodingFailed
}
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
This part is fairly self-explanatory, I am sure. With APIError
we can handle the obvious and expected error responses that we get from the API. Same simplicity was used to create a set of enums
for handling HTTPMethods
.
- Endpoints
Now we shall proceed to create the endpoints required in our app. This too, shall follow the enum pattern we have created for the Error extension-
enum Endpoint {
case examplePostOne
case examplePutOne
case exampleDelete
case examplePostTwo
case exampleGet
case examplePutTwo
var path: String {
switch self {
case .examplePostOne:
return "/api/postOne"
case .examplePutOne:
return "/api/putOne"
case .exampleDelete:
return "/api/delete"
case .examplePostTwo:
return "/api/postTwo"
case .exampleGet:
return "/api/get"
case .examplePutTwo:
return "/api/puTwo"
}
}
var httpMethod: HttpMethod {
switch self {
case .exampleGet:
return .get
case .examplePostOne, .examplePostTwo:
return .post
case .examplePutOne, .examplePutTwo:
return .put
case .exampleDelete:
return .delete
}
}
}
This approach enables us to access the URLEndpoints and HTTPMethods easily, using the following methods-
let endPoint: Endpoint = Endpoint.examplePostOne
print(endPoint.path) // prints "/api/postOne"
print(endPoint.httpMethod) // .post
As the complexity of the app increases and more API endpoints are to be added, we can do it here inside the enum
Endpoint
, and add the HTTPMethod
, with it. This will be a single Source Of Truth for our API endpoints.
- Network Request
Next we make a very basic NetworkManager
class to hold our request
function. We shall use the Combine Framework to implement this functionality-
protocol NetworkService {
func request<T: Decodable>(_ endpoint: Endpoint, parameters: Encodable?) -> AnyPublisher<T, APIError>
}
class NetworkManager: NetworkService {
private let baseURL: String
init(baseURL: String = "") {
self.baseURL = environment.baseURL
}
func request<T: Decodable>(_ endpoint: Endpoint, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
guard let url = URL(string: baseURL + endpoint.path) else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var urlRequest = URLRequest(url: url)
if let parameters = parameters {
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let jsonData = try JSONEncoder().encode(parameters)
urlRequest.httpBody = jsonData
} catch {
return Fail(error: APIError.requestFailed("Encoding parameters failed.")).eraseToAnyPublisher()
}
}
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { (data, response) -> Data in
if let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) {
return data
} else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw APIError.requestFailed("Request failed with status code: \(statusCode)")
}
}
.decode(type: ResponseWrapper<T>.self, decoder: JSONDecoder())
.tryMap { (responseWrapper) -> T in
guard let status = responseWrapper.status else {
throw APIError.requestFailed("Missing status.")
}
switch status {
case 200:
guard let data = responseWrapper.data else {
throw APIError.requestFailed("Missing data.")
}
return data
default:
let message = responseWrapper.message ?? "An error occurred."
throw APIError.requestFailed(message)
}
}
.mapError { error -> APIError in
if error is DecodingError {
return APIError.decodingFailed
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.requestFailed("An unknown error occurred.")
}
}
.eraseToAnyPublisher()
}
}
Let us set the baseURL
as an empty string to start off, here. We can modify that later. One thing to notice here is the generic T
we are using to refer to the decodable
. What we are trying to do here, is to let the function know the data type to which we would like to decode
our response to. We have created the ResponseWrapper
earlier but that is not where should map the final result to. We need to decode the value that we expect to receive inside data
key.
So, we use .decode
on ResponseWrapper
which tries to match its data to the very data type that we pass in (don’t worry, this will be clearer, once we try to fetch data). Once the decoding is done successfully on ResponseWrapper
we look for status
, message
and data
inside it. If the decoding fails, we try to connect the reason of failure, to appropriate APIError .
- BaseURL
We could’ve passed in a simple string for baseURL, but it is a common and neat practise to have a development
, staging
and production
environment, while working as part of a team and when working for an outside client. So to have a clean release cycle, we can classify the baseURL as so-
enum APIEnvironment {
case development
case staging
case production
var baseURL: String {
switch self {
case .development:
return "development.example.com"
case .staging:
return "staging.example.com"
case .production:
return "production.example.com"
}
}
}
Let us now modify the initialisation for NetworkManager
-
init(environment: APIEnvironment = NetworkManager.defaultEnvironment()) {
self.baseURL = environment.baseURL
}
static func defaultEnvironment() -> APIEnvironment {
#if DEBUG
return .development
#elseif STAGING
return .staging
#else
return .production
#endif
}
#if DEBUG
is provided by the compiler. You can define your own custom flags or build configurations, such as STAGING
, to differentiate between different environments during the build process. This is a cleaner way to initialise the class from a ViewModel
where we might try to access the NetworkManager
from.
- Headers
We can add custom header
elements as well, as they tend to come in handy.
private func defaultHeaders() -> [String: String] {
var headers: [String: String] = [
"Platform": "iOS",
"User-Token": "your_user_token",
"uid": "user-id"
]
if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
headers["App-Version"] = appVersion
}
return headers
}
We can add elements like auth-token
, refresh-token
etc in our headers
. Of course, now that we have created the headers, we need to include them in the API call, as well.
First, let’s change the function call-
protocol NetworkService {
func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]?, parameters: Encodable?) -> AnyPublisher<T, APIError>
}
And then, we add the httpMethod inclusion in the function definition-
func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]? = nil, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
guard let url = URL(string: baseURL + endpoint.path) else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.httpMethod.rawValue
let allHeaders = defaultHeaders().merging(headers ?? [:], uniquingKeysWith: { (_, new) in new })
for (key, value) in allHeaders {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
.
.
.
.
.
.
}
With this, we have the function definition, sorted out. When we combine everything, we get this-
import Foundation
import Combine
enum APIError: Error {
case invalidURL
case requestFailed(String)
case decodingFailed
}
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
enum Endpoint {
case examplePostOne
case examplePutOne
case exampleDelete
case examplePostTwo
case exampleGet
case examplePutTwo
var path: String {
switch self {
case .examplePostOne:
return "/api/postOne"
case .examplePutOne:
return "/api/putOne"
case .exampleDelete:
return "/api/delete"
case .examplePostTwo:
return "/api/postTwo"
case .exampleGet:
return "/api/get"
case .examplePutTwo:
return "/api/puTwo"
}
}
var httpMethod: HttpMethod {
switch self {
case .exampleGet:
return .get
case .examplePostOne, .examplePostTwo:
return .post
case .examplePutOne, .examplePutTwo:
return .put
case .exampleDelete:
return .delete
}
}
}
enum APIEnvironment {
case development
case staging
case production
var baseURL: String {
switch self {
case .development:
return "development.example.com"
case .staging:
return "staging.example.com"
case .production:
return "production.example.com"
}
}
}
protocol NetworkService {
func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]?, parameters: Encodable?) -> AnyPublisher<T, APIError>
}
class NetworkManager: NetworkService {
private let baseURL: String
init(environment: APIEnvironment = NetworkManager.defaultEnvironment()) {
self.baseURL = environment.baseURL
}
static func defaultEnvironment() -> APIEnvironment {
#if DEBUG
return .development
#elseif STAGING
return .staging
#else
return .production
#endif
}
private func defaultHeaders() -> [String: String] {
var headers: [String: String] = [
"Platform": "iOS",
"User-Token": "your_user_token",
"uid": "user-id"
]
if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
headers["App-Version"] = appVersion
}
return headers
}
func request<T: Decodable>(_ endpoint: Endpoint, headers: [String: String]? = nil, parameters: Encodable? = nil) -> AnyPublisher<T, APIError> {
guard let url = URL(string: baseURL + endpoint.path) else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.httpMethod.rawValue
let allHeaders = defaultHeaders().merging(headers ?? [:], uniquingKeysWith: { (_, new) in new })
for (key, value) in allHeaders {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
if let parameters = parameters {
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let jsonData = try JSONEncoder().encode(parameters)
urlRequest.httpBody = jsonData
} catch {
return Fail(error: APIError.requestFailed("Encoding parameters failed.")).eraseToAnyPublisher()
}
}
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { (data, response) -> Data in
if let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) {
return data
} else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw APIError.requestFailed("Request failed with status code: \(statusCode)")
}
}
.decode(type: ResponseWrapper<T>.self, decoder: JSONDecoder())
.tryMap { (responseWrapper) -> T in
guard let status = responseWrapper.status else {
throw APIError.requestFailed("Missing status.")
}
switch status {
case 200:
guard let data = responseWrapper.data else {
throw APIError.requestFailed("Missing data.")
}
return data
default:
let message = responseWrapper.message ?? "An error occurred."
throw APIError.requestFailed(message)
}
}
.mapError { error -> APIError in
if error is DecodingError {
return APIError.decodingFailed
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.requestFailed("An unknown error occurred.")
}
}
.eraseToAnyPublisher()
}
}
- Fetch Request
Let us assume that we are expecting a response for the API .exampleGet in the following format-
{
"message": "success",
"status": 200,
"data": {
"id": 123,
"name": "John Doe",
"email": "johndoe@example.com",
"age": 30,
"city": "New York"
}
}
We will start off by creating a new struct- ResponseModel
that conforms to Codable
protocol.
struct ResponseModel: Codable {
var id: Int
var name: String
var email: String
var age: Int
var city: String
}
As you may notice, we don’t have to worry about message
and status
keys since that is taken care of, by the ResponseWrapper
. We are just providing for the data key in ResponseWrapper
.
Next, we can create a ViewModel
and call the request function-
import Foundation
import Combine
class ResponseViewModel: ObservableObject {
private let networkService: NetworkService
private var cancellables: Set<AnyCancellable> = []
init(networkService: NetworkService = NetworkManager()) {
self.networkService = networkService
}
func signUp(onCompletion: @escaping (Bool) -> ()) {
let response: AnyPublisher<ResponseModel, APIError> = networkService.request(.exampleGet, headers: nil)
response
.sink { completion in
switch completion {
case .finished:
break
case .failure(let error):
print("Error: \(error)")
onCompletion(false)
}
}
receiveValue: { response in
print(response)
onCompletion(true)
}
.store(in: &cancellables)
}
}
When we pass in ResponseModel
for the request function, we are effectively doing a double decoding. First we the function maps the response to ResponseWrapper
, and once that is done, it tries to unwrap the data by matching it to ResponseModel
. This sets up a clean structure for API response and API Parsing, which would be integral for setting up clean code.
And there you have it. A reusable Network Layer that is flexible and scalable.
Feel free to make it better and make it yours.
Cheers. Happy Coding.
Top comments (0)