├── Tests ├── LinuxMain.swift ├── CombineGRPCTests │ ├── XCTestManifests.swift │ ├── TestUtils.swift │ ├── Server Implementations │ │ ├── ServerStreamingTestsService.swift │ │ ├── ClientStreamingTestsService.swift │ │ ├── BidirectionalStreamingTestsService.swift │ │ ├── UnaryTestsService.swift │ │ └── RetryPolicyTestsService.swift │ ├── StressTest.swift │ ├── CompletionExpectations.swift │ ├── UnaryTests.swift │ ├── ServerStreamingTests.swift │ ├── ClientStreamingTests.swift │ ├── BidirectionalStreamingTests.swift │ └── RetryPolicyTests.swift └── Protobuf │ └── test_scenarios.proto ├── .gitignore ├── Makefile ├── Sources └── CombineGRPC │ ├── Server │ ├── HeaderUtils.swift │ ├── ClientStreamingHandlerSubscriber.swift │ ├── ServerStreamingHandlerSubscriber.swift │ ├── BidirectionalStreamingHandlerSubscriber.swift │ ├── UnaryHandlerSubscriber.swift │ └── Handlers.swift │ ├── Client │ ├── RPCErrorUtils.swift │ ├── BidirectionalStreamingPublisher.swift │ ├── ClientStreamingPublisher.swift │ ├── RetryPolicy.swift │ ├── DemandBuffer.swift │ ├── ServerStreamingPublisher.swift │ ├── StreamingRequestsSubscriber.swift │ └── GRPCExecutor.swift │ └── RPCError.swift ├── Package.swift ├── CombineGRPC.podspec ├── Package.resolved ├── README.md └── LICENSE /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import CombineGRPCTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += CombineGRPCTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Tests/CombineGRPCTests/Generated/ 2 | .DS_Store 3 | .vscode 4 | project.xcworkspace 5 | xcuserdata 6 | DerivedData/ 7 | .build 8 | build 9 | /CombineGRPC.xcodeproj 10 | .swiftpm 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROTO_DIR = Tests/Protobuf 2 | PROTO_GEN_DIR = Tests/CombineGRPCTests/Generated 3 | 4 | protobuf: 5 | mkdir -p ${PROTO_GEN_DIR} 6 | protoc ${PROTO_DIR}/*.proto --swift_out=FileNaming=DropPath:${PROTO_GEN_DIR} 7 | protoc ${PROTO_DIR}/*.proto --grpc-swift_out=FileNaming=DropPath:${PROTO_GEN_DIR} 8 | 9 | clean: 10 | rm -rf .build/ 11 | rm -rf .swiftpm/ 12 | rm -rf $PROTO_GEN_DIR 13 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/HeaderUtils.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import NIOHPACK 6 | 7 | func augment(headers: HPACKHeaders, with error: RPCError) -> HPACKHeaders { 8 | guard let errorHeaders = error.trailingMetadata else { 9 | return headers 10 | } 11 | var augmented = HPACKHeaders() 12 | augmented.add(contentsOf: headers) 13 | augmented.add(contentsOf: errorHeaders) 14 | return augmented 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/RPCErrorUtils.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import GRPC 5 | 6 | extension RPCError { 7 | 8 | static func from(error: Error, statusCode: GRPCStatus.Code, message: String? = nil, cause: Error? = nil) -> RPCError { 9 | 10 | if let rpcError = error as? RPCError { 11 | return rpcError 12 | } 13 | 14 | return RPCError(status: .init(code: statusCode, message: message, cause: error)) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | 6 | #if !canImport(ObjectiveC) 7 | public func allTests() -> [XCTestCaseEntry] { 8 | return [ 9 | testCase(UnaryTests.allTests), 10 | testCase(ClientStreamingTests.allTests), 11 | testCase(ServerStreamingTests.allTests), 12 | testCase(BidirectionalStreamingTests.allTests), 13 | testCase(RetryPolicyTests.allTests), 14 | ] 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/RPCError.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import GRPC 6 | import NIOHPACK 7 | import NIOHTTP1 8 | 9 | /** 10 | Holds information about a failed gRPC call. 11 | */ 12 | public struct RPCError: Error, Equatable { 13 | public let status: GRPCStatus 14 | public let trailingMetadata: HPACKHeaders? 15 | 16 | public init(status: GRPCStatus) { 17 | self.init(status: status, trailingMetadata: nil) 18 | } 19 | 20 | public init(status: GRPCStatus, trailingMetadata: HTTPHeaders) { 21 | self.init(status: status, trailingMetadata: HPACKHeaders(httpHeaders: trailingMetadata)) 22 | } 23 | 24 | public init(status: GRPCStatus, trailingMetadata: HPACKHeaders?) { 25 | self.status = status 26 | self.trailingMetadata = trailingMetadata 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 2 | // 3 | // Copyright 2019, ComgineGRPC 4 | // Licensed under the Apache License, Version 2.0 5 | 6 | import PackageDescription 7 | 8 | let package = Package( 9 | name: "CombineGRPC", 10 | platforms: [ 11 | .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) 12 | ], 13 | products: [ 14 | .library( 15 | name: "CombineGRPC", 16 | targets: ["CombineGRPC"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.13.1") 20 | ], 21 | targets: [ 22 | .target( 23 | name: "CombineGRPC", 24 | dependencies: [ 25 | .product(name: "GRPC", package: "grpc-swift") 26 | ]), 27 | .testTarget( 28 | name: "CombineGRPCTests", 29 | dependencies: ["CombineGRPC"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | 9 | fileprivate let host = "localhost" 10 | fileprivate let port = 30120 11 | 12 | func makeTestServer(services: [CallHandlerProvider], eventLoopGroupSize: Int = 1) throws -> Server { 13 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: eventLoopGroupSize) 14 | return try Server 15 | .insecure(group: eventLoopGroup) 16 | .withServiceProviders(services) 17 | .bind(host: host, port: port) 18 | .wait() 19 | } 20 | 21 | func makeTestClient(_ clientCreator: (ClientConnection, CallOptions) -> Client) 22 | -> Client where Client: GRPCClient 23 | { 24 | return makeTestClient(eventLoopGroupSize: 1, clientCreator) 25 | } 26 | 27 | func makeTestClient(eventLoopGroupSize: Int = 1, _ clientCreator: (ClientConnection, CallOptions) -> Client) 28 | -> Client where Client: GRPCClient 29 | { 30 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 31 | let channel = ClientConnection 32 | .insecure(group: eventLoopGroup) 33 | .connect(host: host, port: port) 34 | let callOptions = CallOptions(timeLimit: TimeLimit.timeout(.milliseconds(200))) 35 | return clientCreator(channel, callOptions) 36 | } 37 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/BidirectionalStreamingPublisher.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import SwiftProtobuf 8 | import NIOHPACK 9 | import NIO 10 | 11 | class BidirectionalStreamingPublisher: Publisher 12 | where RequestPublisher: Publisher, RequestPublisher.Output == Request, RequestPublisher.Failure == Error, Request: Message, Response: Message { 13 | 14 | typealias Output = Response 15 | typealias Failure = RPCError 16 | 17 | let rpc: BidirectionalStreamingRPC 18 | let callOptions: CallOptions 19 | let requests: RequestPublisher 20 | 21 | init(rpc: @escaping BidirectionalStreamingRPC, callOptions: CallOptions, requests: RequestPublisher) { 22 | self.rpc = rpc 23 | self.callOptions = callOptions 24 | self.requests = requests 25 | } 26 | 27 | func receive(subscriber: S) where S : Subscriber, S.Input == Output, S.Failure == RPCError { 28 | 29 | let buffer = DemandBuffer(subscriber: subscriber) 30 | 31 | let call = rpc(callOptions) { _ = buffer.buffer(value: $0) } 32 | 33 | let requestsSubscriber = StreamingRequestsSubscriber(call: call, buffer: buffer) 34 | subscriber.receive(subscription: requestsSubscriber) 35 | requests.subscribe(requestsSubscriber) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/ClientStreamingPublisher.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import SwiftProtobuf 8 | import NIOHPACK 9 | import NIO 10 | 11 | class ClientStreamingPublisher: Publisher 12 | where RequestPublisher: Publisher, RequestPublisher.Output == Request, RequestPublisher.Failure == Error, Request: Message, Response: Message { 13 | 14 | typealias Output = Response 15 | typealias Failure = RPCError 16 | 17 | let rpc: ClientStreamingRPC 18 | let callOptions: CallOptions 19 | let requests: RequestPublisher 20 | 21 | init(rpc: @escaping ClientStreamingRPC, callOptions: CallOptions, requests: RequestPublisher) { 22 | self.rpc = rpc 23 | self.callOptions = callOptions 24 | self.requests = requests 25 | } 26 | 27 | func receive(subscriber: S) where S : Subscriber, S.Input == Output, S.Failure == RPCError { 28 | 29 | let buffer = DemandBuffer(subscriber: subscriber) 30 | 31 | let call = rpc(callOptions) 32 | call.response.whenSuccess { _ = buffer.buffer(value: $0) } 33 | 34 | let requestsSubscriber = StreamingRequestsSubscriber(call: call, buffer: buffer) 35 | subscriber.receive(subscription: requestsSubscriber) 36 | requests.subscribe(requestsSubscriber) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /CombineGRPC.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | 3 | spec.name = "CombineGRPC" 4 | spec.version = "1.0.8" 5 | spec.summary = "Combine framework integration for Swift gRPC" 6 | spec.description = <<-DESC 7 | CombineGRPC is a library that provides Combine framework integration for Swift gRPC. It provides two flavours of functionality, call and handle. Use call to make gRPC calls on the client side, and handle to handle incoming RPC calls on the server side. CombineGRPC provides versions of call and handle for all RPC styles: Unary, server streaming, client streaming and bidirectional streaming RPCs. 8 | DESC 9 | spec.license = { :type => "Apache 2.0", :file => "LICENSE" } 10 | spec.source = { :git => "https://github.com/vyshane/grpc-swift-combine.git", :tag => "#{spec.version}" } 11 | spec.author = { "Vy-Shane Xie" => "s@vyshane.com" } 12 | spec.social_media_url = "https://twitter.com/vyshane" 13 | spec.homepage = "https://github.com/vyshane/grpc-swift-combine" 14 | 15 | spec.swift_version = "5.2" 16 | spec.ios.deployment_target = "13.0" 17 | spec.osx.deployment_target = "10.15" 18 | spec.tvos.deployment_target = "13.0" 19 | spec.watchos.deployment_target = "6.0" 20 | spec.source_files = 'Sources/CombineGRPC/**/*.swift' 21 | 22 | spec.dependency "gRPC-Swift", "1.6.0" 23 | spec.dependency "CombineExt", "1.5.1" 24 | 25 | spec.pod_target_xcconfig = { "ENABLE_TESTABILITY" => "YES" } 26 | 27 | end 28 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/RetryPolicy.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Combine 5 | import GRPC 6 | 7 | /** 8 | Specifies retry behaviour when a gRPC call fails. 9 | */ 10 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 11 | public enum RetryPolicy { 12 | /** 13 | Automatically retry failed calls up to a maximum number of times, when a condition is met. 14 | 15 | - Parameters: 16 | - upTo: Maximum number of retries. Defaults to 1. 17 | - when: Retry when condition is true. 18 | - delayUntilNext: Wait for the next published value before retrying. Defaults to a publisher that immediately 19 | effectively meaning that there is no delay between retries. `delayUntilNext` is called with the current retry 20 | count and RPCError. 21 | - didGiveUp: Called when number of retries have been exhausted. Defaults to no-op. 22 | 23 | The following example defines a `RetryPolicy` for retrying failed calls up to 3 times when the error is a `GRPCStatus.unavailable`: 24 | 25 | ``` 26 | let retry = RetryPolicy.failedCall(upTo: 3, when: { $0.code == .unavailable })) 27 | ``` 28 | */ 29 | case failedCall(upTo: UInt = 1, 30 | when: (RPCError) -> Bool, 31 | delayUntilNext: (Int, RPCError) -> AnyPublisher = { _, _ in 32 | Just(()).eraseToAnyPublisher() 33 | }, 34 | didGiveUp: () -> Void = {}) 35 | /** 36 | No automatic retries. 37 | */ 38 | case never 39 | } 40 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/Server Implementations/ServerStreamingTestsService.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import NIOHPACK 9 | @testable import CombineGRPC 10 | 11 | class ServerStreamingTestsService: ServerStreamingScenariosProvider { 12 | 13 | var interceptors: ServerStreamingScenariosServerInterceptorFactoryProtocol? 14 | 15 | // OK, echoes back the request message three times 16 | func ok(request: EchoRequest, context: StreamingResponseCallContext) 17 | -> EventLoopFuture 18 | { 19 | CombineGRPC.handle(context) { 20 | let responses = repeatElement(EchoResponse.with { $0.message = request.message}, count: 3) 21 | return Publishers.Sequence(sequence: responses).eraseToAnyPublisher() 22 | } 23 | } 24 | 25 | // Fails 26 | func failedPrecondition(request: EchoRequest, context: StreamingResponseCallContext) 27 | -> EventLoopFuture 28 | { 29 | CombineGRPC.handle(context) { 30 | let status = GRPCStatus(code: .failedPrecondition, message: "Failed precondition message") 31 | let additionalMetadata = HPACKHeaders([("custom", "info")]) 32 | return Fail(error: RPCError(status: status, trailingMetadata: additionalMetadata)) 33 | .eraseToAnyPublisher() 34 | } 35 | } 36 | 37 | // Times out 38 | func noResponse(request: EchoRequest, context: StreamingResponseCallContext) 39 | -> EventLoopFuture 40 | { 41 | CombineGRPC.handle(context) { 42 | Combine.Empty(completeImmediately: false).eraseToAnyPublisher() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/Server Implementations/ClientStreamingTestsService.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import NIOHPACK 9 | @testable import CombineGRPC 10 | 11 | class ClientStreamingTestsService: ClientStreamingScenariosProvider { 12 | 13 | var interceptors: ClientStreamingScenariosServerInterceptorFactoryProtocol? 14 | 15 | // OK, echoes back the last received message 16 | func ok(context: UnaryResponseCallContext) 17 | -> EventLoopFuture<(StreamEvent) -> Void> 18 | { 19 | CombineGRPC.handle(context) { requests in 20 | requests 21 | .last() 22 | .map { request in 23 | EchoResponse.with { $0.message = request.message } 24 | } 25 | .setFailureType(to: RPCError.self) 26 | .eraseToAnyPublisher() 27 | } 28 | } 29 | 30 | // Fails 31 | func failedPrecondition(context: UnaryResponseCallContext) 32 | -> EventLoopFuture<(StreamEvent) -> Void> 33 | { 34 | CombineGRPC.handle(context) { _ in 35 | let status = GRPCStatus(code: .failedPrecondition, message: "Failed precondition message") 36 | let additionalMetadata = HPACKHeaders([("custom", "info")]) 37 | return Fail(error: RPCError(status: status, trailingMetadata: additionalMetadata)) 38 | .eraseToAnyPublisher() 39 | } 40 | } 41 | 42 | // Times out 43 | func noResponse(context: UnaryResponseCallContext) 44 | -> EventLoopFuture<(StreamEvent) -> Void> 45 | { 46 | CombineGRPC.handle(context) { _ in 47 | Combine.Empty(completeImmediately: false).eraseToAnyPublisher() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/Protobuf/test_scenarios.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | // 4 | // Scenarios for end to end tests. 5 | 6 | syntax = "proto3"; 7 | 8 | service UnaryScenarios { 9 | rpc Ok (EchoRequest) returns (EchoResponse); 10 | rpc FailedPrecondition (EchoRequest) returns (Empty); 11 | rpc NoResponse (EchoRequest) returns (Empty); 12 | } 13 | 14 | service ClientStreamingScenarios { 15 | rpc Ok (stream EchoRequest) returns (EchoResponse); 16 | rpc FailedPrecondition (stream EchoRequest) returns (Empty); 17 | rpc NoResponse (stream EchoRequest) returns (Empty); 18 | } 19 | 20 | service ServerStreamingScenarios { 21 | rpc Ok (EchoRequest) returns (stream EchoResponse); 22 | rpc FailedPrecondition (EchoRequest) returns (stream Empty); 23 | rpc NoResponse (EchoRequest) returns (stream Empty); 24 | } 25 | 26 | service BidirectionalStreamingScenarios { 27 | rpc Ok (stream EchoRequest) returns (stream EchoResponse); 28 | rpc FailedPrecondition (stream EchoRequest) returns (stream Empty); 29 | rpc NoResponse (stream EchoRequest) returns (stream Empty); 30 | } 31 | 32 | service RetryScenarios { 33 | rpc FailThenSucceed (FailThenSucceedRequest) returns (FailThenSucceedResponse); 34 | rpc AuthenticatedRpc (EchoRequest) returns (EchoResponse); 35 | } 36 | 37 | message EchoRequest { 38 | string message = 1; 39 | } 40 | 41 | message EchoResponse { 42 | string message = 1; 43 | } 44 | 45 | message Empty {} 46 | 47 | message FailThenSucceedRequest { 48 | // Key used to partition failure counts 49 | string key = 1; 50 | // Number of failures that the service should respond with before succeeding 51 | uint32 num_failures = 2; 52 | } 53 | 54 | message FailThenSucceedResponse { 55 | // Number of failures recorded before succeeding 56 | uint32 num_failures = 1; 57 | } 58 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/ClientStreamingHandlerSubscriber.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import SwiftProtobuf 9 | 10 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 11 | class ClientStreamingHandlerSubscriber: Subscriber, Cancellable where Request: Message, Response: Message { 12 | typealias Input = Response 13 | typealias Failure = RPCError 14 | 15 | private var subscription: Subscription? 16 | private let context: UnaryResponseCallContext 17 | 18 | init(context: UnaryResponseCallContext) { 19 | self.context = context 20 | } 21 | 22 | func receive(subscription: Subscription) { 23 | self.subscription = subscription 24 | self.subscription?.request(.max(1)) 25 | } 26 | 27 | func receive(_ input: Response) -> Subscribers.Demand { 28 | context.responsePromise.succeed(input) 29 | return .max(1) 30 | } 31 | 32 | func receive(completion: Subscribers.Completion) { 33 | switch completion { 34 | case .failure(let error): 35 | if context.eventLoop.inEventLoop { 36 | context.trailers = augment(headers: context.trailers, with: error) 37 | context.responsePromise.fail(error.status) 38 | } else { 39 | context.eventLoop.execute { 40 | self.context.trailers = augment(headers: self.context.trailers, with: error) 41 | self.context.responsePromise.fail(error.status) 42 | } 43 | } 44 | case .finished: 45 | let status = GRPCStatus(code: .aborted, message: "Handler completed without a response") 46 | context.responsePromise.fail(status) 47 | } 48 | } 49 | 50 | func cancel() { 51 | subscription?.cancel() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/ServerStreamingHandlerSubscriber.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import SwiftProtobuf 9 | 10 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 11 | class ServerStreamingHandlerSubscriber: Subscriber, Cancellable where Response: Message { 12 | typealias Input = Response 13 | typealias Failure = RPCError 14 | 15 | var futureStatus: EventLoopFuture { 16 | get { 17 | return context.statusPromise.futureResult 18 | } 19 | } 20 | 21 | private var subscription: Subscription? 22 | private let context: StreamingResponseCallContext 23 | 24 | init(context: StreamingResponseCallContext) { 25 | self.context = context 26 | } 27 | 28 | func receive(subscription: Subscription) { 29 | self.subscription = subscription 30 | self.subscription?.request(.max(1)) 31 | } 32 | 33 | func receive(_ input: Response) -> Subscribers.Demand { 34 | _ = context.sendResponse(input) 35 | return .max(1) 36 | } 37 | 38 | func receive(completion: Subscribers.Completion) { 39 | switch completion { 40 | case .failure(let error): 41 | if context.eventLoop.inEventLoop { 42 | context.trailers = augment(headers: context.trailers, with: error) 43 | context.statusPromise.fail(error.status) 44 | } else { 45 | context.eventLoop.execute { 46 | self.context.trailers = augment(headers: self.context.trailers, with: error) 47 | self.context.statusPromise.fail(error.status) 48 | } 49 | } 50 | case .finished: 51 | context.statusPromise.succeed(.ok) 52 | } 53 | } 54 | 55 | func cancel() { 56 | subscription?.cancel() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/Server Implementations/BidirectionalStreamingTestsService.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import NIOHPACK 9 | @testable import CombineGRPC 10 | 11 | class BidirectionalStreamingTestsService: BidirectionalStreamingScenariosProvider { 12 | 13 | var interceptors: BidirectionalStreamingScenariosServerInterceptorFactoryProtocol? 14 | 15 | // OK, echoes back each message in the request stream 16 | func ok(context: StreamingResponseCallContext) 17 | -> EventLoopFuture<(StreamEvent) -> Void> 18 | { 19 | CombineGRPC.handle(context) { requests in 20 | requests 21 | .map { req in 22 | EchoResponse.with { $0.message = req.message } 23 | } 24 | .setFailureType(to: RPCError.self) 25 | .eraseToAnyPublisher() 26 | } 27 | } 28 | 29 | // Fails 30 | func failedPrecondition(context: StreamingResponseCallContext) 31 | -> EventLoopFuture<(StreamEvent) -> Void> 32 | { 33 | CombineGRPC.handle(context) { _ in 34 | let status = GRPCStatus(code: .failedPrecondition, message: "Failed precondition message") 35 | let additionalMetadata = HPACKHeaders([("custom", "info")]) 36 | let error = RPCError(status: status, trailingMetadata: additionalMetadata) 37 | return Fail(error: error).eraseToAnyPublisher() 38 | } 39 | } 40 | 41 | // An RPC that never completes 42 | func noResponse(context: StreamingResponseCallContext) 43 | -> EventLoopFuture<(StreamEvent) -> Void> 44 | { 45 | CombineGRPC.handle(context) { _ in 46 | Combine.Empty(completeImmediately: false).eraseToAnyPublisher() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/Server Implementations/UnaryTestsService.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import NIOHPACK 9 | @testable import CombineGRPC 10 | 11 | class UnaryTestsService: UnaryScenariosProvider { 12 | 13 | var interceptors: UnaryScenariosServerInterceptorFactoryProtocol? 14 | 15 | // OK, echoes back the message in the request 16 | func ok(request: EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture { 17 | // For large services, it is useful to be able to split each individual RPC handler out. 18 | // This is an example of how you might do that. 19 | CombineGRPC.handle(request, context, handler: self.echoHandler) 20 | } 21 | 22 | // Fails 23 | func failedPrecondition(request: EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture { 24 | CombineGRPC.handle(context) { 25 | let status = GRPCStatus(code: .failedPrecondition, message: "Failed precondition message") 26 | let additionalMetadata = HPACKHeaders([("custom", "info")]) 27 | return Fail(error: RPCError(status: status, trailingMetadata: additionalMetadata)) 28 | .eraseToAnyPublisher() 29 | } 30 | } 31 | 32 | // Times out 33 | func noResponse(request: EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture { 34 | CombineGRPC.handle(context) { 35 | return Combine.Empty(completeImmediately: false).eraseToAnyPublisher() 36 | } 37 | } 38 | 39 | // We define a handler here but you can imagine that it might be in its own separate class. 40 | private func echoHandler(request: EchoRequest) -> AnyPublisher { 41 | Just(EchoResponse.with { $0.message = request.message }) 42 | .setFailureType(to: RPCError.self) 43 | .eraseToAnyPublisher() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/BidirectionalStreamingHandlerSubscriber.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import SwiftProtobuf 9 | 10 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 11 | class BidirectionalStreamingHandlerSubscriber: Subscriber, Cancellable where Response: Message { 12 | typealias Input = Response 13 | typealias Failure = RPCError 14 | 15 | private var subscription: Subscription? 16 | private let context: StreamingResponseCallContext 17 | 18 | init(context: StreamingResponseCallContext) { 19 | self.context = context 20 | } 21 | 22 | func receive(subscription: Subscription) { 23 | self.subscription = subscription 24 | self.subscription?.request(.max(1)) 25 | } 26 | 27 | func receive(_ input: Response) -> Subscribers.Demand { 28 | _ = context.sendResponse(input) 29 | return .max(1) 30 | } 31 | 32 | func receive(completion: Subscribers.Completion) { 33 | switch completion { 34 | case .failure(let error): 35 | if context.eventLoop.inEventLoop { 36 | context.trailers = augment(headers: context.trailers, with: error) 37 | context.statusPromise.fail(error.status) 38 | } else { 39 | context.eventLoop.execute { 40 | self.context.trailers = augment(headers: self.context.trailers, with: error) 41 | self.context.statusPromise.fail(error.status) 42 | } 43 | } 44 | case .finished: 45 | context.statusPromise.succeed(.ok) 46 | } 47 | } 48 | 49 | func cancel() { 50 | subscription?.cancel() 51 | } 52 | 53 | deinit { 54 | // Ensure that we don't leak the promise 55 | context.statusPromise 56 | .fail(GRPCStatus(code: .deadlineExceeded, message: "Handler didn't complete within the deadline")) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/UnaryHandlerSubscriber.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | import SwiftProtobuf 9 | 10 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 11 | class UnaryHandlerSubscriber: Subscriber, Cancellable { 12 | typealias Input = Response 13 | typealias Failure = RPCError 14 | 15 | var futureResult: EventLoopFuture { 16 | get { 17 | responsePromise.futureResult 18 | } 19 | } 20 | 21 | private var subscription: Subscription? 22 | private let context: StatusOnlyCallContext 23 | private let responsePromise: EventLoopPromise 24 | 25 | init(context: StatusOnlyCallContext) { 26 | self.context = context 27 | responsePromise = context.eventLoop.makePromise() 28 | } 29 | 30 | func receive(subscription: Subscription) { 31 | self.subscription = subscription 32 | self.subscription?.request(.max(1)) 33 | } 34 | 35 | func receive(_ input: Response) -> Subscribers.Demand { 36 | responsePromise.succeed(input) 37 | return .max(1) 38 | } 39 | 40 | func receive(completion: Subscribers.Completion) { 41 | switch completion { 42 | case .failure(let error): 43 | if context.eventLoop.inEventLoop { 44 | context.trailers = augment(headers: context.trailers, with: error) 45 | responsePromise.fail(error.status) 46 | } else { 47 | context.eventLoop.execute { 48 | self.context.trailers = augment(headers: self.context.trailers, with: error) 49 | self.responsePromise.fail(error.status) 50 | } 51 | } 52 | case .finished: 53 | let status = GRPCStatus(code: .aborted, message: "Response publisher completed without sending a value") 54 | responsePromise.fail(status) 55 | } 56 | } 57 | 58 | func cancel() { 59 | subscription?.cancel() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/Server Implementations/RetryPolicyTestsService.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import NIO 8 | @testable import CombineGRPC 9 | 10 | class RetryPolicyTestsService: RetryScenariosProvider { 11 | 12 | var interceptors: RetryScenariosServerInterceptorFactoryProtocol? 13 | var failureCounts: [String: UInt32] = [:] 14 | 15 | // Fails with gRPC status failed precondition for the requested number of times, then succeeds. 16 | func failThenSucceed(request: FailThenSucceedRequest, context: StatusOnlyCallContext) 17 | -> EventLoopFuture 18 | { 19 | CombineGRPC.handle(context) { 20 | let status = GRPCStatus(code: .failedPrecondition, message: "Requested failure") 21 | let error: AnyPublisher = 22 | Fail(error: RPCError(status: status)).eraseToAnyPublisher() 23 | 24 | if failureCounts[request.key] == nil { 25 | failureCounts[request.key] = 1 26 | return error 27 | } 28 | if failureCounts[request.key]! < request.numFailures { 29 | failureCounts[request.key]! += 1 30 | return error 31 | } 32 | return Just(FailThenSucceedResponse.with { $0.numFailures = failureCounts[request.key]! }) 33 | .setFailureType(to: RPCError.self) 34 | .eraseToAnyPublisher() 35 | } 36 | } 37 | 38 | func authenticatedRpc(request: EchoRequest, context: StatusOnlyCallContext) 39 | -> EventLoopFuture 40 | { 41 | CombineGRPC.handle(context) { 42 | if context.headers.contains(where: { $0.0 == "authorization" && $0.1 == "Bearer xxx" }) { 43 | return Just(EchoResponse.with { $0.message = request.message }) 44 | .setFailureType(to: RPCError.self) 45 | .eraseToAnyPublisher() 46 | } 47 | let status = GRPCStatus(code: .unauthenticated, message: "Missing expected authorization header") 48 | return Fail(error: RPCError(status: status)) 49 | .eraseToAnyPublisher() 50 | } 51 | } 52 | 53 | func reset() { 54 | failureCounts = [:] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/DemandBuffer.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | 7 | class DemandBuffer { 8 | 9 | struct Demand { 10 | var processed: Subscribers.Demand = .none 11 | var requested: Subscribers.Demand = .none 12 | var sent: Subscribers.Demand = .none 13 | } 14 | 15 | private let lock = NSRecursiveLock() 16 | private var buffer = [S.Input]() 17 | private let subscriber: S 18 | private var completion: Subscribers.Completion? 19 | private var currentDemand = Demand() 20 | 21 | init(subscriber: S) { 22 | self.subscriber = subscriber 23 | } 24 | 25 | func buffer(value: S.Input) -> Subscribers.Demand { 26 | precondition(self.completion == nil) 27 | lock.lock() 28 | defer { lock.unlock() } 29 | 30 | switch currentDemand.requested { 31 | case .unlimited: 32 | return subscriber.receive(value) 33 | default: 34 | buffer.append(value) 35 | return flush() 36 | } 37 | } 38 | 39 | func complete(completion: Subscribers.Completion) { 40 | precondition(self.completion == nil) 41 | 42 | self.completion = completion 43 | _ = flush() 44 | } 45 | 46 | func demand(_ demand: Subscribers.Demand) -> Subscribers.Demand { 47 | flush(adding: demand) 48 | } 49 | 50 | private func flush(adding demand: Subscribers.Demand? = nil) -> Subscribers.Demand { 51 | lock.lock() 52 | defer { lock.unlock() } 53 | 54 | if let demand = demand { 55 | currentDemand.requested += demand 56 | } 57 | 58 | // If buffer isn't ready for flushing, return immediately 59 | guard currentDemand.requested > 0 || demand == Subscribers.Demand.none else { 60 | return .none 61 | } 62 | 63 | while !buffer.isEmpty && currentDemand.processed < currentDemand.requested { 64 | currentDemand.requested += subscriber.receive(buffer.remove(at: 0)) 65 | currentDemand.processed += 1 66 | } 67 | 68 | if let completion = completion { 69 | // Clear waiting events and send completion 70 | buffer = [] 71 | currentDemand = .init() 72 | self.completion = nil 73 | subscriber.receive(completion: completion) 74 | return .none 75 | } 76 | 77 | let sentDemand = currentDemand.requested - currentDemand.sent 78 | currentDemand.sent += sentDemand 79 | return sentDemand 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/StressTest.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | @testable import CombineGRPC 8 | 9 | /** 10 | TODO 11 | */ 12 | class StressTest: XCTestCase { 13 | 14 | static var server: Server? 15 | static var unaryClient: UnaryScenariosNIOClient? 16 | static var serverStreamingClient: ServerStreamingScenariosNIOClient? 17 | static var clientStreamingClient: ClientStreamingScenariosNIOClient? 18 | static var bidirectionalStreamingClient: BidirectionalStreamingScenariosNIOClient? 19 | static var retainedCancellables: [AnyCancellable] = [] 20 | 21 | override class func setUp() { 22 | super.setUp() 23 | 24 | let services: [CallHandlerProvider] = [ 25 | UnaryTestsService(), 26 | ServerStreamingTestsService(), 27 | ClientStreamingTestsService(), 28 | BidirectionalStreamingTestsService() 29 | ] 30 | server = try! makeTestServer(services: services, eventLoopGroupSize: 4) 31 | 32 | unaryClient = makeTestClient(eventLoopGroupSize: 4) { channel, callOptions in 33 | UnaryScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 34 | } 35 | serverStreamingClient = makeTestClient(eventLoopGroupSize: 4) { channel, callOptions in 36 | ServerStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 37 | } 38 | clientStreamingClient = makeTestClient(eventLoopGroupSize: 4) { channel, callOptions in 39 | ClientStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 40 | } 41 | bidirectionalStreamingClient = makeTestClient(eventLoopGroupSize: 4) { channel, callOptions in 42 | BidirectionalStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 43 | } 44 | } 45 | 46 | override class func tearDown() { 47 | try! unaryClient?.channel.close().wait() 48 | try! serverStreamingClient?.channel.close().wait() 49 | try! clientStreamingClient?.channel.close().wait() 50 | try! bidirectionalStreamingClient?.channel.close().wait() 51 | try! server?.close().wait() 52 | retainedCancellables.removeAll() 53 | super.tearDown() 54 | } 55 | 56 | private func randomRequest() -> EchoRequest { 57 | let messageOfRandomSize = (0..<50).map { _ in UUID().uuidString }.reduce("", { $0 + $1 }) 58 | return EchoRequest.with { $0.message = messageOfRandomSize } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/ServerStreamingPublisher.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Foundation 5 | import Combine 6 | import GRPC 7 | import SwiftProtobuf 8 | import NIOHPACK 9 | import NIO 10 | 11 | class ServerStreamingPublisher: Publisher where Request: Message, Response: Message { 12 | 13 | typealias Output = Response 14 | typealias Failure = RPCError 15 | 16 | let rpc: ServerStreamingRPC 17 | let callOptions: CallOptions 18 | let request: Request 19 | 20 | init(rpc: @escaping ServerStreamingRPC, callOptions: CallOptions, request: Request) { 21 | self.rpc = rpc 22 | self.callOptions = callOptions 23 | self.request = request 24 | } 25 | 26 | func receive(subscriber: S) where S : Subscriber, S.Input == Output, S.Failure == RPCError { 27 | 28 | let buffer = DemandBuffer(subscriber: subscriber) 29 | 30 | let call = rpc(request, callOptions) { _ = buffer.buffer(value: $0) } 31 | 32 | subscriber.receive(subscription: ServerStreamingSubscription(call: call, buffer: buffer)) 33 | } 34 | 35 | } 36 | 37 | 38 | class ServerStreamingSubscription: Subscription 39 | where DownstreamSubscriber: Subscriber, DownstreamSubscriber.Failure == RPCError { 40 | 41 | typealias Input = Request 42 | typealias Failure = Error 43 | typealias Call = ServerStreamingCall 44 | 45 | let call: Call 46 | var buffer: DemandBuffer 47 | 48 | init(call: Call, buffer: DemandBuffer) { 49 | self.call = call 50 | self.buffer = buffer 51 | 52 | // Save any trailingMetadata received before status 53 | var trailingMetadata: HPACKHeaders? 54 | call.trailingMetadata 55 | .whenSuccess { metadata in 56 | trailingMetadata = metadata 57 | } 58 | 59 | // Send completion as soon as status is available & the subscription has been received 60 | call.status 61 | .whenSuccess { status in 62 | 63 | switch status.code { 64 | case .ok: 65 | self.complete() 66 | 67 | default: 68 | self.complete(error: RPCError(status: status, trailingMetadata: trailingMetadata)) 69 | } 70 | } 71 | } 72 | 73 | func request(_ demand: Subscribers.Demand) { 74 | _ = buffer.demand(demand) 75 | } 76 | 77 | func cancel() { 78 | 79 | call.cancel(promise: nil) 80 | } 81 | 82 | func complete(error: RPCError? = nil) { 83 | 84 | if let error = error { 85 | 86 | buffer.complete(completion: .failure(error)) 87 | 88 | } else { 89 | 90 | buffer.complete(completion: .finished) 91 | } 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/CompletionExpectations.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | // 4 | // CompletionExpectation is a collection of useful functions for asserting how you 5 | // expect publishers to behave in your tests. 6 | 7 | import Combine 8 | import GRPC 9 | import XCTest 10 | @testable import CombineGRPC 11 | 12 | public func expectFinished(resolve expectation: XCTestExpectation? = nil, onFinished: () -> Void = {}) 13 | -> (_ actual: Subscribers.Completion) -> Void where E: Error 14 | { 15 | { actual in 16 | switch actual { 17 | case .failure(let actualError): 18 | XCTFail("Expecting Completion.finished but got \(actualError)") 19 | case .finished: 20 | expectation?.fulfill() 21 | } 22 | } 23 | } 24 | 25 | public func resolve 26 | (_ expectation: XCTestExpectation? = nil, expectingFailure check: @escaping (T) -> Bool) 27 | -> (_ actual: Subscribers.Completion) 28 | -> Void 29 | { 30 | { actual in 31 | switch actual { 32 | case .failure(let actualError): 33 | if check(actualError) { 34 | expectation?.fulfill() 35 | } else { 36 | XCTFail("Got unexpected error \(actual)") 37 | } 38 | case .finished: 39 | XCTFail("Expecting Completion.failure but got Completion.finished") 40 | } 41 | } 42 | } 43 | 44 | public func expectFailure(_ check: @escaping (T) -> Bool, resolve expectation: XCTestExpectation? = nil) 45 | -> (_ actual: Subscribers.Completion) 46 | -> Void 47 | { 48 | { actual in 49 | switch actual { 50 | case .failure(let actualError): 51 | if check(actualError) { 52 | expectation?.fulfill() 53 | } else { 54 | XCTFail("Got unexpected error \(actual)") 55 | } 56 | case .finished: 57 | XCTFail("Expecting Completion.failure but got Completion.finished") 58 | } 59 | } 60 | } 61 | 62 | public func expectRPCError(code: GRPCStatus.Code, message: String? = nil, resolve expectation: XCTestExpectation? = nil) 63 | -> (_ actual: Subscribers.Completion) 64 | -> Void 65 | { 66 | { actual in 67 | switch actual { 68 | case .failure(let actualError): 69 | if actualError.status.code == code && (message == nil || actualError.status.message == message) { 70 | expectation?.fulfill() 71 | } else { 72 | XCTFail("Expecting (\(code), \(message ?? "nil")) " + 73 | "but got (\(actualError.status), \(actualError.status.message ?? "nil"))") 74 | } 75 | case .finished: 76 | XCTFail("Expecting Completion.failure but got Completion.finished") 77 | } 78 | } 79 | } 80 | 81 | public func expectValue(_ check: @escaping (T) -> Bool) -> (_ value: T) -> Void { 82 | { value in 83 | XCTAssert(check(value)) 84 | } 85 | } 86 | 87 | public func expectNoValue() -> (_ value: T) -> Void { 88 | { _ in 89 | XCTFail("Unexpected value") 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/UnaryTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | @testable import CombineGRPC 8 | 9 | final class UnaryTests: XCTestCase { 10 | 11 | static var server: Server? 12 | static var client: UnaryScenariosNIOClient? 13 | static var retainedCancellables: Set = [] 14 | 15 | override class func setUp() { 16 | super.setUp() 17 | server = try! makeTestServer(services: [UnaryTestsService()]) 18 | client = makeTestClient { channel, callOptions in 19 | UnaryScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 20 | } 21 | } 22 | 23 | override class func tearDown() { 24 | try! client?.channel.close().wait() 25 | try! server?.close().wait() 26 | retainedCancellables.removeAll() 27 | super.tearDown() 28 | } 29 | 30 | func testOk() { 31 | let promise = expectation(description: "Call completes successfully") 32 | let client = Self.client! 33 | let grpc = GRPCExecutor() 34 | 35 | grpc.call(client.ok)(EchoRequest.with { $0.message = "hello" }) 36 | .sink( 37 | receiveCompletion: expectFinished(resolve: promise), 38 | receiveValue: expectValue { $0.message == "hello" } 39 | ) 40 | .store(in: &Self.retainedCancellables) 41 | 42 | wait(for: [promise], timeout: 0.2) 43 | } 44 | 45 | func testFailedPrecondition() { 46 | let promise = expectation(description: "Call fails with failed precondition status") 47 | let failedPrecondition = Self.client!.failedPrecondition 48 | let grpc = GRPCExecutor() 49 | 50 | grpc.call(failedPrecondition)(EchoRequest.with { $0.message = "hello" }) 51 | .sink( 52 | receiveCompletion: resolve(promise, expectingFailure: { error in 53 | error.status.code == .failedPrecondition && error.trailingMetadata?.first(name: "custom") == "info" 54 | }), 55 | receiveValue: expectNoValue() 56 | ) 57 | .store(in: &Self.retainedCancellables) 58 | 59 | wait(for: [promise], timeout: 0.2) 60 | } 61 | 62 | func testNoResponse() { 63 | let promise = expectation(description: "Call fails with deadline exceeded status") 64 | let client = Self.client! 65 | let options = CallOptions(timeLimit: TimeLimit.timeout(.milliseconds(20))) 66 | let grpc = GRPCExecutor(callOptions: Just(options).eraseToAnyPublisher()) 67 | 68 | grpc.call(client.noResponse)(EchoRequest.with { $0.message = "hello" }) 69 | .sink( 70 | receiveCompletion: expectRPCError(code: .deadlineExceeded, resolve: promise), 71 | receiveValue: expectNoValue() 72 | ) 73 | .store(in: &Self.retainedCancellables) 74 | 75 | wait(for: [promise], timeout: 0.2) 76 | } 77 | 78 | static var allTests = [ 79 | ("Unary OK", testOk), 80 | ("Unary failed precondition", testFailedPrecondition), 81 | ("Unary no response", testNoResponse), 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "grpc-swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/grpc/grpc-swift.git", 7 | "state" : { 8 | "revision" : "6bf35472cb8621481e59e6d1450b19627db81cea", 9 | "version" : "1.13.1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-atomics", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-atomics.git", 16 | "state" : { 17 | "revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe", 18 | "version" : "1.0.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-collections", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-collections.git", 25 | "state" : { 26 | "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", 27 | "version" : "1.0.3" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-log", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-log.git", 34 | "state" : { 35 | "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", 36 | "version" : "1.4.4" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-nio", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-nio.git", 43 | "state" : { 44 | "revision" : "edfceecba13d68c1c993382806e72f7e96feaa86", 45 | "version" : "2.44.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-nio-extras", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-nio-extras.git", 52 | "state" : { 53 | "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", 54 | "version" : "1.15.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-nio-http2", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-nio-http2.git", 61 | "state" : { 62 | "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", 63 | "version" : "1.23.1" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-nio-ssl", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/apple/swift-nio-ssl.git", 70 | "state" : { 71 | "revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3", 72 | "version" : "2.23.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-nio-transport-services", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 79 | "state" : { 80 | "revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c", 81 | "version" : "1.15.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-protobuf", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-protobuf.git", 88 | "state" : { 89 | "revision" : "88c7d15e1242fdb6ecbafbc7926426a19be1e98a", 90 | "version" : "1.20.2" 91 | } 92 | } 93 | ], 94 | "version" : 2 95 | } 96 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/ServerStreamingTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | @testable import CombineGRPC 8 | 9 | class ServerStreamingTests: XCTestCase { 10 | 11 | static var server: Server? 12 | static var client: ServerStreamingScenariosNIOClient? 13 | 14 | // Streams will be cancelled prematurely if cancellables are deinitialized 15 | static var retainedCancellables: Set = [] 16 | 17 | override class func setUp() { 18 | super.setUp() 19 | server = try! makeTestServer(services: [ServerStreamingTestsService()]) 20 | client = makeTestClient { channel, callOptions in 21 | ServerStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 22 | } 23 | } 24 | 25 | override class func tearDown() { 26 | try! client?.channel.close().wait() 27 | try! server?.close().wait() 28 | retainedCancellables.removeAll() 29 | super.tearDown() 30 | } 31 | 32 | func testOk() { 33 | let promise = expectation(description: "Call completes successfully") 34 | let client = Self.client! 35 | let grpc = GRPCExecutor() 36 | 37 | grpc.call(client.ok)(EchoRequest.with { $0.message = "hello" }) 38 | .filter { $0.message == "hello" } 39 | .count() 40 | .sink( 41 | receiveCompletion: expectFinished(resolve: promise), 42 | receiveValue: expectValue({ count in count == 3}) 43 | ) 44 | .store(in: &Self.retainedCancellables) 45 | 46 | wait(for: [promise], timeout: 0.2) 47 | } 48 | 49 | func testFailedPrecondition() { 50 | let promise = expectation(description: "Call fails with failed precondition status") 51 | let failedPrecondition = Self.client!.failedPrecondition 52 | let grpc = GRPCExecutor() 53 | 54 | grpc.call(failedPrecondition)(EchoRequest.with { $0.message = "hello" }) 55 | .sink( 56 | receiveCompletion: resolve(promise, expectingFailure: 57 | { error in 58 | error.status.code == .failedPrecondition && error.trailingMetadata?.first(name: "custom") == "info" 59 | }), 60 | receiveValue: expectNoValue() 61 | ) 62 | .store(in: &Self.retainedCancellables) 63 | 64 | wait(for: [promise], timeout: 0.2) 65 | } 66 | 67 | func testNoResponse() { 68 | let promise = expectation(description: "Call fails with deadline exceeded status") 69 | let client = Self.client! 70 | let options = CallOptions(timeLimit: TimeLimit.timeout(.milliseconds(20))) 71 | let grpc = GRPCExecutor(callOptions: Just(options).eraseToAnyPublisher()) 72 | 73 | grpc.call(client.noResponse)(EchoRequest.with { $0.message = "hello" }) 74 | .sink( 75 | receiveCompletion: expectRPCError(code: .deadlineExceeded, resolve: promise), 76 | receiveValue: expectNoValue() 77 | ) 78 | .store(in: &Self.retainedCancellables) 79 | 80 | wait(for: [promise], timeout: 0.2) 81 | } 82 | 83 | // TODO: Backpressure test 84 | 85 | 86 | static var allTests = [ 87 | ("Server streaming OK", testOk), 88 | ("Server streaming failed precondition", testFailedPrecondition), 89 | ("Server streaming no response", testNoResponse), 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/StreamingRequestsSubscriber.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, CombineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Combine 5 | import GRPC 6 | import NIOHPACK 7 | 8 | class StreamingRequestsSubscriber: Subscriber, Subscription 9 | where Call: StreamingRequestClientCall, Call.RequestPayload == Request, Call.ResponsePayload == Response, 10 | DownstreamSubscriber: Subscriber, DownstreamSubscriber.Input == Response, DownstreamSubscriber.Failure == RPCError { 11 | 12 | typealias Input = Request 13 | typealias Failure = Error 14 | 15 | let call: Call 16 | var buffer: DemandBuffer 17 | var subscription: Subscription? 18 | 19 | init(call: Call, buffer: DemandBuffer) { 20 | self.call = call 21 | self.buffer = buffer 22 | } 23 | 24 | func receive(subscription: Subscription) { 25 | self.subscription = subscription 26 | 27 | // Save any trailingMetadata received before status 28 | var trailingMetadata: HPACKHeaders? 29 | call.trailingMetadata 30 | .whenSuccess { metadata in 31 | trailingMetadata = metadata 32 | } 33 | 34 | // Send completion as soon as status is available & the subscription has been received 35 | call.status 36 | .whenSuccess { status in 37 | 38 | switch status.code { 39 | case .ok: 40 | self.complete() 41 | 42 | default: 43 | self.complete(error: RPCError(status: status, trailingMetadata: trailingMetadata)) 44 | } 45 | } 46 | 47 | subscription.request(.max(1)) 48 | } 49 | 50 | func receive(_ input: Request) -> Subscribers.Demand { 51 | 52 | call.sendMessage(input) 53 | .whenComplete { result in 54 | 55 | switch result { 56 | case .success: 57 | 58 | self.subscription?.request(.max(1)) 59 | 60 | case .failure(let error): 61 | 62 | self.complete(error: .from( 63 | error: error, 64 | statusCode: .dataLoss, 65 | message: "Request Stream Send Message Failed" 66 | )) 67 | 68 | self.call.cancel(promise: nil) 69 | } 70 | } 71 | 72 | return .none 73 | } 74 | 75 | func receive(completion: Subscribers.Completion) { 76 | 77 | if case .failure(let error) = completion { 78 | 79 | self.complete(error: .from( 80 | error: error, 81 | statusCode: .aborted, 82 | message: "Request Stream Failed" 83 | )) 84 | 85 | self.call.cancel(promise: nil) 86 | 87 | return 88 | } 89 | 90 | call.sendEnd() 91 | .whenComplete { result in 92 | 93 | if case .failure(let error) = completion { 94 | 95 | self.complete(error: .from( 96 | error: error, 97 | statusCode: .dataLoss, 98 | message: "Request Stream Send End Failed" 99 | )) 100 | } 101 | } 102 | } 103 | 104 | func request(_ demand: Subscribers.Demand) { 105 | _ = buffer.demand(demand) 106 | } 107 | 108 | func cancel() { 109 | 110 | call.cancel(promise: nil) 111 | 112 | subscription?.cancel() 113 | subscription = nil 114 | } 115 | 116 | func complete(error: RPCError? = nil) { 117 | 118 | if let error = error { 119 | 120 | buffer.complete(completion: .failure(error)) 121 | 122 | subscription?.cancel() 123 | 124 | } else { 125 | 126 | buffer.complete(completion: .finished) 127 | } 128 | 129 | self.subscription = nil 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/ClientStreamingTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | @testable import CombineGRPC 8 | 9 | class ClientStreamingTests: XCTestCase { 10 | 11 | static var server: Server? 12 | static var client: ClientStreamingScenariosNIOClient? 13 | static var retainedCancellables: Set = [] 14 | 15 | override class func setUp() { 16 | super.setUp() 17 | server = try! makeTestServer(services: [ClientStreamingTestsService()]) 18 | client = makeTestClient { channel, callOptions in 19 | ClientStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 20 | } 21 | } 22 | 23 | override class func tearDown() { 24 | try! client?.channel.close().wait() 25 | try! server?.close().wait() 26 | retainedCancellables.removeAll() 27 | super.tearDown() 28 | } 29 | 30 | func testOk() { 31 | let promise = expectation(description: "Call completes successfully") 32 | let client = Self.client! 33 | let requests = Publishers.Sequence<[EchoRequest], Error>(sequence: 34 | [EchoRequest.with { $0.message = "hello"}, EchoRequest.with { $0.message = "world!"}] 35 | ).eraseToAnyPublisher() 36 | let grpc = GRPCExecutor() 37 | 38 | grpc.call(client.ok)(requests) 39 | .sink( 40 | receiveCompletion: expectFinished(resolve: promise), 41 | receiveValue: expectValue { $0.message == "world!" } 42 | ) 43 | .store(in: &Self.retainedCancellables) 44 | 45 | wait(for: [promise], timeout: 0.2) 46 | } 47 | 48 | func testFailedPrecondition() { 49 | let promise = expectation(description: "Call fails with failed precondition status") 50 | let failedPrecondition = Self.client!.failedPrecondition 51 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 3) 52 | let requestStream = Publishers.Sequence, Error>(sequence: requests).eraseToAnyPublisher() 53 | let grpc = GRPCExecutor() 54 | 55 | grpc.call(failedPrecondition)(requestStream) 56 | .sink( 57 | receiveCompletion: expectFailure( 58 | { error in 59 | error.status.code == .failedPrecondition && error.trailingMetadata?.first(name: "custom") == "info" 60 | }, 61 | resolve: promise), 62 | receiveValue: expectNoValue() 63 | ) 64 | .store(in: &Self.retainedCancellables) 65 | 66 | wait(for: [promise], timeout: 0.2) 67 | } 68 | 69 | func testNoResponse() { 70 | let promise = expectation(description: "Call fails with deadline exceeded status") 71 | let client = Self.client! 72 | let options = CallOptions(timeLimit: TimeLimit.timeout(.milliseconds(20))) 73 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 3) 74 | let requestStream = Publishers.Sequence, Error>(sequence: requests).eraseToAnyPublisher() 75 | let grpc = GRPCExecutor(callOptions: Just(options).eraseToAnyPublisher()) 76 | 77 | grpc.call(client.noResponse)(requestStream) 78 | .sink( 79 | receiveCompletion: expectRPCError(code: .deadlineExceeded, resolve: promise), 80 | receiveValue: expectNoValue() 81 | ) 82 | .store(in: &Self.retainedCancellables) 83 | 84 | wait(for: [promise], timeout: 0.2) 85 | } 86 | 87 | func testClientStreamError() { 88 | let promise = expectation(description: "Call fails with aborted status") 89 | let client = Self.client! 90 | let grpc = GRPCExecutor() 91 | 92 | struct ClientStreamError: Error {} 93 | let requests = Fail(error: ClientStreamError()).eraseToAnyPublisher() 94 | 95 | grpc.call(client.ok)(requests) 96 | .sink( 97 | receiveCompletion: expectRPCError(code: .aborted, resolve: promise), 98 | receiveValue: expectNoValue() 99 | ) 100 | .store(in: &Self.retainedCancellables) 101 | 102 | wait(for: [promise], timeout: 0.2) 103 | } 104 | 105 | static var allTests = [ 106 | ("Client streaming OK", testOk), 107 | ("Client streaming failed precondition", testFailedPrecondition), 108 | ("Client streaming no response", testNoResponse), 109 | ("Client streaming with client stream error, stream failed", testClientStreamError), 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/BidirectionalStreamingTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | import NIOHPACK 8 | @testable import CombineGRPC 9 | 10 | class BidirectionalStreamingTests: XCTestCase { 11 | 12 | static var server: Server? 13 | static var client: BidirectionalStreamingScenariosNIOClient? 14 | static var retainedCancellables: Set = [] 15 | 16 | override class func setUp() { 17 | super.setUp() 18 | server = try! makeTestServer(services: [BidirectionalStreamingTestsService()]) 19 | client = makeTestClient { channel, callOptions in 20 | BidirectionalStreamingScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 21 | } 22 | } 23 | 24 | override class func tearDown() { 25 | try! client?.channel.close().wait() 26 | try! server?.close().wait() 27 | retainedCancellables.removeAll() 28 | super.tearDown() 29 | } 30 | 31 | func testOk() { 32 | let promise = expectation(description: "Call completes successfully") 33 | let client = Self.client! 34 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 3) 35 | let requestStream = Publishers.Sequence, Error>(sequence: requests).eraseToAnyPublisher() 36 | 37 | GRPCExecutor() 38 | .call(client.ok)(requestStream) 39 | .filter { $0.message == "hello" } 40 | .count() 41 | .sink( 42 | receiveCompletion: expectFinished(resolve: promise), 43 | receiveValue: expectValue { count in count == 3 } 44 | ) 45 | .store(in: &Self.retainedCancellables) 46 | 47 | wait(for: [promise], timeout: 0.2) 48 | } 49 | 50 | func testFailedPrecondition() { 51 | let promise = expectation(description: "Call fails with failed precondition status") 52 | let failedPrecondition = Self.client!.failedPrecondition 53 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 3) 54 | let requestStream = Publishers.Sequence, Error>(sequence: requests).eraseToAnyPublisher() 55 | 56 | GRPCExecutor() 57 | .call(failedPrecondition)(requestStream) 58 | .sink( 59 | receiveCompletion: resolve(promise, expectingFailure: 60 | { error in 61 | error.status.code == .failedPrecondition && error.trailingMetadata?.first(name: "custom") == "info" 62 | }), 63 | receiveValue: { empty in 64 | XCTFail("Call should not return a response") 65 | } 66 | ) 67 | .store(in: &Self.retainedCancellables) 68 | 69 | wait(for: [promise], timeout: 0.2) 70 | } 71 | 72 | func testNoResponse() { 73 | let promise = expectation(description: "Call fails with deadline exceeded status") 74 | let client = Self.client! 75 | let options = CallOptions(timeLimit: TimeLimit.timeout(.milliseconds(20))) 76 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 3) 77 | let requestStream = Publishers.Sequence, Error>(sequence: requests).eraseToAnyPublisher() 78 | 79 | GRPCExecutor(callOptions: Just(options).eraseToAnyPublisher()) 80 | .call(client.noResponse)(requestStream) 81 | .sink( 82 | receiveCompletion: expectRPCError(code: .deadlineExceeded, resolve: promise), 83 | receiveValue: expectNoValue() 84 | ) 85 | .store(in: &Self.retainedCancellables) 86 | 87 | wait(for: [promise], timeout: 0.2) 88 | } 89 | 90 | func testClientStreamError() { 91 | let promise = expectation(description: "Call fails with aborted status") 92 | let client = Self.client! 93 | 94 | struct ClientStreamError: Error {} 95 | let requests = Fail(error: ClientStreamError()).eraseToAnyPublisher() 96 | 97 | GRPCExecutor() 98 | .call(client.ok)(requests) 99 | .sink( 100 | receiveCompletion: expectRPCError(code: .aborted, resolve: promise), 101 | receiveValue: expectNoValue() 102 | ) 103 | .store(in: &Self.retainedCancellables) 104 | 105 | wait(for: [promise], timeout: 0.2) 106 | } 107 | 108 | static var allTests = [ 109 | ("Bidirectional streaming OK", testOk), 110 | ("Bidirectional streaming failed precondition", testFailedPrecondition), 111 | ("Bidirectional streaming no response", testNoResponse), 112 | ("Bidirectional streaming with client stream error, stream failed", testClientStreamError), 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Server/Handlers.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Combine 5 | import GRPC 6 | import NIO 7 | import SwiftProtobuf 8 | 9 | // MARK: Unary 10 | 11 | /** 12 | Handle a unary gRPC call on the server side. 13 | 14 | - Parameters: 15 | - context: The gRPC call context. 16 | - handler: A function that returns a publisher that either publishes a `Response` or fails with an `RPCError`. 17 | */ 18 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 19 | public func handle 20 | (_ context: StatusOnlyCallContext, handler: () -> AnyPublisher) 21 | -> EventLoopFuture 22 | { 23 | let unarySubscriber = UnaryHandlerSubscriber(context: context) 24 | handler().subscribe(unarySubscriber) 25 | return unarySubscriber.futureResult 26 | } 27 | 28 | /** 29 | Handle a unary gRPC call on the server side. 30 | 31 | - Parameters: 32 | - context: The gRPC call context. 33 | - handler: A function that takes a `Request` and returns a publisher that either publishes a `Response` or fails with an `RPCError`. 34 | */ 35 | @available(OSX 10.15, iOS 13, tvOS 13, *) 36 | public func handle 37 | (_ request: Request, _ context: StatusOnlyCallContext, handler: (Request) -> AnyPublisher) 38 | -> EventLoopFuture 39 | { 40 | let unarySubscriber = UnaryHandlerSubscriber(context: context) 41 | handler(request).subscribe(unarySubscriber) 42 | return unarySubscriber.futureResult 43 | } 44 | 45 | // MARK: Server Streaming 46 | 47 | /** 48 | Handle a server streaming gRPC call on the server side. 49 | 50 | - Parameters: 51 | - context: The gRPC call context. 52 | - handler: A function that returns a publisher that publishes a stream of `Response`s. The publisher may fail with an `RPCError` error. 53 | */ 54 | @available(OSX 10.15, iOS 13, tvOS 13, *) 55 | public func handle 56 | (_ context: StreamingResponseCallContext, handler: () -> AnyPublisher) 57 | -> EventLoopFuture 58 | { 59 | let serverStreamingSubscriber = ServerStreamingHandlerSubscriber(context: context) 60 | handler().subscribe(serverStreamingSubscriber) 61 | return serverStreamingSubscriber.futureStatus 62 | } 63 | 64 | /** 65 | Handle a server streaming gRPC call on the server side. 66 | 67 | - Parameters: 68 | - context: The gRPC call context. 69 | - handler: A function that takes a `Request` and returns a publisher that publishes a stream of `Response`s. 70 | The publisher may fail with an `RPCError` error. 71 | */ 72 | @available(OSX 10.15, iOS 13, tvOS 13, *) 73 | public func handle 74 | (_ request: Request, _ context: StreamingResponseCallContext, handler: (Request) 75 | -> AnyPublisher) 76 | -> EventLoopFuture 77 | { 78 | let serverStreamingSubscriber = ServerStreamingHandlerSubscriber(context: context) 79 | handler(request).subscribe(serverStreamingSubscriber) 80 | return serverStreamingSubscriber.futureStatus 81 | } 82 | 83 | // MARK: Client Streaming 84 | 85 | /** 86 | Handle a client streaming gRPC call on the server side. 87 | 88 | - Parameters: 89 | - context: The gRPC call context. 90 | - handler: A function that takes a stream of `Request`s and returns a publisher that publishes a `Response` or fails with an `RPCError` error. 91 | */ 92 | @available(OSX 10.15, iOS 13, tvOS 13, *) 93 | public func handle 94 | (_ context: UnaryResponseCallContext, handler: (AnyPublisher) 95 | -> AnyPublisher) 96 | -> EventLoopFuture<(StreamEvent) -> Void> 97 | { 98 | let requests = PassthroughSubject() 99 | let clientStreamingSubscriber = ClientStreamingHandlerSubscriber(context: context) 100 | handler(requests.eraseToAnyPublisher()).subscribe(clientStreamingSubscriber) 101 | 102 | return context.eventLoop.makeSucceededFuture({ switch $0 { 103 | case .message(let request): 104 | requests.send(request) 105 | case .end: 106 | requests.send(completion: .finished) 107 | }}) 108 | } 109 | 110 | // MARK: Bidirectional Streaming 111 | 112 | /** 113 | Handle a bidirectional streaming gRPC call on the server side. 114 | 115 | - Parameters: 116 | - context: The gRPC call context. 117 | - handler: A function that takes a stream of `Request`s and returns a publisher that publishes a stream of `Response`s. 118 | The response publisher may fail with an `RPCError` error. 119 | */ 120 | @available(OSX 10.15, iOS 13, tvOS 13, *) 121 | public func handle 122 | (_ context: StreamingResponseCallContext, handler: (AnyPublisher) 123 | -> AnyPublisher) 124 | -> EventLoopFuture<(StreamEvent) -> Void> 125 | { 126 | let requests = PassthroughSubject() 127 | let bidirectionalStreamingSubscriber = BidirectionalStreamingHandlerSubscriber(context: context) 128 | handler(requests.eraseToAnyPublisher()).subscribe(bidirectionalStreamingSubscriber) 129 | 130 | return context.eventLoop.makeSucceededFuture({ switch $0 { 131 | case .message(let request): 132 | requests.send(request) 133 | case .end: 134 | requests.send(completion: .finished) 135 | }}) 136 | } 137 | -------------------------------------------------------------------------------- /Tests/CombineGRPCTests/RetryPolicyTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import XCTest 5 | import Combine 6 | import GRPC 7 | import NIOHPACK 8 | @testable import CombineGRPC 9 | 10 | final class RetryPolicyTests: XCTestCase { 11 | 12 | static var server: Server? 13 | static var client: RetryScenariosNIOClient? 14 | static var retainedCancellables: Set = [] 15 | 16 | override class func setUp() { 17 | super.setUp() 18 | server = try! makeTestServer(services: [RetryPolicyTestsService()]) 19 | client = makeTestClient { channel, callOptions in 20 | RetryScenariosNIOClient(channel: channel, defaultCallOptions: callOptions) 21 | } 22 | } 23 | 24 | override class func tearDown() { 25 | try! client?.channel.close().wait() 26 | try! server?.close().wait() 27 | retainedCancellables.removeAll() 28 | super.tearDown() 29 | } 30 | 31 | func testRetriesNotExceeded() { 32 | let promise = expectation(description: "Call completes successfully after retrying twice") 33 | let client = Self.client! 34 | 35 | let grpc = GRPCExecutor(retry: .failedCall( 36 | upTo: 2, 37 | when: { $0.status.code == .failedPrecondition }, 38 | didGiveUp: { XCTFail("onGiveUp callback should not trigger") } 39 | )) 40 | 41 | let request = FailThenSucceedRequest.with { 42 | $0.key = "testRetriesNotExceeded" 43 | $0.numFailures = 2 44 | } 45 | 46 | grpc.call(client.failThenSucceed)(request) 47 | .sink( 48 | receiveCompletion: expectFinished(resolve: promise), 49 | receiveValue: expectValue { response in 50 | Int(response.numFailures) == 2 51 | } 52 | ) 53 | .store(in: &Self.retainedCancellables) 54 | 55 | wait(for: [promise], timeout: 0.2) 56 | } 57 | 58 | func testRetriesExceededGaveUp() { 59 | let callPromise = expectation(description: "Call fails after exceeding max number of retries") 60 | let onGiveUpPromise = expectation(description: "On give up callback called") 61 | 62 | let client = Self.client! 63 | let grpc = GRPCExecutor(retry: 64 | .failedCall(upTo: 2, when: { $0.status.code == .failedPrecondition }, didGiveUp: { onGiveUpPromise.fulfill() }) 65 | ) 66 | 67 | let request = FailThenSucceedRequest.with { 68 | $0.key = "testRetriesExceededGaveUp" 69 | $0.numFailures = 3 70 | } 71 | 72 | grpc.call(client.failThenSucceed)(request) 73 | .sink( 74 | receiveCompletion: expectRPCError(code: .failedPrecondition, resolve: callPromise), 75 | receiveValue: expectNoValue() 76 | ) 77 | .store(in: &Self.retainedCancellables) 78 | 79 | wait(for: [callPromise, onGiveUpPromise], timeout: 0.2) 80 | } 81 | 82 | func testDelayUntilNextParameters() { 83 | let promise = expectation(description: "Call fails twice, then succeeds") 84 | let client = Self.client! 85 | var delayUntilNextFinalRetryCount = 0 86 | 87 | let grpc = GRPCExecutor(retry: .failedCall( 88 | upTo: 99, 89 | when: { $0.status.code == .failedPrecondition }, 90 | delayUntilNext: { count, error in 91 | XCTAssert(error.status.code == .failedPrecondition) 92 | delayUntilNextFinalRetryCount = count 93 | return Just(()).eraseToAnyPublisher() 94 | } 95 | )) 96 | 97 | let request = FailThenSucceedRequest.with { 98 | $0.key = "testDelayUntilNextParameters" 99 | $0.numFailures = 2 100 | } 101 | 102 | grpc.call(client.failThenSucceed)(request) 103 | .sink( 104 | receiveCompletion: expectFinished(resolve: promise, onFinished: { 105 | XCTAssert(delayUntilNextFinalRetryCount == 2) 106 | }), 107 | receiveValue: { _ in } 108 | ) 109 | .store(in: &Self.retainedCancellables) 110 | 111 | wait(for: [promise], timeout: 0.2) 112 | } 113 | 114 | func testRetryStatusDoesNotMatch() { 115 | let promise = expectation(description: "Call fails when retry status does not match") 116 | let client = Self.client! 117 | let grpc = GRPCExecutor(retry: .failedCall(upTo: 2, when: { $0.status.code == .notFound })) 118 | 119 | let request = FailThenSucceedRequest.with { 120 | $0.key = "testRetryStatusDoesNotMatch" 121 | $0.numFailures = 1 122 | } 123 | 124 | grpc.call(client.failThenSucceed)(request) 125 | .sink( 126 | receiveCompletion: expectRPCError(code: .failedPrecondition, resolve: promise), 127 | receiveValue: expectNoValue() 128 | ) 129 | .store(in: &Self.retainedCancellables) 130 | 131 | wait(for: [promise], timeout: 0.2) 132 | } 133 | 134 | func testAuthenticatedRpcScenario() { 135 | let promise = expectation(description: "Call gets retried with authentication and succeeds") 136 | let client = Self.client! 137 | // The first call is unauthenticated 138 | let callOptions = CurrentValueSubject(CallOptions()) 139 | 140 | let grpc = GRPCExecutor( 141 | callOptions: callOptions.eraseToAnyPublisher(), 142 | retry: .failedCall(upTo: 1, when: { $0.status.code == .unauthenticated }, delayUntilNext: { retryCount, error in 143 | XCTAssert(retryCount <= 1) 144 | XCTAssert(error.status.code == .unauthenticated) 145 | // Subsequent calls are authenticated 146 | callOptions.send(CallOptions(customMetadata: HPACKHeaders([("authorization", "Bearer xxx")]))) 147 | return Just(()).eraseToAnyPublisher() 148 | }) 149 | ) 150 | 151 | grpc.call(client.authenticatedRpc)(EchoRequest.with { $0.message = "hello" }) 152 | .sink( 153 | receiveCompletion: expectFinished(resolve: promise), 154 | receiveValue: expectValue { $0.message == "hello" } 155 | ) 156 | .store(in: &Self.retainedCancellables) 157 | 158 | wait(for: [promise], timeout: 0.2) 159 | } 160 | 161 | static var allTests = [ 162 | ("Number of retries not exceeded", testRetriesNotExceeded), 163 | ("Number of retries exceeded, gave up", testRetriesExceededGaveUp), 164 | ("delayUntilNext is called with expected parameters", testDelayUntilNextParameters), 165 | ("Retry status does not match", testRetryStatusDoesNotMatch), 166 | ("Authenticated RPC scenario", testAuthenticatedRpcScenario), 167 | ] 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineGRPC 2 | 3 | CombineGRPC is a library that provides [Combine framework](https://developer.apple.com/documentation/combine) integration for [Swift gRPC](https://github.com/grpc/grpc-swift). 4 | 5 | CombineGRPC provides two flavours of functionality, `call` and `handle`. Use `call` to make gRPC calls on the client side, and `handle` to handle incoming requests on the server side. The library provides versions of `call` and `handle` for all RPC styles. Here are the input and output types for each. 6 | 7 | RPC Style | Input and Output Types 8 | --- | --- 9 | Unary | `Request -> AnyPublisher` 10 | Server streaming | `Request -> AnyPublisher` 11 | Client streaming | `AnyPublisher -> AnyPublisher` 12 | Bidirectional streaming | `AnyPublisher -> AnyPublisher` 13 | 14 | When you make a unary call, you provide a request message, and get back a response publisher. The response publisher will either publish a single response, or fail with an `RPCError` error. Similarly, if you are handling a unary RPC call, you provide a handler that takes a request parameter and returns an `AnyPublisher`. 15 | 16 | You can follow the same intuition to understand the types for the other RPC styles. The only difference is that publishers for the streaming RPCs may publish zero or more messages instead of the single response message that is expected from the unary response publisher. 17 | 18 | ## Quick Tour 19 | 20 | Let's see a quick example. Consider the following protobuf definition for a simple echo service. The service defines one bidirectional RPC. You send it a stream of messages and it echoes the messages back to you. 21 | 22 | ```protobuf 23 | syntax = "proto3"; 24 | 25 | service EchoService { 26 | rpc SayItBack (stream EchoRequest) returns (stream EchoResponse); 27 | } 28 | 29 | message EchoRequest { 30 | string message = 1; 31 | } 32 | 33 | message EchoResponse { 34 | string message = 1; 35 | } 36 | ``` 37 | 38 | ### Server Side 39 | 40 | To implement the server, you provide a handler function that takes an input stream `AnyPublisher` and returns an output stream `AnyPublisher`. 41 | 42 | ```swift 43 | import Foundation 44 | import Combine 45 | import CombineGRPC 46 | import GRPC 47 | import NIO 48 | 49 | class EchoServiceProvider: EchoProvider { 50 | 51 | // Simple bidirectional RPC that echoes back each request message 52 | func sayItBack(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { 53 | CombineGRPC.handle(context) { requests in 54 | requests 55 | .map { req in 56 | EchoResponse.with { $0.message = req.message } 57 | } 58 | .setFailureType(to: RPCError.self) 59 | .eraseToAnyPublisher() 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | Start the server. This is the same process as with Swift gRPC. 66 | 67 | ```swift 68 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 69 | defer { 70 | try! eventLoopGroup.syncShutdownGracefully() 71 | } 72 | 73 | // Start the gRPC server and wait until it shuts down. 74 | _ = try Server 75 | .insecure(group: eventLoopGroup) 76 | .withServiceProviders([EchoServiceProvider()]) 77 | .bind(host: "localhost", port: 8080) 78 | .flatMap { $0.onClose } 79 | .wait() 80 | ``` 81 | 82 | ### Client Side 83 | 84 | Now let's setup our client. Again, it's the same process that you would go through when using Swift gRPC. 85 | 86 | ```swift 87 | let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 88 | let channel = ClientConnection 89 | .insecure(group: eventLoopGroup) 90 | .connect(host: "localhost", port: 8080) 91 | let echoClient = EchoServiceNIOClient(channel: channel) 92 | ``` 93 | 94 | To call the service, create a `GRPCExecutor` and use its `call` method. You provide it with a stream of requests `AnyPublisher` and you get back a stream `AnyPublisher` of responses from the server. 95 | 96 | ```swift 97 | let requests = repeatElement(EchoRequest.with { $0.message = "hello"}, count: 10) 98 | let requestStream: AnyPublisher = 99 | Publishers.Sequence(sequence: requests).eraseToAnyPublisher() 100 | let grpc = GRPCExecutor() 101 | 102 | grpc.call(echoClient.sayItBack)(requestStream) 103 | .filter { $0.message == "hello" } 104 | .count() 105 | .sink(receiveValue: { count in 106 | assert(count == 10) 107 | }) 108 | ``` 109 | 110 | That's it! You have set up bidirectional streaming between a server and client. The method `sayItBack` of `EchoServiceNIOClient` is generated by Swift gRPC. Notice that call is curried. You can preselect RPC calls using partial application: 111 | 112 | ```swift 113 | let sayItBack = grpc.call(echoClient.sayItBack) 114 | 115 | sayItBack(requestStream).map { response in 116 | // ... 117 | } 118 | ``` 119 | 120 | ### Configuring RPC Calls 121 | 122 | The `GRPCExecutor` allows you to configure `CallOptions` for your RPC calls. You can provide the `GRPCExecutor`'s initializer with a stream `AnyPublisher`, and the latest `CallOptions` value will be used when making calls. 123 | 124 | ```swift 125 | let timeoutOptions = CallOptions(timeout: try! .seconds(5)) 126 | let grpc = GRPCExecutor(callOptions: Just(timeoutOptions).eraseToAnyPublisher()) 127 | ``` 128 | 129 | ### Retry Policy 130 | 131 | You can also configure `GRPCExecutor` to automatically retry failed calls by specifying a `RetryPolicy`. In the following example, we retry calls that fail with status `.unauthenticated`. We use `CallOptions` to add a Bearer token to the authorization header, and then retry the call. 132 | 133 | ```swift 134 | // Default CallOptions with no authentication 135 | let callOptions = CurrentValueSubject(CallOptions()) 136 | 137 | let grpc = GRPCExecutor( 138 | callOptions: callOptions.eraseToAnyPublisher(), 139 | retry: .failedCall( 140 | upTo: 1, 141 | when: { error in 142 | error.status.code == .unauthenticated 143 | }, 144 | delayUntilNext: { retryCount, error in // Useful for implementing exponential backoff 145 | // Retry the call with authentication 146 | callOptions.send(CallOptions(customMetadata: HTTPHeaders([("authorization", "Bearer xxx")]))) 147 | return Just(()).eraseToAnyPublisher() 148 | }, 149 | didGiveUp: { 150 | print("Authenticated call failed.") 151 | } 152 | ) 153 | ) 154 | 155 | grpc.call(client.authenticatedRpc)(request) 156 | .map { response in 157 | // ... 158 | } 159 | ``` 160 | 161 | You can imagine doing something along those lines to seamlessly retry calls when an ID token expires. The back-end service replies with status `.unauthenticated`, you obtain a new ID token using your refresh token, and the call is retried. 162 | 163 | ### More Examples 164 | 165 | Check out the [CombineGRPC tests](Tests/CombineGRPCTests) for examples of all the different RPC calls and handler implementations. You can find the matching protobuf [here](Tests/Protobuf/test_scenarios.proto). 166 | 167 | ## Logistics 168 | 169 | ### Generating Swift Code from Protobuf 170 | 171 | To generate Swift code from your .proto files, you'll need to first install the [protoc](https://github.com/protocolbuffers/protobuf) Protocol Buffer compiler. 172 | 173 | ```text 174 | brew install protobuf swift-protobuf grpc-swift 175 | ``` 176 | 177 | Now you are ready to generate Swift code from protobuf interface definition files. 178 | 179 | Let's generate the message types, gRPC server and gRPC client for Swift. 180 | 181 | ```text 182 | protoc example_service.proto --swift_out=Generated/ 183 | protoc example_service.proto --grpc-swift_out=Generated/ 184 | ``` 185 | 186 | You'll see that protoc has created two source files for us. 187 | 188 | ```text 189 | ls Generated/ 190 | example_service.grpc.swift 191 | example_service.pb.swift 192 | ``` 193 | 194 | ### Adding CombineGRPC to Your Project 195 | 196 | You can easily add CombineGRPC to your project using Swift Package Manager. To add the package dependency to your `Package.swift`: 197 | 198 | ```swift 199 | dependencies: [ 200 | .package(url: "https://github.com/vyshane/grpc-swift-combine.git", from: "1.1.0"), 201 | ], 202 | ``` 203 | 204 | ## Compatibility 205 | 206 | Since this library integrates with Combine, it only works on platforms that support Combine. This currently means the following minimum versions: 207 | 208 | Platform | Minimum Supported Version 209 | --- | --- 210 | macOS | 10.15 (Catalina) 211 | iOS & iPadOS | 13 212 | tvOS | 13 213 | watchOS | 6 214 | 215 | ## Feature Status 216 | 217 | RPC Client Calls 218 | 219 | - [x] Unary 220 | - [x] Client streaming 221 | - [x] Server streaming 222 | - [x] Bidirectional streaming 223 | - [x] Retry policy for automatic client call retries 224 | 225 | Server Side Handlers 226 | 227 | - [x] Unary 228 | - [x] Client streaming 229 | - [x] Server streaming 230 | - [x] Bidirectional streaming 231 | 232 | End-to-end Tests 233 | 234 | - [x] Unary 235 | - [x] Client streaming 236 | - [x] Server streaming 237 | - [x] Bidirectional streaming 238 | 239 | ## Contributing 240 | 241 | Generate Swift source for the protobuf that is used in tests: 242 | 243 | ```text 244 | make protobuf 245 | ``` 246 | 247 | You can then open `Package.swift` in Xcode, build and run the tests. 248 | -------------------------------------------------------------------------------- /Sources/CombineGRPC/Client/GRPCExecutor.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2019, ComgineGRPC 2 | // Licensed under the Apache License, Version 2.0 3 | 4 | import Combine 5 | import GRPC 6 | import NIOHPACK 7 | import SwiftProtobuf 8 | 9 | /** 10 | A unary RPC client method generated by Swift gRPC. 11 | */ 12 | public typealias UnaryRPC = 13 | (Request, CallOptions?) -> UnaryCall 14 | where Request: Message, Response: Message 15 | 16 | /** 17 | A server streaming RPC client method generated by Swift gRPC. 18 | */ 19 | public typealias ServerStreamingRPC = 20 | (Request, CallOptions?, @escaping (Response) -> Void) -> ServerStreamingCall 21 | where Request: Message, Response: Message 22 | 23 | /** 24 | A client streaming RPC client method generated by Swift gRPC. 25 | */ 26 | public typealias ClientStreamingRPC = 27 | (CallOptions?) -> ClientStreamingCall 28 | where Request: Message, Response: Message 29 | 30 | /** 31 | A bidirectional streaming RPC client method generated by Swift gRPC. 32 | */ 33 | public typealias BidirectionalStreamingRPC = 34 | (CallOptions?, @escaping (Response) -> Void) -> BidirectionalStreamingCall 35 | where Request: Message, Response: Message 36 | 37 | /** 38 | Executes gRPC calls. 39 | 40 | Can be configured with `CallOptions` to use when making RPC calls, as well as a `RetryPolicy` for automatic retries of failed RPC calls. 41 | */ 42 | @available(OSX 10.15, iOS 13, tvOS 13, watchOS 6, *) 43 | public struct GRPCExecutor { 44 | 45 | private let retryPolicy: RetryPolicy 46 | private let callOptions: CurrentValueSubject 47 | private var retainedCancellables: Set = [] 48 | 49 | /** 50 | Initialize `GRPCExecutor` with a stream of `CallOptions` and a `RetryPolicy`. 51 | 52 | - Parameters: 53 | - callOptions: A publisher of `CallOptions`. The latest `CallOptions` received will be used when making a gRPC call. 54 | Defaults to a stream with just one default `CallOptions()` value. 55 | - retry: `RetryPolicy` to use if a gRPC call fails. Defaults to `RetryPolicy.never`. 56 | */ 57 | public init(callOptions: AnyPublisher = Just(CallOptions()).eraseToAnyPublisher(), 58 | retry: RetryPolicy = .never) { 59 | self.retryPolicy = retry 60 | 61 | let subject = CurrentValueSubject(CallOptions()) 62 | callOptions.sink(receiveValue: { subject.send($0) }).store(in: &retainedCancellables) 63 | self.callOptions = subject 64 | } 65 | 66 | // MARK:- Unary 67 | 68 | /** 69 | Make a unary gRPC call. 70 | 71 | - Parameters: 72 | - rpc: The unary RPC to call, a method that is generated by Swift gRPC. 73 | 74 | - Returns: A function that takes a request and returns a publisher that will either publish one `Response` or fail with an `RPCError` error. 75 | 76 | In the following example the `sayHello` client method is generated by Swift gRPC. 77 | 78 | ``` 79 | let grpc = GRPCExecutor() 80 | let helloResponse = grpc.call(client.sayHello)(HelloRequest()) 81 | ``` 82 | 83 | `call` is curried. You can preconfigure a unary call: 84 | 85 | ``` 86 | let grpc = GRPCExecutor() 87 | let sayHello = grpc.call(client.sayHello) 88 | let helloResponse = sayHello(HelloRequest()) 89 | ``` 90 | */ 91 | public func call(_ rpc: @escaping UnaryRPC) 92 | -> (Request) 93 | -> AnyPublisher 94 | where Request: Message, Response: Message 95 | { 96 | { request in 97 | self.executeWithRetry(policy: self.retryPolicy) { currentCallOptions in 98 | currentCallOptions 99 | .flatMap { callOptions in 100 | Future { promise in 101 | let call = rpc(request, callOptions) 102 | call.response.whenSuccess { promise(.success($0)) } 103 | 104 | // Trailing metadata will be available before status. 105 | var trailingMetadata: HPACKHeaders? 106 | call.trailingMetadata.whenSuccess { trailingMetadata = $0 } 107 | 108 | // The status EventLoopFuture succeeds when RPC fails. 109 | call.status.whenSuccess { 110 | promise(.failure( 111 | RPCError(status: $0, trailingMetadata: trailingMetadata) 112 | )) 113 | } 114 | } 115 | } 116 | .eraseToAnyPublisher() 117 | } 118 | } 119 | } 120 | 121 | // MARK: Server Streaming 122 | 123 | /** 124 | Make a server streaming gRPC call. 125 | 126 | - Parameters: 127 | - rpc: The server streaming RPC to call, a method that is generated by Swift gRPC. 128 | 129 | - Returns: A function that takes a request and returns a publisher that publishes a stream of `Response`s. 130 | The response publisher may fail with an `RPCError` error. 131 | 132 | `call` is curried. 133 | 134 | Example: 135 | 136 | ``` 137 | let grpc = GRPCExecutor() 138 | let responses: AnyPublisher = grpc.call(client.listPosts)(listPostsRequest) 139 | ``` 140 | */ 141 | public func call(_ rpc: @escaping ServerStreamingRPC) 142 | -> (Request) 143 | -> AnyPublisher 144 | where Request: Message, Response: Message 145 | { 146 | { request in 147 | self.executeWithRetry(policy: self.retryPolicy) { currentCallOptions in 148 | currentCallOptions 149 | .flatMap { callOptions in 150 | ServerStreamingPublisher(rpc: rpc, callOptions: callOptions, request: request) 151 | } 152 | .eraseToAnyPublisher() 153 | } 154 | } 155 | } 156 | 157 | // MARK: Client Streaming 158 | 159 | /** 160 | Make a client streaming gRPC call. 161 | 162 | - Parameters: 163 | - rpc: The client streaming RPC to call, a method that is generated by Swift gRPC. 164 | 165 | - Returns: A function that takes a stream of requests and returns a publisher that publishes either a `Response` 166 | or fails with an `RPCError` error. 167 | 168 | `call` is curried. 169 | */ 170 | public func call(_ rpc: @escaping ClientStreamingRPC) 171 | -> (AnyPublisher) 172 | -> AnyPublisher 173 | where Request: Message, Response: Message 174 | { 175 | { requests in 176 | self.executeWithRetry(policy: self.retryPolicy) { currentCallOptions in 177 | currentCallOptions 178 | .flatMap { callOptions in 179 | ClientStreamingPublisher(rpc: rpc, callOptions: callOptions, requests: requests) 180 | } 181 | .eraseToAnyPublisher() 182 | } 183 | } 184 | } 185 | 186 | // MARK: Bidirectional Streaming 187 | 188 | /** 189 | Make a bidirectional streaming gRPC call. 190 | 191 | - Parameters: 192 | - rpc: The bidirectional streaming RPC to call, a method that is generated by Swift gRPC. 193 | 194 | - Returns: A function that takes a stream of requests and returns a publisher that publishes a stream of `Response`s. 195 | The response publisher may fail with an `RPCError` error. 196 | 197 | `call` is curried. 198 | */ 199 | public func call(_ rpc: @escaping BidirectionalStreamingRPC) 200 | -> (AnyPublisher) 201 | -> AnyPublisher 202 | where Request: Message, Response: Message 203 | { 204 | { requests in 205 | self.executeWithRetry(policy: self.retryPolicy) { currentCallOptions in 206 | currentCallOptions 207 | .flatMap { callOptions in 208 | BidirectionalStreamingPublisher(rpc: rpc, callOptions: callOptions, requests: requests) 209 | } 210 | .eraseToAnyPublisher() 211 | } 212 | } 213 | } 214 | 215 | // MARK: - 216 | 217 | private func executeWithRetry( 218 | policy: RetryPolicy, 219 | _ call: @escaping (AnyPublisher) -> AnyPublisher 220 | ) -> AnyPublisher { 221 | switch policy { 222 | case .never: 223 | return call(currentCallOptions()) 224 | 225 | case .failedCall(let maxRetries, let shouldRetry, let delayUntilNext, let didGiveUp): 226 | precondition(maxRetries >= 1, "RetryPolicy.failedCall upTo parameter should be at least 1") 227 | var retryCount = 0 228 | 229 | func attemptCall() -> AnyPublisher { 230 | call(currentCallOptions()) 231 | .catch { error -> AnyPublisher in 232 | if shouldRetry(error) && retryCount < maxRetries { 233 | retryCount += 1 234 | return delayUntilNext(retryCount, error) 235 | .setFailureType(to: RPCError.self) 236 | .flatMap { _ in attemptCall() } 237 | .eraseToAnyPublisher() 238 | } 239 | if shouldRetry(error) && retryCount == maxRetries { 240 | didGiveUp() 241 | } 242 | return Fail(error: error).eraseToAnyPublisher() 243 | } 244 | .map { response in 245 | // Reset retry count if we successfully receive a value. 246 | retryCount = 0 247 | return response 248 | } 249 | .eraseToAnyPublisher() 250 | } 251 | 252 | return attemptCall() 253 | } 254 | } 255 | 256 | private func currentCallOptions() -> AnyPublisher { 257 | self.callOptions 258 | .output(at: 0) 259 | .setFailureType(to: RPCError.self) 260 | .eraseToAnyPublisher() 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------