Creating a networking framework

Creating a Framework for SPM


I recently decided with my growing experience with Swift programming, that it would be an exciting adventure to create a Swift package to use with SPM (Swift Package Manager). It was a small task, just to get some experience with creating an external library.

I started off very small with a networking manager. I implemented a networking class, complete with the new Result type and Swift Generics to enable quick decoding without cluttering up my project with most of the boilerplate networking code most developers write over and over again.

So to enable custom errors I implemented my own NetworkError enum.

enum NetworkError: Error {
	case unknownError
	case invalidResponse
	case invalidData
	case decodeError
	
	var localizedDescription: String {
		switch self {
		case .unknownError:
			return "An unknown error occurred"
		case .invalidResponse:
			return "The response from the server was invalid. Please try again"
		case .invalidData:
			return "The data returned from the server was invalid."
		case .decodeError:
			return "There was an error decoding objects."
		}
	}
}

After that, I decided to enumerate the different HTTP Methods to remove hard-coded strings in my projects

enum HTTPMethod: String {
	case get = "GET"
	case post = "POST"
	case delete = "DELETE"
	case put = "PUT"
}

Now that I had the basic building blocks for a network call, I decided to make a network loader protocol so that I could mock network calls to enable easier testing. Also making sure to extend URLSession and make it adhere to my protocol.

protocol NetworkDataLoader {
	func loadData(using url: URL, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void)
	func loadData(using request: URLRequest, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void)
}

extension URLSession: NetworkDataLoader {
	func loadData(using url: URL, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
		dataTask(with: url) { data, response, error in
			completion(data, response as? HTTPURLResponse, error)
		}.resume()
	}
	
	 func loadData(using request: URLRequest, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
		dataTask(with: request) { data, response, error in
			completion(data, response as? HTTPURLResponse, error)
		}.resume()
	}
}

With all that done, now it was time to dive into the networking. I made a generic decoding method to decode whatever objects you throw at it with ease.

private func decode<T: Decodable>(to type: T.Type, data: Data) -> T? {
	let decoder = JSONDecoder()
	do {
		let decodedType = try decoder.decode(T.self, from: data)
		return decodedType
	} catch {
		return nil
	}
}

Finally, one more method left to enable me to run networking calls. I had to write a decodeObjects method to take advantge of my new decode method. I added an initializer to set the networking mechanism which allowed me to swap out a mock class for URLSession when it came time to write tests. I also included a Set of known success codes for HTTP Responses. After drilling through and handling all errors, I finally get to "the happy path" and decode my objects.

private let networkLoader: NetworkDataLoader
private let expectedResponseCodes = Set.init(200 ... 299)

init(networkLoader: NetworkDataLoader = URLSession.shared) {
	self.networkLoader = networkLoader
}

func decodeObjects<T: Decodable>(using url: URL, completion: @escaping (Result<T, NetworkError>) -> Void) {
	networkLoader.loadData(using: url) { data, response, error in
		guard error == nil else {
			completion(.failure(.unknownError))
			return
		}
		
		if let response = response, !self.expectedResponseCodes.contains(response.statusCode) {
			completion(.failure(.invalidResponse))
			return
		}
		
		guard let data = data else {
			completion(.failure(.invalidData))
			return
		}
		
		guard let results = self.decode(to: T.self, data: data) else {
			completion(.failure(.decodeError))
			return
		}
		
		completion(.success(results))
	}
}

Now that all looks great right? Generic code, no build errors , bundled up into a Swift Package so I can import it into any project and networking worries are gone right?

Wrong!

The experienced iOS developers would see the issue in a heartbeat. This works fine for testing and networking in this bundle. But once imported into another project, I had no access to my initializer or any of my methods.

So back to the drawing board. I eventually discovered my issue was access control.

So I made the following changes.

public enum NetworkError: Error { ...
	public var localizedDescription: String {
		...
	}
}

public enum HTTPMethod: String {
	...
}

public protocol NetworkDataLoader {
	...
}

extension URLSession: NetworkDataLoader {
	public func loadData(using url: URL, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
		...
	}
	
	 public func loadData(using request: URLRequest, completion: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
		...
	}
}

public init(networkLoader: NetworkDataLoader = URLSession.shared) {
	...	
}

public func decodeObjects<T: Decodable>(using url: URL, completion: @escaping (Result<T, NetworkError>) -> Void) {
	...
}

Success! I was now able to access the "public API" of my framework and run the methods I had worked so hard on writing.

I tested out my framework in a simple TableView by querying the pokeAPI and achieved success. My generic decoder was working and I now had populated a TableView with data from an API and had absolutely zero networking code in my app aside from the framework I created and imported into my app with Swift Package Manager!

I can't wait to further my knowledge and create a framework that is more powerful, but this was a fun start and I learned a lot about creating and managing frameworks and access levels.

Stay tuned for more goodies as I expand my knowledge and create more cool tools!