Graphql/apollo-ios with TCA

Background: I have a graphql server that supports a web application using apollo, and am building an ios app using TCA that will utilize the same graphql endpoint.

Where I am struggling is with Effects in TCA, specifically getting it to work with the non async/await structure of apollo-ios. Can anyone help me out here? I'm looking to simply make an api call with apollo and update the state but I'm really not sure how to do that with completionhandlers. Here's a ReducerProtocol I have setup to work with my apollo client dependency which specifies all my network calls

APIClient:

import ComposableArchitecture
import Apollo
import os

private let graphqlEndpointKey = "com.example.app.apiClient.baseUrl"

public struct APIClient {
    
    // API
    public var fetchCurrentTime: @Sendable (
        _ completion: @escaping (Result<GraphQLResult<GeneratedSchema.CurrentTimeQuery.Data>, Error>) -> Void
    ) -> Cancellable
    
    private static func createClient (graphqlEndpoint: URL) -> ApolloClient {
        let cache = InMemoryNormalizedCache()
        let store = ApolloStore(cache: cache)
        
        let client = URLSessionClient()
        let provider = NetworkInterceptorProvider(store: store, client: client)
        let requestChainTransport = RequestChainNetworkTransport(
           interceptorProvider: provider,
           endpointURL: graphqlEndpoint
         )

        return ApolloClient(networkTransport: requestChainTransport, store: store)
    }
}

extension APIClient: DependencyKey {
    public static let liveValue = APIClient.live()
    
    public static func live(graphqlEndpoint defaultEndpoint: URL = URL(string: "https://api.example.com/graphql")!) -> Self {
        let url = UserDefaults.standard.url(forKey: graphqlEndpointKey) ?? defaultEndpoint
        let apollo: ApolloClient = self.createClient(graphqlEndpoint: url)
        
        return Self(
            fetchCurrentTime: { completion in
                apollo.fetch(query: GeneratedSchema.CurrentTimeQuery(), resultHandler: completion)
            }
        )
    }
}

And here is my Reducer Protocol (which I am aware does not work as an effect as is, but I am also wondering how to refactor so that it does)

struct SignIn: ReducerProtocol {
    
   
    @Dependency(\.apiClient) var apiClient

    struct State: Equatable {
        var testText: String
    }

    enum Action {
        case buttonTapped
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
                switch action {
                case .buttonTapped:
                       apiClient.fetchCurrentTime { result in
                        switch result {
                        case .success(let graphQLResult):
                            print("success, update the state.testText") 
                             // TODO: update the testText
                        case .failure(let error):
                            print("error")
                        }
                    }
                }
        }
    }
    
}

The apiClient works and the correct data returns, the issue I'm having is fitting this into the Effect framework of TCA. The desired design here is to use apollo-ios 's code gen and then breakdown each network call into api client to be called by the reducers. Appreciate any help in advance!

2 Likes

In TCA if you need to mutate state with async work, you just need to do it through Effect which sends back new action to the store. Therefore, in situation like this you can always begin with

return .run { send in 
// Some async work here
}

Code inside this closure is TCA agnostic swift code. You need to get values and send them back to the store as actions via send.

In your case it's just a matter of bridging callbacks to async context. Most standard way to do is to use "continuation functions", for example withCheckedContinuation. Co in your case it could look like this:

{...}
case .buttonTapped:
return .run { send in
	let result = await withCheckedContinuation { continuation
		self.apiClient.fetchCurrentTime { result
			continuation.resume(returning: result)
		}
	}
		send(.processFetchedTime(result))
	}
{...}

To make example shorter I moved handling errors to .processFetchedTime action. Naturally, you can do it also in .buttonTapped action.

Personally, I would prefer to make this conversion from callbacks to async in ApiClient, and in reducer just call it as:

return .run { send in
  await send(self.apiClient.fetchCurrentTime)
}
1 Like