diff --git a/docs/swift/interceptors.md b/docs/swift/interceptors.md index 91c084b..f7c3efc 100644 --- a/docs/swift/interceptors.md +++ b/docs/swift/interceptors.md @@ -59,7 +59,7 @@ let client = ProtocolClient( networkProtocol: .connect, codec: ProtoCodec(), //highlight-next-line - interceptors: [ExampleAuthInterceptor.init] + interceptors: [{ ExampleAuthInterceptor(config: $0) }] ) ) ``` @@ -70,7 +70,12 @@ path, and in LIFO order on the response path. For example, if the following interceptors are registered: ```swift -InterceptorsOption(interceptors: [A.init, B.init, C.init, D.init]) +interceptors: [ + { A(config: $0) }, + { B(config: $0) }, + { C(config: $0) }, + { D(config: $0) }, +] ``` They'll be created each time a request is initiated by the client, then diff --git a/docs/swift/testing.md b/docs/swift/testing.md index 95fa991..3785003 100644 --- a/docs/swift/testing.md +++ b/docs/swift/testing.md @@ -87,19 +87,19 @@ import Connect import Foundation import SwiftProtobuf -public protocol Connectrpc_Eliza_V1_ElizaServiceClientInterface { +public protocol Connectrpc_Eliza_V1_ElizaServiceClientInterface: Sendable { @discardableResult - func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers, completion: @escaping (ResponseMessage) -> Void) -> Cancelable + func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers, completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers) async -> ResponseMessage - func `converse`(headers: Headers, onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface + func `converse`(headers: Headers, onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface func `converse`(headers: Headers) -> any BidirectionalAsyncStreamInterface } /// Concrete implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`. -public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface { +public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface, Sendable { private let client: ProtocolClientInterface public init(client: ProtocolClientInterface) { @@ -107,7 +107,7 @@ public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_E } @discardableResult - public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping (ResponseMessage) -> Void) -> Cancelable { + public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { return self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers, completion: completion) } @@ -115,7 +115,7 @@ public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_E return await self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers) } - public func `converse`(headers: Headers = [:], onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface { + public func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { return self.client.bidirectionalStream(path: "connectrpc.eliza.v1.ElizaService/Converse", headers: headers, onResult: onResult) } @@ -141,12 +141,7 @@ import Foundation import SwiftProtobuf /// Mock implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`. -/// -/// Production implementations can be substituted with instances of this -/// class, allowing for mocking RPC calls. Behavior can be customized -/// either through the properties on this class or by -/// subclassing the class and overriding its methods. -open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface { +open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface, @unchecked Sendable { private var cancellables = [Combine.AnyCancellable]() /// Mocked for calls to `say()`. @@ -161,7 +156,7 @@ open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_Eliza public init() {} @discardableResult - open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping (ResponseMessage) -> Void) -> Cancelable { + open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage) -> Void) -> Cancelable { completion(self.mockSay(request)) return Cancelable {} } @@ -170,7 +165,7 @@ open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_Eliza return self.mockAsyncSay(request) } - open func `converse`(headers: Headers = [:], onResult: @escaping (StreamResult) -> Void) -> any BidirectionalStreamInterface { + open func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult) -> Void) -> any BidirectionalStreamInterface { self.mockConverse.$inputs.first { !$0.isEmpty }.sink { _ in self.mockConverse.outputs.forEach(onResult) }.store(in: &self.cancellables) return self.mockConverse } @@ -239,7 +234,7 @@ final class ElizaAppTests: XCTestCase { let client = Connectrpc_Eliza_V1_ElizaServiceClientMock() client.mockAsyncSay = { request in XCTAssertEqual(request.sentence, "hello!") - return ResponseMessage(message: .with { $0.sentence = "hi, i'm eliza!" }) + return ResponseMessage(result: .success(.with { $0.sentence = "hi, i'm eliza!" })) } let viewModel = MessagingViewModel(elizaClient: client) @@ -280,6 +275,78 @@ func testBidirectionalStreamMessagingViewModel() async { } ``` +## Testing with `@Sendable` closures + +If your codebase is not yet using `async`/`await` and is instead consuming +generated clients that provide completion/result closures which are annotated +with `@Sendable`, writing tests can prove challenging. For example: + +```swift +func testGetUser() { + let client = Users_V1_UsersMock() + client.mockGetUserInfo = { request in + return ResponseMessage(result: .success(...)) + } + + var receivedMessage: Users_V1_UserInfoResponse? + client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in + //highlight-next-line + // ERROR: Mutation of captured var 'receivedMessage' in concurrently-executing code + //highlight-next-line + receivedMessage = response.message + } + XCTAssertEqual(receivedMessage?.name, "jane") +} +``` + +One workaround for this is to wrap the captured type with a class +that conforms to `Sendable`. For example: + +```swift +public final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var wrappedValue: T + + /// Thread-safe access to the underlying value. + public var value: T { + get { + self.lock.lock() + defer { self.lock.unlock() } + return self.wrappedValue + } + set { + self.lock.lock() + self.wrappedValue = newValue + self.lock.unlock() + } + } + + public init(_ value: T) { + self.wrappedValue = value + } +} +``` + +The above error can be solved by updating the test to use this wrapper: + +```swift +func testGetUser() { + let client = Users_V1_UsersMock() + client.mockGetUserInfo = { request in + return ResponseMessage(result: .success(...)) + } + + //highlight-next-line + let receivedMessage = Locked(nil) + client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in + //highlight-next-line + receivedMessage.value = response.message + } + //highlight-next-line + XCTAssertEqual(receivedMessage.value?.name, "jane") +} +``` + [connect-swift]: https://github.com/bufbuild/connect-swift [connect-swift-plugin]: https://buf.build/bufbuild/connect-swift [connect-swift-mocks-plugin]: https://buf.build/bufbuild/connect-swift-mocks