Skip to content

Sample iOS project to understand how PokéAPI works.

Notifications You must be signed in to change notification settings

mattiacantalu/Pokedex

Folders and files

NameName
Last commit message
Last commit date

Latest commit

3822318 · Jun 25, 2021

History

8 Commits
Jun 25, 2021
Jun 24, 2021
Jun 25, 2021
Jun 25, 2021

Repository files navigation

Pokédex

Sample iOS application to understand how PokéAPI works.

The project is oriented toward the following patterns:

✅ MVVM Architecture

✅ Protocol Oriented

✅ Functional Programming

✅ Clean Code

✅ Dependency Injection

✅ Unit Tests

It's based on a GET API request and built over a UITableViewController and UIViewController.

HOW IT WORKS

Each controller is built by 4 files

  1. Coordinator (routing layer)
  2. Model (model)
  3. ViewModel (business logic for a use case)
  4. View (display data)

CONFIGURATION

The coordinator layer performs the injection:

🔸 Model

🔸 ViewModel

let viewModel = ListViewModel(service: service,
                              imageDownloader: imageDownloader,
                              coordinator: self)

... building the main services of the application:

🔸 Cache and Image services

let imageDownloader = MImageDownloader(service: configuration.service,
                                       cache: MCacheService())

🔸 Network Service

struct MURLConfiguration {
    let service: MURLService
    let baseUrl: String

    init(service: MURLService,
        baseUrl: String) {
        self.service = service
        self.baseUrl = baseUrl
   }
}

let service = MServicePerformer(configuration: configuration)

MVVM FLOW

  1. View calls ViewModel

    override func viewDidLoad() {
        [...]
        loadData()
    }
    
    func loadData() {
        viewModel.fetch(success: { [weak self] in self?.dataSource = $0 },
                        failure: { [weak self] in self?.error = $0 })
    }
    
  2. ViewModewl performs the business logic

    var name: String {
        pokemon.map { $0.name }.notNil
    }
    var weight: String {
        pokemon.map { $0.weight.stringValue }.notNil
    }
    var height: String {
        pokemon.map { $0.height.stringValue }.notNil
    }
    
    func fetch(success: @escaping (DetailViewModel) -> Void,
               failure: @escaping (Error) -> Void) {
        performTry({ try service.pokemon(by: poke.url) { result in
            switch result {
            case .success(let response):
                self.pokemon = response
                success(self)
            case .failure(let error):
                failure(error)
            }
        }
        }, fallback: { failure($0) })
    }
    

    ... using Models:

    struct Pokemon: Codable {
        let name: String
        let experience: Int
        let weight: Int
        let height: Int
        let abilities: [PokeAbility]
        let moves: [PokeMove]
        let types: [PokeType]
        let stats: [PokeStat]
        let images: PokeImages
    
        private enum CodingKeys : String, CodingKey {
            case name = "name",
                 experience = "base_experience",
                 weight = "weight",
                height = "height",
                abilities = "abilities",
                moves = "moves",
                types = "types",
                stats = "stats",
                images = "sprites"
        }
    }
    
  3. View updates the UI

    private var viewModel: DetailViewModel {
        didSet {
            show(name: viewModel.name,
                 weight: viewModel.weight,
                 height: viewModel.height)
            show(abilities: viewModel.abiltyViewModel)
            show(moves: viewModel.moveViewModel)
            show(types: viewModel.typeViewModel)
            show(stats: viewModel.statViewModel)
            show(sprites: viewModel.spritesViewModel)
        }
    }
    

CORE SERVICES

  1. 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))
            }
    }
    
    [...]
}
  1. MURLService is a concrete implementation of MURLServiceProtocol: manages the performTask 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)
            }
        }
    }
}
  1. MURLSession implements the MURLSessionProtocol, 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()
    }
  1. MServicePerformer also makes the deconding and mapping, based on generic Decodable 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)) }
    }
  1. Images are downloaded by MImageDownloader, using MCacheable to cache them
    func makeRequest(with url: URL,
                     completion: @escaping (_ image: Data?) -> Void) {
        (cache.object(for: url.absoluteString) as? Data)
            .fold(some: { cached(data: $0, completion: completion) },
                  none: { perform(url: url, completion: completion) })
    }
    func cached(data: Data,
                completion: @escaping (_ image: Data?) -> Void) {
        completion(data)
    }

    func perform(url: URL,
                 completion: @escaping (_ image: Data?) -> 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 else {
                completion(nil)
                return
            }
            cache.set(obj: data, for: url.absoluteString)
            completion(data)
        }
    }

Comands (get pokemon)

The get pokedex request (one of the commands) is implemented inside PokeCommands as an extension of MServicePerformer, conformed to MServicePerformerProtocol

    func pokedex(offset: Int,
                 limit: Int,
                 completion: @escaping ((Result<Pokedex, 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.pokemon)
                .appendQuery(name: MConstants.URL.Query.offset, value: offset.stringValue)
                .appendQuery(name: MConstants.URL.Query.limit, value: limit.stringValue)
        }

        try makeRequest(request(),
                        map: Pokedex.self,
                        completion: completion)
    }

