I'm migrating an unreleased iOS app from gRPC Swift v1 to v2 and looking for advice.
Context:
My app communicates with a gRPC server providing two logical services:
- API Service for general operations (most methods require authentication).
- Auth Service specifically for authentication.
These services run on the same gRPC server; the separation is purely logical and not technical. It could be better to think of them as one service for the purpose of this post.
For the transport layer, I'm using HTTP2ClientTransport.TransportServices from grpc-swift-nio-transport / GRPCNIOTransportHTTP2TransportServices, as this seems the recommended choice for iOS applications.
My Current Approach:
My understanding is that the idiomatic gRPC way is to create a single, long-lived client for performance and resource optimization. Based on this, my current implementation is:
- Create one transport client shared by both the API and Auth clients.
- Implement an authentication interceptor, which prompts the user for authentication when necessary.
However, this introduces complexity:
- The interceptor must be created when initializing the transport client.
- The interceptor needs access to the Auth client (which itself wraps the same transport client).
- To solve this circular dependency, I'm currently using an optional variable for the Auth client inside the interceptor, which I set after initialization.
My Concern
I'm worried that this pattern (setting an optional variable after initialization) might be non-idiomatic / complex to understand.
Is my approach of creating a single transport client and managing authentication via an interceptor (with a post-initialization reference to the Auth client) acceptable, idiomatic, or is there a better design pattern recommended?
For a better understanding there is some example code for illustration (may not compile, writing this for the post).
class MyClient {
// ...
init() throws {
// Interceptors
let authenticationInterceptor = AuthenticationInterceptor()
// gRPC "transport" client
gRPCTransportClient = GRPCClient(
transport: try .http2NIOTS(
target: .dns(host: host, port: port),
transportSecurity: .tls
),
interceptorPipeline: [
.apply(authenticationInterceptor, to: .services([Grpc_Api.descriptor]))
]
)
Task { [gRPCTransportClient] in
try await gRPCTransportClient.runConnections()
}
// gRPC clients
apiClient = Grpc_Api.Client(wrapping: gRPCTransportClient)
authClient = Grpc_Auth.Client(wrapping: gRPCTransportClient)
authenticationInterceptor.setAuthClient(authClient: authClient)
}
}
class AuthenticationInterceptor: ClientInterceptor, @unchecked Sendable {
private var authClient: Grpc_Auth.Client<HTTP2ClientTransport.TransportServices>?
private let publicMethods: [GRPCCore.MethodDescriptor] = [
Grpc_Api.Method.PublicMethod.descriptor
]
init() {}
func intercept<Input: Sendable, Output: Sendable>(
request: StreamingClientRequest<Input>,
context: ClientContext,
next: (StreamingClientRequest<Input>, ClientContext) async throws ->
StreamingClientResponse<Output>
) async throws -> StreamingClientResponse<Output> where Input: Sendable, Output: Sendable {
var request = request
if let authClient, !publicMethods.contains(context.descriptor) {
// ... UI + gRPC call to get credentials and add metadata to the request
}
return try await next(request, context)
}
/// This is unsafe, be sure to only call this method in `MyClient.init`.
fileprivate func setAuthClient(
authClient: Grpc_Auth.Client<HTTP2ClientTransport.TransportServices>
) {
self.authClient = authClient
}
}
I'd appreciate any insights or experiences from the community!