2

I have some difficulty using Combine in SwiftUI with making an API request and then decoding the data and returning it. When calling the API Service, it states in the 'AnyPublisher<UserLoginResponse, APIError>' that the result will be of such type. However, I would want to reuse the API Service and decode the response to different model structures. How can I call the API Service while defining which data structure it has to decode the returned data to? For example, in another ViewModel I would want to decode the API data to a 'NewsUpdatesResponse' instead of 'UserLoginResponse'. The code I have now is as follows:

Most code comes from: tundsdev

API Service

struct APIService {

func request(from endpoint: APIRequest, body: String) -> AnyPublisher<UserLoginResponse, APIError> {
    
    var request = endpoint.urlRequest
    request.httpMethod = endpoint.method
    
    if endpoint.authenticated == true {
        request.setValue("testToken", forHTTPHeaderField: "token")
    }
    if body != "" {
        let finalBody = body.data(using: .utf8)
        request.httpBody = finalBody
    }
    
    return URLSession
        .shared
        .dataTaskPublisher(for: request)
        .receive(on: DispatchQueue.main)
        .mapError { _ in APIError.unknown}
        .flatMap { data, response -> AnyPublisher<UserLoginResponse, APIError> in
            
            guard let response = response as? HTTPURLResponse else {
                return Fail(error: APIError.unknown).eraseToAnyPublisher()
            }
            
            print(response.statusCode)
            
            if response.statusCode == 200 {
                let jsonDecoder = JSONDecoder()
                
                return Just(data)
                    .decode(type: UserLoginResponse.self, decoder: jsonDecoder)
                    .mapError { _ in APIError.decodingError }
                    .eraseToAnyPublisher()
            }
            else {
                return Fail(error: APIError.errorCode(response.statusCode)).eraseToAnyPublisher()
            }
        }
        .eraseToAnyPublisher()
    }
}

Login ViewModel

class LoginViewModel: ObservableObject {

@Published var loginState: ResultState = .loading

private var cancellables = Set<AnyCancellable>()
private let service: APIService

init(service: APIService) {
    self.service = service
}

func login(username: String, password: String) {
    
    self.loginState = .loading
    
    let cancellable = service
        .request(from: .login, body: "username=admin&password=admin")
        .sink { res in
            print(res)
            switch res {
            case .finished:
                self.loginState = .success
            case .failure(let error):
                self.loginState = .failed(error: error)
            }
        } receiveValue: { response in
            print(response)
        }
    
    self.cancellables.insert(cancellable)
    }
}
Björn
  • 338
  • 1
  • 13

2 Answers2

2

the following is untested, but you could try something along this line, using generic Decodable:

struct APIService {
    
    func request<T: Decodable>(from endpoint: APIRequest, body: String) -> AnyPublisher<T, APIError> { 
        
        var request = endpoint.urlRequest
        request.httpMethod = endpoint.method
        
        if endpoint.authenticated == true {
            request.setValue("testToken", forHTTPHeaderField: "token")
        }
        if body != "" {
            let finalBody = body.data(using: .utf8)
            request.httpBody = finalBody
        }
        
        return URLSession
            .shared
            .dataTaskPublisher(for: request)
            .receive(on: DispatchQueue.main)
            .mapError { _ in APIError.unknown}
            .flatMap { data, response -> AnyPublisher<T, APIError> in  // <-- here
                
                guard let response = response as? HTTPURLResponse else {
                    return Fail(error: APIError.unknown).eraseToAnyPublisher()
                }
                
                print(response.statusCode)
                
                if response.statusCode == 200 {
                    let jsonDecoder = JSONDecoder()
                    return Just(data)
                        .decode(type: T.self, decoder: jsonDecoder)  // <-- here
                        .mapError { _ in APIError.decodingError }
                        .eraseToAnyPublisher()
                }
                else {
                    return Fail(error: APIError.errorCode(response.statusCode)).eraseToAnyPublisher()
                }
            }
            .eraseToAnyPublisher()
    }
}

you may also want to return an array of such Decodable:

func requestThem<T: Decodable>(from endpoint: APIRequest, body: String) -> AnyPublisher<[T], APIError> {
  ....
  .flatMap { data, response -> AnyPublisher<[T], APIError> in
  ...
  .decode(type: [T].self, decoder: jsonDecoder)
  ...
  • Thanks for the solution! It seems to work, although I am running into some problems since I am using Cancellables. For the line 'let cancellable: UserLoginResponse = service', I am getting an error: 'Protocol 'Any' as a type cannot conform to 'Decodable' and 'Cannot convert value of type 'AnyCancellable' to specified type 'UserLoginResponse'. Do you have an idea how to solve this? – Björn Aug 05 '21 at 14:53
  • try replacing the line: `self.cancellables.insert(cancellable)` with `.store(in: &cancellable)` – workingdog support Ukraine Aug 05 '21 at 22:15
  • I have not tried this solution, but have figured it out myself. Was missing an extra argument in the calling of the function. Thanks for the help! – Björn Aug 05 '21 at 22:34
1

The final solution which worked for me was the following with the help of workingdog.

API Service

struct APIService {

func request<T: Decodable>(ofType type: T.Type, from endpoint: APIRequest, body: String) -> AnyPublisher<T, Error> {
    
    var request = endpoint.urlRequest
    request.httpMethod = endpoint.method
    
    if endpoint.authenticated == true {
        request.setValue("testToken", forHTTPHeaderField: "token")
    }
    if body != "" {
        let finalBody = body.data(using: .utf8)
        request.httpBody = finalBody
    }
    
    return URLSession
        .shared
        .dataTaskPublisher(for: request)
        .receive(on: DispatchQueue.main)
        .mapError { _ in Error.unknown}
        .flatMap { data, response -> AnyPublisher<T, Error> in
            
            guard let response = response as? HTTPURLResponse else {
                return Fail(error: Error.unknown).eraseToAnyPublisher()
            }
            
            print(response.statusCode)
            let jsonDecoder = JSONDecoder()
            
            if response.statusCode == 200 {
                return Just(data)
                    .decode(type: T.self, decoder: jsonDecoder)
                    .mapError { _ in Error.decodingError }
                    .eraseToAnyPublisher()
            }
            else {
                do {
                    let errorMessage = try jsonDecoder.decode(APIErrorMessage.self, from: data)
                    return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: errorMessage.error ?? "Er is iets foutgegaan")).eraseToAnyPublisher()
                }
                catch {
                    return Fail(error: Error.decodingError).eraseToAnyPublisher()
                }
            }
        }
        .eraseToAnyPublisher()
     }
 }

Login ViewModel

class LoginViewModel: ObservableObject {

@Published var loginState: ResultState = .loading

private var cancellables = Set<AnyCancellable>()
private let service: APIService

init(service: APIService) {
    self.service = service
}

func login(username: String, password: String) {
    
    self.loginState = .loading
    
    let preparedBody = APIPrepper.prepBody(parametersDict: ["username": username, "password": password])

    let cancellable = service.request(ofType: UserLoginResponse.self, from: .login, body: preparedBody).sink { res in
        switch res {
        case .finished:
            self.loginState = .success
            print(self.loginState)
        case .failure(let error):
            self.loginState = .failed(stateIdentifier: error.statusCode, errorMessage: error.errorMessage)
            print(self.loginState)
        }
    } receiveValue: { response in
        print(response)
    }
    
    self.cancellables.insert(cancellable)
    }
}

Note that I have made some minor changes to the passing of the username and password parameters in the meantime.

Björn
  • 338
  • 1
  • 13