TESTS

Each module is unit tested (mocks oriented): decoding, mapping, services, model, viewModel:

  1. viewModel sample test
    func testFetch_withSucceededService_shouldSucceed() throws {
        service?.pokedexHandler = { offset, limit, completion in
            XCTAssertEqual(offset, 0)
            XCTAssertEqual(limit, 20)
            completion(.success(Pokedex.mock))
        }

        XCTAssertEqual(sut?.viewModel.count, 0)
        
        sut?.fetch(success: {
            XCTAssertEqual($0.count, 1)
            XCTAssertEqual($0.first?.name, "poke_name")
        }, failure: { XCTFail("Expected success. Got \($0)") })
        
        XCTAssertEqual(service?.counterPokedex, 1)
        XCTAssertEqual(sut?.viewModel.count, 1)
    }
    func testShowPokemon() {
        let final = UIViewController()
        let sender = UIViewController()

        coordinator?.detailControllerHandler = {
            XCTAssertEqual($0.name, "name")
            XCTAssertEqual($0.url, "poke_url")
            return final
        }
        coordinator?.pushHandler = {
            XCTAssertEqual($0, final)
            XCTAssertEqual($1 as? UIViewController, sender)
        }

        sut?.show(pokemon: Poke(name: "name", url: "poke_url"),
                  sender: sender)

        XCTAssertEqual(coordinator?.counterPush, 1)
    }
  1. Comand (decoding and mapping) test
func testGetPokemonResponseShouldSuccess() {
        guard let data = JSONMock.loadJson(fromResource: "valid_get_pokemon") else {
            XCTFail("JSON data error!")
            return
        }
        let session = MockedSession(data: data, response: nil, error: nil) { _ in }

        do {
            try MServicePerformer(configuration: configure(session))
                .pokemon(by: "https://pokeapi.co/api/v2/pokemon/1") { result in
                    switch result {
                    case .success(let response):
                        XCTAssertEqual(response.name, "bulbasaur")
                        XCTAssertEqual(response.height, 7)
                        XCTAssertEqual(response.weight, 69)
                        XCTAssertEqual(response.experience, 64)

                        XCTAssertEqual(response.abilities.count, 2)
                        XCTAssertEqual(response.abilities.first?.ability.name, "overgrow")
                        XCTAssertEqual(response.abilities.first?.ability.url, "https://pokeapi.co/api/v2/ability/65/")

                        XCTAssertEqual(response.moves.count, 78)
                        XCTAssertEqual(response.moves.first?.move.name, "razor-wind")
                        XCTAssertEqual(response.moves.first?.move.url, "https://pokeapi.co/api/v2/move/13/")

                        XCTAssertEqual(response.images.front, "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png")
                        XCTAssertEqual(response.images.back, "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/1.png")

                        XCTAssertEqual(response.stats.count, 6)
                        XCTAssertEqual(response.stats.first?.stat.name, "hp")
                        XCTAssertEqual(response.stats.first?.stat.url, "https://pokeapi.co/api/v2/stat/1/")
                        XCTAssertEqual(response.stats.first?.value, 45)

                        XCTAssertEqual(response.types.count, 2)
                        XCTAssertEqual(response.types.first?.type.name, "grass")
                        XCTAssertEqual(response.types.first?.type.url, "https://pokeapi.co/api/v2/type/12/")
                    case .failure(let error):
                        XCTFail("Should be success! Got: \(error)")
                    }
                }
        } catch { XCTFail("Unexpected error \(error)!") }
    }
  1. API Request tests
    func testCreateRequest() {
        guard let url = URL(string: "https://pokeapi.co/api/v2") else {
            XCTFail("URL error!")
            return
        }

        let request = MURLRequest
            .get(url: url)
            .with(component: "pokemon")
            .appendQuery(name: "offset", value: "20")
            .appendQuery(name: "limit", value: "10")
        XCTAssertEqual(request.url.absoluteString, "https://pokeapi.co/api/v2/pokemon?offset=20&limit=10")
        XCTAssertEqual(request.method.rawValue, "GET")
    }
  1. API Error tests
    func testMapError() {
        guard let data = JSONMock.loadJson(fromResource: "valid_error") else {
            XCTFail("JSON data error!")
            return
        }

        let url = URL(string: "https://pokeapi.co/api/v2/poke")!
        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://pokeapi.co/api/v2")
        
        do {
            try MServicePerformer(configuration: config).pokedex() { result in
                    switch result {
                    case .success:
                        XCTFail("Should be fail! Got success.")
                    case .failure(let error):
                        XCTAssertEqual(error.localizedDescription, "The operation couldn’t be completed. ( error 401.)")
                    }
                }
        } catch { XCTFail("Unexpected error \(error)!") }
    }

CONTRIBUTORS

Any suggestions are welcome 👨🏻‍💻

REQUIREMENTS

• Swift 5

• Xcode 12.5

About

Sample iOS project to understand how PokéAPI works.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages