Swift Generics Protocols: can only be used as a generic constraint problem

Viewed 323

Input data: Working on client-server application on IOS (Swift 5.2) with GraphQL and using Apollo for networking. Without going into the details of the GraphQL and Apollo work, we have:

public protocol GraphQLOperation: class {

  associatedtype Data: GraphQLSelectionSet
  ...
}
public protocol GraphQLQuery: GraphQLOperation {}
public protocol GraphQLMutation: GraphQLOperation {}

these protocols for GraphQL operations in Apollo, GraphQLMutation for mutations, and GraphQLQuery for queries respectively. They are inherited from main GraphQLOperation with associated type which is used for return type. Mutations and Queries are generated by Apollo with code generation into final classes inherited from GraphQLQuery or GraphQLMutation with generated Data structure inherited from the GraphQLSelectionSet inside of the class. Simplified version of generated class looks like:

public final class RegisterUserMutation: GraphQLMutation {
   ...
   public struct Data: GraphQLSelectionSet { ... generated data ... }
   ...
}

For executing requests Apollo has two methods:

func perform<Mutation: GraphQLMutation>(mutation: Mutation...)
func fetch<Query: GraphQLQuery>(query: Query...)

What I need? Need own NetworkClient with ApolloClient under the hood.

Wherefore: errors handling and mapping GraphQL errors to custom errors, user token prolongation logic for all requests -> all in one place. Incapsulate network logic behind the protocol.

Accordingly, i want to have one entry point in the network client for all requests and manage it already inside.

Solution: I am trying to write Network Client with one entry point for all requests:

protocol NetworkClient {
    
    func execute<Command: NetworkCommand>(command: Command) -> Response<Command.Data>
}

Where Command looks like:

protocol NetworkCommand {
    associatedtype Operation: GraphQLOperation
    typealias Data = Operation.Data
    
    var operationType: GraphQLOperationType { get }
    func getOperation() -> Operation
    
}

protocol MutationCommand: NetworkCommand where Operation: GraphQLMutation {}
protocol QueryCommand: NetworkCommand where Operation: GraphQLQuery {}

class RegisterUserCommand: MutationCommand {
    typealias Operation = RegisterUserMutation
    
    var operationType: GraphQLOperationType = .mutation
    
    func getOperation() -> RegisterUserMutation {
        return RegisterUserMutation(birthDate: "", email: "", password: "", sex: .female, username: "")
    }
}

And the implementation looks like:

class NetworkClientImpl: NetworkClient {
    
    private let apolloClient: ApolloClient
    
    init(client: ApolloClient) {
        self.apolloClient = client
    }
    
    func execute<Command>(command: Command) -> Response<Command.Data> where Command : NetworkCommand {
        switch command.operationType {
        case .mutation:
            let mutation = command.getOperation()
            apolloClient.perform(mutation: mutation as! GraphQLMutation)
        default:
            <#code#>
        }
    }
    
    
}

Problem: I am getting the error in combining Apollo request types: enter image description here

Is it possible to combine them?

1 Answers

The cast from mutation to GraphQLMutation doesn't work because GraphQLMutation is a protocol with associated types, and at this time protocol existentials don't support generics. And even if it would support, there's an infinite set of potential conformers to that protocol, so the compiler can't possibly know to which conformer you'd want to refer when casting.

Protocols with associated types lives in the static dispatch world, due to this, you need to define compile-time constructs for each type you want to be send to your NetworkClient.

So, instead of one generic execute method, you'll need to add static overloads for all operations you want to support. For example:

class Mutation1: GraphQLMutation {
    // protocol conformance    
}

class Mutation2: GraphQLMutation {
    // protocol conformance    
}

protocol NetworkClient {
    func execute(command: Mutation1) -> Response<Mutation1.Data>
    func execute(command: Mutation2) -> Response<Mutation2.Data>
}

class NetworkClientImpl: NetworkClient {
    
    func execute(command: Mutation1) -> Response<Mutation1.Data> {
        apolloClient.perform(mutation: command)
    }
    
    func execute(command: Mutation2) -> Response<Mutation2.Data> {
        apolloClient.perform(mutation: command)
    }
}

If you're stuck with protocols with associated types, you are forced into this kind of coding.

If you want to benefit the polymorphism, then you need to add another protocol, that doesn't have associated types, and circulate that one instead. Something like this:

protocol NetworkCommand {
    func execute(withApolloClient apolloClient: ApolloClient)
}

protocol NetworkClient {
    func execute<Command: NetworkCommand>(command: Command)
}

class NetworkClientImpl: NetworkClient {
    
    private let apolloClient: ApolloClient
    
    init(client: ApolloClient) {
        self.apolloClient = client
    }
    
    func execute<Command: NetworkCommand>(command: Command) {
        command.execute(withApolloClient: apolloClient)
    }
}

Though the gain is not big, you could simply circulate the ApolloClient instance instead of the Networkclient one.

Related