Sample iOS application to understand how Github Stargazers list API works.
The project is oriented toward the following patterns:
✅ VIPER Architecture
✅ Protocol Oriented
✅ Functional Programming
✅ Clean Code
✅ Dependency Injection
✅ Unit Tests
It's based on a GET
API request and built over a UIViewController
and UITableView
.
Each controller is built by 4 files
- Router (routing layer)
- Presenter (view logic)
- Interactor (business logic for a use case)
- View (display data)
The routing layer performs the injection:
🔸 Presenter
🔸 View
🔸 Interactor
let view = UIStoryboard(name: "Main",
bundle: nil)
.instantiateViewController(withIdentifier: "listViewController") as? ListViewController
let imageDownloader = MImageDownloader(service: configuration.service, cache: MCacheService())
let service = MServicePerformer(configuration: configuration)
let interactor = GetStargazersInteractor(service: service)
let presenter = ListPresenter(view: view,
stargazersInteractor: interactor,
user: user)
interactor.presenter = presenter
view?.presenter = presenter
view?.downloader = imageDownloader
... building the main services of the application:
🔸 Cache and Image services
let imageDownloader = MImageDownloader(service: configuration.service, cache: MCacheService())
🔸 Network Service
let service = MServicePerformer(configuration: configuration)
let interactor = GetStargazersInteractor(service: service)
-
View calls Presenter:
override func viewDidLoad() { [...] presenter?.fetch() }
-
Presenter performs Interactor call
func fetch() { stargazersInteractor.perform(user: user, page: self.page) }
-
Interactor performs the "business logic" and notifies Presenter
func perform(user: MUser, page: Int) { performTry({ try service.stargazers(for: user, page: page) { result in switch result { case .success(let response): self.presenter?.stagazers(list: response) case .failure(let error): self.presenter?.on(error: error) } } }, fallback: { self.presenter?.on(error: $0) }) }
-
Presenter revices data from Interactor and notifies the View
func stagazers(list: [MStargazer]) { view?.load(stargazers: list) }
-
View updates the UI
func load(stargazers: [MStargazer]) { self.stargazers = (self.stargazers ?? []) + stargazers }
MServicePerformer
makes the requests
struct MServicePerformer {
private let configuration: MURLConfiguration
init(configuration: MURLConfiguration) {
self.configuration = configuration
}
var baseUrl: URL? {
URL(string: configuration.baseUrl)
}
func makeRequest<T: Decodable>(_ request: MURLRequest,
map: T.Type,
completion: @escaping ((Result<T, Error>) -> Void)) throws {
let urlRequest = request
.build()
configuration
.service
.performTask(with: urlRequest) { responseData, urlResponse, responseError in
completion(self.makeDecode(response: responseData,
urlResponse: urlResponse,
map: map,
error: responseError))
}
}
[...]
}
MURLService
is a concrete implementation ofMURLServiceProtocol
: manages theperformTask
and dispatches the response
extension MURLService: MURLServiceProtocol {
func performTask(with request: URLRequest,
completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
session.dataTask(with: request) { responseData, urlResponse, responseError in
self.dispatcher.dispatch {
completion(responseData, urlResponse, responseError)
}
}
}
func performTask(with url: URL,
completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
session.dataTask(with: url) { responseData, urlResponse, responseError in
self.dispatcher.dispatch {
completion(responseData, urlResponse, responseError)
}
}
}
}
MURLSession
implements theMURLSessionProtocol
, creating network tasks
func dataTask(with request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
let task = session.dataTask(with: request) { responseData, urlResponse, responseError in
completion(responseData, urlResponse, responseError)
}
task.resume()
}
func dataTask(with url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
let task = session.dataTask(with: url) { responseData, urlResponse, responseError in
completion(responseData, urlResponse, responseError)
}
task.resume()
}
MServicePerformer
also makes the deconding and mapping, based on genericDecodable
objects
private func makeDecode<T: Decodable>(response: Data?,
urlResponse: URLResponse?,
map: T.Type,
error: Error?) -> (Result<T, Error>) {
if let error = error { return (.failure(error)) }
guard let jsonData = response else { return (.failure(MServiceError.noData)) }
let statusCode = urlResponse?.httpResponse?.statusCode ?? MConstants.URL.statusCodeOk
guard statusCode.inRange(MConstants.URL.statusCodeOk ..< MConstants.URL.statusCodemultipleChoice) else {
return decode(response: jsonData,
map: MError.self)
.mapError(code: statusCode)
}
return decode(response: jsonData, map: map)
}
private func decode<T: Decodable>(response: Data,
map: T.Type) -> (Result<T, Error>) {
do {
let decoded = try JSONDecoder().decode(map, from: response)
return (.success(decoded))
} catch { return (.failure(error)) }
}
- Images are downloaded by
MImageDownloader
, usingMCacheable
to cache them
func makeRequest(with url: URL,
completion: @escaping (_ image: UIImage?) -> Void) {
(cache.object(for: url.absoluteString) as? UIImage)
.fold(some: { cached(image: $0, completion: completion) },
none: { perform(url: url, completion: completion) })
}
func cached(image: UIImage,
completion: @escaping (_ image: UIImage?) -> Void) {
completion(image)
}
func perform(url: URL,
completion: @escaping (_ image: UIImage?) -> Void) {
service.performTask(with: url) { (data, response, error) in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == MConstants.URL.statusCodeOk,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let image = UIImage(data: data) else {
completion(nil)
return
}
cache.set(obj: image, for: url.absoluteString)
completion(image)
}
}
The get stargazers request is implemented inside MGithubComands
as an extension of MServicePerformer
, conformed to MServicePerformerProtocol
func stargazers(for user: MUser,
page: Int,
completion: @escaping ((Result<[MStargazer], Error>) -> Void)) throws {
guard let url = baseUrl else {
completion(.failure(MServiceError.couldNotCreate(url: baseUrl?.absoluteString)))
return
}
let request = { () -> MURLRequest in
MURLRequest
.get(url: url)
.with(component: MConstants.URL.Component.repos)
.with(component: user.name)
.with(component: user.repo)
.with(component: MConstants.URL.Component.stargazers)
.appendQuery(name: MConstants.URL.Query.perPage, value: "20")
.appendQuery(name: MConstants.URL.Query.page, value: page.stringValue)
}
try makeRequest(request(),
map: [MStargazer].self,
completion: completion)
}
Each module is unit tested (mocks oriented): decoding, mapping, services, presenter, interactor and view (and utilies for sure).
- Presenter sample test
func testSearchUser() {
wire?.listControllerHandler = {
XCTAssertEqual($0.name, "user1")
XCTAssertEqual($0.repo, "myrepo")
XCTAssertTrue($1 is SearchViewProtocol)
}
let user = MUser(name: "user1",
repo: "myrepo")
sut?.search(user: user)
XCTAssertEqual(wire?.counterListController, 1)
}
class MockedWireframe: WireProtocol {
var counterSearchController: Int = 0
var counterListController: Int = 0
var searchControllerHandler: (() -> UINavigationController)?
var listControllerHandler: ((MUser, Any?) -> Void)?
public init() {}
func searchController() -> UINavigationController {
counterSearchController += 1
if let searchControllerHandler = searchControllerHandler {
return searchControllerHandler()
}
return UINavigationController()
}
func listController(user: MUser, from sender: Any?) {
counterListController += 1
if let listControllerHandler = listControllerHandler {
return listControllerHandler(user, sender)
}
}
}
- Comand (decoding and mapping) test
func testGetStargazersResponseShouldSuccess() {
guard let data = JSONMock.loadJson(fromResource: "valid_stargazer") else {
XCTFail("JSON data error!")
return
}
let session = MockedSession(data: data, response: nil, error: nil) { _ in }
do {
try MServicePerformer(configuration: configure(session))
.stargazers(for: MUser(name: "", repo: ""), page: 0) { result in
switch result {
case .success(let response):
XCTAssertEqual(response.count, 8)
XCTAssertEqual(response.first?.user, "dcampogiani")
XCTAssertEqual(response.first?.avatar, "https://avatars.githubusercontent.com/u/1054526?v=4")
case .failure(let error):
XCTFail("Should be success! Got: \(error)")
}
}
} catch { XCTFail("Unexpected error \(error)!") }
}
- API Request tests
func testCreateRequest() {
guard let url = URL(string: "https://api.github.com") else {
XCTFail("URL error!")
return
}
let request = MURLRequest
.get(url: url)
.with(component: "repos")
.with(component: "user1")
.appendQuery(name: "page", value: "1")
.appendQuery(name: "per_page", value: "5")
XCTAssertEqual(request.url.absoluteString, "https://api.github.com/repos/user1?page=1&per_page=5")
XCTAssertEqual(request.method.rawValue, "GET")
}
- API Error tests
func testMapError() {
guard let data = JSONMock.loadJson(fromResource: "valid_error") else {
XCTFail("JSON data error!")
return
}
let url = URL(string: "https://api.github.com")!
let response = HTTPURLResponse(url: url,
statusCode: 401,
httpVersion: "1.0",
headerFields: [:])
let session = MockedSession.simulate(failure: response, data: data) { _ in }
let service = MURLService(session: session,
dispatcher: SyncDispatcher())
let config = MURLConfiguration(service: service,
baseUrl: "https://api.github.com")
do {
try MServicePerformer(configuration: config)
.stargazers(for: MUser(name: "", repo: ""), page: 0) { result in
switch result {
case .success:
XCTFail("Should be fail! Got success.")
case .failure(let error):
XCTAssertEqual(error.localizedDescription, "Not Found")
}
}
} catch { XCTFail("Unexpected error \(error)!") }
}
Any suggestions are welcome 👨🏻💻
• Swift 5
• Xcode 12.5