Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature [RM88] Handle Server Error #91

Merged
merged 2 commits into from
Aug 27, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions Rick-and-Morty/CharacterRepository.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Foundation

protocol CharacterRepositoryProtocol {
func getCharacters(completion: @escaping (([Character]) -> Void))
func getCharacters(completion: @escaping (([Character]) -> Void), error: @escaping ((NSError?) -> Void))
}

final class CharacterRepository: CharacterRepositoryProtocol {
private var characterPageURL: URL? = RickAndMortyService.baseURL.appendingPathComponent("character")

private let rickAndMortyService: RickAndMortyServiceProtocol = RickAndMortyService()

func getCharacters(completion: @escaping (([Character]) -> Void)) {
func getCharacters(completion: @escaping (([Character]) -> Void), error: @escaping ((NSError?) -> Void)) {
if let url = characterPageURL {
rickAndMortyService.fetchData(url: url) { (charactersResponse: CharacterResponse) in
if let nextURLString = charactersResponse.info.next {
Expand All @@ -21,9 +21,8 @@ final class CharacterRepository: CharacterRepositoryProtocol {
}

completion(charactersResponse.characters)
} error: { error in
print(error.debugDescription)
return
} error: { (err: NSError?) in
error(err)
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions Rick-and-Morty/Rick And Morty.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
1711B39E26B1898100BE935B /* CharacterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1711B39D26B1898100BE935B /* CharacterListView.swift */; };
174655E826CBB74100A52819 /* EpisodeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174655E726CBB74100A52819 /* EpisodeDetailView.swift */; };
174655EA26CBBD2300A52819 /* EpisodeDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174655E926CBBD2300A52819 /* EpisodeDetailViewModel.swift */; };
174878F626D4E3CA004293AB /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174878F526D4E3CA004293AB /* ErrorView.swift */; };
174B064B26C6611D0080ADD0 /* RickAndMortyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174B064A26C6611D0080ADD0 /* RickAndMortyService.swift */; };
174B064D26C661470080ADD0 /* RickAndMortyServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174B064C26C661470080ADD0 /* RickAndMortyServiceProtocol.swift */; };
174B065126C671580080ADD0 /* CharacterRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174B065026C671580080ADD0 /* CharacterRepository.swift */; };
Expand Down Expand Up @@ -41,6 +42,7 @@
1711B39D26B1898100BE935B /* CharacterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterListView.swift; sourceTree = "<group>"; };
174655E726CBB74100A52819 /* EpisodeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeDetailView.swift; sourceTree = "<group>"; };
174655E926CBBD2300A52819 /* EpisodeDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeDetailViewModel.swift; sourceTree = "<group>"; };
174878F526D4E3CA004293AB /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
174B064A26C6611D0080ADD0 /* RickAndMortyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RickAndMortyService.swift; sourceTree = "<group>"; };
174B064C26C661470080ADD0 /* RickAndMortyServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RickAndMortyServiceProtocol.swift; sourceTree = "<group>"; };
174B065026C671580080ADD0 /* CharacterRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterRepository.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -140,6 +142,7 @@
1711B39D26B1898100BE935B /* CharacterListView.swift */,
17588BAE26C273BB008ECC31 /* CharacterCard.swift */,
174655E726CBB74100A52819 /* EpisodeDetailView.swift */,
174878F526D4E3CA004293AB /* ErrorView.swift */,
174941D126CD1216001AFD9A /* Utilities */,
);
path = Views;
Expand Down Expand Up @@ -308,6 +311,7 @@
B811686D1CFF1C9900301A0A /* AppDelegate.swift in Sources */,
174B065926C680850080ADD0 /* EpisodeRepository.swift in Sources */,
174655EA26CBBD2300A52819 /* EpisodeDetailViewModel.swift in Sources */,
174878F626D4E3CA004293AB /* ErrorView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
13 changes: 8 additions & 5 deletions Rick-and-Morty/Rick And Morty/Services/RickAndMortyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import Foundation
final class RickAndMortyService: RickAndMortyServiceProtocol {
static let baseURL = URL(string: "https://rickandmortyapi.com/api")!

func fetchData<T:Decodable>(url: URL, success: @escaping (T) -> (), error: @escaping (Error?) -> ()) {
func fetchData<T:Decodable>(url: URL, success: @escaping (T) -> (), error: @escaping (NSError?) -> ()) {

let request = URLRequest(url: url)

URLSession.shared.dataTask(with: request) { data, response, requestError in
Expand All @@ -16,14 +17,16 @@ final class RickAndMortyService: RickAndMortyServiceProtocol {
success(decodedData)
}
} catch let decodingError {
DispatchQueue.main.async {
error(decodingError)
if let err = decodingError as NSError? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a possibility that we are swallowing an error in the case that the error is not an NSError so we should add an else to catch these with a more generic error response. Something like Error: wtf happened here. We don't know. Probably couldnt parse the error as NSError though

DispatchQueue.main.async {
error(err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think there is any need to shorten the variable name here or anywhere else like this

Suggested change
if let err = decodingError as NSError? {
DispatchQueue.main.async {
error(err)
}
if let error = decodingError as NSError? {
DispatchQueue.main.async {
error(error)
}

}
}
} else {
if requestError != nil {
if let err = requestError as NSError? {
DispatchQueue.main.async {
error(requestError)
error(err)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation

protocol RickAndMortyServiceProtocol {
func fetchData<T:Decodable>(url: URL, success: @escaping (T) -> (), error: @escaping (Error?) -> ())
func fetchData<T:Decodable>(url: URL, success: @escaping (T) -> (), error: @escaping (NSError?) -> ())
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ final class CharacterListViewModel: ObservableObject {

struct CharacterListViewState {
let title: String = "Characters"
var errorMessage: String? = nil
var characters: [Character]
var state: State = .doneLoading

enum State {
case loading
case doneLoading
case error
}
}

private let characterRepository: CharacterRepositoryProtocol = CharacterRepository()
Expand All @@ -16,11 +24,28 @@ final class CharacterListViewModel: ObservableObject {
}

func loadCharacters() {
characterRepository.getCharacters {
characters in
characterListViewState.state = .loading

characterRepository.getCharacters { characters in
self.characterListViewState.state = .doneLoading
self.characterListViewState.errorMessage = nil

for character in characters {
self.characterListViewState.characters.append(character)
}
} error: { error in

self.characterListViewState.state = .error

if let err = error {
if err.code == 4864 {
self.characterListViewState.errorMessage = "Wubba Lubba Dub Dub! There was a problem loading the characters."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

excellent erro message 😂

} else {
self.characterListViewState.errorMessage = err.localizedDescription
}

self.characterListViewState.errorMessage?.append(" Tap the refresh button to try again..")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be worth extracting this all into a neat little error handling function. Pyramids of doom are appearing, normally a good sign

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah definitely, for simplicity Im just doing a custom message for that code at the moment. All the rest of the errors just display the localized description

}
}

Expand Down
19 changes: 14 additions & 5 deletions Rick-and-Morty/Rick And Morty/Views/CharacterListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@ struct CharacterListView: View {

var body: some View {
NavigationView {
List(viewModel.characterListViewState.characters, id: \.id) { character in
CharacterCard(viewModel: CharacterCardViewModel(character: character))
.onAppear(perform: {
viewModel.loadIfNeeded(characterID: character.id)
})
ZStack {
List(viewModel.characterListViewState.characters, id: \.id) { character in
CharacterCard(viewModel: CharacterCardViewModel(character: character))
.onAppear(perform: {
viewModel.loadIfNeeded(characterID: character.id)
})
}

if viewModel.characterListViewState.state == .error {
ErrorView(errorMessage: viewModel.characterListViewState.errorMessage, refreshAction: viewModel.loadCharacters)
} else if viewModel.characterListViewState.state == .loading {
ProgressView()
}
Comment on lines +16 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic should be in the VM. It shouldnt be possible to unit test the view :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats your suggestion to fix this? I think in swiftUI its normal to use conditionals based on the state in the view

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what happens in most cases now is you would have a reactive chain connected to the state, so whenever that changes the view would update to ErrorView or ProgressView. In your case though id have a function in the viewModel called viewForState()

Suggested change
if viewModel.characterListViewState.state == .error {
ErrorView(errorMessage: viewModel.characterListViewState.errorMessage, refreshAction: viewModel.loadCharacters)
} else if viewModel.characterListViewState.state == .loading {
ProgressView()
}
viewModel.viewForState()

In the viewModel:

    func viewForState() -> View {
        if viewModel.characterListViewState.state == .error {
            return ErrorView(errorMessage: characterListViewState.errorMessage, refreshAction: loadCharacters)
        } else if characterListViewState.state == .loading {
            return ProgressView()
        }
    }

This isnt a perfect solution, as ideally the ViewModel doesnt know anything about views either and this function would exist inside a ViewFactory of some sort that you would pass as a dependency to the VM. That would look something like:

import SwiftUI

protocol ViewFactoryProtocol {
    func viewForState(errorMessage: String, refreshAction: () -> Void) -> View
}

class ViewFactory {
    static func viewForState(errorMessage: String, refreshAction: () -> Void) -> View {
        if viewModel.characterListViewState.state == .error {
            return ErrorView(errorMessage: characterListViewState.errorMessage, refreshAction: loadCharacters)
        } else if characterListViewState.state == .loading {
            return ProgressView()
        }
    }
}

The factory name `viewForState` could be more descriptive but my morning brain aint working for now.

Hope this is clear! Ping me if not :)

Now the ViewModel would be updated with the init to look like so:

private let viewFactory: ViewFactoryProtocol

init(viewFactory: ViewFactoryProtocol) {
    self.viewFactory = viewFactory
}

then your viewForState func would be even cleaner and look like this:

func viewForState() -> View {
    return viewFactory.viewForState(errorMessage: characterListViewState.errorMessage, refreshAction: loadCharacters)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wrumble Do you think viewForState() should be in viewmodel? Wouldn't that make view model less testable? If this would not be swift UI the logic we're talking about would be in ViewControllers which belong to View layer. If we want to extract that we could call the factory from CharacterListView.swift and test the factory. wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, i guess so, whatever your preference i guess, i just made the step from moving it to view model then to factory didnt think any further 😂

}
.listStyle(PlainListStyle())
.navigationTitle(viewModel.characterListViewState.title)
}
}
Expand Down
37 changes: 37 additions & 0 deletions Rick-and-Morty/Rick And Morty/Views/ErrorView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// ErrorView.swift
// Rick And Morty
//
// Created by Scottie Gray on 2021-08-24.
// Copyright © 2021 Novoda. All rights reserved.
//

import SwiftUI

struct ErrorView: View {
let errorMessage: String?
let refreshAction: () -> ()

var body: some View {
if let error = errorMessage {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about logic here. You may need a tiny VM for this View

VStack {
Text(error)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding()
Button(action: refreshAction, label: {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: 30, height: 30, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
})
}
}
}
}

struct ErrorView_Previews: PreviewProvider {
static var previews: some View {
ErrorView(errorMessage: "Wubba Lubba Dub Dub! There was a problem loading. Tap the button to try again.", refreshAction: {})

}
}