From 056f8b57b54de101f9ffe394d0fdfb345973d6ac Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 14 Jan 2019 15:52:57 +0000 Subject: [PATCH 01/30] First pass implementation of NIO client. --- Package.swift | 3 + Sources/Examples/EchoNIO/EchoClient.swift | 44 +++++ .../Examples/EchoNIO/EchoProviderNIO.swift | 71 ++++++++ .../Examples/EchoNIO/Generated/echo.pb.swift | 1 + .../EchoNIO/Generated/echo_nio.grpc.swift | 1 + Sources/Examples/EchoNIO/main.swift | 155 ++++++++++++++++++ .../ClientCalls/BaseClientCall.swift | 126 ++++++++++++++ .../BidirectionalStreamingClientCall.swift | 30 ++++ .../ClientStreamingClientCall.swift | 34 ++++ .../ServerStreamingClientCall.swift | 32 ++++ .../ClientCalls/UnaryClientCall.swift | 36 ++++ Sources/SwiftGRPCNIO/GRPCClient.swift | 80 +++++++++ .../GRPCClientResponseChannelHandler.swift | 72 ++++++++ Sources/SwiftGRPCNIO/GRPCStatus.swift | 4 +- .../HTTP1ToRawGRPCClientCodec.swift | 154 +++++++++++++++++ .../HTTP1ToRawGRPCServerCodec.swift | 6 +- Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift | 90 ++++++++++ .../UnaryResponseCallContext.swift | 2 +- 18 files changed, 936 insertions(+), 5 deletions(-) create mode 100644 Sources/Examples/EchoNIO/EchoClient.swift create mode 100644 Sources/Examples/EchoNIO/EchoProviderNIO.swift create mode 120000 Sources/Examples/EchoNIO/Generated/echo.pb.swift create mode 120000 Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift create mode 100644 Sources/Examples/EchoNIO/main.swift create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCClient.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift create mode 100644 Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift create mode 100644 Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift diff --git a/Package.swift b/Package.swift index 4ac744378..561c4b1f0 100644 --- a/Package.swift +++ b/Package.swift @@ -78,6 +78,9 @@ let package = Package( .target(name: "Simple", dependencies: ["SwiftGRPC", "Commander"], path: "Sources/Examples/Simple"), + .target(name: "EchoNIO", + dependencies: ["SwiftGRPCNIO", "SwiftProtobuf", "Commander"], + path: "Sources/Examples/EchoNIO"), .testTarget(name: "SwiftGRPCTests", dependencies: ["SwiftGRPC"]), .testTarget(name: "SwiftGRPCNIOTests", dependencies: ["SwiftGRPC", "SwiftGRPCNIO"]) ], diff --git a/Sources/Examples/EchoNIO/EchoClient.swift b/Sources/Examples/EchoNIO/EchoClient.swift new file mode 100644 index 000000000..e67f4a8c4 --- /dev/null +++ b/Sources/Examples/EchoNIO/EchoClient.swift @@ -0,0 +1,44 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftGRPCNIO +import NIO + + +class EchoClient: GRPCClientWrapper { + let client: GRPCClient + let service = "echo.Echo" + + init(client: GRPCClient) { + self.client = client + } + + func get(request: Echo_EchoRequest) -> UnaryClientCall { + return UnaryClientCall(client: client, path: path(for: "Get"), request: request) + } + + func expand(request: Echo_EchoRequest, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { + return ServerStreamingClientCall(client: client, path: path(for: "Expand"), request: request, handler: handler) + } + + func collect() -> ClientStreamingClientCall { + return ClientStreamingClientCall(client: client, path: path(for: "Collect")) + } + + func update(handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { + return BidirectionalStreamingClientCall(client: client, path: path(for: "Update"), handler: handler) + } +} diff --git a/Sources/Examples/EchoNIO/EchoProviderNIO.swift b/Sources/Examples/EchoNIO/EchoProviderNIO.swift new file mode 100644 index 000000000..846372fc8 --- /dev/null +++ b/Sources/Examples/EchoNIO/EchoProviderNIO.swift @@ -0,0 +1,71 @@ +/* + * Copyright 2018, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import SwiftGRPCNIO + +class EchoProviderNIO: Echo_EchoProvider_NIO { + func get(request: Echo_EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture { + var response = Echo_EchoResponse() + response.text = "Swift echo get: " + request.text + return context.eventLoop.newSucceededFuture(result: response) + } + + func expand(request: Echo_EchoRequest, context: StreamingResponseCallContext) -> EventLoopFuture { + var endOfSendOperationQueue = context.eventLoop.newSucceededFuture(result: ()) + let parts = request.text.components(separatedBy: " ") + for (i, part) in parts.enumerated() { + var response = Echo_EchoResponse() + response.text = "Swift echo expand (\(i)): \(part)" + endOfSendOperationQueue = endOfSendOperationQueue.then { context.sendResponse(response) } + } + return endOfSendOperationQueue.map { GRPCStatus.ok } + } + + func collect(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { + var parts: [String] = [] + return context.eventLoop.newSucceededFuture(result: { event in + switch event { + case .message(let message): + parts.append(message.text) + + case .end: + var response = Echo_EchoResponse() + response.text = "Swift echo collect: " + parts.joined(separator: " ") + context.responsePromise.succeed(result: response) + } + }) + } + + func update(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { + var endOfSendOperationQueue = context.eventLoop.newSucceededFuture(result: ()) + var count = 0 + return context.eventLoop.newSucceededFuture(result: { event in + switch event { + case .message(let message): + var response = Echo_EchoResponse() + response.text = "Swift echo update (\(count)): \(message.text)" + endOfSendOperationQueue = endOfSendOperationQueue.then { context.sendResponse(response) } + count += 1 + + case .end: + endOfSendOperationQueue + .map { GRPCStatus.ok } + .cascade(promise: context.statusPromise) + } + }) + } +} diff --git a/Sources/Examples/EchoNIO/Generated/echo.pb.swift b/Sources/Examples/EchoNIO/Generated/echo.pb.swift new file mode 120000 index 000000000..c95f2daee --- /dev/null +++ b/Sources/Examples/EchoNIO/Generated/echo.pb.swift @@ -0,0 +1 @@ +../../../../Tests/SwiftGRPCNIOTests/echo.pb.swift \ No newline at end of file diff --git a/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift b/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift new file mode 120000 index 000000000..b6bf95ab4 --- /dev/null +++ b/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift @@ -0,0 +1 @@ +../../../../Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift \ No newline at end of file diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift new file mode 100644 index 000000000..a23957923 --- /dev/null +++ b/Sources/Examples/EchoNIO/main.swift @@ -0,0 +1,155 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Commander +import Dispatch +import Foundation +import NIO +import SwiftGRPCNIO + +// Common flags and options +func addressOption(_ address: String) -> Option { + return Option("address", default: address, description: "address of server") +} + +let portOption = Option("port", default: 8080) +let messageOption = Option("message", + default: "Testing 1 2 3", + description: "message to send") + +func makeEchoClient(address: String, port: Int) throws -> EchoClient { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + return try GRPCClient.start(host: address, port: port, eventLoopGroup: eventLoopGroup) + .map { client in EchoClient(client: client) } + .wait() +} + +Group { + $0.command( + "serve", + addressOption("0.0.0.0"), + portOption, + description: "Run an echo server." + ) { address, port in + let sem = DispatchSemaphore(value: 0) + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + + print("starting insecure server") + _ = try! GRPCServer.start(hostname: address, + port: port, + eventLoopGroup: eventLoopGroup, + serviceProviders: [EchoProviderNIO()]) + .wait() + + // This blocks to keep the main thread from finishing while the server runs, + // but the server never exits. Kill the process to stop it. + _ = sem.wait() + } + + $0.command( + "get", + addressOption("localhost"), + portOption, + messageOption, + description: "Perform a unary get()." + ) { address, port, message in + print("calling get") + let echo = try! makeEchoClient(address: address, port: port) + + var requestMessage = Echo_EchoRequest() + requestMessage.text = message + + print("get sending: \(requestMessage.text)") + let get = echo.get(request: requestMessage) + get.response.whenSuccess { response in + print("get received: \(response.text)") + } + + _ = try! get.response.wait() + } + + $0.command( + "expand", + addressOption("localhost"), + portOption, + messageOption, + description: "Perform a server-streaming expand()." + ) { address, port, message in + print("calling expand") + let echo = try! makeEchoClient(address: address, port: port) + + var requestMessage = Echo_EchoRequest() + requestMessage.text = message + + print("expand sending: \(requestMessage.text)") + let expand = echo.expand(request: requestMessage) { response in + print("expand received: \(response.text)") + } + + _ = try! expand.status.wait() + } + + $0.command( + "collect", + addressOption("localhost"), + portOption, + messageOption, + description: "Perform a client-streaming collect()." + ) { address, port, message in + print("calling collect") + let echo = try! makeEchoClient(address: address, port: port) + + let collect = echo.collect() + + for part in message.components(separatedBy: " ") { + var requestMessage = Echo_EchoRequest() + requestMessage.text = part + print("collect sending: \(requestMessage.text)") + collect.send(.message(requestMessage)) + } + collect.send(.end) + + collect.response.whenSuccess { resposne in + print("collect received: \(resposne.text)") + } + + _ = try! collect.status.wait() + } + + $0.command( + "update", + addressOption("localhost"), + portOption, + messageOption, + description: "Perform a bidirectional-streaming update()." + ) { address, port, message in + print("calling update") + let echo = try! makeEchoClient(address: address, port: port) + + let update = echo.update { response in + print("update received: \(response.text)") + } + + for part in message.components(separatedBy: " ") { + var requestMessage = Echo_EchoRequest() + requestMessage.text = part + print("update sending: \(requestMessage.text)") + update.send(.message(requestMessage)) + } + update.send(.end) + + _ = try! update.status.wait() + } +}.run() diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift new file mode 100644 index 000000000..f3e8d2905 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -0,0 +1,126 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 +import NIOHTTP2 +import SwiftProtobuf + +public protocol ClientCall { + associatedtype RequestMessage: Message + associatedtype ResponseMessage: Message + + /// HTTP2 stream that requests and responses are sent and received on. + var subchannel: EventLoopFuture { get } + + /// Initial response metadata. + var metadata: EventLoopFuture { get } + + /// Response status. + var status: EventLoopFuture { get } + + /// Trailing response metadata. + /// + /// This is the same metadata as `GRPCStatus.trailingMetadata` returned by `status`. + var trailingMetadata: EventLoopFuture { get } +} + + +extension ClientCall { + public var trailingMetadata: EventLoopFuture { + return status.map { $0.trailingMetadata } + } +} + + +public protocol StreamingRequestClientCall: ClientCall { + func send(_ event: StreamEvent) +} + + +extension StreamingRequestClientCall { + /// Sends a request to the service. Callers must terminate the stream of messages + /// with an `.end` event. + /// + /// - Parameter event: event to send. + public func send(_ event: StreamEvent) { + let request: GRPCClientRequestPart + switch event { + case .message(let message): + request = .message(message) + + case .end: + request = .end + } + + subchannel.whenSuccess { $0.write(NIOAny(request), promise: nil) } + } +} + + +public protocol UnaryResponseClientCall: ClientCall { + var response: EventLoopFuture { get } +} + + +public class BaseClientCall: ClientCall { + public let subchannel: EventLoopFuture + public let metadata: EventLoopFuture + public let status: EventLoopFuture + + /// Sets up a gRPC call. + /// + /// Creates a new HTTP2 stream (`subchannel`) using the given multiplexer and configures the pipeline to + /// handle client gRPC requests and responses. + /// + /// - Parameters: + /// - channel: the main channel. + /// - multiplexer: HTTP2 stream multiplexer on which HTTP2 streams are created. + /// - responseHandler: handler for received messages. + init( + channel: Channel, + multiplexer: HTTP2StreamMultiplexer, + responseHandler: GRPCClientResponseChannelHandler.ResponseMessageHandler + ) { + let subchannelPromise: EventLoopPromise = channel.eventLoop.newPromise() + let metadataPromise: EventLoopPromise = channel.eventLoop.newPromise() + let statusPromise: EventLoopPromise = channel.eventLoop.newPromise() + + let channelHandler = GRPCClientResponseChannelHandler(metadata: metadataPromise, status: statusPromise, messageHandler: responseHandler) + + /// Create a new HTTP2 stream to handle calls. + channel.eventLoop.execute { + multiplexer.createStreamChannel(promise: subchannelPromise) { (subchannel, streamID) -> EventLoopFuture in + subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), + HTTP1ToRawGRPCClientCodec(), + RawGRPCToGRPCCodec(), + channelHandler], + first: false) + } + } + + self.subchannel = subchannelPromise.futureResult + self.metadata = metadataPromise.futureResult + self.status = statusPromise.futureResult + } + + internal func makeRequestHead(path: String, host: String) -> HTTPRequestHead { + var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) + requestHead.headers.add(name: "host", value: host) + requestHead.headers.add(name: "content-type", value: "application/grpc") + return requestHead + } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift new file mode 100644 index 000000000..450f845a2 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftProtobuf +import NIO + +public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { + + public init(client: GRPCClient, path: String, handler: @escaping (ResponseMessage) -> Void) { + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) + + let requestHead = makeRequestHead(path: path, host: client.host) + subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + } + } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift new file mode 100644 index 000000000..8de18d511 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -0,0 +1,34 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftProtobuf +import NIO + +public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { + public let response: EventLoopFuture + + public init(client: GRPCClient, path: String) { + let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() + self.response = responsePromise.futureResult + + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) + + let requestHead = makeRequestHead(path: path, host: client.host) + subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + } + } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift new file mode 100644 index 000000000..1e1435708 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -0,0 +1,32 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftProtobuf +import NIO + +public class ServerStreamingClientCall: BaseClientCall { + + public init(client: GRPCClient, path: String, request: RequestMessage, handler: @escaping (ResponseMessage) -> Void) { + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) + + let requestHead = makeRequestHead(path: path, host: client.host) + subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + channel.write(GRPCClientRequestPart.message(request), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) + } + } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift new file mode 100644 index 000000000..341355851 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -0,0 +1,36 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftProtobuf +import NIO + +public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { + public let response: EventLoopFuture + + public init(client: GRPCClient, path: String, request: Request) { + let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() + self.response = responsePromise.futureResult + + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) + + let requestHead = makeRequestHead(path: path, host: client.host) + subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + channel.write(GRPCClientRequestPart.message(request), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) + } + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift new file mode 100644 index 000000000..e4393b444 --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -0,0 +1,80 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP2 + +public final class GRPCClient { + + public static func start( + host: String, + port: Int, + eventLoopGroup: EventLoopGroup + ) -> EventLoopFuture { + let bootstrap = ClientBootstrap(group: eventLoopGroup) + // Enable SO_REUSEADDR. + .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .channelInitializer { channel in + channel.pipeline.add(handler: HTTP2Parser(mode: .client)) + } + + return bootstrap.connect(host: host, port: port).then { (channel: Channel) -> EventLoopFuture in + let multiplexer = HTTP2StreamMultiplexer(inboundStreamStateInitializer: nil) + return channel.pipeline.add(handler: multiplexer) + .map { GRPCClient(channel: channel, multiplexer: multiplexer, host: host) } + } + } + + public let channel: Channel + public let multiplexer: HTTP2StreamMultiplexer + public let host: String + + init(channel: Channel, multiplexer: HTTP2StreamMultiplexer, host: String) { + self.channel = channel + self.multiplexer = multiplexer + self.host = host + } + + /// Fired when the client shuts down. + public var onClose: EventLoopFuture { + return channel.closeFuture + } + + public func close() -> EventLoopFuture { + return channel.close(mode: .all) + } +} + +public protocol GRPCClientWrapper { + var client: GRPCClient { get } + + /// Name of the service this client wrapper is for. + var service: String { get } + + /// Return the path for the given method in the format "/Sevice-Name/Method-Name". + /// + /// This may be overriden if consumers require a different path format. + /// + /// - Parameter method: name of method to return a path for. + /// - Returns: path for the given method used in gRPC request headers. + func path(for method: String) -> String +} + +extension GRPCClientWrapper { + public func path(for method: String) -> String { + return "/\(service)/\(method)" + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift new file mode 100644 index 000000000..c3a779007 --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift @@ -0,0 +1,72 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 +import SwiftProtobuf + + +public class GRPCClientResponseChannelHandler { + private let messageObserver: (ResponseMessage) -> Void + private let metadataPromise: EventLoopPromise + private let statusPromise: EventLoopPromise + + init(metadata: EventLoopPromise, status: EventLoopPromise, messageHandler: ResponseMessageHandler) { + self.metadataPromise = metadata + self.statusPromise = status + self.messageObserver = messageHandler.observer + } + + enum ResponseMessageHandler { + /// Fulfill the given promise on receiving the first response message. + case fulfill(promise: EventLoopPromise) + + /// Call the given handler for each response message received. + case callback(handler: (ResponseMessage) -> Void) + + var observer: (ResponseMessage) -> Void { + switch self { + case .callback(let observer): + return observer + + case .fulfill(let promise): + return { promise.succeed(result: $0) } + } + } + } +} + + +extension GRPCClientResponseChannelHandler: ChannelInboundHandler { + public typealias InboundIn = GRPCClientResponsePart + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .headers(let headers): + self.metadataPromise.succeed(result: headers) + + case .message(let message): + self.messageObserver(message) + + case .status(let status): + //! FIXME: error status codes should fail the response promise (if one exists). + self.statusPromise.succeed(result: status) + + // We don't expect any more requests/responses beyond this point. + _ = ctx.channel.close(mode: .all) + } + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index 9e4109b82..c6a252ceb 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -6,11 +6,11 @@ public struct GRPCStatus: Error { /// The code to return in the `grpc-status` header. public let code: StatusCode /// The message to return in the `grpc-message` header. - public let message: String + public let message: String? /// Additional HTTP headers to return in the trailers. public let trailingMetadata: HTTPHeaders - public init(code: StatusCode, message: String, trailingMetadata: HTTPHeaders = HTTPHeaders()) { + public init(code: StatusCode, message: String?, trailingMetadata: HTTPHeaders = HTTPHeaders()) { self.code = code self.message = message self.trailingMetadata = trailingMetadata diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift new file mode 100644 index 000000000..b7f1a458b --- /dev/null +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -0,0 +1,154 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 + +/// Outgoing gRPC package with an unknown message type (represented by a byte buffer). +public enum RawGRPCClientRequestPart { + case head(HTTPRequestHead) + case message(ByteBuffer) + case end +} + +/// Incoming gRPC package with an unknown message type (represented by a byte buffer). +public enum RawGRPCClientResponsePart { + case headers(HTTPHeaders) + case message(ByteBuffer) + case status(GRPCStatus) +} + +public final class HTTP1ToRawGRPCClientCodec { + private enum State { + case expectingHeaders + case expectingBodyOrTrailers + case expectingCompressedFlag + case expectingMessageLength + case receivedMessageLength(UInt32) + + var expectingBody: Bool { + switch self { + case .expectingHeaders: return false + case .expectingBodyOrTrailers, .expectingCompressedFlag, .expectingMessageLength, .receivedMessageLength: return true + } + } + } + + public init() { + } + + private var state: State = .expectingHeaders + private var buffer: NIO.ByteBuffer? +} + +extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { + public typealias InboundIn = HTTPClientResponsePart + public typealias InboundOut = RawGRPCClientResponsePart + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + guard case .expectingHeaders = state + else { preconditionFailure("received headers while in state \(state)") } + + state = .expectingBodyOrTrailers + ctx.fireChannelRead(wrapInboundOut(.headers(head.headers))) + + case .body(var message): + if case .expectingBodyOrTrailers = state { + state = .expectingCompressedFlag + if buffer == nil { + buffer = ctx.channel.allocator.buffer(capacity: 5) + } + } + + precondition(state.expectingBody, "received body while in state \(state)") + + guard var buffer = buffer else { + preconditionFailure("buffer is not initialized") + } + + buffer.write(buffer: &message) + + requestProcessing: while true { + switch state { + case .expectingHeaders, .expectingBodyOrTrailers: + preconditionFailure("unexpected state '\(state)'") + + case .expectingCompressedFlag: + guard let compressionFlag: Int8 = buffer.readInteger() else { break requestProcessing } + precondition(compressionFlag == 0, "unexpected compression flag \(compressionFlag); compression is not supported and we did not indicate support for it") + state = .expectingMessageLength + + case .expectingMessageLength: + guard let messageLength: UInt32 = buffer.readInteger() else { break requestProcessing } + state = .receivedMessageLength(messageLength) + + case .receivedMessageLength(let messageLength): + guard let responseBuffer = buffer.readSlice(length: numericCast(messageLength)) else { break } + ctx.fireChannelRead(self.wrapInboundOut(.message(responseBuffer))) + + state = .expectingBodyOrTrailers + break requestProcessing + } + } + + case .end(let headers): + guard case .expectingBodyOrTrailers = state + else { preconditionFailure("received trailers while in state \(state)") } + + let statusCode = parseGRPCStatus(from: headers?["grpc-status"].first) + let statusMessage = headers?["grpc-message"].first + + ctx.fireChannelRead(wrapInboundOut(.status(GRPCStatus(code: statusCode, message: statusMessage)))) + state = .expectingHeaders + + } + } + + private func parseGRPCStatus(from status: String?) -> StatusCode { + guard let status = status, + let statusInt = Int(status), + let statusCode = StatusCode(rawValue: statusInt) + else { return .unknown } + + return statusCode + } +} + + +extension HTTP1ToRawGRPCClientCodec: ChannelOutboundHandler { + public typealias OutboundIn = RawGRPCClientRequestPart + public typealias OutboundOut = HTTPClientRequestPart + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch unwrapOutboundIn(data) { + case .head(let requestHead): + ctx.write(wrapOutboundOut(.head(requestHead)), promise: promise) + + case .message(var messageBytes): + var requestBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + 5) + requestBuffer.write(integer: Int8(0)) + requestBuffer.write(integer: UInt32(messageBytes.readableBytes)) + requestBuffer.write(buffer: &messageBytes) + ctx.write(wrapOutboundOut(.body(.byteBuffer(requestBuffer))), promise: promise) + + case .end: + ctx.writeAndFlush(wrapOutboundOut(.end(nil)), promise: promise) + } + + } +} diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index f8a84cfe3..678a0706b 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -121,7 +121,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { //! FIXME: Should return a different version if we want to support pPRC. ctx.write(self.wrapOutboundOut(.head(HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok, headers: headers))), promise: promise) case .message(var messageBytes): - // Write out a length-delimited message payload. See `channelRead` fpor the corresponding format. + // Write out a length-delimited message payload. See `channelRead` for the corresponding format. var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + 5) responseBuffer.write(integer: Int8(0)) // Compression flag: no compression responseBuffer.write(integer: UInt32(messageBytes.readableBytes)) @@ -130,7 +130,9 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { case .status(let status): var trailers = status.trailingMetadata trailers.add(name: "grpc-status", value: String(describing: status.code.rawValue)) - trailers.add(name: "grpc-message", value: status.message) + if let message = status.message { + trailers.add(name: "grpc-message", value: message) + } ctx.write(self.wrapOutboundOut(.end(trailers)), promise: promise) } } diff --git a/Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift b/Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift new file mode 100644 index 000000000..3e7f5c5b8 --- /dev/null +++ b/Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift @@ -0,0 +1,90 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 +import SwiftProtobuf + +/// Outgoing gRPC package with a fixed message type. +public enum GRPCClientRequestPart { + case head(HTTPRequestHead) + case message(MessageType) + case end +} + +/// Incoming gRPC package with a fixed message type. +public enum GRPCClientResponsePart { + case headers(HTTPHeaders) + case message(MessageType) + case status(GRPCStatus) +} + +public final class RawGRPCToGRPCCodec { + public init() {} +} + +extension RawGRPCToGRPCCodec: ChannelInboundHandler { + public typealias InboundIn = RawGRPCClientResponsePart + public typealias InboundOut = GRPCClientResponsePart + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + let response = unwrapInboundIn(data) + + switch response { + case .headers(let headers): + ctx.fireChannelRead(wrapInboundOut(.headers(headers))) + + case .message(var message): + let messageAsData = message.readData(length: message.readableBytes)! + do { + ctx.fireChannelRead(self.wrapInboundOut(.message(try ResponseMessage(serializedData: messageAsData)))) + } catch { + ctx.fireErrorCaught(error) + } + + case .status(let status): + ctx.fireChannelRead(wrapInboundOut(.status(status))) + } + } +} + +extension RawGRPCToGRPCCodec: ChannelOutboundHandler { + public typealias OutboundIn = GRPCClientRequestPart + public typealias OutboundOut = RawGRPCClientRequestPart + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let request = unwrapOutboundIn(data) + + switch request { + case .head(let head): + ctx.write(wrapOutboundOut(.head(head)), promise: promise) + + case .message(let message): + do { + let messageAsData = try message.serializedData() + var buffer = ctx.channel.allocator.buffer(capacity: messageAsData.count) + buffer.write(bytes: messageAsData) + ctx.write(wrapOutboundOut(.message(buffer)), promise: promise) + } catch { + print(error) + ctx.fireErrorCaught(error) + } + + case .end: + ctx.writeAndFlush(wrapOutboundOut(.end), promise: promise) + } + } +} diff --git a/Sources/SwiftGRPCNIO/ServerCallContexts/UnaryResponseCallContext.swift b/Sources/SwiftGRPCNIO/ServerCallContexts/UnaryResponseCallContext.swift index 2f329842e..635aac805 100644 --- a/Sources/SwiftGRPCNIO/ServerCallContexts/UnaryResponseCallContext.swift +++ b/Sources/SwiftGRPCNIO/ServerCallContexts/UnaryResponseCallContext.swift @@ -3,7 +3,7 @@ import SwiftProtobuf import NIO import NIOHTTP1 -/// Abstract base class exposing a method that exposes a promise fot the RPC response. +/// Abstract base class exposing a method that exposes a promise for the RPC response. /// /// - When `responsePromise` is fulfilled, the call is closed and the provided response transmitted with status `responseStatus` (`.ok` by default). /// - If `statusPromise` is failed and the error is of type `GRPCStatus`, that error will be returned to the client. From 30511f4aef50ce0840588ab44453f76c913eb15c Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 23 Jan 2019 10:41:47 +0000 Subject: [PATCH 02/30] Renaming and removal of force unwrap/try --- Sources/Examples/EchoNIO/main.swift | 62 +++++++++++++++---- .../ClientCalls/BaseClientCall.swift | 8 ++- .../ServerStreamingClientCall.swift | 1 - .../ClientCalls/UnaryClientCall.swift | 14 ++--- Sources/SwiftGRPCNIO/GRPCClient.swift | 1 - .../HTTP1ToRawGRPCClientCodec.swift | 11 +--- 6 files changed, 65 insertions(+), 32 deletions(-) diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift index a23957923..6a6d8b342 100644 --- a/Sources/Examples/EchoNIO/main.swift +++ b/Sources/Examples/EchoNIO/main.swift @@ -29,11 +29,17 @@ let messageOption = Option("message", default: "Testing 1 2 3", description: "message to send") -func makeEchoClient(address: String, port: Int) throws -> EchoClient { +/// Create en `EchoClient` and wait for it to initialize. Returns nil if initialisation fails. +func makeEchoClient(address: String, port: Int) -> EchoClient? { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - return try GRPCClient.start(host: address, port: port, eventLoopGroup: eventLoopGroup) - .map { client in EchoClient(client: client) } - .wait() + do { + return try GRPCClient.start(host: address, port: port, eventLoopGroup: eventLoopGroup) + .map { client in EchoClient(client: client) } + .wait() + } catch { + print("Unable to create an EchoClient: \(error)") + return nil + } } Group { @@ -66,7 +72,7 @@ Group { description: "Perform a unary get()." ) { address, port, message in print("calling get") - let echo = try! makeEchoClient(address: address, port: port) + guard let echo = makeEchoClient(address: address, port: port) else { return } var requestMessage = Echo_EchoRequest() requestMessage.text = message @@ -77,7 +83,17 @@ Group { print("get received: \(response.text)") } - _ = try! get.response.wait() + get.response.whenFailure { error in + print("get response failed with error: \(error)") + } + + // wait() on the status to stop the program from exiting. + do { + let status = try get.status.wait() + print("get completed with status: \(status)") + } catch { + print("get status failed with error: \(error)") + } } $0.command( @@ -88,7 +104,7 @@ Group { description: "Perform a server-streaming expand()." ) { address, port, message in print("calling expand") - let echo = try! makeEchoClient(address: address, port: port) + guard let echo = makeEchoClient(address: address, port: port) else { return } var requestMessage = Echo_EchoRequest() requestMessage.text = message @@ -98,7 +114,13 @@ Group { print("expand received: \(response.text)") } - _ = try! expand.status.wait() + // wait() on the status to stop the program from exiting. + do { + let status = try expand.status.wait() + print("expand completed with status: \(status)") + } catch { + print("expand status failed with error: \(error)") + } } $0.command( @@ -109,7 +131,7 @@ Group { description: "Perform a client-streaming collect()." ) { address, port, message in print("calling collect") - let echo = try! makeEchoClient(address: address, port: port) + guard let echo = makeEchoClient(address: address, port: port) else { return } let collect = echo.collect() @@ -125,7 +147,17 @@ Group { print("collect received: \(resposne.text)") } - _ = try! collect.status.wait() + collect.response.whenFailure { error in + print("collect response failed with error: \(error)") + } + + // wait() on the status to stop the program from exiting. + do { + let status = try collect.status.wait() + print("collect completed with status: \(status)") + } catch { + print("collect status failed with error: \(error)") + } } $0.command( @@ -136,7 +168,7 @@ Group { description: "Perform a bidirectional-streaming update()." ) { address, port, message in print("calling update") - let echo = try! makeEchoClient(address: address, port: port) + guard let echo = makeEchoClient(address: address, port: port) else { return } let update = echo.update { response in print("update received: \(response.text)") @@ -150,6 +182,12 @@ Group { } update.send(.end) - _ = try! update.status.wait() + // wait() on the status to stop the program from exiting. + do { + let status = try update.status.wait() + print("update completed with status: \(status)") + } catch { + print("update status failed with error: \(error)") + } } }.run() diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index f3e8d2905..35878f77e 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -27,7 +27,7 @@ public protocol ClientCall { var subchannel: EventLoopFuture { get } /// Initial response metadata. - var metadata: EventLoopFuture { get } + var initialMetadata: EventLoopFuture { get } /// Response status. var status: EventLoopFuture { get } @@ -78,7 +78,7 @@ public protocol UnaryResponseClientCall: ClientCall { public class BaseClientCall: ClientCall { public let subchannel: EventLoopFuture - public let metadata: EventLoopFuture + public let initialMetadata: EventLoopFuture public let status: EventLoopFuture /// Sets up a gRPC call. @@ -113,7 +113,7 @@ public class BaseClientCall: } self.subchannel = subchannelPromise.futureResult - self.metadata = metadataPromise.futureResult + self.initialMetadata = metadataPromise.futureResult self.status = statusPromise.futureResult } @@ -121,6 +121,8 @@ public class BaseClientCall: var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) requestHead.headers.add(name: "host", value: host) requestHead.headers.add(name: "content-type", value: "application/grpc") + requestHead.headers.add(name: "te", value: "trailers") + requestHead.headers.add(name: "user-agent", value: "grpc-swift-nio") return requestHead } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index 1e1435708..3edd87686 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -18,7 +18,6 @@ import SwiftProtobuf import NIO public class ServerStreamingClientCall: BaseClientCall { - public init(client: GRPCClient, path: String, request: RequestMessage, handler: @escaping (ResponseMessage) -> Void) { super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 341355851..a1086c4d9 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -17,20 +17,20 @@ import Foundation import SwiftProtobuf import NIO -public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { - public let response: EventLoopFuture +public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { + public let response: EventLoopFuture - public init(client: GRPCClient, path: String, request: Request) { - let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() + public init(client: GRPCClient, path: String, request: RequestMessage) { + let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() self.response = responsePromise.futureResult super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) let requestHead = makeRequestHead(path: path, host: client.host) subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - channel.write(GRPCClientRequestPart.message(request), promise: nil) - channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + channel.write(GRPCClientRequestPart.message(request), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) } } } diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift index e4393b444..e70354c4c 100644 --- a/Sources/SwiftGRPCNIO/GRPCClient.swift +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -18,7 +18,6 @@ import NIO import NIOHTTP2 public final class GRPCClient { - public static func start( host: String, port: Int, diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index b7f1a458b..307f22247 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -110,7 +110,7 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { guard case .expectingBodyOrTrailers = state else { preconditionFailure("received trailers while in state \(state)") } - let statusCode = parseGRPCStatus(from: headers?["grpc-status"].first) + let statusCode = headers?["grpc-status"].first.flatMap { parseGRPCStatus(from: $0) } ?? .unknown let statusMessage = headers?["grpc-message"].first ctx.fireChannelRead(wrapInboundOut(.status(GRPCStatus(code: statusCode, message: statusMessage)))) @@ -119,13 +119,8 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { } } - private func parseGRPCStatus(from status: String?) -> StatusCode { - guard let status = status, - let statusInt = Int(status), - let statusCode = StatusCode(rawValue: statusInt) - else { return .unknown } - - return statusCode + private func parseGRPCStatus(from status: String) -> StatusCode? { + return Int(status).flatMap { StatusCode(rawValue: $0) } } } From 6b2d8bcd38426bebab2ed0de9e18ec3303213728 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 29 Jan 2019 15:34:49 +0000 Subject: [PATCH 03/30] Improve error handling in NIO server. - Adds a user-configurable error handler to the server - Updates NIO server codegen to provide an optional error handler - Errors are handled by GRPCChannelHandler or BaseCallHandler, depending on the pipeline state - Adds some error handling tests - Tidies some logic in HTTP1ToRawGRPCServerCodec - Extends message handling logic in HTTP1ToRawGRPCServerCodec to handle messages split across multiple ByteBuffers (i.e. when a message exceeds a the size of a frame) --- Makefile | 2 +- .../CallHandlers/BaseCallHandler.swift | 63 ++++- .../BidirectionalStreamingCallHandler.swift | 4 +- .../ClientStreamingCallHandler.swift | 4 +- .../ServerStreamingCallHandler.swift | 10 +- .../CallHandlers/UnaryCallHandler.swift | 10 +- Sources/SwiftGRPCNIO/GRPCChannelHandler.swift | 47 ++-- Sources/SwiftGRPCNIO/GRPCServer.swift | 6 +- Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 12 +- Sources/SwiftGRPCNIO/GRPCStatus.swift | 15 ++ .../HTTP1ToRawGRPCServerCodec.swift | 227 ++++++++++++------ .../Generator-Server.swift | 4 +- ...nnelHandlerResponseCapturingTestCase.swift | 78 ++++++ .../GRPCChannelHandlerTests.swift | 197 +++++++++++++++ Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 10 + Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift | 10 +- 16 files changed, 578 insertions(+), 121 deletions(-) create mode 100644 Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift create mode 100644 Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift diff --git a/Makefile b/Makefile index b19dbccfd..c30e61663 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ test-plugin: test-plugin-nio: swift build $(CFLAGS) --product protoc-gen-swiftgrpc protoc Sources/Examples/Echo/echo.proto --proto_path=Sources/Examples/Echo --plugin=.build/debug/protoc-gen-swift --plugin=.build/debug/protoc-gen-swiftgrpc --swiftgrpc_out=/tmp --swiftgrpc_opt=Client=false,NIO=true - diff -u /tmp/echo.grpc.swift Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift + diff -u /tmp/echo.grpc.swift Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift xcodebuild: project xcodebuild -project SwiftGRPC.xcodeproj -configuration "Debug" -parallelizeTargets -target SwiftGRPC -target Echo -target Simple -target protoc-gen-swiftgrpc build diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift index 33d18d5e6..1808cafa5 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift @@ -8,29 +8,78 @@ import NIOHTTP1 /// Calls through to `processMessage` for individual messages it receives, which needs to be implemented by subclasses. public class BaseCallHandler: GRPCCallHandler { public func makeGRPCServerCodec() -> ChannelHandler { return GRPCServerCodec() } - + /// Called whenever a message has been received. /// /// Overridden by subclasses. - public func processMessage(_ message: RequestMessage) { + public func processMessage(_ message: RequestMessage) throws { fatalError("needs to be overridden") } - + /// Called when the client has half-closed the stream, indicating that they won't send any further data. /// /// Overridden by subclasses if the "end-of-stream" event is relevant. public func endOfStreamReceived() { } + + /// Whether this handler can still write messages to the client. + private var serverCanWrite = true + + /// Called for each error recieved in `errorCaught(ctx:error:)`. + private let errorHandler: ((Error) -> Void)? + + public init(errorHandler: ((Error) -> Void)? = nil) { + self.errorHandler = errorHandler + } } extension BaseCallHandler: ChannelInboundHandler { public typealias InboundIn = GRPCServerRequestPart - public typealias OutboundOut = GRPCServerResponsePart + + /// Passes errors to the user-provided `errorHandler`. After an error has been received an + /// appropriate status is written. Errors which don't conform to `GRPCStatusTransformable` + /// return a status with code `.internalError`. + public func errorCaught(ctx: ChannelHandlerContext, error: Error) { + errorHandler?(error) + + let status = (error as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError + self.write(ctx: ctx, data: NIOAny(GRPCServerResponsePart.status(status)), promise: nil) + } public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { switch self.unwrapInboundIn(data) { - case .head: preconditionFailure("should not have received headers") - case .message(let message): processMessage(message) - case .end: endOfStreamReceived() + case .head: + // Head should have been handled by `GRPCChannelHandler`. + self.errorCaught(ctx: ctx, error: GRPCStatus(code: .unknown, message: "unexpectedly received head")) + + case .message(let message): + do { + try processMessage(message) + } catch { + self.errorCaught(ctx: ctx, error: error) + } + + case .end: + endOfStreamReceived() + } + } +} + +extension BaseCallHandler: ChannelOutboundHandler { + public typealias OutboundIn = GRPCServerResponsePart + public typealias OutboundOut = GRPCServerResponsePart + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + guard serverCanWrite else { + promise?.fail(error: GRPCStatus.processingError) + return + } + + // We can only write one status; make sure we don't write again. + if case .status = unwrapOutboundIn(data) { + serverCanWrite = false + ctx.writeAndFlush(data, promise: promise) + } else { + ctx.write(data, promise: promise) } } } diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift index 46f4b7622..f147ec719 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift @@ -15,8 +15,8 @@ public class BidirectionalStreamingCallHandler) -> EventLoopFuture) { - super.init() + public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (StreamingResponseCallContext) -> EventLoopFuture) { + super.init(errorHandler: errorHandler) let context = StreamingResponseCallContextImpl(channel: channel, request: request) self.context = context let eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift index bd03ae744..8886d5660 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift @@ -14,8 +14,8 @@ public class ClientStreamingCallHandler) -> EventLoopFuture) { - super.init() + public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (UnaryResponseCallContext) -> EventLoopFuture) { + super.init(errorHandler: errorHandler) let context = UnaryResponseCallContextImpl(channel: channel, request: request) self.context = context let eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index 893745c69..ed01046d3 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -13,8 +13,8 @@ public class ServerStreamingCallHandler? - public init(channel: Channel, request: HTTPRequestHead, eventObserverFactory: (StreamingResponseCallContext) -> EventObserver) { - super.init() + public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (StreamingResponseCallContext) -> EventObserver) { + super.init(errorHandler: errorHandler) let context = StreamingResponseCallContextImpl(channel: channel, request: request) self.context = context self.eventObserver = eventObserverFactory(context) @@ -26,12 +26,10 @@ public class ServerStreamingCallHandler private var context: UnaryResponseCallContext? - public init(channel: Channel, request: HTTPRequestHead, eventObserverFactory: (UnaryResponseCallContext) -> EventObserver) { - super.init() + public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (UnaryResponseCallContext) -> EventObserver) { + super.init(errorHandler: errorHandler) let context = UnaryResponseCallContextImpl(channel: channel, request: request) self.context = context self.eventObserver = eventObserverFactory(context) @@ -26,12 +26,10 @@ public class UnaryCallHandler } } - public override func processMessage(_ message: RequestMessage) { + public override func processMessage(_ message: RequestMessage) throws { guard let eventObserver = self.eventObserver, let context = self.context else { - //! FIXME: Better handle this error? - print("multiple messages received on unary call") - return + throw GRPCStatus(code: .unimplemented, message: "multiple messages received on unary call") } let resultFuture = eventObserver(message) diff --git a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift index 3b8b475eb..00ad07d43 100644 --- a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift @@ -19,7 +19,7 @@ public protocol CallHandlerProvider: class { /// Determines, calls and returns the appropriate request handler (`GRPCCallHandler`), depending on the request's /// method. Returns nil for methods not handled by this service. - func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel) -> GRPCCallHandler? + func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)?) -> GRPCCallHandler? } /// Listens on a newly-opened HTTP2 subchannel and yields to the sub-handler matching a call, if available. @@ -28,30 +28,31 @@ public protocol CallHandlerProvider: class { /// for an `GRPCCallHandler` object. That object is then forwarded the individual gRPC messages. public final class GRPCChannelHandler { private let servicesByName: [String: CallHandlerProvider] + private let errorHandler: ((Error) -> Void)? - public init(servicesByName: [String: CallHandlerProvider]) { + public init(servicesByName: [String: CallHandlerProvider], errorHandler: ((Error) -> Void)? = nil) { self.servicesByName = servicesByName + self.errorHandler = errorHandler } } extension GRPCChannelHandler: ChannelInboundHandler { public typealias InboundIn = RawGRPCServerRequestPart public typealias OutboundOut = RawGRPCServerResponsePart - + + public func errorCaught(ctx: ChannelHandlerContext, error: Error) { + errorHandler?(error) + + let status = (error as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError + ctx.writeAndFlush(wrapOutboundOut(.status(status)), promise: nil) + } + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { let requestPart = self.unwrapInboundIn(data) switch requestPart { case .head(let requestHead): - // URI format: "/package.Servicename/MethodName", resulting in the following components separated by a slash: - // - uriComponents[0]: empty - // - uriComponents[1]: service name (including the package name); - // `CallHandlerProvider`s should provide the service name including the package name. - // - uriComponents[2]: method name. - let uriComponents = requestHead.uri.components(separatedBy: "/") - guard uriComponents.count >= 3 && uriComponents[0].isEmpty, - let providerForServiceName = servicesByName[uriComponents[1]], - let callHandler = providerForServiceName.handleMethod(uriComponents[2], request: requestHead, serverHandler: self, channel: ctx.channel) else { - ctx.writeAndFlush(self.wrapOutboundOut(.status(.unimplemented(method: requestHead.uri))), promise: nil) + guard let callHandler = getCallHandler(channel: ctx.channel, requestHead: requestHead) else { + errorCaught(ctx: ctx, error: GRPCStatus.unimplemented(method: requestHead.uri)) return } @@ -71,7 +72,25 @@ extension GRPCChannelHandler: ChannelInboundHandler { .whenComplete { ctx.pipeline.remove(handler: self, promise: handlerRemoved) } case .message, .end: - preconditionFailure("received \(requestPart), should have been removed as a handler at this point") + // We can reach this point if we're receiving messages for a method that isn't implemented. + // A status resposne will have been fired which should also close the stream; there's not a + // lot we can do at this point. + break + } + } + + private func getCallHandler(channel: Channel, requestHead: HTTPRequestHead) -> GRPCCallHandler? { + // URI format: "/package.Servicename/MethodName", resulting in the following components separated by a slash: + // - uriComponents[0]: empty + // - uriComponents[1]: service name (including the package name); + // `CallHandlerProvider`s should provide the service name including the package name. + // - uriComponents[2]: method name. + let uriComponents = requestHead.uri.components(separatedBy: "/") + guard uriComponents.count >= 3 && uriComponents[0].isEmpty, + let providerForServiceName = servicesByName[uriComponents[1]], + let callHandler = providerForServiceName.handleMethod(uriComponents[2], request: requestHead, serverHandler: self, channel: channel, errorHandler: errorHandler) else { + return nil } + return callHandler } } diff --git a/Sources/SwiftGRPCNIO/GRPCServer.swift b/Sources/SwiftGRPCNIO/GRPCServer.swift index f87a5166a..67aa1a9c0 100644 --- a/Sources/SwiftGRPCNIO/GRPCServer.swift +++ b/Sources/SwiftGRPCNIO/GRPCServer.swift @@ -12,7 +12,9 @@ public final class GRPCServer { hostname: String, port: Int, eventLoopGroup: EventLoopGroup, - serviceProviders: [CallHandlerProvider]) -> EventLoopFuture { + serviceProviders: [CallHandlerProvider], + errorHandler: ((Error) -> Void)? = nil + ) -> EventLoopFuture { let servicesByName = Dictionary(uniqueKeysWithValues: serviceProviders.map { ($0.serviceName, $0) }) let bootstrap = ServerBootstrap(group: eventLoopGroup) // Specify a backlog to avoid overloading the server. @@ -27,7 +29,7 @@ public final class GRPCServer { let multiplexer = HTTP2StreamMultiplexer { (channel, streamID) -> EventLoopFuture in return channel.pipeline.add(handler: HTTP2ToHTTP1ServerCodec(streamID: streamID)) .then { channel.pipeline.add(handler: HTTP1ToRawGRPCServerCodec()) } - .then { channel.pipeline.add(handler: GRPCChannelHandler(servicesByName: servicesByName)) } + .then { channel.pipeline.add(handler: GRPCChannelHandler(servicesByName: servicesByName, errorHandler: errorHandler)) } } return channel.pipeline.add(handler: multiplexer) diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index 21652e8bb..4cc5b214d 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -19,7 +19,7 @@ public enum GRPCServerResponsePart { } /// A simple channel handler that translates raw gRPC packets into decoded protobuf messages, and vice versa. -public final class GRPCServerCodec { } +public final class GRPCServerCodec {} extension GRPCServerCodec: ChannelInboundHandler { public typealias InboundIn = RawGRPCServerRequestPart @@ -35,8 +35,7 @@ extension GRPCServerCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try RequestMessage(serializedData: messageAsData)))) } catch { - //! FIXME: Ensure that the last handler in the pipeline returns `.dataLoss` here? - ctx.fireErrorCaught(error) + ctx.fireErrorCaught(GRPCStatus.requestProtoParseError) } case .end: @@ -54,6 +53,7 @@ extension GRPCServerCodec: ChannelOutboundHandler { switch responsePart { case .headers(let headers): ctx.write(self.wrapOutboundOut(.headers(headers)), promise: promise) + case .message(let message): do { let messageData = try message.serializedData() @@ -61,9 +61,11 @@ extension GRPCServerCodec: ChannelOutboundHandler { responseBuffer.write(bytes: messageData) ctx.write(self.wrapOutboundOut(.message(responseBuffer)), promise: promise) } catch { - promise?.fail(error: error) - ctx.fireErrorCaught(error) + let status = GRPCStatus.responseProtoSerializationError + promise?.fail(error: status) + ctx.fireErrorCaught(status) } + case .status(let status): ctx.write(self.wrapOutboundOut(.status(status)), promise: promise) } diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index 9e4109b82..cb1ae07d4 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -27,4 +27,19 @@ public struct GRPCStatus: Error { public static func unimplemented(method: String) -> GRPCStatus { return GRPCStatus(code: .unimplemented, message: "unknown method " + method) } + + // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md + static internal let requestProtoParseError = GRPCStatus(code: .internalError, message: "could not parse request proto") + static internal let responseProtoSerializationError = GRPCStatus(code: .internalError, message: "could not serialize response proto") + static internal let unsupportedCompression = GRPCStatus(code: .unimplemented, message: "compression is not supported on the server") +} + +protocol GRPCStatusTransformable: Error { + func asGRPCStatus() -> GRPCStatus +} + +extension GRPCStatus: GRPCStatusTransformable { + func asGRPCStatus() -> GRPCStatus { + return self + } } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index f8a84cfe3..3db609585 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -27,111 +27,200 @@ public enum RawGRPCServerResponsePart { /// /// The translation from HTTP2 to HTTP1 is done by `HTTP2ToHTTP1ServerCodec`. public final class HTTP1ToRawGRPCServerCodec { - private enum State { + internal var inboundState = InboundState.expectingHeaders + internal var outboundState = OutboundState.expectingHeaders + + private var buffer: NIO.ByteBuffer? + + // 1-byte for compression flag, 4-bytes for message length. + private let protobufMetadataSize = 5 +} + +extension HTTP1ToRawGRPCServerCodec { + enum InboundState { case expectingHeaders - case expectingCompressedFlag - case expectingMessageLength - case receivedMessageLength(UInt32) - - var expectingBody: Bool { - switch self { - case .expectingHeaders: return false - case .expectingCompressedFlag, .expectingMessageLength, .receivedMessageLength: return true - } + case expectingBody(Body) + // ignore any additional messages; e.g. we've seen .end or we've sent an error and are waiting for the stream to close. + case ignore + + enum Body { + case expectingCompressedFlag + case expectingMessageLength + case receivedMessageLength(UInt32) } } - private var state = State.expectingHeaders + enum OutboundState { + case expectingHeaders + case expectingBodyOrStatus + case ignore + } +} - private var buffer: NIO.ByteBuffer? +extension HTTP1ToRawGRPCServerCodec { + struct StateMachineError: Error, GRPCStatusTransformable { + private let message: String + + init(_ message: String) { + self.message = message + } + + func asGRPCStatus() -> GRPCStatus { + return GRPCStatus.processingError + } + } } extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { public typealias InboundIn = HTTPServerRequestPart public typealias InboundOut = RawGRPCServerRequestPart - + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { - switch self.unwrapInboundIn(data) { - case .head(let requestHead): - guard case .expectingHeaders = state - else { preconditionFailure("received headers while in state \(state)") } - - state = .expectingCompressedFlag - buffer = ctx.channel.allocator.buffer(capacity: 5) - - ctx.fireChannelRead(self.wrapInboundOut(.head(requestHead))) - - case .body(var body): - guard var buffer = buffer - else { preconditionFailure("buffer not initialized") } - assert(state.expectingBody, "received body while in state \(state)") - buffer.write(buffer: &body) - - // Iterate over all available incoming data, trying to read length-delimited messages. - // Each message has the following format: - // - 1 byte "compressed" flag (currently always zero, as we do not support for compression) - // - 4 byte signed-integer payload length (N) - // - N bytes payload (normally a valid wire-format protocol buffer) - requestProcessing: while true { - switch state { - case .expectingHeaders: preconditionFailure("unexpected state \(state)") - case .expectingCompressedFlag: - guard let compressionFlag: Int8 = buffer.readInteger() else { break requestProcessing } - //! FIXME: Avoid crashing here and instead drop the connection. - precondition(compressionFlag == 0, "unexpected compression flag \(compressionFlag); compression is not supported and we did not indicate support for it") - state = .expectingMessageLength - - case .expectingMessageLength: - guard let messageLength: UInt32 = buffer.readInteger() else { break requestProcessing } - state = .receivedMessageLength(messageLength) - - case .receivedMessageLength(let messageLength): - guard let messageBytes = buffer.readBytes(length: numericCast(messageLength)) else { break } - - //! FIXME: Use a slice of this buffer instead of copying to a new buffer. - var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.count) - responseBuffer.write(bytes: messageBytes) - ctx.fireChannelRead(self.wrapInboundOut(.message(responseBuffer))) - //! FIXME: Call buffer.discardReadBytes() here? - //! ALTERNATIVE: Check if the buffer has no further data right now, then clear it. - - state = .expectingCompressedFlag - } + if case .ignore = inboundState { return } + + do { + switch self.unwrapInboundIn(data) { + case .head(let requestHead): + inboundState = try processHead(ctx: ctx, requestHead: requestHead) + + case .body(var body): + inboundState = try processBody(ctx: ctx, body: &body) + + case .end(let trailers): + inboundState = try processEnd(ctx: ctx, trailers: trailers) } + } catch { + ctx.fireErrorCaught(error) + inboundState = .ignore + } + } + + func processHead(ctx: ChannelHandlerContext, requestHead: HTTPRequestHead) throws -> InboundState { + guard case .expectingHeaders = inboundState else { + throw StateMachineError("expecteded state .expectingHeaders, got \(inboundState)") + } + + ctx.fireChannelRead(self.wrapInboundOut(.head(requestHead))) - case .end(let trailers): - if let trailers = trailers { - //! FIXME: Better handle this error. - print("unexpected trailers received: \(trailers)") - return + return .expectingBody(.expectingCompressedFlag) + } + + func processBody(ctx: ChannelHandlerContext, body: inout ByteBuffer) throws -> InboundState { + guard case .expectingBody(let bodyState) = inboundState else { + throw StateMachineError("expecteded state .expectingBody(_), got \(inboundState)") + } + + return .expectingBody(try processBodyState(ctx: ctx, initialState: bodyState, messageBuffer: &body)) + } + + func processBodyState(ctx: ChannelHandlerContext, initialState: InboundState.Body, messageBuffer: inout ByteBuffer) throws -> InboundState.Body { + var bodyState = initialState + + // Iterate over all available incoming data, trying to read length-delimited messages. + // Each message has the following format: + // - 1 byte "compressed" flag (currently always zero, as we do not support for compression) + // - 4 byte signed-integer payload length (N) + // - N bytes payload (normally a valid wire-format protocol buffer) + while true { + switch bodyState { + case .expectingCompressedFlag: + guard let compressionFlag: Int8 = messageBuffer.readInteger() else { return .expectingCompressedFlag } + + // TODO: Add support for compression. + guard compressionFlag == 0 else { throw GRPCStatus.unsupportedCompression } + + bodyState = .expectingMessageLength + + case .expectingMessageLength: + guard let messageLength: UInt32 = messageBuffer.readInteger() else { return .expectingMessageLength } + bodyState = .receivedMessageLength(messageLength) + + case .receivedMessageLength(let messageLength): + // We need to account for messages being spread across multiple `ByteBuffer`s so buffer them + // into `buffer`. Note: when messages are contained within a single `ByteBuffer` we're just + // taking a slice so don't incur any extra writes. + guard messageBuffer.readableBytes >= messageLength else { + let remainingBytes = messageLength - numericCast(messageBuffer.readableBytes) + + if var buffer = buffer { + buffer.write(buffer: &messageBuffer) + self.buffer = buffer + } else { + messageBuffer.reserveCapacity(numericCast(messageLength)) + self.buffer = messageBuffer + } + + return .receivedMessageLength(remainingBytes) + } + + // We know buffer.readableBytes >= messageLength, so it's okay to force unwrap here. + var slice = messageBuffer.readSlice(length: numericCast(messageLength))! + + if var buffer = buffer { + buffer.write(buffer: &slice) + ctx.fireChannelRead(self.wrapInboundOut(.message(buffer))) + } else { + ctx.fireChannelRead(self.wrapInboundOut(.message(slice))) + } + + self.buffer = nil + bodyState = .expectingCompressedFlag } - ctx.fireChannelRead(self.wrapInboundOut(.end)) } } + + private func processEnd(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) throws -> InboundState { + guard trailers == nil else { + throw StateMachineError("unexpected trailers received \(String(describing: trailers))") + } + + ctx.fireChannelRead(self.wrapInboundOut(.end)) + return .ignore + } } extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { public typealias OutboundIn = RawGRPCServerResponsePart public typealias OutboundOut = HTTPServerResponsePart - + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + if case .ignore = outboundState { return } + let responsePart = self.unwrapOutboundIn(data) switch responsePart { case .headers(let headers): + guard case .expectingHeaders = outboundState else { return } + //! FIXME: Should return a different version if we want to support pPRC. ctx.write(self.wrapOutboundOut(.head(HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok, headers: headers))), promise: promise) + outboundState = .expectingBodyOrStatus + case .message(var messageBytes): - // Write out a length-delimited message payload. See `channelRead` fpor the corresponding format. - var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + 5) + guard case .expectingBodyOrStatus = outboundState else { return } + + // Write out a length-delimited message payload. See `processBodyState` for the corresponding format. + var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + protobufMetadataSize) responseBuffer.write(integer: Int8(0)) // Compression flag: no compression responseBuffer.write(integer: UInt32(messageBytes.readableBytes)) responseBuffer.write(buffer: &messageBytes) ctx.write(self.wrapOutboundOut(.body(.byteBuffer(responseBuffer))), promise: promise) + outboundState = .expectingBodyOrStatus + case .status(let status): var trailers = status.trailingMetadata trailers.add(name: "grpc-status", value: String(describing: status.code.rawValue)) trailers.add(name: "grpc-message", value: status.message) - ctx.write(self.wrapOutboundOut(.end(trailers)), promise: promise) + + // "Trailers-Only" response + if case .expectingHeaders = outboundState { + trailers.add(name: "content-type", value: "application/grpc") + let responseHead = HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok) + ctx.write(self.wrapOutboundOut(.head(responseHead)), promise: nil) + } + + ctx.writeAndFlush(self.wrapOutboundOut(.end(trailers)), promise: promise) + outboundState = .ignore + inboundState = .ignore } } } diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift index 45de736ff..ec46ab4e1 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift @@ -85,7 +85,7 @@ extension Generator { if options.generateNIOImplementation { println("/// Determines, calls and returns the appropriate request handler, depending on the request's method.") println("/// Returns nil for methods not handled by this service.") - println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel) -> GRPCCallHandler? {") + println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)? = nil) -> GRPCCallHandler? {") indent() println("switch methodName {") for method in service.methods { @@ -99,7 +99,7 @@ extension Generator { case .clientStreaming: callHandlerType = "ClientStreamingCallHandler" case .bidirectionalStreaming: callHandlerType = "BidirectionalStreamingCallHandler" } - println("return \(callHandlerType)(channel: channel, request: request) { context in") + println("return \(callHandlerType)(channel: channel, request: request, errorHandler: errorHandler) { context in") indent() switch streamingType(method) { case .unary, .serverStreaming: diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift new file mode 100644 index 000000000..3f0eda98f --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift @@ -0,0 +1,78 @@ +import Foundation +import NIO +import NIOHTTP1 +@testable import SwiftGRPCNIO +import XCTest + +internal struct CaseExtractError: Error { + let message: String +} + +@discardableResult +func extractHeaders(_ response: RawGRPCServerResponsePart) throws -> HTTPHeaders { + guard case .headers(let headers) = response else { + throw CaseExtractError(message: "\(response) did not match .headers") + } + return headers +} + +@discardableResult +func extractMessage(_ response: RawGRPCServerResponsePart) throws -> ByteBuffer { + guard case .message(let message) = response else { + throw CaseExtractError(message: "\(response) did not match .message") + } + return message +} + +@discardableResult +func extractStatus(_ response: RawGRPCServerResponsePart) throws -> GRPCStatus { + guard case .status(let status) = response else { + throw CaseExtractError(message: "\(response) did not match .status") + } + return status +} + +class CollectingChannelHandler: ChannelOutboundHandler { + var responses: [OutboundIn] = [] + var responseExpectation: XCTestExpectation? + + init(responseExpectation: XCTestExpectation? = nil) { + self.responseExpectation = responseExpectation + } + + func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + responses.append(unwrapOutboundIn(data)) + responseExpectation?.fulfill() + } +} + +class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { + static let defaultTimeout: TimeInterval = 0.2 + static let echoProvider: [String: CallHandlerProvider] = ["echo.Echo": EchoProvider_NIO()] + + func configureChannel(withHandlers handlers: [ChannelHandler]) -> EventLoopFuture { + let channel = EmbeddedChannel() + return channel.pipeline.addHandlers(handlers, first: true) + .map { _ in channel } + } + + /// Waits for `count` responses to be collected and then returns them. The test fails if too many + /// responses are collected or not enough are collected before the timeout. + func waitForGRPCChannelHandlerResponses( + count: Int, + servicesByName: [String: CallHandlerProvider] = echoProvider, + timeout: TimeInterval = defaultTimeout, + callback: @escaping (EmbeddedChannel) throws -> Void + ) -> [RawGRPCServerResponsePart] { + let responseExpectation = expectation(description: "expecting \(count) responses") + responseExpectation.expectedFulfillmentCount = count + responseExpectation.assertForOverFulfill = true + + let collector = CollectingChannelHandler(responseExpectation: responseExpectation) + _ = configureChannel(withHandlers: [collector, GRPCChannelHandler(servicesByName: servicesByName)]) + .thenThrowing(callback) + + waitForExpectations(timeout: timeout) + return collector.responses + } +} diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift new file mode 100644 index 000000000..1bae3fa20 --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -0,0 +1,197 @@ +import Foundation +import XCTest +import NIO +import NIOHTTP1 +@testable import SwiftGRPCNIO + +func gRPCMessage(channel: EmbeddedChannel, compression: Bool = false, message: Data? = nil) -> ByteBuffer { + let messageLength = message?.count ?? 0 + var buffer = channel.allocator.buffer(capacity: 5 + messageLength) + buffer.write(integer: Int8(compression ? 1 : 0)) + buffer.write(integer: UInt32(messageLength)) + if let bytes = message { + buffer.write(bytes: bytes) + } + return buffer +} + +class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { + func testUnimplementedMethodReturnsUnimplementedStatus() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 1) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "unimplemented") + try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) + } + + XCTAssertNoThrow(try extractStatus(responses[0])) { status in + XCTAssertEqual(status.code, .unimplemented) + } + } + + func testImplementedMethodReturnsHeadersMessageAndStatus() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) + + let request = Echo_EchoRequest.with { $0.text = "echo!" } + let requestData = try request.serializedData() + var buffer = channel.allocator.buffer(capacity: requestData.count) + buffer.write(bytes: requestData) + try channel.writeInbound(RawGRPCServerRequestPart.message(buffer)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractMessage(responses[1])) + XCTAssertNoThrow(try extractStatus(responses[2])) { status in + XCTAssertEqual(status.code, .ok) + } + } + + func testImplementedMethodReturnsStatusForBadlyFormedProto() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) + + var buffer = channel.allocator.buffer(capacity: 3) + buffer.write(bytes: [1, 2, 3]) + try channel.writeInbound(RawGRPCServerRequestPart.message(buffer)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + let expectedStatus = GRPCStatus.requestProtoParseError + XCTAssertEqual(status.code, expectedStatus.code) + XCTAssertEqual(status.message, expectedStatus.message) + } + } +} + +class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCase { + func testUnimplementedStatusReturnedWhenCompressionFlagIsSet() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + let expected = GRPCStatus.unsupportedCompression + XCTAssertEqual(status.code, expected.code) + XCTAssertEqual(status.message, expected.message) + } + } + + func testMessageCanBeSentAcrossMultipleByteBuffers() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + // Sending the header allocates a buffer. + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + + let request = Echo_EchoRequest.with { $0.text = "echo!" } + let requestAsData = try request.serializedData() + + var buffer = channel.allocator.buffer(capacity: 1) + buffer.write(integer: Int8(0)) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + + buffer = channel.allocator.buffer(capacity: 4) + buffer.write(integer: Int32(requestAsData.count)) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + + buffer = channel.allocator.buffer(capacity: requestAsData.count) + buffer.write(bytes: requestAsData) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractMessage(responses[1])) + XCTAssertNoThrow(try extractStatus(responses[2])) { status in + XCTAssertEqual(status.code, .ok) + } + } + + func testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + + let buffer = gRPCMessage(channel: channel, message: Data(bytes: [42])) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + let expected = GRPCStatus.requestProtoParseError + XCTAssertEqual(status.code, expected.code) + XCTAssertEqual(status.message, expected.message) + } + } + + func testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + // We have to use "Collect" (client streaming) as the tests rely on `EmbeddedChannel` which runs in this thread. + // In the current server implementation, responses from unary calls send a status immediately after sending the response. + // As such, a unary "Get" would return an "ok" status before the trailers would be sent. + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Collect") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) + + var trailers = HTTPHeaders() + trailers.add(name: "foo", value: "bar") + try channel.writeInbound(HTTPServerRequestPart.end(trailers)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + XCTAssertEqual(status.code, .internalError) + } + } + + func testOnlyOneStatusIsReturned() throws { + let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) + + // Sending trailers with `.end` should trigger an error. However, writing a message to a unary call + // will trigger a response and status to be sent back. Since we're using `EmbeddedChannel` this will + // be done before the trailers are sent. If a 4th resposne were to be sent (for the error status) then + // the test would fail. + + var trailers = HTTPHeaders() + trailers.add(name: "foo", value: "bar") + try channel.writeInbound(HTTPServerRequestPart.end(trailers)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractMessage(responses[1])) + XCTAssertNoThrow(try extractStatus(responses[2])) { status in + XCTAssertEqual(status.code, .ok) + } + } + + override func waitForGRPCChannelHandlerResponses( + count: Int, + servicesByName: [String: CallHandlerProvider] = GRPCChannelHandlerResponseCapturingTestCase.echoProvider, + timeout: TimeInterval = GRPCChannelHandlerResponseCapturingTestCase.defaultTimeout, + callback: @escaping (EmbeddedChannel) throws -> Void + ) -> [RawGRPCServerResponsePart] { + return super.waitForGRPCChannelHandlerResponses(count: count, servicesByName: servicesByName, timeout: timeout) { channel in + _ = channel.pipeline.addHandlers(HTTP1ToRawGRPCServerCodec(), first: true) + .thenThrowing { _ in try callback(channel) } + } + } +} + +// Assert the given expression does not throw, and validate the return value from that expression. +public func XCTAssertNoThrow( + _ expression: @autoclosure () throws -> T, + _ message: String = "", + file: StaticString = #file, + line: UInt = #line, + validate: (T) -> Void +) { + var value: T? = nil + XCTAssertNoThrow(try value = expression(), message, file: file, line: line) + value.map { validate($0) } +} diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 61801389d..60fbbe526 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -122,6 +122,12 @@ extension NIOServerTests { XCTAssertEqual("Swift echo get: foo", try! client.get(Echo_EchoRequest(text: "foo")).text) } + func testUnaryWithLargeData() throws { + // Default max frame size is: 16,384. We'll exceed this as we also have to send the size and compression flag. + let request = Echo_EchoRequest.with { $0.text = String(repeating: "e", count: 16_384) } + XCTAssertNoThrow(try client.get(request)) + } + func testUnaryLotsOfRequests() { // Sending that many requests at once can sometimes trip things up, it seems. client.timeout = 5.0 @@ -135,6 +141,10 @@ extension NIOServerTests { } print("total time for \(numberOfRequests) requests: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } + + func testUnaryEmptyRequest() throws { + XCTAssertNoThrow(try client.get(Echo_EchoRequest())) + } } extension NIOServerTests { diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift index e3cfbfb44..6a235fece 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift @@ -40,29 +40,29 @@ extension Echo_EchoProvider_NIO { /// Determines, calls and returns the appropriate request handler, depending on the request's method. /// Returns nil for methods not handled by this service. - internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel) -> GRPCCallHandler? { + internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)? = nil) -> GRPCCallHandler? { switch methodName { case "Get": - return UnaryCallHandler(channel: channel, request: request) { context in + return UnaryCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in return { request in self.get(request: request, context: context) } } case "Expand": - return ServerStreamingCallHandler(channel: channel, request: request) { context in + return ServerStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in return { request in self.expand(request: request, context: context) } } case "Collect": - return ClientStreamingCallHandler(channel: channel, request: request) { context in + return ClientStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in return self.collect(context: context) } case "Update": - return BidirectionalStreamingCallHandler(channel: channel, request: request) { context in + return BidirectionalStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in return self.update(context: context) } From d2be70d10bbc9ffd65011670d6a7dd7ec9ef9d47 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 30 Jan 2019 14:19:41 +0000 Subject: [PATCH 04/30] Update error delegate --- .../CallHandlers/BaseCallHandler.swift | 11 ++++---- .../BidirectionalStreamingCallHandler.swift | 4 +-- .../ClientStreamingCallHandler.swift | 4 +-- .../ServerStreamingCallHandler.swift | 4 +-- .../CallHandlers/UnaryCallHandler.swift | 4 +-- Sources/SwiftGRPCNIO/GRPCChannelHandler.swift | 15 +++++------ Sources/SwiftGRPCNIO/GRPCServer.swift | 4 +-- .../SwiftGRPCNIO/ServerErrorDelegate.swift | 23 +++++++++++++++++ .../Generator-Server.swift | 4 +-- ...nnelHandlerResponseCapturingTestCase.swift | 25 ++++++------------- .../GRPCChannelHandlerTests.swift | 21 ++++++++-------- Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift | 10 ++++---- 12 files changed, 71 insertions(+), 58 deletions(-) create mode 100644 Sources/SwiftGRPCNIO/ServerErrorDelegate.swift diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift index 1808cafa5..3fdfa1b20 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift @@ -25,10 +25,10 @@ public class BaseCallHandler: private var serverCanWrite = true /// Called for each error recieved in `errorCaught(ctx:error:)`. - private let errorHandler: ((Error) -> Void)? + private weak var errorDelegate: ServerErrorDelegate? - public init(errorHandler: ((Error) -> Void)? = nil) { - self.errorHandler = errorHandler + public init(errorDelegate: ServerErrorDelegate? = nil) { + self.errorDelegate = errorDelegate } } @@ -39,9 +39,10 @@ extension BaseCallHandler: ChannelInboundHandler { /// appropriate status is written. Errors which don't conform to `GRPCStatusTransformable` /// return a status with code `.internalError`. public func errorCaught(ctx: ChannelHandlerContext, error: Error) { - errorHandler?(error) + errorDelegate?.observe(error) - let status = (error as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError + let transformed = errorDelegate?.transform(error) ?? error + let status = (transformed as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError self.write(ctx: ctx, data: NIOAny(GRPCServerResponsePart.status(status)), promise: nil) } diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift index f147ec719..2d5a4294c 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BidirectionalStreamingCallHandler.swift @@ -15,8 +15,8 @@ public class BidirectionalStreamingCallHandler Void)?, eventObserverFactory: (StreamingResponseCallContext) -> EventLoopFuture) { - super.init(errorHandler: errorHandler) + public init(channel: Channel, request: HTTPRequestHead, errorDelegate: ServerErrorDelegate?, eventObserverFactory: (StreamingResponseCallContext) -> EventLoopFuture) { + super.init(errorDelegate: errorDelegate) let context = StreamingResponseCallContextImpl(channel: channel, request: request) self.context = context let eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift index 8886d5660..a6213a497 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ClientStreamingCallHandler.swift @@ -14,8 +14,8 @@ public class ClientStreamingCallHandler Void)?, eventObserverFactory: (UnaryResponseCallContext) -> EventLoopFuture) { - super.init(errorHandler: errorHandler) + public init(channel: Channel, request: HTTPRequestHead, errorDelegate: ServerErrorDelegate?, eventObserverFactory: (UnaryResponseCallContext) -> EventLoopFuture) { + super.init(errorDelegate: errorDelegate) let context = UnaryResponseCallContextImpl(channel: channel, request: request) self.context = context let eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index ed01046d3..dde2c2306 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -13,8 +13,8 @@ public class ServerStreamingCallHandler? - public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (StreamingResponseCallContext) -> EventObserver) { - super.init(errorHandler: errorHandler) + public init(channel: Channel, request: HTTPRequestHead, errorDelegate: ServerErrorDelegate?, eventObserverFactory: (StreamingResponseCallContext) -> EventObserver) { + super.init(errorDelegate: errorDelegate) let context = StreamingResponseCallContextImpl(channel: channel, request: request) self.context = context self.eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/UnaryCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/UnaryCallHandler.swift index a1fe31c26..2e275d207 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/UnaryCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/UnaryCallHandler.swift @@ -14,8 +14,8 @@ public class UnaryCallHandler private var context: UnaryResponseCallContext? - public init(channel: Channel, request: HTTPRequestHead, errorHandler: ((Error) -> Void)?, eventObserverFactory: (UnaryResponseCallContext) -> EventObserver) { - super.init(errorHandler: errorHandler) + public init(channel: Channel, request: HTTPRequestHead, errorDelegate: ServerErrorDelegate?, eventObserverFactory: (UnaryResponseCallContext) -> EventObserver) { + super.init(errorDelegate: errorDelegate) let context = UnaryResponseCallContextImpl(channel: channel, request: request) self.context = context self.eventObserver = eventObserverFactory(context) diff --git a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift index 00ad07d43..3a913b061 100644 --- a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift @@ -19,7 +19,7 @@ public protocol CallHandlerProvider: class { /// Determines, calls and returns the appropriate request handler (`GRPCCallHandler`), depending on the request's /// method. Returns nil for methods not handled by this service. - func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)?) -> GRPCCallHandler? + func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate?) -> GRPCCallHandler? } /// Listens on a newly-opened HTTP2 subchannel and yields to the sub-handler matching a call, if available. @@ -28,11 +28,11 @@ public protocol CallHandlerProvider: class { /// for an `GRPCCallHandler` object. That object is then forwarded the individual gRPC messages. public final class GRPCChannelHandler { private let servicesByName: [String: CallHandlerProvider] - private let errorHandler: ((Error) -> Void)? + private weak var errorDelegate: ServerErrorDelegate? - public init(servicesByName: [String: CallHandlerProvider], errorHandler: ((Error) -> Void)? = nil) { + public init(servicesByName: [String: CallHandlerProvider], errorDelegate: ServerErrorDelegate? = nil) { self.servicesByName = servicesByName - self.errorHandler = errorHandler + self.errorDelegate = errorDelegate } } @@ -41,9 +41,10 @@ extension GRPCChannelHandler: ChannelInboundHandler { public typealias OutboundOut = RawGRPCServerResponsePart public func errorCaught(ctx: ChannelHandlerContext, error: Error) { - errorHandler?(error) + errorDelegate?.observe(error) - let status = (error as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError + let transformedError = (errorDelegate?.transform(error) ?? error) + let status = (transformedError as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError ctx.writeAndFlush(wrapOutboundOut(.status(status)), promise: nil) } @@ -88,7 +89,7 @@ extension GRPCChannelHandler: ChannelInboundHandler { let uriComponents = requestHead.uri.components(separatedBy: "/") guard uriComponents.count >= 3 && uriComponents[0].isEmpty, let providerForServiceName = servicesByName[uriComponents[1]], - let callHandler = providerForServiceName.handleMethod(uriComponents[2], request: requestHead, serverHandler: self, channel: channel, errorHandler: errorHandler) else { + let callHandler = providerForServiceName.handleMethod(uriComponents[2], request: requestHead, serverHandler: self, channel: channel, errorDelegate: errorDelegate) else { return nil } return callHandler diff --git a/Sources/SwiftGRPCNIO/GRPCServer.swift b/Sources/SwiftGRPCNIO/GRPCServer.swift index 67aa1a9c0..c658d5fce 100644 --- a/Sources/SwiftGRPCNIO/GRPCServer.swift +++ b/Sources/SwiftGRPCNIO/GRPCServer.swift @@ -13,7 +13,7 @@ public final class GRPCServer { port: Int, eventLoopGroup: EventLoopGroup, serviceProviders: [CallHandlerProvider], - errorHandler: ((Error) -> Void)? = nil + errorDelegate: ServerErrorDelegate? = nil ) -> EventLoopFuture { let servicesByName = Dictionary(uniqueKeysWithValues: serviceProviders.map { ($0.serviceName, $0) }) let bootstrap = ServerBootstrap(group: eventLoopGroup) @@ -29,7 +29,7 @@ public final class GRPCServer { let multiplexer = HTTP2StreamMultiplexer { (channel, streamID) -> EventLoopFuture in return channel.pipeline.add(handler: HTTP2ToHTTP1ServerCodec(streamID: streamID)) .then { channel.pipeline.add(handler: HTTP1ToRawGRPCServerCodec()) } - .then { channel.pipeline.add(handler: GRPCChannelHandler(servicesByName: servicesByName, errorHandler: errorHandler)) } + .then { channel.pipeline.add(handler: GRPCChannelHandler(servicesByName: servicesByName, errorDelegate: errorDelegate)) } } return channel.pipeline.add(handler: multiplexer) diff --git a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift new file mode 100644 index 000000000..fee423ceb --- /dev/null +++ b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift @@ -0,0 +1,23 @@ +import Foundation + +public protocol ServerErrorDelegate: class { + /// Called when an error thrown in the channel pipeline. + func observe(_ error: Error) + + /// Transforms the given error into a new error. + /// + /// This allows framework to transform errors which may be out of their control + /// due to third-party libraries, for example, into more meaningful errors or + /// `GRPCStatus` errors. Errors returned from this protocol are not passed to + /// `observe`. + /// + /// - note: + /// This defaults to returning the provided error. + func transform(_ error: Error) -> Error +} + +public extension ServerErrorDelegate { + func transform(_ error: Error) -> Error { + return error + } +} diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift index ec46ab4e1..3d782e9a9 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift @@ -85,7 +85,7 @@ extension Generator { if options.generateNIOImplementation { println("/// Determines, calls and returns the appropriate request handler, depending on the request's method.") println("/// Returns nil for methods not handled by this service.") - println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)? = nil) -> GRPCCallHandler? {") + println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate? = nil) -> GRPCCallHandler? {") indent() println("switch methodName {") for method in service.methods { @@ -99,7 +99,7 @@ extension Generator { case .clientStreaming: callHandlerType = "ClientStreamingCallHandler" case .bidirectionalStreaming: callHandlerType = "BidirectionalStreamingCallHandler" } - println("return \(callHandlerType)(channel: channel, request: request, errorHandler: errorHandler) { context in") + println("return \(callHandlerType)(channel: channel, request: request, errorDelegate: errorDelegate) { context in") indent() switch streamingType(method) { case .unary, .serverStreaming: diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift index 3f0eda98f..5daaf4eb8 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift @@ -34,20 +34,13 @@ func extractStatus(_ response: RawGRPCServerResponsePart) throws -> GRPCStatus { class CollectingChannelHandler: ChannelOutboundHandler { var responses: [OutboundIn] = [] - var responseExpectation: XCTestExpectation? - - init(responseExpectation: XCTestExpectation? = nil) { - self.responseExpectation = responseExpectation - } func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { responses.append(unwrapOutboundIn(data)) - responseExpectation?.fulfill() } } class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { - static let defaultTimeout: TimeInterval = 0.2 static let echoProvider: [String: CallHandlerProvider] = ["echo.Echo": EchoProvider_NIO()] func configureChannel(withHandlers handlers: [ChannelHandler]) -> EventLoopFuture { @@ -56,23 +49,19 @@ class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { .map { _ in channel } } - /// Waits for `count` responses to be collected and then returns them. The test fails if too many - /// responses are collected or not enough are collected before the timeout. + /// Waits for `count` responses to be collected and then returns them. The test fails if the number + /// of collected responses does not match the expected. func waitForGRPCChannelHandlerResponses( count: Int, servicesByName: [String: CallHandlerProvider] = echoProvider, - timeout: TimeInterval = defaultTimeout, callback: @escaping (EmbeddedChannel) throws -> Void - ) -> [RawGRPCServerResponsePart] { - let responseExpectation = expectation(description: "expecting \(count) responses") - responseExpectation.expectedFulfillmentCount = count - responseExpectation.assertForOverFulfill = true - - let collector = CollectingChannelHandler(responseExpectation: responseExpectation) - _ = configureChannel(withHandlers: [collector, GRPCChannelHandler(servicesByName: servicesByName)]) + ) throws -> [RawGRPCServerResponsePart] { + let collector = CollectingChannelHandler() + try configureChannel(withHandlers: [collector, GRPCChannelHandler(servicesByName: servicesByName)]) .thenThrowing(callback) + .wait() - waitForExpectations(timeout: timeout) + XCTAssertEqual(count, collector.responses.count) return collector.responses } } diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index 1bae3fa20..4ecd8b96e 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -17,7 +17,7 @@ func gRPCMessage(channel: EmbeddedChannel, compression: Bool = false, message: D class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { func testUnimplementedMethodReturnsUnimplementedStatus() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 1) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 1) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "unimplemented") try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) } @@ -28,7 +28,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { } func testImplementedMethodReturnsHeadersMessageAndStatus() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) @@ -47,7 +47,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { } func testImplementedMethodReturnsStatusForBadlyFormedProto() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) @@ -67,7 +67,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCase { func testUnimplementedStatusReturnedWhenCompressionFlagIsSet() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) @@ -82,7 +82,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } func testMessageCanBeSentAcrossMultipleByteBuffers() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") // Sending the header allocates a buffer. try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) @@ -111,7 +111,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } func testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) @@ -128,7 +128,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } func testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 2) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in // We have to use "Collect" (client streaming) as the tests rely on `EmbeddedChannel` which runs in this thread. // In the current server implementation, responses from unary calls send a status immediately after sending the response. // As such, a unary "Get" would return an "ok" status before the trailers would be sent. @@ -148,7 +148,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } func testOnlyOneStatusIsReturned() throws { - let responses = waitForGRPCChannelHandlerResponses(count: 3) { channel in + let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) @@ -173,10 +173,9 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas override func waitForGRPCChannelHandlerResponses( count: Int, servicesByName: [String: CallHandlerProvider] = GRPCChannelHandlerResponseCapturingTestCase.echoProvider, - timeout: TimeInterval = GRPCChannelHandlerResponseCapturingTestCase.defaultTimeout, callback: @escaping (EmbeddedChannel) throws -> Void - ) -> [RawGRPCServerResponsePart] { - return super.waitForGRPCChannelHandlerResponses(count: count, servicesByName: servicesByName, timeout: timeout) { channel in + ) throws -> [RawGRPCServerResponsePart] { + return try super.waitForGRPCChannelHandlerResponses(count: count, servicesByName: servicesByName) { channel in _ = channel.pipeline.addHandlers(HTTP1ToRawGRPCServerCodec(), first: true) .thenThrowing { _ in try callback(channel) } } diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift index 6a235fece..4d39364b3 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift @@ -40,29 +40,29 @@ extension Echo_EchoProvider_NIO { /// Determines, calls and returns the appropriate request handler, depending on the request's method. /// Returns nil for methods not handled by this service. - internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorHandler: ((Error) -> Void)? = nil) -> GRPCCallHandler? { + internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate? = nil) -> GRPCCallHandler? { switch methodName { case "Get": - return UnaryCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in + return UnaryCallHandler(channel: channel, request: request, errorDelegate: errorDelegate) { context in return { request in self.get(request: request, context: context) } } case "Expand": - return ServerStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in + return ServerStreamingCallHandler(channel: channel, request: request, errorDelegate: errorDelegate) { context in return { request in self.expand(request: request, context: context) } } case "Collect": - return ClientStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in + return ClientStreamingCallHandler(channel: channel, request: request, errorDelegate: errorDelegate) { context in return self.collect(context: context) } case "Update": - return BidirectionalStreamingCallHandler(channel: channel, request: request, errorHandler: errorHandler) { context in + return BidirectionalStreamingCallHandler(channel: channel, request: request, errorDelegate: errorDelegate) { context in return self.update(context: context) } From fd76ad399d89fce4663edf013298cd637eb0a3f7 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 30 Jan 2019 16:46:37 +0000 Subject: [PATCH 05/30] Renaming, tidy up HTTP1ToRawGRPCClientCodec, CallOptions --- Sources/Examples/EchoNIO/EchoClient.swift | 17 ++- Sources/Examples/EchoNIO/main.swift | 4 +- .../ClientCalls/BaseClientCall.swift | 8 +- .../BidirectionalStreamingClientCall.swift | 5 +- .../ClientStreamingClientCall.swift | 4 +- .../ServerStreamingClientCall.swift | 4 +- .../ClientCalls/UnaryClientCall.swift | 4 +- Sources/SwiftGRPCNIO/ClientOptions.swift | 25 ++++ Sources/SwiftGRPCNIO/GRPCClient.swift | 20 ++- ...oGRPCCodec.swift => GRPCClientCodec.swift} | 20 +-- Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 8 +- Sources/SwiftGRPCNIO/GRPCStatus.swift | 2 +- .../HTTP1ToRawGRPCClientCodec.swift | 137 +++++++++--------- .../LengthPrefixedMessageReader.swift | 93 ++++++++++++ .../LengthPrefixedMessageWriter.swift | 35 +++++ 15 files changed, 272 insertions(+), 114 deletions(-) create mode 100644 Sources/SwiftGRPCNIO/ClientOptions.swift rename Sources/SwiftGRPCNIO/{RawGRPCToGRPCCodec.swift => GRPCClientCodec.swift} (77%) create mode 100644 Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift create mode 100644 Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift diff --git a/Sources/Examples/EchoNIO/EchoClient.swift b/Sources/Examples/EchoNIO/EchoClient.swift index e67f4a8c4..69e6e0d6a 100644 --- a/Sources/Examples/EchoNIO/EchoClient.swift +++ b/Sources/Examples/EchoNIO/EchoClient.swift @@ -19,6 +19,7 @@ import NIO class EchoClient: GRPCClientWrapper { + let client: GRPCClient let service = "echo.Echo" @@ -26,19 +27,19 @@ class EchoClient: GRPCClientWrapper { self.client = client } - func get(request: Echo_EchoRequest) -> UnaryClientCall { - return UnaryClientCall(client: client, path: path(for: "Get"), request: request) + func get(request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { + return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? defaultCallOptions()) } - func expand(request: Echo_EchoRequest, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { - return ServerStreamingClientCall(client: client, path: path(for: "Expand"), request: request, handler: handler) + func expand(request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { + return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler) } - func collect() -> ClientStreamingClientCall { - return ClientStreamingClientCall(client: client, path: path(for: "Collect")) + func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { + return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? defaultCallOptions()) } - func update(handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { - return BidirectionalStreamingClientCall(client: client, path: path(for: "Update"), handler: handler) + func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { + return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? defaultCallOptions(), handler: handler) } } diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift index 6a6d8b342..4ad8be969 100644 --- a/Sources/Examples/EchoNIO/main.swift +++ b/Sources/Examples/EchoNIO/main.swift @@ -143,8 +143,8 @@ Group { } collect.send(.end) - collect.response.whenSuccess { resposne in - print("collect received: \(resposne.text)") + collect.response.whenSuccess { respone in + print("collect received: \(respone.text)") } collect.response.whenFailure { error in diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 35878f77e..617b4864c 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -106,7 +106,7 @@ public class BaseClientCall: multiplexer.createStreamChannel(promise: subchannelPromise) { (subchannel, streamID) -> EventLoopFuture in subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), HTTP1ToRawGRPCClientCodec(), - RawGRPCToGRPCCodec(), + GRPCClientCodec(), channelHandler], first: false) } @@ -117,8 +117,12 @@ public class BaseClientCall: self.status = statusPromise.futureResult } - internal func makeRequestHead(path: String, host: String) -> HTTPRequestHead { + internal func makeRequestHead(path: String, host: String, customMetadata: HTTPHeaders? = nil) -> HTTPRequestHead { var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) + customMetadata?.forEach { name, value in + requestHead.headers.add(name: name, value: value) + } + requestHead.headers.add(name: "host", value: host) requestHead.headers.add(name: "content-type", value: "application/grpc") requestHead.headers.add(name: "te", value: "trailers") diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index 450f845a2..db0f7e8cb 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -18,11 +18,10 @@ import SwiftProtobuf import NIO public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { - - public init(client: GRPCClient, path: String, handler: @escaping (ResponseMessage) -> Void) { + public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - let requestHead = makeRequestHead(path: path, host: client.host) + let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) subchannel.whenSuccess { channel in channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index 8de18d511..138a55d64 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -20,13 +20,13 @@ import NIO public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { public let response: EventLoopFuture - public init(client: GRPCClient, path: String) { + public init(client: GRPCClient, path: String, callOptions: CallOptions) { let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() self.response = responsePromise.futureResult super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) - let requestHead = makeRequestHead(path: path, host: client.host) + let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) subchannel.whenSuccess { channel in channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index 3edd87686..b32332078 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -18,10 +18,10 @@ import SwiftProtobuf import NIO public class ServerStreamingClientCall: BaseClientCall { - public init(client: GRPCClient, path: String, request: RequestMessage, handler: @escaping (ResponseMessage) -> Void) { + public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - let requestHead = makeRequestHead(path: path, host: client.host) + let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) subchannel.whenSuccess { channel in channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) channel.write(GRPCClientRequestPart.message(request), promise: nil) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index a1086c4d9..862fa3108 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -20,13 +20,13 @@ import NIO public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { public let response: EventLoopFuture - public init(client: GRPCClient, path: String, request: RequestMessage) { + public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions) { let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() self.response = responsePromise.futureResult super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) - let requestHead = makeRequestHead(path: path, host: client.host) + let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) subchannel.whenSuccess { channel in channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) channel.write(GRPCClientRequestPart.message(request), promise: nil) diff --git a/Sources/SwiftGRPCNIO/ClientOptions.swift b/Sources/SwiftGRPCNIO/ClientOptions.swift new file mode 100644 index 000000000..51e78db84 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientOptions.swift @@ -0,0 +1,25 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIOHTTP1 + +public final class CallOptions { + var customMetadata: HTTPHeaders + + public init(customMetadata: HTTPHeaders = HTTPHeaders()) { + self.customMetadata = customMetadata + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift index e70354c4c..00d184d16 100644 --- a/Sources/SwiftGRPCNIO/GRPCClient.swift +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -57,23 +57,33 @@ public final class GRPCClient { } } -public protocol GRPCClientWrapper { +public protocol GRPCClientWrapper: CallOptionsProvider { var client: GRPCClient { get } /// Name of the service this client wrapper is for. var service: String { get } - /// Return the path for the given method in the format "/Sevice-Name/Method-Name". + /// Return the path for the given method in the format "/Service-Name/Method-Name". /// /// This may be overriden if consumers require a different path format. /// - /// - Parameter method: name of method to return a path for. + /// - Parameter forMethod: name of method to return a path for. /// - Returns: path for the given method used in gRPC request headers. - func path(for method: String) -> String + func path(forMethod method: String) -> String } extension GRPCClientWrapper { - public func path(for method: String) -> String { + public func path(forMethod method: String) -> String { return "/\(service)/\(method)" } } + +public protocol CallOptionsProvider { + func defaultCallOptions() -> CallOptions +} + +extension CallOptionsProvider { + public func defaultCallOptions() -> CallOptions { + return CallOptions() + } +} diff --git a/Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift similarity index 77% rename from Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift rename to Sources/SwiftGRPCNIO/GRPCClientCodec.swift index 3e7f5c5b8..84466089a 100644 --- a/Sources/SwiftGRPCNIO/RawGRPCToGRPCCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -19,24 +19,24 @@ import NIOHTTP1 import SwiftProtobuf /// Outgoing gRPC package with a fixed message type. -public enum GRPCClientRequestPart { +public enum GRPCClientRequestPart { case head(HTTPRequestHead) - case message(MessageType) + case message(RequestMessage) case end } /// Incoming gRPC package with a fixed message type. -public enum GRPCClientResponsePart { +public enum GRPCClientResponsePart { case headers(HTTPHeaders) - case message(MessageType) + case message(ResponseMessage) case status(GRPCStatus) } -public final class RawGRPCToGRPCCodec { +public final class GRPCClientCodec { public init() {} } -extension RawGRPCToGRPCCodec: ChannelInboundHandler { +extension GRPCClientCodec: ChannelInboundHandler { public typealias InboundIn = RawGRPCClientResponsePart public typealias InboundOut = GRPCClientResponsePart @@ -61,7 +61,7 @@ extension RawGRPCToGRPCCodec: ChannelInboundHandler { } } -extension RawGRPCToGRPCCodec: ChannelOutboundHandler { +extension GRPCClientCodec: ChannelOutboundHandler { public typealias OutboundIn = GRPCClientRequestPart public typealias OutboundOut = RawGRPCClientRequestPart @@ -74,12 +74,8 @@ extension RawGRPCToGRPCCodec: ChannelOutboundHandler { case .message(let message): do { - let messageAsData = try message.serializedData() - var buffer = ctx.channel.allocator.buffer(capacity: messageAsData.count) - buffer.write(bytes: messageAsData) - ctx.write(wrapOutboundOut(.message(buffer)), promise: promise) + ctx.write(wrapOutboundOut(.message(try message.serializedData())), promise: promise) } catch { - print(error) ctx.fireErrorCaught(error) } diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index 21652e8bb..320e3d32c 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -5,16 +5,16 @@ import NIOFoundationCompat import NIOHTTP1 /// Incoming gRPC package with a fixed message type. -public enum GRPCServerRequestPart { +public enum GRPCServerRequestPart { case head(HTTPRequestHead) - case message(MessageType) + case message(RequestMessage) case end } /// Outgoing gRPC package with a fixed message type. -public enum GRPCServerResponsePart { +public enum GRPCServerResponsePart { case headers(HTTPHeaders) - case message(MessageType) + case message(ResponseMessage) case status(GRPCStatus) } diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index c6a252ceb..ca2f1ca3b 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -10,7 +10,7 @@ public struct GRPCStatus: Error { /// Additional HTTP headers to return in the trailers. public let trailingMetadata: HTTPHeaders - public init(code: StatusCode, message: String?, trailingMetadata: HTTPHeaders = HTTPHeaders()) { + public init(code: StatusCode, message: String? = nil, trailingMetadata: HTTPHeaders = HTTPHeaders()) { self.code = code self.message = message self.trailingMetadata = trailingMetadata diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index 307f22247..aeff22b8e 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -17,10 +17,10 @@ import Foundation import NIO import NIOHTTP1 -/// Outgoing gRPC package with an unknown message type (represented by a byte buffer). +/// Outgoing gRPC package with an unknown message type (represented as the serialzed protobuf message). public enum RawGRPCClientRequestPart { case head(HTTPRequestHead) - case message(ByteBuffer) + case message(Data) case end } @@ -31,27 +31,29 @@ public enum RawGRPCClientResponsePart { case status(GRPCStatus) } +/// Codec for translating HTTP/1 resposnes from the server into untyped gRPC packages +/// and vice-versa. +/// +/// Most of the inbound processing is done by `LengthPrefixedMessageReader`; which +/// reads length-prefxied gRPC messages into `ByteBuffer`s containing serialized +/// Protobuf messages. +/// +/// The outbound processing transforms serialized Protobufs into length-prefixed +/// gRPC messages stored in `ByteBuffer`s. +/// +/// See `HTTP1ToRawGRPCServerCodec` for the corresponding server codec. public final class HTTP1ToRawGRPCClientCodec { + public init() {} + private enum State { case expectingHeaders case expectingBodyOrTrailers - case expectingCompressedFlag - case expectingMessageLength - case receivedMessageLength(UInt32) - - var expectingBody: Bool { - switch self { - case .expectingHeaders: return false - case .expectingBodyOrTrailers, .expectingCompressedFlag, .expectingMessageLength, .receivedMessageLength: return true - } - } - } - - public init() { + case ignore } private var state: State = .expectingHeaders - private var buffer: NIO.ByteBuffer? + private let messageReader = LengthPrefixedMessageReader(mode: .client) + private let messageWriter = LengthPrefixedMessageWriter() } extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { @@ -59,68 +61,65 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { public typealias InboundOut = RawGRPCClientResponsePart public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + if case .ignore = state { return } + switch unwrapInboundIn(data) { case .head(let head): - guard case .expectingHeaders = state - else { preconditionFailure("received headers while in state \(state)") } - - state = .expectingBodyOrTrailers - ctx.fireChannelRead(wrapInboundOut(.headers(head.headers))) + state = processHead(ctx: ctx, head: head) case .body(var message): - if case .expectingBodyOrTrailers = state { - state = .expectingCompressedFlag - if buffer == nil { - buffer = ctx.channel.allocator.buffer(capacity: 5) - } - } - - precondition(state.expectingBody, "received body while in state \(state)") - - guard var buffer = buffer else { - preconditionFailure("buffer is not initialized") + do { + state = try processBody(ctx: ctx, messageBuffer: &message) + } catch { + ctx.fireErrorCaught(error) + state = .ignore } - buffer.write(buffer: &message) - - requestProcessing: while true { - switch state { - case .expectingHeaders, .expectingBodyOrTrailers: - preconditionFailure("unexpected state '\(state)'") - - case .expectingCompressedFlag: - guard let compressionFlag: Int8 = buffer.readInteger() else { break requestProcessing } - precondition(compressionFlag == 0, "unexpected compression flag \(compressionFlag); compression is not supported and we did not indicate support for it") - state = .expectingMessageLength + case .end(let trailers): + state = processTrailers(ctx: ctx, trailers: trailers) + } + } - case .expectingMessageLength: - guard let messageLength: UInt32 = buffer.readInteger() else { break requestProcessing } - state = .receivedMessageLength(messageLength) + /// Forwards the headers from the request head to the next handler. + /// + /// - note: Requires the `.expectingHeaders` state. + private func processHead(ctx: ChannelHandlerContext, head: HTTPResponseHead) -> State { + guard case .expectingHeaders = state + else { preconditionFailure("received headers while in state \(state)") } - case .receivedMessageLength(let messageLength): - guard let responseBuffer = buffer.readSlice(length: numericCast(messageLength)) else { break } - ctx.fireChannelRead(self.wrapInboundOut(.message(responseBuffer))) + ctx.fireChannelRead(wrapInboundOut(.headers(head.headers))) + return .expectingBodyOrTrailers + } - state = .expectingBodyOrTrailers - break requestProcessing - } - } + /// Processes the given buffer; if a complete message is read then it is forwarded to the + /// next channel handler. + /// + /// - note: Requires the `.expectingBodyOrTrailers` state. + private func processBody(ctx: ChannelHandlerContext, messageBuffer: inout ByteBuffer) throws -> State { + guard case .expectingBodyOrTrailers = state + else { preconditionFailure("received body while in state \(state)") } - case .end(let headers): - guard case .expectingBodyOrTrailers = state - else { preconditionFailure("received trailers while in state \(state)") } + if let message = try self.messageReader.read(messageBuffer: &messageBuffer) { + ctx.fireChannelRead(wrapInboundOut(.message(message))) + } - let statusCode = headers?["grpc-status"].first.flatMap { parseGRPCStatus(from: $0) } ?? .unknown - let statusMessage = headers?["grpc-message"].first + return .expectingBodyOrTrailers + } - ctx.fireChannelRead(wrapInboundOut(.status(GRPCStatus(code: statusCode, message: statusMessage)))) - state = .expectingHeaders + /// Forwards a `GRPCStatus` to the next handler. The status and message are extracted + /// from the trailers if they exist; the `.unknown` status code and an empty message + /// are used otherwise. + private func processTrailers(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) -> State { + guard case .expectingBodyOrTrailers = state + else { preconditionFailure("received trailers while in state \(state)") } - } - } + let statusCode = trailers?["grpc-status"].first + .flatMap { Int($0) } + .flatMap { StatusCode(rawValue: $0) } + let statusMessage = trailers?["grpc-message"].first - private func parseGRPCStatus(from status: String) -> StatusCode? { - return Int(status).flatMap { StatusCode(rawValue: $0) } + ctx.fireChannelRead(wrapInboundOut(.status(GRPCStatus(code: statusCode ?? .unknown, message: statusMessage)))) + return .ignore } } @@ -134,16 +133,12 @@ extension HTTP1ToRawGRPCClientCodec: ChannelOutboundHandler { case .head(let requestHead): ctx.write(wrapOutboundOut(.head(requestHead)), promise: promise) - case .message(var messageBytes): - var requestBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + 5) - requestBuffer.write(integer: Int8(0)) - requestBuffer.write(integer: UInt32(messageBytes.readableBytes)) - requestBuffer.write(buffer: &messageBytes) - ctx.write(wrapOutboundOut(.body(.byteBuffer(requestBuffer))), promise: promise) + case .message(let message): + let request = messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) + ctx.write(wrapOutboundOut(.body(.byteBuffer(request))), promise: promise) case .end: ctx.writeAndFlush(wrapOutboundOut(.end(nil)), promise: promise) } - } } diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift new file mode 100644 index 000000000..ed57484a7 --- /dev/null +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -0,0 +1,93 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 + +internal class LengthPrefixedMessageReader { + var buffer: ByteBuffer? + var state: State = .expectingCompressedFlag + var mode: Mode + + init(mode: Mode) { + self.mode = mode + } + + enum Mode { case client, server } + + enum State { + case expectingCompressedFlag + case expectingMessageLength + case receivedMessageLength(UInt32) + } + + func read(messageBuffer: inout ByteBuffer) throws -> ByteBuffer? { + while true { + switch state { + case .expectingCompressedFlag: + guard let compressionFlag: Int8 = messageBuffer.readInteger() else { return nil } + + try handleCompressionFlag(enabled: compressionFlag != 0) + state = .expectingMessageLength + + case .expectingMessageLength: + guard let messageLength: UInt32 = messageBuffer.readInteger() else { return nil } + state = .receivedMessageLength(messageLength) + + case .receivedMessageLength(let messageLength): + // We need to account for messages being spread across multiple `ByteBuffer`s so buffer them + // into `buffer`. Note: when messages are contained within a single `ByteBuffer` we're just + // taking a slice so don't incur any extra writes. + guard messageBuffer.readableBytes >= messageLength else { + let remainingBytes = messageLength - numericCast(messageBuffer.readableBytes) + + if var buffer = buffer { + buffer.write(buffer: &messageBuffer) + self.buffer = buffer + } else { + messageBuffer.reserveCapacity(numericCast(messageLength)) + self.buffer = messageBuffer + } + + state = .receivedMessageLength(remainingBytes) + return nil + } + + // We know buffer.readableBytes >= messageLength, so it's okay to force unwrap here. + var slice = messageBuffer.readSlice(length: numericCast(messageLength))! + self.buffer?.write(buffer: &slice) + let message = self.buffer ?? slice + + self.buffer = nil + self.state = .expectingCompressedFlag + return message + } + } + } + + private func handleCompressionFlag(enabled: Bool) throws { + switch mode { + case .client: + // TODO: handle this better; cancel the call? + precondition(!enabled, "compression is not supported") + + case .server: + if enabled { + throw GRPCStatus(code: .unimplemented, message: "compression is not yet supported on the server") + } + } + } +} diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift new file mode 100644 index 000000000..c33d7ec07 --- /dev/null +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO + +enum MessageCompression { + case none + + var enabled: Bool { return false } +} + +internal class LengthPrefixedMessageWriter { + func write(allocator: ByteBufferAllocator, compression: MessageCompression, message: Data) -> ByteBuffer { + var buffer = allocator.buffer(capacity: 5 + message.count) + + buffer.write(integer: Int8(compression.enabled ? 1 : 0)) + buffer.write(integer: UInt32(message.count)) + buffer.write(bytes: message) + + return buffer + } +} From bb0ce3836543c4e85a73cb3a8fc7b631342b6472 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 31 Jan 2019 16:17:35 +0000 Subject: [PATCH 06/30] Client code-gen --- Sources/Examples/EchoNIO/EchoClient.swift | 45 ------ Sources/Examples/EchoNIO/main.swift | 11 +- .../Generator-Client.swift | 140 ++++++++++++++++++ .../Generator-Names.swift | 6 +- Sources/protoc-gen-swiftgrpc/Generator.swift | 4 +- Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift | 64 ++++++++ 6 files changed, 215 insertions(+), 55 deletions(-) delete mode 100644 Sources/Examples/EchoNIO/EchoClient.swift diff --git a/Sources/Examples/EchoNIO/EchoClient.swift b/Sources/Examples/EchoNIO/EchoClient.swift deleted file mode 100644 index 69e6e0d6a..000000000 --- a/Sources/Examples/EchoNIO/EchoClient.swift +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2019, gRPC Authors All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Foundation -import SwiftGRPCNIO -import NIO - - -class EchoClient: GRPCClientWrapper { - - let client: GRPCClient - let service = "echo.Echo" - - init(client: GRPCClient) { - self.client = client - } - - func get(request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { - return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? defaultCallOptions()) - } - - func expand(request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { - return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler) - } - - func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { - return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? defaultCallOptions()) - } - - func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { - return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? defaultCallOptions(), handler: handler) - } -} diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift index 4ad8be969..9daa476cf 100644 --- a/Sources/Examples/EchoNIO/main.swift +++ b/Sources/Examples/EchoNIO/main.swift @@ -30,11 +30,11 @@ let messageOption = Option("message", description: "message to send") /// Create en `EchoClient` and wait for it to initialize. Returns nil if initialisation fails. -func makeEchoClient(address: String, port: Int) -> EchoClient? { +func makeEchoClient(address: String, port: Int) -> Echo_EchoService_NIOClient? { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) do { return try GRPCClient.start(host: address, port: port, eventLoopGroup: eventLoopGroup) - .map { client in EchoClient(client: client) } + .map { client in Echo_EchoService_NIOClient(client: client) } .wait() } catch { print("Unable to create an EchoClient: \(error)") @@ -78,7 +78,7 @@ Group { requestMessage.text = message print("get sending: \(requestMessage.text)") - let get = echo.get(request: requestMessage) + let get = echo.get(requestMessage) get.response.whenSuccess { response in print("get received: \(response.text)") } @@ -106,11 +106,10 @@ Group { print("calling expand") guard let echo = makeEchoClient(address: address, port: port) else { return } - var requestMessage = Echo_EchoRequest() - requestMessage.text = message + let requestMessage = Echo_EchoRequest.with { $0.text = message } print("expand sending: \(requestMessage.text)") - let expand = echo.expand(request: requestMessage) { response in + let expand = echo.expand(requestMessage) { response in print("expand received: \(response.text)") } diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift index ca8d78ffc..6ddb3d99b 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift @@ -20,6 +20,16 @@ import SwiftProtobufPluginLibrary extension Generator { internal func printClient(asynchronousCode: Bool, synchronousCode: Bool) { + if options.generateNIOImplementation { + printNIOGRPCClient() + } else { + printCGRPCClient(asynchronousCode: asynchronousCode, + synchronousCode: synchronousCode) + } + } + + private func printCGRPCClient(asynchronousCode: Bool, + synchronousCode: Bool) { for method in service.methods { self.method = method switch streamingType(method) { @@ -312,4 +322,134 @@ extension Generator { outdent() println("}") } + + private func printNIOGRPCClient() { + println() + printNIOServiceClientProtocol() + println() + printNIOServiceClientImplementation() + } + + private func printNIOServiceClientProtocol() { + println("/// Instantiate \(serviceClassName)Client, then call methods of this protocol to make API calls.") + println("\(options.visibility.sourceSnippet) protocol \(serviceClassName) {") + indent() + for method in service.methods { + self.method = method + switch streamingType(method) { + case .unary: + println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions?) -> UnaryClientCall<\(methodInputName), \(methodOutputName)>") + + case .serverStreaming: + println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions?, handler: @escaping (\(methodOutputName)) -> Void) -> ServerStreamingClientCall<\(methodInputName), \(methodOutputName)>") + + case .clientStreaming: + println("func \(methodFunctionName)(callOptions: CallOptions?) -> ClientStreamingClientCall<\(methodInputName), \(methodOutputName)>") + + case .bidirectionalStreaming: + println("func \(methodFunctionName)(callOptions: CallOptions?, handler: @escaping (\(methodOutputName)) -> Void) -> BidirectionalStreamingClientCall<\(methodInputName), \(methodOutputName)>") + } + } + outdent() + println("}") + } + + private func printNIOServiceClientImplementation() { + println("\(access) final class \(serviceClassName)Client: GRPCClientWrapper, \(serviceClassName) {") + indent() + println("\(access) let client: GRPCClient") + println("\(access) let service = \"\(servicePath)\"") + println() + println("\(access) init(client: GRPCClient) {") + indent() + println("self.client = client") + outdent() + println("}") + println() + + for method in service.methods { + self.method = method + switch streamingType(method) { + case .unary: + println("/// Asynchronous unary call to \(method.name).") + println("///") + printParameters() + printRequestParameter() + printCallOptionsParameter() + println("/// - Returns: A `UnaryClientCall` with futures for the metadata, status and response.") + println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil) -> UnaryClientCall<\(methodInputName), \(methodOutputName)> {") + indent() + println("return UnaryClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? defaultCallOptions())") + outdent() + println("}") + + case .serverStreaming: + println("/// Asynchronous server-streaming call to \(method.name).") + println("///") + printParameters() + printRequestParameter() + printCallOptionsParameter() + printHandlerParameter() + println("/// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status.") + println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> ServerStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + indent() + println("return ServerStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler)") + outdent() + println("}") + + case .clientStreaming: + println("/// Asynchronous client-streaming call to \(method.name).") + println("///") + printClientStreamingDetails() + println("///") + printParameters() + printCallOptionsParameter() + println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") + println("func \(methodFunctionName)(callOptions: CallOptions? = nil) -> ClientStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + indent() + println("return ClientStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? defaultCallOptions())") + outdent() + println("}") + + case .bidirectionalStreaming: + println("/// Asynchronous bidirectional-streaming call to \(method.name).") + println("///") + printClientStreamingDetails() + println("///") + printParameters() + printCallOptionsParameter() + printHandlerParameter() + println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") + println("func \(methodFunctionName)(callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> BidirectionalStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + indent() + println("return BidirectionalStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? defaultCallOptions(), handler: handler)") + outdent() + println("}") + } + println() + } + outdent() + println("}") + } + + private func printClientStreamingDetails() { + println("/// Callers should use the `send` method on the returned object to send messages") + println("/// to the server. The caller should send an `.end` after the final message has been sent.") + } + + private func printParameters() { + println("/// - Parameters:") + } + + private func printRequestParameter() { + println("/// - request: Request to send to \(method.name).") + } + + private func printCallOptionsParameter() { + println("/// - callOptions: Call options; `defaultCallOptions()` is used if `nil`.") + } + + private func printHandlerParameter() { + println("/// - handler: A closure called when each response is received from the server.") + } } diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Names.swift b/Sources/protoc-gen-swiftgrpc/Generator-Names.swift index 6fe3ddea1..34abbb73c 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Names.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Names.swift @@ -44,7 +44,11 @@ extension Generator { } internal var serviceClassName: String { - return nameForPackageService(file, service) + "Service" + if options.generateNIOImplementation { + return nameForPackageService(file, service) + "Service_NIO" + } else { + return nameForPackageService(file, service) + "Service" + } } internal var providerName: String { diff --git a/Sources/protoc-gen-swiftgrpc/Generator.swift b/Sources/protoc-gen-swiftgrpc/Generator.swift index bff68b30b..e722e1f70 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator.swift @@ -105,9 +105,7 @@ class Generator { } println() - if options.generateClient { - guard !options.generateNIOImplementation else { fatalError("Generating client code is not yet supported for SwiftGRPC-NIO.") } - + if options.generateClient { for service in file.services { self.service = service printClient(asynchronousCode: options.generateAsynchronous, diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift index e3cfbfb44..dad219c92 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift @@ -27,6 +27,70 @@ import SwiftGRPCNIO import SwiftProtobuf +/// Instantiate Echo_EchoService_NIOClient, then call methods of this protocol to make API calls. +internal protocol Echo_EchoService_NIO { + func get(_ request: Echo_EchoRequest, callOptions: CallOptions?) -> UnaryClientCall + func expand(_ request: Echo_EchoRequest, callOptions: CallOptions?, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall + func collect(callOptions: CallOptions?) -> ClientStreamingClientCall + func update(callOptions: CallOptions?, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall +} + +internal final class Echo_EchoService_NIOClient: GRPCClientWrapper, Echo_EchoService_NIO { + internal let client: GRPCClient + internal let service = "echo.Echo" + + internal init(client: GRPCClient) { + self.client = client + } + + /// Asynchronous unary call to Get. + /// + /// - Parameters: + /// - request: Request to send to Get. + /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - Returns: A `UnaryClientCall` with futures for the metadata, status and response. + func get(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { + return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? defaultCallOptions()) + } + + /// Asynchronous server-streaming call to Expand. + /// + /// - Parameters: + /// - request: Request to send to Expand. + /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status. + func expand(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { + return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler) + } + + /// Asynchronous client-streaming call to Collect. + /// + /// Callers should use the `send` method on the returned object to send messages + /// to the server. The caller should send an `.end` after the final message has been sent. + /// + /// - Parameters: + /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. + func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { + return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? defaultCallOptions()) + } + + /// Asynchronous bidirectional-streaming call to Update. + /// + /// Callers should use the `send` method on the returned object to send messages + /// to the server. The caller should send an `.end` after the final message has been sent. + /// + /// - Parameters: + /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. + func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { + return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? defaultCallOptions(), handler: handler) + } + +} + /// To build a server, implement a class that conforms to this protocol. internal protocol Echo_EchoProvider_NIO: CallHandlerProvider { func get(request: Echo_EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture From 005c70db4267921dce593d48b0297c30d664f2d7 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 31 Jan 2019 10:19:53 +0000 Subject: [PATCH 07/30] Strongly hold errorDelegate in the server until shutdown --- Sources/SwiftGRPCNIO/GRPCServer.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftGRPCNIO/GRPCServer.swift b/Sources/SwiftGRPCNIO/GRPCServer.swift index c658d5fce..a54b6c3d8 100644 --- a/Sources/SwiftGRPCNIO/GRPCServer.swift +++ b/Sources/SwiftGRPCNIO/GRPCServer.swift @@ -41,13 +41,22 @@ public final class GRPCServer { .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) return bootstrap.bind(host: hostname, port: port) - .map { GRPCServer(channel: $0) } + .map { GRPCServer(channel: $0, errorDelegate: errorDelegate) } } private let channel: Channel + private var errorDelegate: ServerErrorDelegate? - private init(channel: Channel) { + private init(channel: Channel, errorDelegate: ServerErrorDelegate?) { self.channel = channel + + // Maintain a strong reference to ensure it lives as long as the server. + self.errorDelegate = errorDelegate + + // `BaseCallHandler` holds a weak reference to the delegate; nil out this reference to avoid retain cycles. + onClose.whenComplete { + self.errorDelegate = nil + } } /// Fired when the server shuts down. @@ -55,6 +64,7 @@ public final class GRPCServer { return channel.closeFuture } + /// Shut down the server; this should be called to avoid leaking resources. public func close() -> EventLoopFuture { return channel.close(mode: .all) } From 80eada1e098ca0ec816de27576919fb47316529d Mon Sep 17 00:00:00 2001 From: George Barnett Date: Fri, 1 Feb 2019 10:57:07 +0000 Subject: [PATCH 08/30] Timeoutes, tidying up, documentation --- .../ServerStreamingCallHandler.swift | 3 +- .../ClientCalls/BaseClientCall.swift | 126 +++++------ .../BidirectionalStreamingClientCall.swift | 8 +- .../SwiftGRPCNIO/ClientCalls/ClientCall.swift | 78 +++++++ .../ClientStreamingClientCall.swift | 20 +- .../ServerStreamingClientCall.swift | 10 +- .../ClientCalls/UnaryClientCall.swift | 22 +- Sources/SwiftGRPCNIO/ClientOptions.swift | 8 +- .../SwiftGRPCNIO/CompressionMechanism.swift | 55 +++++ Sources/SwiftGRPCNIO/GRPCClient.swift | 23 +- Sources/SwiftGRPCNIO/GRPCClientCodec.swift | 2 +- .../GRPCClientResponseChannelHandler.swift | 2 - Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 6 +- Sources/SwiftGRPCNIO/GRPCStatus.swift | 2 + Sources/SwiftGRPCNIO/GRPCTimeout.swift | 76 +++++++ .../HTTP1ToRawGRPCClientCodec.swift | 19 +- .../HTTP1ToRawGRPCServerCodec.swift | 83 +++----- .../LengthPrefixedMessageReader.swift | 131 ++++++++---- .../LengthPrefixedMessageWriter.swift | 22 +- .../StreamingResponseCallContext.swift | 2 +- .../Generator-Client.swift | 21 +- .../SwiftGRPCNIOTests/NIOServerTestCase.swift | 20 +- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 201 ++++++------------ Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift | 27 ++- 24 files changed, 564 insertions(+), 403 deletions(-) create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift create mode 100644 Sources/SwiftGRPCNIO/CompressionMechanism.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCTimeout.swift diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index 893745c69..4b706ec83 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -24,8 +24,7 @@ public class ServerStreamingCallHandler { get } - - /// Initial response metadata. - var initialMetadata: EventLoopFuture { get } - - /// Response status. - var status: EventLoopFuture { get } - - /// Trailing response metadata. - /// - /// This is the same metadata as `GRPCStatus.trailingMetadata` returned by `status`. - var trailingMetadata: EventLoopFuture { get } -} - - -extension ClientCall { - public var trailingMetadata: EventLoopFuture { - return status.map { $0.trailingMetadata } - } -} - - -public protocol StreamingRequestClientCall: ClientCall { - func send(_ event: StreamEvent) -} - - -extension StreamingRequestClientCall { - /// Sends a request to the service. Callers must terminate the stream of messages - /// with an `.end` event. - /// - /// - Parameter event: event to send. - public func send(_ event: StreamEvent) { - let request: GRPCClientRequestPart - switch event { - case .message(let message): - request = .message(message) - - case .end: - request = .end - } - - subchannel.whenSuccess { $0.write(NIOAny(request), promise: nil) } - } -} - - -public protocol UnaryResponseClientCall: ClientCall { - var response: EventLoopFuture { get } -} - - public class BaseClientCall: ClientCall { - public let subchannel: EventLoopFuture - public let initialMetadata: EventLoopFuture - public let status: EventLoopFuture + private let subchannelPromise: EventLoopPromise + private let initialMetadataPromise: EventLoopPromise + private let statusPromise: EventLoopPromise + + public var subchannel: EventLoopFuture { return subchannelPromise.futureResult } + public var initialMetadata: EventLoopFuture { return initialMetadataPromise.futureResult } + public var status: EventLoopFuture { return statusPromise.futureResult } /// Sets up a gRPC call. /// @@ -95,15 +42,17 @@ public class BaseClientCall: multiplexer: HTTP2StreamMultiplexer, responseHandler: GRPCClientResponseChannelHandler.ResponseMessageHandler ) { - let subchannelPromise: EventLoopPromise = channel.eventLoop.newPromise() - let metadataPromise: EventLoopPromise = channel.eventLoop.newPromise() - let statusPromise: EventLoopPromise = channel.eventLoop.newPromise() + self.subchannelPromise = channel.eventLoop.newPromise() + self.initialMetadataPromise = channel.eventLoop.newPromise() + self.statusPromise = channel.eventLoop.newPromise() - let channelHandler = GRPCClientResponseChannelHandler(metadata: metadataPromise, status: statusPromise, messageHandler: responseHandler) + let channelHandler = GRPCClientResponseChannelHandler(metadata: self.initialMetadataPromise, + status: self.statusPromise, + messageHandler: responseHandler) /// Create a new HTTP2 stream to handle calls. channel.eventLoop.execute { - multiplexer.createStreamChannel(promise: subchannelPromise) { (subchannel, streamID) -> EventLoopFuture in + multiplexer.createStreamChannel(promise: self.subchannelPromise) { (subchannel, streamID) -> EventLoopFuture in subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), HTTP1ToRawGRPCClientCodec(), GRPCClientCodec(), @@ -111,10 +60,32 @@ public class BaseClientCall: first: false) } } + } + + internal func send(requestHead: HTTPRequestHead, request: RequestMessage? = nil) { + subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + if let request = request { + channel.write(GRPCClientRequestPart.message(request), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) + } + } + } + + internal func setTimeout(_ timeout: GRPCTimeout?) { + guard let timeout = timeout else { return } + + self.subchannel.whenSuccess { channel in + let timeoutPromise = channel.eventLoop.newPromise(of: Void.self) + + timeoutPromise.futureResult.whenFailure { + self.failPromises(error: $0) + } - self.subchannel = subchannelPromise.futureResult - self.initialMetadata = metadataPromise.futureResult - self.status = statusPromise.futureResult + channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { + timeoutPromise.fail(error: GRPCStatus(code: .deadlineExceeded)) + } + } } internal func makeRequestHead(path: String, host: String, customMetadata: HTTPHeaders? = nil) -> HTTPRequestHead { @@ -127,6 +98,25 @@ public class BaseClientCall: requestHead.headers.add(name: "content-type", value: "application/grpc") requestHead.headers.add(name: "te", value: "trailers") requestHead.headers.add(name: "user-agent", value: "grpc-swift-nio") + + let acceptedEncoding = CompressionMechanism.acceptEncoding + .map { $0.rawValue } + .joined(separator: ",") + + requestHead.headers.add(name: "grpc-accept-encoding", value: acceptedEncoding) + return requestHead } + + internal func failPromises(error: Error) { + self.statusPromise.fail(error: error) + self.initialMetadataPromise.fail(error: error) + } + + public func cancel() { + self.subchannel.whenSuccess { channel in + channel.close(mode: .all, promise: nil) + } + self.failPromises(error: GRPCStatus.cancelled) + } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index db0f7e8cb..f450c284e 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -21,9 +21,9 @@ public class BidirectionalStreamingClientCall Void) { super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) - subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - } + self.setTimeout(callOptions.timeout) + + let requestHead = self.makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) + self.send(requestHead: requestHead) } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift new file mode 100644 index 000000000..1d0703a51 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -0,0 +1,78 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 +import NIOHTTP2 +import SwiftProtobuf + +public protocol ClientCall { + associatedtype RequestMessage: Message + associatedtype ResponseMessage: Message + + /// HTTP2 stream that requests and responses are sent and received on. + var subchannel: EventLoopFuture { get } + + /// Initial response metadata. + var initialMetadata: EventLoopFuture { get } + + /// Response status. + var status: EventLoopFuture { get } + + /// Trailing response metadata. + /// + /// This is the same metadata as `GRPCStatus.trailingMetadata` returned by `status`. + var trailingMetadata: EventLoopFuture { get } + + /// Cancels the current call. + func cancel() +} + +extension ClientCall { + public var trailingMetadata: EventLoopFuture { + return status.map { $0.trailingMetadata } + } +} + +/// A `ClientCall` with server-streaming; i.e. server-streaming and bidirectional-streaming. +public protocol StreamingRequestClientCall: ClientCall { + /// Sends a request to the service. Callers must terminate the stream of messages + /// with an `.end` event. + /// + /// - Parameter event: event to send. + func send(_ event: StreamEvent) +} + +extension StreamingRequestClientCall { + public func send(_ event: StreamEvent) { + switch event { + case .message(let message): + subchannel.whenSuccess { channel in + channel.write(NIOAny(GRPCClientRequestPart.message(message)), promise: nil) + } + + case .end: + subchannel.whenSuccess { channel in + channel.writeAndFlush(NIOAny(GRPCClientRequestPart.end), promise: nil) + } + } + } +} + +/// A `ClientCall` with a unary response; i.e. unary and client-streaming. +public protocol UnaryResponseClientCall: ClientCall { + var response: EventLoopFuture { get } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index 138a55d64..efe25c52f 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -18,17 +18,21 @@ import SwiftProtobuf import NIO public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { - public let response: EventLoopFuture + private let responsePromise: EventLoopPromise + public var response: EventLoopFuture { return responsePromise.futureResult } public init(client: GRPCClient, path: String, callOptions: CallOptions) { - let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() - self.response = responsePromise.futureResult + self.responsePromise = client.channel.eventLoop.newPromise() + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: self.responsePromise)) - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) + self.setTimeout(callOptions.timeout) - let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) - subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - } + let requestHead = self.makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) + self.send(requestHead: requestHead) + } + + override internal func failPromises(error: Error) { + super.failPromises(error: error) + self.responsePromise.fail(error: error) } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index b32332078..e6d1d3c78 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -21,11 +21,9 @@ public class ServerStreamingClientCall Void) { super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) - subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - channel.write(GRPCClientRequestPart.message(request), promise: nil) - channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) - } + self.setTimeout(callOptions.timeout) + + let requestHead = self.makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) + self.send(requestHead: requestHead, request: request) } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 862fa3108..02f1b1b89 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -18,19 +18,21 @@ import SwiftProtobuf import NIO public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { - public let response: EventLoopFuture + private let responsePromise: EventLoopPromise + public var response: EventLoopFuture { return responsePromise.futureResult } public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions) { - let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() - self.response = responsePromise.futureResult + self.responsePromise = client.channel.eventLoop.newPromise() + super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: self.responsePromise)) - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: responsePromise)) + self.setTimeout(callOptions.timeout) - let requestHead = makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) - subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - channel.write(GRPCClientRequestPart.message(request), promise: nil) - channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) - } + let requestHead = self.makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) + self.send(requestHead: requestHead, request: request) + } + + override internal func failPromises(error: Error) { + super.failPromises(error: error) + self.responsePromise.fail(error: error) } } diff --git a/Sources/SwiftGRPCNIO/ClientOptions.swift b/Sources/SwiftGRPCNIO/ClientOptions.swift index 51e78db84..aa0440a0e 100644 --- a/Sources/SwiftGRPCNIO/ClientOptions.swift +++ b/Sources/SwiftGRPCNIO/ClientOptions.swift @@ -16,10 +16,12 @@ import Foundation import NIOHTTP1 -public final class CallOptions { - var customMetadata: HTTPHeaders +public struct CallOptions { + public var customMetadata: HTTPHeaders + public var timeout: GRPCTimeout? - public init(customMetadata: HTTPHeaders = HTTPHeaders()) { + public init(customMetadata: HTTPHeaders = HTTPHeaders(), timeout: GRPCTimeout? = .minutes(1)) { self.customMetadata = customMetadata + self.timeout = timeout } } diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift new file mode 100644 index 000000000..acd565d64 --- /dev/null +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -0,0 +1,55 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +enum CompressionError: Error { + case unsupported(CompressionMechanism) +} + +internal enum CompressionMechanism: String, CaseIterable { + case none + case identity + case gzip + case deflate + case snappy + case unknown + + /// Whether there should be a corresponding header flag. + var requiresFlag: Bool { + switch self { + case .none: + return false + case .identity, .gzip, .deflate, .snappy, .unknown: + return true + } + } + + /// Whether the given compression is supported. + var supported: Bool { + switch self { + case .identity, .none: + return true + case .gzip, .deflate, .snappy, .unknown: + return false + } + } + + static var acceptEncoding: [CompressionMechanism] { + return CompressionMechanism + .allCases + .filter { $0.supported && $0.requiresFlag } + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift index 00d184d16..78672a525 100644 --- a/Sources/SwiftGRPCNIO/GRPCClient.swift +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -40,11 +40,13 @@ public final class GRPCClient { public let channel: Channel public let multiplexer: HTTP2StreamMultiplexer public let host: String + public var callOptions: CallOptions - init(channel: Channel, multiplexer: HTTP2StreamMultiplexer, host: String) { + init(channel: Channel, multiplexer: HTTP2StreamMultiplexer, host: String, callOptions: CallOptions = CallOptions()) { self.channel = channel self.multiplexer = multiplexer self.host = host + self.callOptions = callOptions } /// Fired when the client shuts down. @@ -57,12 +59,15 @@ public final class GRPCClient { } } -public protocol GRPCClientWrapper: CallOptionsProvider { +public protocol GRPCServiceClient { var client: GRPCClient { get } - /// Name of the service this client wrapper is for. + /// Name of the service this client is for (e.g. "echo.Echo"). var service: String { get } + /// The call options to use should the user not provide per-call options. + var callOptions: CallOptions { get set } + /// Return the path for the given method in the format "/Service-Name/Method-Name". /// /// This may be overriden if consumers require a different path format. @@ -72,18 +77,8 @@ public protocol GRPCClientWrapper: CallOptionsProvider { func path(forMethod method: String) -> String } -extension GRPCClientWrapper { +extension GRPCServiceClient { public func path(forMethod method: String) -> String { return "/\(service)/\(method)" } } - -public protocol CallOptionsProvider { - func defaultCallOptions() -> CallOptions -} - -extension CallOptionsProvider { - public func defaultCallOptions() -> CallOptions { - return CallOptions() - } -} diff --git a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift index 84466089a..010125fbc 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -74,7 +74,7 @@ extension GRPCClientCodec: ChannelOutboundHandler { case .message(let message): do { - ctx.write(wrapOutboundOut(.message(try message.serializedData())), promise: promise) + ctx.writeAndFlush(wrapOutboundOut(.message(try message.serializedData())), promise: promise) } catch { ctx.fireErrorCaught(error) } diff --git a/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift index c3a779007..73b35b589 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift @@ -18,7 +18,6 @@ import NIO import NIOHTTP1 import SwiftProtobuf - public class GRPCClientResponseChannelHandler { private let messageObserver: (ResponseMessage) -> Void private let metadataPromise: EventLoopPromise @@ -49,7 +48,6 @@ public class GRPCClientResponseChannelHandler { } } - extension GRPCClientResponseChannelHandler: ChannelInboundHandler { public typealias InboundIn = GRPCClientResponsePart diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index 320e3d32c..5f957650b 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -57,15 +57,13 @@ extension GRPCServerCodec: ChannelOutboundHandler { case .message(let message): do { let messageData = try message.serializedData() - var responseBuffer = ctx.channel.allocator.buffer(capacity: messageData.count) - responseBuffer.write(bytes: messageData) - ctx.write(self.wrapOutboundOut(.message(responseBuffer)), promise: promise) + ctx.write(self.wrapOutboundOut(.message(messageData)), promise: promise) } catch { promise?.fail(error: error) ctx.fireErrorCaught(error) } case .status(let status): - ctx.write(self.wrapOutboundOut(.status(status)), promise: promise) + ctx.writeAndFlush(self.wrapOutboundOut(.status(status)), promise: promise) } } } diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index ca2f1ca3b..b8e15834c 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -22,6 +22,8 @@ public struct GRPCStatus: Error { public static let ok = GRPCStatus(code: .ok, message: "OK") /// "Internal server error" status. public static let processingError = GRPCStatus(code: .internalError, message: "unknown error processing request") + /// Client cancelled the call. + public static let cancelled = GRPCStatus(code: .cancelled, message: "cancelled by the client") /// Status indicating that the given method is not implemented. public static func unimplemented(method: String) -> GRPCStatus { diff --git a/Sources/SwiftGRPCNIO/GRPCTimeout.swift b/Sources/SwiftGRPCNIO/GRPCTimeout.swift new file mode 100644 index 000000000..06434cf8f --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCTimeout.swift @@ -0,0 +1,76 @@ +import Foundation +import NIO + +public enum GRPCTimeoutUnit: String { + case hours = "H" + case minutes = "M" + case seconds = "S" + case milliseconds = "m" + case microseconds = "u" + case nanoseconds = "n" + + internal var asNanoseconds: Int { + switch self { + case .hours: + return 60 * 60 * 1000 * 1000 * 1000 + + case .minutes: + return 60 * 1000 * 1000 * 1000 + + case .seconds: + return 1000 * 1000 * 1000 + + case .milliseconds: + return 1000 * 1000 + + case .microseconds: + return 1000 + + case .nanoseconds: + return 1 + } + } +} + +public struct GRPCTimeout: CustomStringConvertible { + public let description: String + private let nanoseconds: Int64 + + private init?(_ amount: Int, _ unit: GRPCTimeoutUnit) { + // Timeouts must be positive and at most 8-digits. + guard amount >= 0, amount < 100_000_000 else { return nil } + + self.description = "\(amount) \(unit.rawValue)" + self.nanoseconds = Int64(amount) * Int64(unit.asNanoseconds) + } + + public static func hours(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .hours) + } + + public static func minutes(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .minutes) + } + + public static func seconds(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .seconds) + } + + public static func milliseconds(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .milliseconds) + } + + public static func microseconds(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .microseconds) + } + + public static func nanoseconds(_ amount: Int) -> GRPCTimeout? { + return GRPCTimeout(amount, .nanoseconds) + } +} + +extension GRPCTimeout { + public var asNIOTimeAmount: TimeAmount { + return TimeAmount.nanoseconds(numericCast(nanoseconds)) + } +} diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index aeff22b8e..a8f80fc19 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -54,6 +54,7 @@ public final class HTTP1ToRawGRPCClientCodec { private var state: State = .expectingHeaders private let messageReader = LengthPrefixedMessageReader(mode: .client) private let messageWriter = LengthPrefixedMessageWriter() + private var inboundCompression: CompressionMechanism? } extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { @@ -87,6 +88,10 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { guard case .expectingHeaders = state else { preconditionFailure("received headers while in state \(state)") } + if let encodingType = head.headers["grpc-encoding"].first { + inboundCompression = CompressionMechanism(rawValue: encodingType) ?? .unknown + } + ctx.fireChannelRead(wrapInboundOut(.headers(head.headers))) return .expectingBodyOrTrailers } @@ -99,8 +104,10 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { guard case .expectingBodyOrTrailers = state else { preconditionFailure("received body while in state \(state)") } - if let message = try self.messageReader.read(messageBuffer: &messageBuffer) { - ctx.fireChannelRead(wrapInboundOut(.message(message))) + while messageBuffer.readableBytes > 0 { + if let message = try self.messageReader.read(messageBuffer: &messageBuffer, compression: inboundCompression ?? .none) { + ctx.fireChannelRead(wrapInboundOut(.message(message))) + } } return .expectingBodyOrTrailers @@ -134,8 +141,12 @@ extension HTTP1ToRawGRPCClientCodec: ChannelOutboundHandler { ctx.write(wrapOutboundOut(.head(requestHead)), promise: promise) case .message(let message): - let request = messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) - ctx.write(wrapOutboundOut(.body(.byteBuffer(request))), promise: promise) + do { + let request = try messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) + ctx.write(wrapOutboundOut(.body(.byteBuffer(request))), promise: promise) + } catch { + ctx.fireErrorCaught(error) + } case .end: ctx.writeAndFlush(wrapOutboundOut(.end(nil)), promise: promise) diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index 678a0706b..f4bbef739 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -9,10 +9,10 @@ public enum RawGRPCServerRequestPart { case end } -/// Outgoing gRPC package with an unknown message type (represented by a byte buffer). +/// Outgoing gRPC package with an unknown message type (represented by `Data`). public enum RawGRPCServerResponsePart { case headers(HTTPHeaders) - case message(ByteBuffer) + case message(Data) case status(GRPCStatus) } @@ -29,21 +29,12 @@ public enum RawGRPCServerResponsePart { public final class HTTP1ToRawGRPCServerCodec { private enum State { case expectingHeaders - case expectingCompressedFlag - case expectingMessageLength - case receivedMessageLength(UInt32) - - var expectingBody: Bool { - switch self { - case .expectingHeaders: return false - case .expectingCompressedFlag, .expectingMessageLength, .receivedMessageLength: return true - } - } + case expectingBody } private var state = State.expectingHeaders - - private var buffer: NIO.ByteBuffer? + private let messageReader = LengthPrefixedMessageReader(mode: .server) + private let messageWriter = LengthPrefixedMessageWriter() } extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { @@ -56,47 +47,21 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { guard case .expectingHeaders = state else { preconditionFailure("received headers while in state \(state)") } - state = .expectingCompressedFlag - buffer = ctx.channel.allocator.buffer(capacity: 5) - + state = .expectingBody ctx.fireChannelRead(self.wrapInboundOut(.head(requestHead))) case .body(var body): - guard var buffer = buffer - else { preconditionFailure("buffer not initialized") } - assert(state.expectingBody, "received body while in state \(state)") - buffer.write(buffer: &body) - - // Iterate over all available incoming data, trying to read length-delimited messages. - // Each message has the following format: - // - 1 byte "compressed" flag (currently always zero, as we do not support for compression) - // - 4 byte signed-integer payload length (N) - // - N bytes payload (normally a valid wire-format protocol buffer) - requestProcessing: while true { - switch state { - case .expectingHeaders: preconditionFailure("unexpected state \(state)") - case .expectingCompressedFlag: - guard let compressionFlag: Int8 = buffer.readInteger() else { break requestProcessing } - //! FIXME: Avoid crashing here and instead drop the connection. - precondition(compressionFlag == 0, "unexpected compression flag \(compressionFlag); compression is not supported and we did not indicate support for it") - state = .expectingMessageLength - - case .expectingMessageLength: - guard let messageLength: UInt32 = buffer.readInteger() else { break requestProcessing } - state = .receivedMessageLength(messageLength) - - case .receivedMessageLength(let messageLength): - guard let messageBytes = buffer.readBytes(length: numericCast(messageLength)) else { break } - - //! FIXME: Use a slice of this buffer instead of copying to a new buffer. - var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.count) - responseBuffer.write(bytes: messageBytes) - ctx.fireChannelRead(self.wrapInboundOut(.message(responseBuffer))) - //! FIXME: Call buffer.discardReadBytes() here? - //! ALTERNATIVE: Check if the buffer has no further data right now, then clear it. - - state = .expectingCompressedFlag + guard case .expectingBody = state + else { preconditionFailure("received body while in state \(state)") } + + do { + while body.readableBytes > 0 { + if let message = try messageReader.read(messageBuffer: &body, compression: .none) { + ctx.fireChannelRead(wrapInboundOut(.message(message))) + } } + } catch { + ctx.fireErrorCaught(error) } case .end(let trailers): @@ -120,13 +85,15 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { case .headers(let headers): //! FIXME: Should return a different version if we want to support pPRC. ctx.write(self.wrapOutboundOut(.head(HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok, headers: headers))), promise: promise) - case .message(var messageBytes): - // Write out a length-delimited message payload. See `channelRead` for the corresponding format. - var responseBuffer = ctx.channel.allocator.buffer(capacity: messageBytes.readableBytes + 5) - responseBuffer.write(integer: Int8(0)) // Compression flag: no compression - responseBuffer.write(integer: UInt32(messageBytes.readableBytes)) - responseBuffer.write(buffer: &messageBytes) - ctx.write(self.wrapOutboundOut(.body(.byteBuffer(responseBuffer))), promise: promise) + + case .message(let message): + do { + let responseBuffer = try messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) + ctx.write(self.wrapOutboundOut(.body(.byteBuffer(responseBuffer))), promise: promise) + } catch { + ctx.fireErrorCaught(error) + } + case .status(let status): var trailers = status.trailingMetadata trailers.add(name: "grpc-status", value: String(describing: status.code.rawValue)) diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index ed57484a7..b8f915b6c 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -17,77 +17,128 @@ import Foundation import NIO import NIOHTTP1 +/// This class reads and decodes length-prefixed gRPC messages. +/// +/// Messages are expected to be in the following format: +/// - compression flag: 0/1 as a 1-byte unsigned integer, +/// - message length: length of the message as a 4-byte unsigned integer, +/// - message: `message_length` bytes. +/// +/// - SeeAlso: +/// [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) internal class LengthPrefixedMessageReader { - var buffer: ByteBuffer? - var state: State = .expectingCompressedFlag - var mode: Mode + private var buffer: ByteBuffer! + private var state: State = .expectingCompressedFlag + private let mode: Mode - init(mode: Mode) { + internal init(mode: Mode) { self.mode = mode } - enum Mode { case client, server } + internal enum Mode { case client, server } - enum State { + private enum State { case expectingCompressedFlag case expectingMessageLength - case receivedMessageLength(UInt32) + case receivedMessageLength(Int) + case willBuffer(requiredBytes: Int) + case isBuffering(requiredBytes: Int) } - func read(messageBuffer: inout ByteBuffer) throws -> ByteBuffer? { + /// Reads bytes from the given buffer until it is exhausted or a message has been read. + /// + /// Length prefixed messages may be split across multiple input buffers in any of the + /// following places: + /// 1. after the compression flag, + /// 2. after the message length flag, + /// 3. at any point within the message. + /// + /// - Note: + /// This method relies on state; if a message is _not_ returned then the next time this + /// method is called it expect to read the bytes which follow the most recently read bytes. + /// If a message _is_ returned without exhausting the given buffer then reading a + /// different buffer is not an issue. + /// + /// - Parameter messageBuffer: buffer to read from. + /// - Returns: A buffer containing a message if one has been read, or `nil` if not enough + /// bytes have been consumed to return a message. + /// - Throws: Throws an error if the compression algorithm is not supported. This depends + // on the `Mode` this instance is running in. + internal func read(messageBuffer: inout ByteBuffer, compression: CompressionMechanism) throws -> ByteBuffer? { while true { switch state { case .expectingCompressedFlag: guard let compressionFlag: Int8 = messageBuffer.readInteger() else { return nil } + precondition(compressionFlag == 0) - try handleCompressionFlag(enabled: compressionFlag != 0) - state = .expectingMessageLength + try handleCompressionFlag(enabled: compressionFlag != 0, compression: compression) + self.state = .expectingMessageLength case .expectingMessageLength: guard let messageLength: UInt32 = messageBuffer.readInteger() else { return nil } - state = .receivedMessageLength(messageLength) + self.state = .receivedMessageLength(numericCast(messageLength)) case .receivedMessageLength(let messageLength): - // We need to account for messages being spread across multiple `ByteBuffer`s so buffer them - // into `buffer`. Note: when messages are contained within a single `ByteBuffer` we're just - // taking a slice so don't incur any extra writes. - guard messageBuffer.readableBytes >= messageLength else { - let remainingBytes = messageLength - numericCast(messageBuffer.readableBytes) - - if var buffer = buffer { - buffer.write(buffer: &messageBuffer) - self.buffer = buffer - } else { - messageBuffer.reserveCapacity(numericCast(messageLength)) - self.buffer = messageBuffer - } - - state = .receivedMessageLength(remainingBytes) - return nil + // If this holds true, we can skip buffering and return a slice. + guard messageLength <= messageBuffer.readableBytes else { + self.state = .willBuffer(requiredBytes: messageLength) + break } - // We know buffer.readableBytes >= messageLength, so it's okay to force unwrap here. - var slice = messageBuffer.readSlice(length: numericCast(messageLength))! - self.buffer?.write(buffer: &slice) - let message = self.buffer ?? slice + self.state = .expectingCompressedFlag + // We know messageBuffer.readableBytes >= messageLength, so it's okay to force unwrap here. + return messageBuffer.readSlice(length: messageLength)! + + case .willBuffer(let requiredBytes): + messageBuffer.reserveCapacity(requiredBytes) + self.buffer = messageBuffer + + let readableBytes = messageBuffer.readableBytes + // Move the reader index to avoid reading the bytes again. + messageBuffer.moveReaderIndex(forwardBy: readableBytes) - self.buffer = nil + self.state = .isBuffering(requiredBytes: requiredBytes - readableBytes) + return nil + + case .isBuffering(let requiredBytes): + guard requiredBytes <= messageBuffer.readableBytes else { + self.state = .isBuffering(requiredBytes: requiredBytes - self.buffer.write(buffer: &messageBuffer)) + return nil + } + + // We know messageBuffer.readableBytes >= requiredBytes, so it's okay to force unwrap here. + var slice = messageBuffer.readSlice(length: requiredBytes)! + self.buffer.write(buffer: &slice) self.state = .expectingCompressedFlag - return message + + defer { self.buffer = nil } + return buffer } } } - private func handleCompressionFlag(enabled: Bool) throws { - switch mode { - case .client: - // TODO: handle this better; cancel the call? - precondition(!enabled, "compression is not supported") + private func handleCompressionFlag(enabled flagEnabled: Bool, compression: CompressionMechanism) throws { + // Do we agree on state? + guard flagEnabled == compression.requiresFlag else { + switch mode { + case .client: + // TODO: handle this better; cancel the call? + preconditionFailure("compression is not supported") - case .server: - if enabled { + case .server: throw GRPCStatus(code: .unimplemented, message: "compression is not yet supported on the server") } } + + guard compression.supported else { + switch mode { + case .client: + // TODO: handle this better; cancel the call? + preconditionFailure("\(compression) compression is not supported") + + case .server: + throw GRPCStatus(code: .unimplemented, message: "\(compression) compression is not yet supported on the server") + } + } } } diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift index c33d7ec07..b4825bda7 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -16,17 +16,25 @@ import Foundation import NIO -enum MessageCompression { - case none +internal class LengthPrefixedMessageWriter { - var enabled: Bool { return false } -} + /// Writes the data into a `ByteBuffer` as a gRPC length-prefixed message. + /// + /// - Parameters: + /// - allocator: Buffer allocator. + /// - compression: Compression mechanism to use. + /// - message: The serialized Protobuf message to write. + /// - Returns: A `ByteBuffer` containing a gRPC length-prefixed message. + /// - Throws: `CompressionError` if the compression mechanism is not supported. + /// - Note: See `LengthPrefixedMessageReader` for more details on the format. + func write(allocator: ByteBufferAllocator, compression: CompressionMechanism, message: Data) throws -> ByteBuffer { + guard compression.supported else { throw CompressionError.unsupported(compression) } -internal class LengthPrefixedMessageWriter { - func write(allocator: ByteBufferAllocator, compression: MessageCompression, message: Data) -> ByteBuffer { + // 1-byte for compression flag, 4-bytes for the message length. var buffer = allocator.buffer(capacity: 5 + message.count) - buffer.write(integer: Int8(compression.enabled ? 1 : 0)) + //: TODO: Add compression support, use the length and compressed content. + buffer.write(integer: Int8(compression.requiresFlag ? 1 : 0)) buffer.write(integer: UInt32(message.count)) buffer.write(bytes: message) diff --git a/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift b/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift index 9b18f04dc..6a47e852a 100644 --- a/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift +++ b/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift @@ -45,7 +45,7 @@ open class StreamingResponseCallContextImpl: Streaming open override func sendResponse(_ message: ResponseMessage) -> EventLoopFuture { let promise: EventLoopPromise = eventLoop.newPromise() - channel.writeAndFlush(NIOAny(WrappedResponse.message(message)), promise: promise) + channel.write(NIOAny(WrappedResponse.message(message)), promise: promise) return promise.futureResult } } diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift index 6ddb3d99b..fbe776956 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift @@ -355,14 +355,21 @@ extension Generator { } private func printNIOServiceClientImplementation() { - println("\(access) final class \(serviceClassName)Client: GRPCClientWrapper, \(serviceClassName) {") + println("\(access) final class \(serviceClassName)Client: GRPCServiceClient, \(serviceClassName) {") indent() println("\(access) let client: GRPCClient") println("\(access) let service = \"\(servicePath)\"") + println("\(access) var callOptions: CallOptions") println() - println("\(access) init(client: GRPCClient) {") + println("/// Creates a client for the \(servicePath) service.") + println("///") + printParameters() + println("/// - client: `GRPCClient` with a connection to the service host.") + println("/// - callOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.callOptions`.") + println("\(access) init(client: GRPCClient, callOptions: CallOptions? = nil) {") indent() println("self.client = client") + println("self.callOptions = callOptions ?? client.callOptions") outdent() println("}") println() @@ -379,7 +386,7 @@ extension Generator { println("/// - Returns: A `UnaryClientCall` with futures for the metadata, status and response.") println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil) -> UnaryClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return UnaryClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? defaultCallOptions())") + println("return UnaryClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.callOptions)") outdent() println("}") @@ -393,7 +400,7 @@ extension Generator { println("/// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status.") println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> ServerStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return ServerStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler)") + println("return ServerStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.callOptions, handler: handler)") outdent() println("}") @@ -407,7 +414,7 @@ extension Generator { println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") println("func \(methodFunctionName)(callOptions: CallOptions? = nil) -> ClientStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return ClientStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? defaultCallOptions())") + println("return ClientStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.callOptions)") outdent() println("}") @@ -422,7 +429,7 @@ extension Generator { println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") println("func \(methodFunctionName)(callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> BidirectionalStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return BidirectionalStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? defaultCallOptions(), handler: handler)") + println("return BidirectionalStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.callOptions, handler: handler)") outdent() println("}") } @@ -446,7 +453,7 @@ extension Generator { } private func printCallOptionsParameter() { - println("/// - callOptions: Call options; `defaultCallOptions()` is used if `nil`.") + println("/// - callOptions: Call options; `self.callOptions` is used if `nil`.") } private func printHandlerParameter() { diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift b/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift index cd0848214..68910aefc 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift @@ -15,7 +15,8 @@ */ import Dispatch import Foundation -@testable import SwiftGRPC +import NIO +@testable import SwiftGRPCNIO import XCTest extension Echo_EchoRequest { @@ -31,22 +32,15 @@ extension Echo_EchoResponse { } class NIOServerTestCase: XCTestCase { - func makeProvider() -> Echo_EchoProvider { return EchoProvider() } - - var provider: Echo_EchoProvider! - var client: Echo_EchoServiceClient! - - var defaultTimeout: TimeInterval { return 1.0 } - var address: String { return "localhost:5050" } + var client: Echo_EchoService_NIOClient! override func setUp() { super.setUp() - provider = makeProvider() - - client = Echo_EchoServiceClient(address: address, secure: false) - - client.timeout = defaultTimeout + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.client = try! GRPCClient.start(host: "localhost", port: 5050, eventLoopGroup: eventLoopGroup) + .map { Echo_EchoService_NIOClient(client: $0) } + .wait() } override func tearDown() { diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 61801389d..fc2ba25bf 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -18,7 +18,6 @@ import Foundation import NIO import NIOHTTP1 import NIOHTTP2 -@testable import SwiftGRPC @testable import SwiftGRPCNIO import XCTest @@ -59,12 +58,15 @@ final class EchoProvider_NIO: Echo_EchoProvider_NIO { func update(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { var endOfSendOperationQueue = context.eventLoop.newSucceededFuture(result: ()) var count = 0 + return context.eventLoop.newSucceededFuture(result: { event in switch event { case .message(let message): var response = Echo_EchoResponse() response.text = "Swift echo update (\(count)): \(message.text)" - endOfSendOperationQueue = endOfSendOperationQueue.then { context.sendResponse(response) } + endOfSendOperationQueue = endOfSendOperationQueue.then { + context.sendResponse(response) + } count += 1 case .end: @@ -92,19 +94,21 @@ class NIOServerTests: NIOServerTestCase { ] } - static let lotsOfStrings = (0..<1000).map { String(describing: $0) } + static let aFewStrings = ["foo", "bar", "baz"] + static let lotsOfStrings = (0..<10_000).map { String(describing: $0) } var eventLoopGroup: MultiThreadedEventLoopGroup! var server: GRPCServer! override func setUp() { - super.setUp() - // This is how a GRPC server would actually be set up. eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) server = try! GRPCServer.start( hostname: "localhost", port: 5050, eventLoopGroup: eventLoopGroup, serviceProviders: [EchoProvider_NIO()]) .wait() + + // `super.setUp()` sets up a client; do this after the server. + super.setUp() } override func tearDown() { @@ -118,185 +122,100 @@ class NIOServerTests: NIOServerTestCase { } extension NIOServerTests { - func testUnary() { - XCTAssertEqual("Swift echo get: foo", try! client.get(Echo_EchoRequest(text: "foo")).text) + func testUnary() throws { + XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo" }).response.wait().text, "Swift echo get: foo") } - func testUnaryLotsOfRequests() { + func testUnaryLotsOfRequests() throws { // Sending that many requests at once can sometimes trip things up, it seems. - client.timeout = 5.0 let clockStart = clock() let numberOfRequests = 2_000 + for i in 0.. 0 { print("\(i) requests sent so far, elapsed time: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } - XCTAssertEqual("Swift echo get: foo \(i)", try client.get(Echo_EchoRequest(text: "foo \(i)")).text) + XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo \(i)" }).response.wait().text, "Swift echo get: foo \(i)") } print("total time for \(numberOfRequests) requests: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } } extension NIOServerTests { - func testClientStreaming() { - let completionHandlerExpectation = expectation(description: "final completion handler called") - let call = try! client.collect { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - completionHandlerExpectation.fulfill() - } - - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "foo"))) - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "bar"))) - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "baz"))) - call.waitForSendOperationsToFinish() - - let response = try! call.closeAndReceive() - XCTAssertEqual("Swift echo collect: foo bar baz", response.text) + func doTestClientStreaming(messages: [String]) throws { + let call = client.collect() - waitForExpectations(timeout: defaultTimeout) - } - - func testClientStreamingLotsOfMessages() { - let completionHandlerExpectation = expectation(description: "completion handler called") - let call = try! client.collect { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - completionHandlerExpectation.fulfill() + for message in messages { + call.send(.message(Echo_EchoRequest.with { $0.text = message })) } + call.send(.end) - for string in NIOServerTests.lotsOfStrings { - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: string))) - } - call.waitForSendOperationsToFinish() + XCTAssertEqual("Swift echo collect: " + messages.joined(separator: " "), try call.response.wait().text) + XCTAssertEqual(.ok, try call.status.wait().code) + } - let response = try! call.closeAndReceive() - XCTAssertEqual("Swift echo collect: " + NIOServerTests.lotsOfStrings.joined(separator: " "), response.text) + func testClientStreaming() { + XCTAssertNoThrow(try doTestClientStreaming(messages: NIOServerTests.aFewStrings)) + } - waitForExpectations(timeout: defaultTimeout) + func testClientStreamingLotsOfMessages() throws { + XCTAssertNoThrow(try doTestClientStreaming(messages: NIOServerTests.lotsOfStrings)) } } extension NIOServerTests { - func testServerStreaming() { - let completionHandlerExpectation = expectation(description: "completion handler called") - let call = try! client.expand(Echo_EchoRequest(text: "foo bar baz")) { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - completionHandlerExpectation.fulfill() + func doTestServerStreaming(messages: [String]) throws { + var index = 0 + let call = client.expand(Echo_EchoRequest.with { $0.text = messages.joined(separator: " ") }) { response in + XCTAssertEqual("Swift echo expand (\(index)): \(messages[index])", response.text) + index += 1 } - XCTAssertEqual("Swift echo expand (0): foo", try! call.receive()!.text) - XCTAssertEqual("Swift echo expand (1): bar", try! call.receive()!.text) - XCTAssertEqual("Swift echo expand (2): baz", try! call.receive()!.text) - XCTAssertNil(try! call.receive()) + XCTAssertEqual(try call.status.wait().code, .ok) + } - waitForExpectations(timeout: defaultTimeout) + func testServerStreaming() { + XCTAssertNoThrow(try doTestServerStreaming(messages: NIOServerTests.aFewStrings)) } func testServerStreamingLotsOfMessages() { - let completionHandlerExpectation = expectation(description: "completion handler called") - let call = try! client.expand(Echo_EchoRequest(text: NIOServerTests.lotsOfStrings.joined(separator: " "))) { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - completionHandlerExpectation.fulfill() - } - - for string in NIOServerTests.lotsOfStrings { - XCTAssertEqual("Swift echo expand (\(string)): \(string)", try! call.receive()!.text) - } - XCTAssertNil(try! call.receive()) - - waitForExpectations(timeout: defaultTimeout) + XCTAssertNoThrow(try doTestServerStreaming(messages: NIOServerTests.lotsOfStrings)) } } extension NIOServerTests { - func testBidirectionalStreamingBatched() { - let finalCompletionHandlerExpectation = expectation(description: "final completion handler called") - let call = try! client.update { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - finalCompletionHandlerExpectation.fulfill() + private func doTestBidirectionalStreaming(messages: [String], waitForEachResponse: Bool = false) throws { + let responseReceived = waitForEachResponse ? DispatchSemaphore(value: 0) : nil + var index = 0 + + let call = client.update { response in + XCTAssertEqual("Swift echo update (\(index)): \(messages[index])", response.text) + responseReceived?.signal() + index += 1 } - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "foo"))) - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "bar"))) - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "baz"))) - - call.waitForSendOperationsToFinish() - - XCTAssertNoThrow(try call.closeSend()) - - XCTAssertEqual("Swift echo update (0): foo", try! call.receive()!.text) - XCTAssertEqual("Swift echo update (1): bar", try! call.receive()!.text) - XCTAssertEqual("Swift echo update (2): baz", try! call.receive()!.text) - XCTAssertNil(try! call.receive()) - - waitForExpectations(timeout: defaultTimeout) - } - - func testBidirectionalStreamingPingPong() { - let finalCompletionHandlerExpectation = expectation(description: "final completion handler called") - let call = try! client.update { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - finalCompletionHandlerExpectation.fulfill() + messages.forEach { part in + call.send(.message(Echo_EchoRequest.with { $0.text = part })) + responseReceived?.wait() } + call.send(.end) - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "foo"))) - XCTAssertEqual("Swift echo update (0): foo", try! call.receive()!.text) - - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "bar"))) - XCTAssertEqual("Swift echo update (1): bar", try! call.receive()!.text) - - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: "baz"))) - XCTAssertEqual("Swift echo update (2): baz", try! call.receive()!.text) - - call.waitForSendOperationsToFinish() - - XCTAssertNoThrow(try call.closeSend()) - - XCTAssertNil(try! call.receive()) - - waitForExpectations(timeout: defaultTimeout) + XCTAssertEqual(try call.status.wait().code, .ok) } - func testBidirectionalStreamingLotsOfMessagesBatched() { - let finalCompletionHandlerExpectation = expectation(description: "final completion handler called") - let call = try! client.update { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - finalCompletionHandlerExpectation.fulfill() - } - - for string in NIOServerTests.lotsOfStrings { - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: string))) - } - - call.waitForSendOperationsToFinish() - - XCTAssertNoThrow(try call.closeSend()) - - for string in NIOServerTests.lotsOfStrings { - XCTAssertEqual("Swift echo update (\(string)): \(string)", try! call.receive()!.text) - } - XCTAssertNil(try! call.receive()) - - waitForExpectations(timeout: defaultTimeout) + func testBidirectionalStreamingBatched() throws { + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.aFewStrings)) } - func testBidirectionalStreamingLotsOfMessagesPingPong() { - let finalCompletionHandlerExpectation = expectation(description: "final completion handler called") - let call = try! client.update { callResult in - XCTAssertEqual(.ok, callResult.statusCode) - finalCompletionHandlerExpectation.fulfill() - } - - for string in NIOServerTests.lotsOfStrings { - XCTAssertNoThrow(try call.send(Echo_EchoRequest(text: string))) - XCTAssertEqual("Swift echo update (\(string)): \(string)", try! call.receive()!.text) - } - - call.waitForSendOperationsToFinish() - - XCTAssertNoThrow(try call.closeSend()) + func testBidirectionalStreamingPingPong() throws { + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.aFewStrings, waitForEachResponse: true)) + } - XCTAssertNil(try! call.receive()) + func testBidirectionalStreamingLotsOfMessagesBatched() throws { + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings)) + } - waitForExpectations(timeout: defaultTimeout) + func testBidirectionalStreamingLotsOfMessagesPingPong() throws { + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings, waitForEachResponse: true)) } } diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift index dad219c92..f3fca2a41 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift @@ -35,33 +35,40 @@ internal protocol Echo_EchoService_NIO { func update(callOptions: CallOptions?, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall } -internal final class Echo_EchoService_NIOClient: GRPCClientWrapper, Echo_EchoService_NIO { +internal final class Echo_EchoService_NIOClient: GRPCServiceClient, Echo_EchoService_NIO { internal let client: GRPCClient internal let service = "echo.Echo" + internal var callOptions: CallOptions - internal init(client: GRPCClient) { + /// Creates a client for the echo.Echo service. + /// + /// - Parameters: + /// - client: `GRPCClient` with a connection to the service host. + /// - callOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.callOptions`. + internal init(client: GRPCClient, callOptions: CallOptions? = nil) { self.client = client + self.callOptions = callOptions ?? client.callOptions } /// Asynchronous unary call to Get. /// /// - Parameters: /// - request: Request to send to Get. - /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - callOptions: Call options; `self.callOptions` is used if `nil`. /// - Returns: A `UnaryClientCall` with futures for the metadata, status and response. func get(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { - return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? defaultCallOptions()) + return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? self.callOptions) } /// Asynchronous server-streaming call to Expand. /// /// - Parameters: /// - request: Request to send to Expand. - /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - callOptions: Call options; `self.callOptions` is used if `nil`. /// - handler: A closure called when each response is received from the server. /// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status. func expand(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { - return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? defaultCallOptions(), handler: handler) + return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? self.callOptions, handler: handler) } /// Asynchronous client-streaming call to Collect. @@ -70,10 +77,10 @@ internal final class Echo_EchoService_NIOClient: GRPCClientWrapper, Echo_EchoSer /// to the server. The caller should send an `.end` after the final message has been sent. /// /// - Parameters: - /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - callOptions: Call options; `self.callOptions` is used if `nil`. /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { - return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? defaultCallOptions()) + return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? self.callOptions) } /// Asynchronous bidirectional-streaming call to Update. @@ -82,11 +89,11 @@ internal final class Echo_EchoService_NIOClient: GRPCClientWrapper, Echo_EchoSer /// to the server. The caller should send an `.end` after the final message has been sent. /// /// - Parameters: - /// - callOptions: Call options; `defaultCallOptions()` is used if `nil`. + /// - callOptions: Call options; `self.callOptions` is used if `nil`. /// - handler: A closure called when each response is received from the server. /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { - return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? defaultCallOptions(), handler: handler) + return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? self.callOptions, handler: handler) } } From 66e8d6e4aac46e9059e02d7c60aa9d7978f4e7d1 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 5 Feb 2019 15:21:30 +0000 Subject: [PATCH 09/30] GRPCTimeout documentation --- Sources/SwiftGRPCNIO/GRPCTimeout.swift | 108 ++++++++++++++++++------- 1 file changed, 77 insertions(+), 31 deletions(-) diff --git a/Sources/SwiftGRPCNIO/GRPCTimeout.swift b/Sources/SwiftGRPCNIO/GRPCTimeout.swift index 06434cf8f..46c597c7e 100644 --- a/Sources/SwiftGRPCNIO/GRPCTimeout.swift +++ b/Sources/SwiftGRPCNIO/GRPCTimeout.swift @@ -1,41 +1,19 @@ import Foundation import NIO -public enum GRPCTimeoutUnit: String { - case hours = "H" - case minutes = "M" - case seconds = "S" - case milliseconds = "m" - case microseconds = "u" - case nanoseconds = "n" - - internal var asNanoseconds: Int { - switch self { - case .hours: - return 60 * 60 * 1000 * 1000 * 1000 - - case .minutes: - return 60 * 1000 * 1000 * 1000 - - case .seconds: - return 1000 * 1000 * 1000 - - case .milliseconds: - return 1000 * 1000 - - case .microseconds: - return 1000 - - case .nanoseconds: - return 1 - } - } -} - +/// A timeout for a gRPC call. +/// +/// Timeouts must be positive and at most 8-digits long. public struct GRPCTimeout: CustomStringConvertible { + /// A description of the timeout in the format described in the + /// [gRPC protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md). public let description: String + private let nanoseconds: Int64 + /// Creates a new GRPCTimeout with the given `amount` of the `unit`. + /// + /// `amount` must be positive and at most 8-digits. private init?(_ amount: Int, _ unit: GRPCTimeoutUnit) { // Timeouts must be positive and at most 8-digits. guard amount >= 0, amount < 100_000_000 else { return nil } @@ -44,33 +22,101 @@ public struct GRPCTimeout: CustomStringConvertible { self.nanoseconds = Int64(amount) * Int64(unit.asNanoseconds) } + /// Creates a new GRPCTimeout for the given amount of hours. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of hours this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of hours if the amount was valid, `nil` otherwise. public static func hours(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .hours) } + /// Creates a new GRPCTimeout for the given amount of minutes. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of minutes this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of minutes if the amount was valid, `nil` otherwise. public static func minutes(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .minutes) } + /// Creates a new GRPCTimeout for the given amount of seconds. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of seconds this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of seconds if the amount was valid, `nil` otherwise. public static func seconds(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .seconds) } + /// Creates a new GRPCTimeout for the given amount of milliseconds. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of milliseconds this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of milliseconds if the amount was valid, `nil` otherwise. public static func milliseconds(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .milliseconds) } + /// Creates a new GRPCTimeout for the given amount of microseconds. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of microseconds this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of microseconds if the amount was valid, `nil` otherwise. public static func microseconds(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .microseconds) } + /// Creates a new GRPCTimeout for the given amount of nanoseconds. + /// + /// `amount` must be positive and at most 8-digits. + /// + /// - Parameter amount: the amount of nanoseconds this `GRPCTimeout` represents. + /// - Returns: A `GRPCTimeout` representing the given number of nanoseconds if the amount was valid, `nil` otherwise. public static func nanoseconds(_ amount: Int) -> GRPCTimeout? { return GRPCTimeout(amount, .nanoseconds) } } extension GRPCTimeout { + /// Returns a NIO `TimeAmount` representing the amount of time as this timeout. public var asNIOTimeAmount: TimeAmount { return TimeAmount.nanoseconds(numericCast(nanoseconds)) } } + +private enum GRPCTimeoutUnit: String { + case hours = "H" + case minutes = "M" + case seconds = "S" + case milliseconds = "m" + case microseconds = "u" + case nanoseconds = "n" + + internal var asNanoseconds: Int { + switch self { + case .hours: + return 60 * 60 * 1000 * 1000 * 1000 + + case .minutes: + return 60 * 1000 * 1000 * 1000 + + case .seconds: + return 1000 * 1000 * 1000 + + case .milliseconds: + return 1000 * 1000 + + case .microseconds: + return 1000 + + case .nanoseconds: + return 1 + } + } +} From d3727f658dc42ad10f765ac37f84afaf3144ad52 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 5 Feb 2019 15:31:01 +0000 Subject: [PATCH 10/30] Add timeout to request headers --- Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift | 9 +++++++-- .../ClientCalls/BidirectionalStreamingClientCall.swift | 2 +- .../ClientCalls/ClientStreamingClientCall.swift | 2 +- .../ClientCalls/ServerStreamingClientCall.swift | 2 +- Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift | 2 +- .../StreamingResponseCallContext.swift | 2 +- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 3b2b568b6..99655f026 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -88,9 +88,10 @@ public class BaseClientCall: } } - internal func makeRequestHead(path: String, host: String, customMetadata: HTTPHeaders? = nil) -> HTTPRequestHead { + internal func makeRequestHead(path: String, host: String, callOptions: CallOptions) -> HTTPRequestHead { var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) - customMetadata?.forEach { name, value in + + callOptions.customMetadata.forEach { name, value in requestHead.headers.add(name: name, value: value) } @@ -105,6 +106,10 @@ public class BaseClientCall: requestHead.headers.add(name: "grpc-accept-encoding", value: acceptedEncoding) + if let timeout = callOptions.timeout { + requestHead.headers.add(name: "grpc-timeout", value: String(describing: timeout)) + } + return requestHead } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index f450c284e..eff35b791 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -23,7 +23,7 @@ public class BidirectionalStreamingClientCall: self.setTimeout(callOptions.timeout) - let requestHead = self.makeRequestHead(path: path, host: client.host, customMetadata: callOptions.customMetadata) + let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) self.send(requestHead: requestHead, request: request) } diff --git a/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift b/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift index 6a47e852a..9b18f04dc 100644 --- a/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift +++ b/Sources/SwiftGRPCNIO/ServerCallContexts/StreamingResponseCallContext.swift @@ -45,7 +45,7 @@ open class StreamingResponseCallContextImpl: Streaming open override func sendResponse(_ message: ResponseMessage) -> EventLoopFuture { let promise: EventLoopPromise = eventLoop.newPromise() - channel.write(NIOAny(WrappedResponse.message(message)), promise: promise) + channel.writeAndFlush(NIOAny(WrappedResponse.message(message)), promise: promise) return promise.futureResult } } From 64da48a026026a1f11e7a543f386f1379fa18453 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 6 Feb 2019 15:38:46 +0000 Subject: [PATCH 11/30] Add client cancelling and timeout tests. --- .../ClientCalls/BaseClientCall.swift | 155 +++++++++----- .../BidirectionalStreamingClientCall.swift | 7 +- .../ClientStreamingClientCall.swift | 23 +- .../ClientCalls/ResponseObserver.swift | 41 ++++ .../ServerStreamingClientCall.swift | 9 +- .../ClientCalls/UnaryClientCall.swift | 24 +-- Sources/SwiftGRPCNIO/ClientOptions.swift | 7 +- .../SwiftGRPCNIO/CompressionMechanism.swift | 1 + .../GRPCClientChannelHandler.swift | 201 ++++++++++++++++++ Sources/SwiftGRPCNIO/GRPCClientCodec.swift | 1 + .../GRPCClientResponseChannelHandler.swift | 70 ------ .../LengthPrefixedMessageReader.swift | 32 +-- .../LengthPrefixedMessageWriter.swift | 1 - ...TestCase.swift => BasicEchoTestCase.swift} | 28 ++- .../ClientCancellingTests.swift | 91 ++++++++ .../ClientTimeoutTests.swift | 136 ++++++++++++ Tests/SwiftGRPCNIOTests/EchoProvider.swift | 1 - Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 30 +-- 18 files changed, 639 insertions(+), 219 deletions(-) create mode 100644 Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift delete mode 100644 Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift rename Tests/SwiftGRPCNIOTests/{NIOServerTestCase.swift => BasicEchoTestCase.swift} (51%) create mode 100644 Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift create mode 100644 Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift delete mode 120000 Tests/SwiftGRPCNIOTests/EchoProvider.swift diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 99655f026..df1dbc167 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -19,76 +19,139 @@ import NIOHTTP1 import NIOHTTP2 import SwiftProtobuf -public class BaseClientCall: ClientCall { - private let subchannelPromise: EventLoopPromise - private let initialMetadataPromise: EventLoopPromise - private let statusPromise: EventLoopPromise +public class BaseClientCall { + /// The underlying `GRPCClient` providing the HTTP/2 channel and multiplexer. + internal let client: GRPCClient - public var subchannel: EventLoopFuture { return subchannelPromise.futureResult } - public var initialMetadata: EventLoopFuture { return initialMetadataPromise.futureResult } - public var status: EventLoopFuture { return statusPromise.futureResult } + /// Promise for an HTTP/2 stream. + internal let streamPromise: EventLoopPromise + + /// Client channel handler. Handles some internal state for reading/writing messages to the channel. + /// The handler also owns the promises for the futures that this class surfaces to the user (such as + /// `initialMetadata` and `status`). + internal let clientChannelHandler: GRPCClientChannelHandler /// Sets up a gRPC call. /// - /// Creates a new HTTP2 stream (`subchannel`) using the given multiplexer and configures the pipeline to - /// handle client gRPC requests and responses. + /// A number of actions are performed: + /// - a new HTTP/2 stream is created and configured using channel and multiplexer provided by `client`, + /// - a callback is registered on the new stream (`subchannel`) to send the request head, + /// - a timeout is scheduled if one is set in the `callOptions`. /// /// - Parameters: - /// - channel: the main channel. - /// - multiplexer: HTTP2 stream multiplexer on which HTTP2 streams are created. - /// - responseHandler: handler for received messages. + /// - client: client containing the HTTP/2 channel and multiplexer to use for this call. + /// - path: path for this RPC method. + /// - callOptions: options to use when configuring this call. + /// - responseObserver: observer for received messages. init( - channel: Channel, - multiplexer: HTTP2StreamMultiplexer, - responseHandler: GRPCClientResponseChannelHandler.ResponseMessageHandler + client: GRPCClient, + path: String, + callOptions: CallOptions, + responseObserver: ResponseObserver ) { - self.subchannelPromise = channel.eventLoop.newPromise() - self.initialMetadataPromise = channel.eventLoop.newPromise() - self.statusPromise = channel.eventLoop.newPromise() + self.client = client + self.streamPromise = client.channel.eventLoop.newPromise() + self.clientChannelHandler = GRPCClientChannelHandler( + initialMetadataPromise: client.channel.eventLoop.newPromise(), + statusPromise: client.channel.eventLoop.newPromise(), + responseObserver: responseObserver) + + self.createStreamChannel() + self.setTimeout(callOptions.timeout) + + let requestHead = BaseClientCall.makeRequestHead(path: path, host: client.host, callOptions: callOptions) + self.sendRequestHead(requestHead) + } +} + +extension BaseClientCall: ClientCall { + /// HTTP/2 stream associated with this call. + public var subchannel: EventLoopFuture { + return self.streamPromise.futureResult + } - let channelHandler = GRPCClientResponseChannelHandler(metadata: self.initialMetadataPromise, - status: self.statusPromise, - messageHandler: responseHandler) + /// Initial metadata returned from the server. + public var initialMetadata: EventLoopFuture { + return self.clientChannelHandler.initialMetadataPromise.futureResult + } + /// Status of this call which may originate from the server or client. + /// + /// Note: despite `GRPCStatus` being an `Error`, the value will be delievered as a __success__ + /// result even if the status represents a __negative__ outcome. + public var status: EventLoopFuture { + return self.clientChannelHandler.statusPromise.futureResult + } + + /// Cancel the current call. + /// + /// Closes the HTTP/2 stream once it becomes available. Additional writes to the channel will be ignored. + /// Any unfulfilled promises will be failed with a cancelled status (excepting `status` which will be + /// succeeded, if not already succeeded). + public func cancel() { + self.client.channel.eventLoop.execute { + self.subchannel.whenSuccess { channel in + channel.close(mode: .all, promise: nil) + } + } + } +} + +extension BaseClientCall { + /// Creates and configures an HTTP/2 stream channel. `subchannel` will contain the stream channel when it is created. + internal func createStreamChannel() { /// Create a new HTTP2 stream to handle calls. - channel.eventLoop.execute { - multiplexer.createStreamChannel(promise: self.subchannelPromise) { (subchannel, streamID) -> EventLoopFuture in + self.client.channel.eventLoop.execute { + self.client.multiplexer.createStreamChannel(promise: self.streamPromise) { (subchannel, streamID) -> EventLoopFuture in subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), HTTP1ToRawGRPCClientCodec(), GRPCClientCodec(), - channelHandler], + self.clientChannelHandler], first: false) } } } - internal func send(requestHead: HTTPRequestHead, request: RequestMessage? = nil) { - subchannel.whenSuccess { channel in + /// Send the request head once `subchannel` becomes available. + internal func sendRequestHead(_ requestHead: HTTPRequestHead) { + self.subchannel.whenSuccess { channel in channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) - if let request = request { - channel.write(GRPCClientRequestPart.message(request), promise: nil) - channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) - } } } - internal func setTimeout(_ timeout: GRPCTimeout?) { - guard let timeout = timeout else { return } + /// Send the given request once `subchannel` becomes available. + internal func sendRequest(_ request: RequestMessage) { + self.subchannel.whenSuccess { channel in + channel.write(GRPCClientRequestPart.message(request), promise: nil) + } + } + /// Send `end` once `subchannel` becomes available. + internal func sendEnd() { self.subchannel.whenSuccess { channel in - let timeoutPromise = channel.eventLoop.newPromise(of: Void.self) + channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) + } + } - timeoutPromise.futureResult.whenFailure { - self.failPromises(error: $0) - } + /// Creates a client-side timeout for this call. + internal func setTimeout(_ timeout: GRPCTimeout?) { + guard let timeout = timeout else { return } - channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { - timeoutPromise.fail(error: GRPCStatus(code: .deadlineExceeded)) - } + let clientChannelHandler = self.clientChannelHandler + self.client.channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { + let status = GRPCStatus(code: .deadlineExceeded, message: "client timed out after \(timeout)") + clientChannelHandler.observeStatus(status) } } - internal func makeRequestHead(path: String, host: String, callOptions: CallOptions) -> HTTPRequestHead { + /// Makes a new `HTTPRequestHead` for this call. + /// + /// - Parameters: + /// - path: path for this RPC method. + /// - host: the address of the host we are connected to. + /// - callOptions: options to use when configuring this call. + /// - Returns: `HTTPRequestHead` configured for this call. + internal class func makeRequestHead(path: String, host: String, callOptions: CallOptions) -> HTTPRequestHead { var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) callOptions.customMetadata.forEach { name, value in @@ -112,16 +175,4 @@ public class BaseClientCall: return requestHead } - - internal func failPromises(error: Error) { - self.statusPromise.fail(error: error) - self.initialMetadataPromise.fail(error: error) - } - - public func cancel() { - self.subchannel.whenSuccess { channel in - channel.close(mode: .all, promise: nil) - } - self.failPromises(error: GRPCStatus.cancelled) - } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index eff35b791..10926927a 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -19,11 +19,6 @@ import NIO public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - - self.setTimeout(callOptions.timeout) - - let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.send(requestHead: requestHead) + super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index 2402b7765..d9b10e1ba 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -18,21 +18,16 @@ import SwiftProtobuf import NIO public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { - private let responsePromise: EventLoopPromise - public var response: EventLoopFuture { return responsePromise.futureResult } - - public init(client: GRPCClient, path: String, callOptions: CallOptions) { - self.responsePromise = client.channel.eventLoop.newPromise() - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: self.responsePromise)) - - self.setTimeout(callOptions.timeout) - - let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.send(requestHead: requestHead) + public var response: EventLoopFuture { + // It's okay to force unwrap because we know the handler is holding the response promise. + return self.clientChannelHandler.responsePromise!.futureResult } - override internal func failPromises(error: Error) { - super.failPromises(error: error) - self.responsePromise.fail(error: error) + public init(client: GRPCClient, path: String, callOptions: CallOptions) { + super.init( + client: client, + path: path, + callOptions: callOptions, + responseObserver: .succeedPromise(client.channel.eventLoop.newPromise())) } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift new file mode 100644 index 000000000..9d66c2a65 --- /dev/null +++ b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift @@ -0,0 +1,41 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import SwiftProtobuf + +/// An response message observer. +/// +/// - succeedPromise: succeed the given promise on receipt of a message. +/// - callback: calls the given callback for each response observed. +internal enum ResponseObserver { + /// Fulfill the given promise on receiving the first response message. + case succeedPromise(EventLoopPromise) + + /// Call the given handler for each response message received. + case callback((ResponseMessage) -> Void) + + /// Observe the given message. + func observe(_ message: ResponseMessage) { + switch self { + case .callback(let callback): + callback(message) + + case .succeedPromise(let promise): + promise.succeed(result: message) + } + } +} diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index 221565317..e4f43344a 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -19,11 +19,8 @@ import NIO public class ServerStreamingClientCall: BaseClientCall { public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .callback(handler: handler)) - - self.setTimeout(callOptions.timeout) - - let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.send(requestHead: requestHead, request: request) + super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) + self.sendRequest(request) + self.sendEnd() } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 1ba78bea8..1d767ade2 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -18,21 +18,19 @@ import SwiftProtobuf import NIO public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { - private let responsePromise: EventLoopPromise - public var response: EventLoopFuture { return responsePromise.futureResult } + public var response: EventLoopFuture { + // It's okay to force unwrap because we know the handler is holding the response promise. + return self.clientChannelHandler.responsePromise!.futureResult + } public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions) { - self.responsePromise = client.channel.eventLoop.newPromise() - super.init(channel: client.channel, multiplexer: client.multiplexer, responseHandler: .fulfill(promise: self.responsePromise)) - - self.setTimeout(callOptions.timeout) - - let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.send(requestHead: requestHead, request: request) - } + super.init( + client: client, + path: path, + callOptions: callOptions, + responseObserver: .succeedPromise(client.channel.eventLoop.newPromise())) - override internal func failPromises(error: Error) { - super.failPromises(error: error) - self.responsePromise.fail(error: error) + self.sendRequest(request) + self.sendEnd() } } diff --git a/Sources/SwiftGRPCNIO/ClientOptions.swift b/Sources/SwiftGRPCNIO/ClientOptions.swift index aa0440a0e..28c439da0 100644 --- a/Sources/SwiftGRPCNIO/ClientOptions.swift +++ b/Sources/SwiftGRPCNIO/ClientOptions.swift @@ -17,10 +17,15 @@ import Foundation import NIOHTTP1 public struct CallOptions { + public static let defaultTimeout = GRPCTimeout.minutes(1) + + /// Additional metadata to send to the service. public var customMetadata: HTTPHeaders + + /// The call timeout; defaults to to 1 minute. public var timeout: GRPCTimeout? - public init(customMetadata: HTTPHeaders = HTTPHeaders(), timeout: GRPCTimeout? = .minutes(1)) { + public init(customMetadata: HTTPHeaders = HTTPHeaders(), timeout: GRPCTimeout? = CallOptions.defaultTimeout) { self.customMetadata = customMetadata self.timeout = timeout } diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift index acd565d64..bb35feae7 100644 --- a/Sources/SwiftGRPCNIO/CompressionMechanism.swift +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -47,6 +47,7 @@ internal enum CompressionMechanism: String, CaseIterable { } } + /// Compression mechanisms we should list in an accept-encoding header. static var acceptEncoding: [CompressionMechanism] { return CompressionMechanism .allCases diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift new file mode 100644 index 000000000..873a770eb --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -0,0 +1,201 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIO +import NIOHTTP1 +import SwiftProtobuf + +/// The final client-side channel handler. +/// +/// This handler holds promises for the initial metadata and status, as well as an observer +/// for responses. For unary and client-streaming calls the observer will succeed a response +/// promise. For server-streaming and bidirectional-streaming the observer will call the supplied +/// callback with each response received. +/// +/// Errors are also handled by the channel handler. Promises for the initial metadata and +/// response (if applicable) are failed with first error received. The status promise is __succeeded__ +/// with the error as a `GRPCStatus`. The stream is also closed and any inbound or outbound messages +/// are ignored. +public class GRPCClientChannelHandler { + internal let initialMetadataPromise: EventLoopPromise + internal let statusPromise: EventLoopPromise + internal let responseObserver: ResponseObserver + + /// A promise for a unary response. + internal var responsePromise: EventLoopPromise? { + guard case .succeedPromise(let promise) = responseObserver else { return nil } + return promise + } + + /// Promise that the `HTTPRequestHead` has been sent to the network. + /// + /// If we attempt to close the stream before this has been fulfilled then the program will fatal + /// error because of an issue with nghttp2/swift-nio-http2. + /// + /// Since we need this promise to succeed before we can close the channel, `BaseClientCall` sends + /// the request head in `init` which will in turn initialize this promise in `write(ctx:data:promise:)`. + /// + /// See: https://github.com/apple/swift-nio-http2/issues/39. + private var requestHeadSentPromise: EventLoopPromise! + + private enum InboundState { + case expectingHeaders + case expectingMessageOrStatus + case ignore + } + + private enum OutboundState { + case expectingHead + case expectingMessageOrEnd + case ignore + } + + private var inboundState: InboundState = .expectingHeaders + private var outboundState: OutboundState = .expectingHead + + /// Creates a new `GRPCClientChannelHandler`. + /// + /// - Parameters: + /// - initialMetadataPromise: a promise to succeed on receiving the initial metadata from the service. + /// - statusPromise: a promise to succeed with the outcome of the call. + /// - responseObserver: an observer for response messages from the server; for unary responses this should + /// be the `succeedPromise` case. + internal init( + initialMetadataPromise: EventLoopPromise, + statusPromise: EventLoopPromise, + responseObserver: ResponseObserver + ) { + self.initialMetadataPromise = initialMetadataPromise + self.statusPromise = statusPromise + self.responseObserver = responseObserver + } + + /// Observe the given status. + /// + /// The `status` promise is __succeeded__ with the given status despite `GRPCStatus` being an + /// `Error`. If `status.code != .ok` then the initial metadata and response promises (if applicable) + /// are failed with the given status. + /// + /// - Parameter status: the status to observe. + internal func observeStatus(_ status: GRPCStatus) { + if status.code != .ok { + self.initialMetadataPromise.fail(error: status) + self.responsePromise?.fail(error: status) + } + self.statusPromise.succeed(result: status) + } +} + +extension GRPCClientChannelHandler: ChannelInboundHandler { + public typealias InboundIn = GRPCClientResponsePart + + /// Reads inbound data. + /// + /// On receipt of: + /// - headers: the initial metadata promise is succeeded. + /// - message: the message observer is called with the message; for unary responses a response + /// promise is succeeded, otherwise a callback is called. + /// - status: the status promise is succeeded; if the status is not `ok` then the initial metadata + /// and response promise (if available) are failed with the status. The channel is then closed. + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + guard self.inboundState != .ignore else { return } + + switch unwrapInboundIn(data) { + case .headers(let headers): + guard self.inboundState == .expectingHeaders else { + self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + return + } + + self.initialMetadataPromise.succeed(result: headers) + self.inboundState = .expectingMessageOrStatus + + case .message(let message): + guard self.inboundState == .expectingMessageOrStatus else { + self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + return + } + + self.responseObserver.observe(message) + + case .status(let status): + guard self.inboundState == .expectingMessageOrStatus else { + self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + return + } + + self.observeStatus(status) + + // We don't expect any more requests/responses beyond this point. + self.close(ctx: ctx, mode: .all, promise: nil) + } + } +} + + +extension GRPCClientChannelHandler: ChannelOutboundHandler { + public typealias OutboundIn = GRPCClientRequestPart + public typealias OutboundOut = GRPCClientRequestPart + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + guard self.inboundState != .ignore else { return } + + switch unwrapOutboundIn(data) { + case .head: + guard self.outboundState == .expectingHead else { + self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + return + } + + // See the documentation for `requestHeadSentPromise` for an explanation of this. + self.requestHeadSentPromise = promise ?? ctx.eventLoop.newPromise() + ctx.write(data, promise: self.requestHeadSentPromise) + self.outboundState = .expectingMessageOrEnd + + default: + guard self.outboundState == .expectingMessageOrEnd else { + self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + return + } + + ctx.write(data, promise: promise) + } + } +} + +extension GRPCClientChannelHandler { + /// Closes the HTTP/2 stream. Inbound and outbound state are set to ignore. + public func close(ctx: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise?) { + self.observeStatus(GRPCStatus.cancelled) + + requestHeadSentPromise.futureResult.whenComplete { + ctx.close(mode: mode, promise: promise) + } + + self.inboundState = .ignore + self.outboundState = .ignore + } + + /// Observe an error from the pipeline. Errors are cast to `GRPCStatus` or `GRPCStatus.processingError` + /// if the cast failed and promises are fulfilled with the status. The channel is also closed. + public func errorCaught(ctx: ChannelHandlerContext, error: Error) { + let status = (error as? GRPCStatus) ?? .processingError + self.observeStatus(status) + + // We don't expect any more requests/responses beyond this point. + self.close(ctx: ctx, mode: .all, promise: nil) + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift index 010125fbc..7db6ec6fb 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -48,6 +48,7 @@ extension GRPCClientCodec: ChannelInboundHandler { ctx.fireChannelRead(wrapInboundOut(.headers(headers))) case .message(var message): + // Force unwrapping is okay here; we're only reading the readable bytes. let messageAsData = message.readData(length: message.readableBytes)! do { ctx.fireChannelRead(self.wrapInboundOut(.message(try ResponseMessage(serializedData: messageAsData)))) diff --git a/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift deleted file mode 100644 index 73b35b589..000000000 --- a/Sources/SwiftGRPCNIO/GRPCClientResponseChannelHandler.swift +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2019, gRPC Authors All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Foundation -import NIO -import NIOHTTP1 -import SwiftProtobuf - -public class GRPCClientResponseChannelHandler { - private let messageObserver: (ResponseMessage) -> Void - private let metadataPromise: EventLoopPromise - private let statusPromise: EventLoopPromise - - init(metadata: EventLoopPromise, status: EventLoopPromise, messageHandler: ResponseMessageHandler) { - self.metadataPromise = metadata - self.statusPromise = status - self.messageObserver = messageHandler.observer - } - - enum ResponseMessageHandler { - /// Fulfill the given promise on receiving the first response message. - case fulfill(promise: EventLoopPromise) - - /// Call the given handler for each response message received. - case callback(handler: (ResponseMessage) -> Void) - - var observer: (ResponseMessage) -> Void { - switch self { - case .callback(let observer): - return observer - - case .fulfill(let promise): - return { promise.succeed(result: $0) } - } - } - } -} - -extension GRPCClientResponseChannelHandler: ChannelInboundHandler { - public typealias InboundIn = GRPCClientResponsePart - - public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { - switch unwrapInboundIn(data) { - case .headers(let headers): - self.metadataPromise.succeed(result: headers) - - case .message(let message): - self.messageObserver(message) - - case .status(let status): - //! FIXME: error status codes should fail the response promise (if one exists). - self.statusPromise.succeed(result: status) - - // We don't expect any more requests/responses beyond this point. - _ = ctx.channel.close(mode: .all) - } - } -} diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index b8f915b6c..61fb1929a 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -62,16 +62,13 @@ internal class LengthPrefixedMessageReader { /// - Parameter messageBuffer: buffer to read from. /// - Returns: A buffer containing a message if one has been read, or `nil` if not enough /// bytes have been consumed to return a message. - /// - Throws: Throws an error if the compression algorithm is not supported. This depends - // on the `Mode` this instance is running in. + /// - Throws: Throws an error if the compression algorithm is not supported. internal func read(messageBuffer: inout ByteBuffer, compression: CompressionMechanism) throws -> ByteBuffer? { while true { switch state { case .expectingCompressedFlag: guard let compressionFlag: Int8 = messageBuffer.readInteger() else { return nil } - precondition(compressionFlag == 0) - - try handleCompressionFlag(enabled: compressionFlag != 0, compression: compression) + try handleCompressionFlag(enabled: compressionFlag != 0, mechanism: compression) self.state = .expectingMessageLength case .expectingMessageLength: @@ -117,28 +114,13 @@ internal class LengthPrefixedMessageReader { } } - private func handleCompressionFlag(enabled flagEnabled: Bool, compression: CompressionMechanism) throws { - // Do we agree on state? - guard flagEnabled == compression.requiresFlag else { - switch mode { - case .client: - // TODO: handle this better; cancel the call? - preconditionFailure("compression is not supported") - - case .server: - throw GRPCStatus(code: .unimplemented, message: "compression is not yet supported on the server") - } + private func handleCompressionFlag(enabled flagEnabled: Bool, mechanism: CompressionMechanism) throws { + guard flagEnabled == mechanism.requiresFlag else { + throw GRPCStatus.processingError } - guard compression.supported else { - switch mode { - case .client: - // TODO: handle this better; cancel the call? - preconditionFailure("\(compression) compression is not supported") - - case .server: - throw GRPCStatus(code: .unimplemented, message: "\(compression) compression is not yet supported on the server") - } + guard mechanism.supported else { + throw GRPCStatus(code: .unimplemented, message: "\(mechanism) compression is not currently supported on the \(mode)") } } } diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift index b4825bda7..44a7925c2 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -17,7 +17,6 @@ import Foundation import NIO internal class LengthPrefixedMessageWriter { - /// Writes the data into a `ByteBuffer` as a gRPC length-prefixed message. /// /// - Parameters: diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift b/Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift similarity index 51% rename from Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift rename to Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift index 68910aefc..660e33e93 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift @@ -31,20 +31,40 @@ extension Echo_EchoResponse { } } -class NIOServerTestCase: XCTestCase { +class BasicEchoTestCase: XCTestCase { + var defaultTimeout: TimeInterval = 1.0 + + var serverEventLoopGroup: EventLoopGroup! + var server: GRPCServer! + + var clientEventLoopGroup: EventLoopGroup! var client: Echo_EchoService_NIOClient! override func setUp() { super.setUp() - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - self.client = try! GRPCClient.start(host: "localhost", port: 5050, eventLoopGroup: eventLoopGroup) + self.serverEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.server = try! GRPCServer.start( + hostname: "localhost", port: 5050, eventLoopGroup: self.serverEventLoopGroup, serviceProviders: [EchoProvider_NIO()]) + .wait() + + self.clientEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.client = try! GRPCClient.start( + host: "localhost", port: 5050, eventLoopGroup: self.clientEventLoopGroup) .map { Echo_EchoService_NIOClient(client: $0) } .wait() } override func tearDown() { - client = nil + XCTAssertNoThrow(try self.client.client.close().wait()) + XCTAssertNoThrow(try self.clientEventLoopGroup.syncShutdownGracefully()) + self.clientEventLoopGroup = nil + self.client = nil + + XCTAssertNoThrow(try self.server.close().wait()) + XCTAssertNoThrow(try self.serverEventLoopGroup.syncShutdownGracefully()) + self.serverEventLoopGroup = nil + self.server = nil super.tearDown() } diff --git a/Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift b/Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift new file mode 100644 index 000000000..b949c95ec --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift @@ -0,0 +1,91 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftGRPCNIO +import XCTest + +class ClientCancellingTests: BasicEchoTestCase { + static var allTests: [(String, (ClientCancellingTests) -> () throws -> Void)] { + return [ + ("testUnary", testUnary), + ("testClientStreaming", testClientStreaming), + ("testServerStreaming", testServerStreaming), + ("testBidirectionalStreaming", testBidirectionalStreaming) + ] + } +} + +extension ClientCancellingTests { + func testUnary() { + let statusReceived = self.expectation(description: "status received") + + let call = client.get(Echo_EchoRequest(text: "foo bar baz")) + call.cancel() + + call.status.whenSuccess { status in + XCTAssertEqual(status.code, .cancelled) + statusReceived.fulfill() + } + + waitForExpectations(timeout: self.defaultTimeout) + } + + func testClientStreaming() throws { + let statusReceived = self.expectation(description: "status received") + + let call = client.collect() + call.cancel() + + call.status.whenSuccess { status in + XCTAssertEqual(status.code, .cancelled) + statusReceived.fulfill() + } + + waitForExpectations(timeout: self.defaultTimeout) + } + + func testServerStreaming() { + let statusReceived = self.expectation(description: "status received") + + let call = client.expand(Echo_EchoRequest(text: "foo bar baz")) { response in + XCTFail("response should not be received after cancelling call") + } + call.cancel() + + call.status.whenSuccess { status in + XCTAssertEqual(status.code, .cancelled) + statusReceived.fulfill() + } + + waitForExpectations(timeout: self.defaultTimeout) + } + + func testBidirectionalStreaming() { + let statusReceived = self.expectation(description: "status received") + + let call = client.update { response in + XCTFail("response should not be received after cancelling call") + } + call.cancel() + + call.status.whenSuccess { status in + XCTAssertEqual(status.code, .cancelled) + statusReceived.fulfill() + } + + waitForExpectations(timeout: self.defaultTimeout) + } +} diff --git a/Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift b/Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift new file mode 100644 index 000000000..bafb837a4 --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift @@ -0,0 +1,136 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import SwiftGRPCNIO +import NIO +import XCTest + +class ClientTimeoutTests: BasicEchoTestCase { + static var allTests: [(String, (ClientTimeoutTests) -> () throws -> Void)] { + return [ + ("testUnaryTimeoutAfterSending", testUnaryTimeoutAfterSending), + ("testServerStreamingTimeoutAfterSending", testServerStreamingTimeoutAfterSending), + ("testClientStreamingTimeoutBeforeSending", testClientStreamingTimeoutBeforeSending), + ("testClientStreamingTimeoutAfterSending", testClientStreamingTimeoutAfterSending), + ("testBidirectionalStreamingTimeoutBeforeSending", testBidirectionalStreamingTimeoutBeforeSending), + ("testBidirectionalStreamingTimeoutAfterSending", testBidirectionalStreamingTimeoutAfterSending) + ] + } + + private func expectDeadlineExceeded(forStatus status: EventLoopFuture) { + let statusExpectation = self.expectation(description: "status received") + + status.whenSuccess { status in + XCTAssertEqual(status.code, .deadlineExceeded) + statusExpectation.fulfill() + } + + status.whenFailure { error in + XCTFail("unexpectedelty received error for status: \(error)") + } + } + + private func expectDeadlineExceeded(forResponse response: EventLoopFuture) { + let responseExpectation = self.expectation(description: "response received") + + response.whenFailure { error in + XCTAssertEqual((error as? GRPCStatus)?.code, .deadlineExceeded) + responseExpectation.fulfill() + } + + response.whenSuccess { response in + XCTFail("response recevied after deadline") + } + } +} + +extension ClientTimeoutTests { + func testUnaryTimeoutAfterSending() { + // The request gets fired on call creation, so we need a very short timeout. + let callOptions = CallOptions(timeout: .milliseconds(1)) + let call = client.get(Echo_EchoRequest(text: "foo"), callOptions: callOptions) + + self.expectDeadlineExceeded(forStatus: call.status) + self.expectDeadlineExceeded(forResponse: call.response) + + waitForExpectations(timeout: defaultTimeout) + } + + func testServerStreamingTimeoutAfterSending() { + // The request gets fired on call creation, so we need a very short timeout. + let callOptions = CallOptions(timeout: .milliseconds(1)) + let call = client.expand(Echo_EchoRequest(text: "foo bar baz"), callOptions: callOptions) { response in + XCTFail("response received after deadline") + } + + self.expectDeadlineExceeded(forStatus: call.status) + + waitForExpectations(timeout: defaultTimeout) + } + + func testClientStreamingTimeoutBeforeSending() { + let callOptions = CallOptions(timeout: .milliseconds(50)) + let call = client.collect(callOptions: callOptions) + + self.expectDeadlineExceeded(forStatus: call.status) + self.expectDeadlineExceeded(forResponse: call.response) + + waitForExpectations(timeout: defaultTimeout) + } + + func testClientStreamingTimeoutAfterSending() { + let callOptions = CallOptions(timeout: .milliseconds(50)) + let call = client.collect(callOptions: callOptions) + + self.expectDeadlineExceeded(forStatus: call.status) + self.expectDeadlineExceeded(forResponse: call.response) + + call.send(.message(Echo_EchoRequest(text: "foo"))) + + // Timeout before sending `.end` + Thread.sleep(forTimeInterval: 0.1) + call.send(.end) + + waitForExpectations(timeout: defaultTimeout) + } + + func testBidirectionalStreamingTimeoutBeforeSending() { + let callOptions = CallOptions(timeout: .milliseconds(50)) + let call = client.update(callOptions: callOptions) { response in + XCTFail("response received after deadline") + } + + self.expectDeadlineExceeded(forStatus: call.status) + + Thread.sleep(forTimeInterval: 0.1) + waitForExpectations(timeout: defaultTimeout) + } + + func testBidirectionalStreamingTimeoutAfterSending() { + let callOptions = CallOptions(timeout: .milliseconds(50)) + let call = client.update(callOptions: callOptions) { _ in } + + self.expectDeadlineExceeded(forStatus: call.status) + + call.send(.message(Echo_EchoRequest(text: "foo"))) + + // Timeout before sending `.end` + Thread.sleep(forTimeInterval: 0.1) + call.send(.end) + + waitForExpectations(timeout: defaultTimeout) + } +} diff --git a/Tests/SwiftGRPCNIOTests/EchoProvider.swift b/Tests/SwiftGRPCNIOTests/EchoProvider.swift deleted file mode 120000 index 7d14bfe94..000000000 --- a/Tests/SwiftGRPCNIOTests/EchoProvider.swift +++ /dev/null @@ -1 +0,0 @@ -../../Sources/Examples/Echo/EchoProvider.swift \ No newline at end of file diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index fc2ba25bf..8723eb597 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -68,7 +68,7 @@ final class EchoProvider_NIO: Echo_EchoProvider_NIO { context.sendResponse(response) } count += 1 - + case .end: endOfSendOperationQueue .map { GRPCStatus.ok } @@ -78,7 +78,7 @@ final class EchoProvider_NIO: Echo_EchoProvider_NIO { } } -class NIOServerTests: NIOServerTestCase { +class NIOServerTests: BasicEchoTestCase { static var allTests: [(String, (NIOServerTests) -> () throws -> Void)] { return [ ("testUnary", testUnary), @@ -96,34 +96,12 @@ class NIOServerTests: NIOServerTestCase { static let aFewStrings = ["foo", "bar", "baz"] static let lotsOfStrings = (0..<10_000).map { String(describing: $0) } - - var eventLoopGroup: MultiThreadedEventLoopGroup! - var server: GRPCServer! - - override func setUp() { - // This is how a GRPC server would actually be set up. - eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - server = try! GRPCServer.start( - hostname: "localhost", port: 5050, eventLoopGroup: eventLoopGroup, serviceProviders: [EchoProvider_NIO()]) - .wait() - - // `super.setUp()` sets up a client; do this after the server. - super.setUp() - } - - override func tearDown() { - XCTAssertNoThrow(try server.close().wait()) - - XCTAssertNoThrow(try eventLoopGroup.syncShutdownGracefully()) - eventLoopGroup = nil - - super.tearDown() - } } extension NIOServerTests { func testUnary() throws { - XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo" }).response.wait().text, "Swift echo get: foo") + let options = CallOptions(timeout: nil) + XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo" }, callOptions: options).response.wait().text, "Swift echo get: foo") } func testUnaryLotsOfRequests() throws { From 86e1e3dfd26852adcb38affc3bbc390100d6185d Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 7 Feb 2019 15:55:52 +0000 Subject: [PATCH 12/30] Fix typos, missing doc --- Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift | 3 +-- .../ClientCalls/BidirectionalStreamingClientCall.swift | 9 +++++++++ Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift | 2 +- .../ClientCalls/ClientStreamingClientCall.swift | 10 ++++++++++ .../SwiftGRPCNIO/ClientCalls/ResponseObserver.swift | 5 +---- .../ClientCalls/ServerStreamingClientCall.swift | 6 ++++++ Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift | 7 +++++++ Sources/SwiftGRPCNIO/CompressionMechanism.swift | 10 ++++++++-- Sources/SwiftGRPCNIO/GRPCClient.swift | 2 +- Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift | 3 +-- Sources/SwiftGRPCNIO/GRPCClientCodec.swift | 4 +++- Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift | 10 ++++------ 12 files changed, 52 insertions(+), 19 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index df1dbc167..20fe09648 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -34,7 +34,7 @@ public class BaseClientCall { /// Sets up a gRPC call. /// /// A number of actions are performed: - /// - a new HTTP/2 stream is created and configured using channel and multiplexer provided by `client`, + /// - a new HTTP/2 stream is created and configured using the channel and multiplexer provided by `client`, /// - a callback is registered on the new stream (`subchannel`) to send the request head, /// - a timeout is scheduled if one is set in the `callOptions`. /// @@ -100,7 +100,6 @@ extension BaseClientCall: ClientCall { extension BaseClientCall { /// Creates and configures an HTTP/2 stream channel. `subchannel` will contain the stream channel when it is created. internal func createStreamChannel() { - /// Create a new HTTP2 stream to handle calls. self.client.channel.eventLoop.execute { self.client.multiplexer.createStreamChannel(promise: self.streamPromise) { (subchannel, streamID) -> EventLoopFuture in subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index 10926927a..46f92045a 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -17,6 +17,15 @@ import Foundation import SwiftProtobuf import NIO +/// A bidirectional-streaming gRPC call. Each response is passed to the provided observer. +/// +/// Messages should be sent via the `send` method; an `.end` message should be sent +/// to indicate the final message has been sent. +/// +/// The following futures are available to the caller: +/// - `initialMetadata`: the initial metadata returned from the server, +/// - `status`: the status of the gRPC call, +/// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift index 1d0703a51..36ac96af6 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -23,7 +23,7 @@ public protocol ClientCall { associatedtype RequestMessage: Message associatedtype ResponseMessage: Message - /// HTTP2 stream that requests and responses are sent and received on. + /// HTTP/2 stream that requests and responses are sent and received on. var subchannel: EventLoopFuture { get } /// Initial response metadata. diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index d9b10e1ba..ec2614fb5 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -17,6 +17,16 @@ import Foundation import SwiftProtobuf import NIO +/// A client-streaming gRPC call. +/// +/// Messages should be sent via the `send` method; an `.end` message should be sent +/// to indicate the final message has been sent. +/// +/// The following futures are available to the caller: +/// - `initialMetadata`: the initial metadata returned from the server, +/// - `response`: the response from the call, +/// - `status`: the status of the gRPC call, +/// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { public var response: EventLoopFuture { // It's okay to force unwrap because we know the handler is holding the response promise. diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift index 9d66c2a65..a4a437580 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift @@ -17,15 +17,12 @@ import Foundation import NIO import SwiftProtobuf -/// An response message observer. +/// A response message observer. /// /// - succeedPromise: succeed the given promise on receipt of a message. /// - callback: calls the given callback for each response observed. internal enum ResponseObserver { - /// Fulfill the given promise on receiving the first response message. case succeedPromise(EventLoopPromise) - - /// Call the given handler for each response message received. case callback((ResponseMessage) -> Void) /// Observe the given message. diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index e4f43344a..5a7795a2a 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -17,6 +17,12 @@ import Foundation import SwiftProtobuf import NIO +/// A server-streaming gRPC call. The request is sent on initialisation, each response is passed to the provided observer. +/// +/// The following futures are available to the caller: +/// - `initialMetadata`: the initial metadata returned from the server, +/// - `status`: the status of the gRPC call, +/// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class ServerStreamingClientCall: BaseClientCall { public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 1d767ade2..6c9c038e3 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -17,6 +17,13 @@ import Foundation import SwiftProtobuf import NIO +/// A unary gRPC call. The request is sent on initialisation. +/// +/// The following futures are available to the caller: +/// - `initialMetadata`: the initial metadata returned from the server, +/// - `response`: the response from the unary call, +/// - `status`: the status of the gRPC call, +/// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { public var response: EventLoopFuture { // It's okay to force unwrap because we know the handler is holding the response promise. diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift index bb35feae7..d5551b9ca 100644 --- a/Sources/SwiftGRPCNIO/CompressionMechanism.swift +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -15,16 +15,22 @@ */ import Foundation -enum CompressionError: Error { +internal enum CompressionError: Error { case unsupported(CompressionMechanism) } +/// The mechanism to use for message compression. internal enum CompressionMechanism: String, CaseIterable { + /// No compression was indicated. case none - case identity + + /// Compression indicated via a header. + case identity // no compression case gzip case deflate case snappy + + /// Compression indiciated via a header, but not one listed in the specification. case unknown /// Whether there should be a corresponding header flag. diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift index 78672a525..bcfc3ea5a 100644 --- a/Sources/SwiftGRPCNIO/GRPCClient.swift +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -17,7 +17,7 @@ import Foundation import NIO import NIOHTTP2 -public final class GRPCClient { +open class GRPCClient { public static func start( host: String, port: Int, diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift index 873a770eb..52d534704 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -29,7 +29,7 @@ import SwiftProtobuf /// response (if applicable) are failed with first error received. The status promise is __succeeded__ /// with the error as a `GRPCStatus`. The stream is also closed and any inbound or outbound messages /// are ignored. -public class GRPCClientChannelHandler { +internal class GRPCClientChannelHandler { internal let initialMetadataPromise: EventLoopPromise internal let statusPromise: EventLoopPromise internal let responseObserver: ResponseObserver @@ -145,7 +145,6 @@ extension GRPCClientChannelHandler: ChannelInboundHandler { } } - extension GRPCClientChannelHandler: ChannelOutboundHandler { public typealias OutboundIn = GRPCClientRequestPart public typealias OutboundOut = GRPCClientRequestPart diff --git a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift index 7db6ec6fb..f35ce8739 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -32,6 +32,8 @@ public enum GRPCClientResponsePart { case status(GRPCStatus) } +/// This channel handler simply encodes and decodes protobuf messages into into typed messages +/// and `Data`. public final class GRPCClientCodec { public init() {} } @@ -48,7 +50,7 @@ extension GRPCClientCodec: ChannelInboundHandler { ctx.fireChannelRead(wrapInboundOut(.headers(headers))) case .message(var message): - // Force unwrapping is okay here; we're only reading the readable bytes. + // Force unwrapping is okay here; we're reading the readable bytes. let messageAsData = message.readData(length: message.readableBytes)! do { ctx.fireChannelRead(self.wrapInboundOut(.message(try ResponseMessage(serializedData: messageAsData)))) diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index a8f80fc19..71ca9871f 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -17,7 +17,7 @@ import Foundation import NIO import NIOHTTP1 -/// Outgoing gRPC package with an unknown message type (represented as the serialzed protobuf message). +/// Outgoing gRPC package with an unknown message type (represented as the serialized protobuf message). public enum RawGRPCClientRequestPart { case head(HTTPRequestHead) case message(Data) @@ -54,7 +54,7 @@ public final class HTTP1ToRawGRPCClientCodec { private var state: State = .expectingHeaders private let messageReader = LengthPrefixedMessageReader(mode: .client) private let messageWriter = LengthPrefixedMessageWriter() - private var inboundCompression: CompressionMechanism? + private var inboundCompression: CompressionMechanism = .none } extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { @@ -105,7 +105,7 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { else { preconditionFailure("received body while in state \(state)") } while messageBuffer.readableBytes > 0 { - if let message = try self.messageReader.read(messageBuffer: &messageBuffer, compression: inboundCompression ?? .none) { + if let message = try self.messageReader.read(messageBuffer: &messageBuffer, compression: inboundCompression) { ctx.fireChannelRead(wrapInboundOut(.message(message))) } } @@ -114,8 +114,7 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { } /// Forwards a `GRPCStatus` to the next handler. The status and message are extracted - /// from the trailers if they exist; the `.unknown` status code and an empty message - /// are used otherwise. + /// from the trailers if they exist; the `.unknown` status code is used if no status exists. private func processTrailers(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) -> State { guard case .expectingBodyOrTrailers = state else { preconditionFailure("received trailers while in state \(state)") } @@ -130,7 +129,6 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { } } - extension HTTP1ToRawGRPCClientCodec: ChannelOutboundHandler { public typealias OutboundIn = RawGRPCClientRequestPart public typealias OutboundOut = HTTPClientRequestPart From 6c34cff31073c9114447e6ac762f6d5237f378dd Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 18 Feb 2019 14:17:10 +0000 Subject: [PATCH 13/30] Add allCases to CompressionMehcnaism for swift < 4.2 --- Sources/SwiftGRPCNIO/CompressionMechanism.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift index d5551b9ca..768a9d685 100644 --- a/Sources/SwiftGRPCNIO/CompressionMechanism.swift +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -20,7 +20,7 @@ internal enum CompressionError: Error { } /// The mechanism to use for message compression. -internal enum CompressionMechanism: String, CaseIterable { +internal enum CompressionMechanism: String { /// No compression was indicated. case none @@ -38,6 +38,7 @@ internal enum CompressionMechanism: String, CaseIterable { switch self { case .none: return false + case .identity, .gzip, .deflate, .snappy, .unknown: return true } @@ -48,6 +49,7 @@ internal enum CompressionMechanism: String, CaseIterable { switch self { case .identity, .none: return true + case .gzip, .deflate, .snappy, .unknown: return false } @@ -60,3 +62,11 @@ internal enum CompressionMechanism: String, CaseIterable { .filter { $0.supported && $0.requiresFlag } } } + +#if swift(>=4.2) +extension CompressionMechanism: CaseIterable {} +#else +extension CompressionMechanism { + public static let allCases: [CompressionMechanism] = [.none, .identity, .gzip, .deflate, .snappy, .unknown] +} +#endif From 97326e3b0acf49574fe7e4c1ff0778860a98a259 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 18 Feb 2019 15:55:46 +0000 Subject: [PATCH 14/30] Update LinuxMain --- Tests/LinuxMain.swift | 4 +++- ...hoTestCase.swift => NIOBasicEchoTestCase.swift} | 2 +- ...gTests.swift => NIOClientCancellingTests.swift} | 6 +++--- ...eoutTests.swift => NIOClientTimeoutTests.swift} | 14 +++++--------- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) rename Tests/SwiftGRPCNIOTests/{BasicEchoTestCase.swift => NIOBasicEchoTestCase.swift} (98%) rename Tests/SwiftGRPCNIOTests/{ClientCancellingTests.swift => NIOClientCancellingTests.swift} (93%) rename Tests/SwiftGRPCNIOTests/{ClientTimeoutTests.swift => NIOClientTimeoutTests.swift} (92%) diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index f8dac310b..28bb286bc 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -36,5 +36,7 @@ XCTMain([ testCase(ServerTimeoutTests.allTests), // SwiftGRPCNIO - testCase(NIOServerTests.allTests) + testCase(NIOServerTests.allTests), + testCase(NIOClientCancellingTests.allTests), + testCase(NIOClientTimeoutTests.allTests) ]) diff --git a/Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift b/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift similarity index 98% rename from Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift rename to Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift index 660e33e93..df992f330 100644 --- a/Tests/SwiftGRPCNIOTests/BasicEchoTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift @@ -31,7 +31,7 @@ extension Echo_EchoResponse { } } -class BasicEchoTestCase: XCTestCase { +class NIOBasicEchoTestCase: XCTestCase { var defaultTimeout: TimeInterval = 1.0 var serverEventLoopGroup: EventLoopGroup! diff --git a/Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift similarity index 93% rename from Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift rename to Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift index b949c95ec..77cd1e588 100644 --- a/Tests/SwiftGRPCNIOTests/ClientCancellingTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift @@ -17,8 +17,8 @@ import Foundation import SwiftGRPCNIO import XCTest -class ClientCancellingTests: BasicEchoTestCase { - static var allTests: [(String, (ClientCancellingTests) -> () throws -> Void)] { +class NIOClientCancellingTests: NIOBasicEchoTestCase { + static var allTests: [(String, (NIOClientCancellingTests) -> () throws -> Void)] { return [ ("testUnary", testUnary), ("testClientStreaming", testClientStreaming), @@ -28,7 +28,7 @@ class ClientCancellingTests: BasicEchoTestCase { } } -extension ClientCancellingTests { +extension NIOClientCancellingTests { func testUnary() { let statusReceived = self.expectation(description: "status received") diff --git a/Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift similarity index 92% rename from Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift rename to Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift index bafb837a4..b21584062 100644 --- a/Tests/SwiftGRPCNIOTests/ClientTimeoutTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift @@ -18,8 +18,8 @@ import SwiftGRPCNIO import NIO import XCTest -class ClientTimeoutTests: BasicEchoTestCase { - static var allTests: [(String, (ClientTimeoutTests) -> () throws -> Void)] { +class NIOClientTimeoutTests: NIOBasicEchoTestCase { + static var allTests: [(String, (NIOClientTimeoutTests) -> () throws -> Void)] { return [ ("testUnaryTimeoutAfterSending", testUnaryTimeoutAfterSending), ("testServerStreamingTimeoutAfterSending", testServerStreamingTimeoutAfterSending), @@ -57,7 +57,7 @@ class ClientTimeoutTests: BasicEchoTestCase { } } -extension ClientTimeoutTests { +extension NIOClientTimeoutTests { func testUnaryTimeoutAfterSending() { // The request gets fired on call creation, so we need a very short timeout. let callOptions = CallOptions(timeout: .milliseconds(1)) @@ -72,9 +72,7 @@ extension ClientTimeoutTests { func testServerStreamingTimeoutAfterSending() { // The request gets fired on call creation, so we need a very short timeout. let callOptions = CallOptions(timeout: .milliseconds(1)) - let call = client.expand(Echo_EchoRequest(text: "foo bar baz"), callOptions: callOptions) { response in - XCTFail("response received after deadline") - } + let call = client.expand(Echo_EchoRequest(text: "foo bar baz"), callOptions: callOptions) { _ in } self.expectDeadlineExceeded(forStatus: call.status) @@ -109,9 +107,7 @@ extension ClientTimeoutTests { func testBidirectionalStreamingTimeoutBeforeSending() { let callOptions = CallOptions(timeout: .milliseconds(50)) - let call = client.update(callOptions: callOptions) { response in - XCTFail("response received after deadline") - } + let call = client.update(callOptions: callOptions) { _ in } self.expectDeadlineExceeded(forStatus: call.status) diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 8723eb597..77d847c59 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -78,7 +78,7 @@ final class EchoProvider_NIO: Echo_EchoProvider_NIO { } } -class NIOServerTests: BasicEchoTestCase { +class NIOServerTests: NIOBasicEchoTestCase { static var allTests: [(String, (NIOServerTests) -> () throws -> Void)] { return [ ("testUnary", testUnary), From f9e60b14b16ed2e21da8729f735ed48397170570 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 19 Feb 2019 17:21:07 +0000 Subject: [PATCH 15/30] More errors to a dedicated enum, fix typos, etc. --- .../CallHandlers/BaseCallHandler.swift | 8 +-- .../ServerStreamingCallHandler.swift | 2 +- .../CallHandlers/UnaryCallHandler.swift | 2 +- Sources/SwiftGRPCNIO/GRPCChannelHandler.swift | 6 +- Sources/SwiftGRPCNIO/GRPCError.swift | 70 +++++++++++++++++++ Sources/SwiftGRPCNIO/GRPCServer.swift | 4 +- Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 8 +-- Sources/SwiftGRPCNIO/GRPCStatus.swift | 16 +---- .../HTTP1ToRawGRPCServerCodec.swift | 63 +++++++---------- .../LoggingServerErrorDelegate.swift | 24 +++++++ .../SwiftGRPCNIO/ServerErrorDelegate.swift | 21 +++++- .../Generator-Server.swift | 2 +- ...nnelHandlerResponseCapturingTestCase.swift | 56 +++++++-------- .../GRPCChannelHandlerTests.swift | 57 ++++++++------- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 6 +- Tests/SwiftGRPCNIOTests/TestHelpers.swift | 61 ++++++++++++++++ Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift | 2 +- 17 files changed, 277 insertions(+), 131 deletions(-) create mode 100644 Sources/SwiftGRPCNIO/GRPCError.swift create mode 100644 Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift create mode 100644 Tests/SwiftGRPCNIOTests/TestHelpers.swift diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift index 3fdfa1b20..718e211b1 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift @@ -27,7 +27,7 @@ public class BaseCallHandler: /// Called for each error recieved in `errorCaught(ctx:error:)`. private weak var errorDelegate: ServerErrorDelegate? - public init(errorDelegate: ServerErrorDelegate? = nil) { + public init(errorDelegate: ServerErrorDelegate?) { self.errorDelegate = errorDelegate } } @@ -48,9 +48,9 @@ extension BaseCallHandler: ChannelInboundHandler { public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { switch self.unwrapInboundIn(data) { - case .head: + case .head(let requestHead): // Head should have been handled by `GRPCChannelHandler`. - self.errorCaught(ctx: ctx, error: GRPCStatus(code: .unknown, message: "unexpectedly received head")) + self.errorCaught(ctx: ctx, error: GRPCError.invalidState("unexpected request head received \(requestHead)")) case .message(let message): do { @@ -71,7 +71,7 @@ extension BaseCallHandler: ChannelOutboundHandler { public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { guard serverCanWrite else { - promise?.fail(error: GRPCStatus.processingError) + promise?.fail(error: GRPCError.serverNotWritable) return } diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index dde2c2306..e282c2964 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -29,7 +29,7 @@ public class ServerStreamingCallHandler public override func processMessage(_ message: RequestMessage) throws { guard let eventObserver = self.eventObserver, let context = self.context else { - throw GRPCStatus(code: .unimplemented, message: "multiple messages received on unary call") + throw GRPCError.requestCardinalityViolation } let resultFuture = eventObserver(message) diff --git a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift index 3a913b061..156a44863 100644 --- a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift @@ -30,7 +30,7 @@ public final class GRPCChannelHandler { private let servicesByName: [String: CallHandlerProvider] private weak var errorDelegate: ServerErrorDelegate? - public init(servicesByName: [String: CallHandlerProvider], errorDelegate: ServerErrorDelegate? = nil) { + public init(servicesByName: [String: CallHandlerProvider], errorDelegate: ServerErrorDelegate?) { self.servicesByName = servicesByName self.errorDelegate = errorDelegate } @@ -43,7 +43,7 @@ extension GRPCChannelHandler: ChannelInboundHandler { public func errorCaught(ctx: ChannelHandlerContext, error: Error) { errorDelegate?.observe(error) - let transformedError = (errorDelegate?.transform(error) ?? error) + let transformedError = errorDelegate?.transform(error) ?? error let status = (transformedError as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError ctx.writeAndFlush(wrapOutboundOut(.status(status)), promise: nil) } @@ -53,7 +53,7 @@ extension GRPCChannelHandler: ChannelInboundHandler { switch requestPart { case .head(let requestHead): guard let callHandler = getCallHandler(channel: ctx.channel, requestHead: requestHead) else { - errorCaught(ctx: ctx, error: GRPCStatus.unimplemented(method: requestHead.uri)) + errorCaught(ctx: ctx, error: GRPCError.unimplementedMethod(requestHead.uri)) return } diff --git a/Sources/SwiftGRPCNIO/GRPCError.swift b/Sources/SwiftGRPCNIO/GRPCError.swift new file mode 100644 index 000000000..5106feb07 --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCError.swift @@ -0,0 +1,70 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +public enum GRPCError: Error, Equatable { + /// The RPC method is not implemented on the server. + case unimplementedMethod(String) + + /// It was not possible to parse the request protobuf. + case requestProtoParseFailure + + /// It was not possible to serialize the response protobuf. + case responseProtoSerializationFailure + + /// The given compression mechanism is not supported. + case unsupportedCompressionMechanism(String) + + /// Compression was indicated in the gRPC message, but not for the call. + case unexpectedCompression + + /// More than one request was sent for a unary-request call. + case requestCardinalityViolation + + /// The server received a message when it was not in a writable state. + case serverNotWritable + + /// An invalid state has been reached; something has gone very wrong. + case invalidState(String) +} + +extension GRPCError: GRPCStatusTransformable { + public func asGRPCStatus() -> GRPCStatus { + // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md + switch self { + case .unimplementedMethod(let method): + return GRPCStatus(code: .unimplemented, message: "unknown method \(method)") + + case .requestProtoParseFailure: + return GRPCStatus(code: .internalError, message: "could not parse request proto") + + case .responseProtoSerializationFailure: + return GRPCStatus(code: .internalError, message: "could not serialize response proto") + + case .unsupportedCompressionMechanism(let mechanism): + return GRPCStatus(code: .unimplemented, message: "unsupported compression mechanism \(mechanism)") + + case .unexpectedCompression: + return GRPCStatus(code: .unimplemented, message: "compression was enabled for this gRPC message but not for this call") + + case .requestCardinalityViolation: + return GRPCStatus(code: .unimplemented, message: "request cardinality violation; method requires exactly one request but client sent more") + + case .serverNotWritable, .invalidState: + return GRPCStatus.processingError + } + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCServer.swift b/Sources/SwiftGRPCNIO/GRPCServer.swift index a54b6c3d8..8ca9edc5c 100644 --- a/Sources/SwiftGRPCNIO/GRPCServer.swift +++ b/Sources/SwiftGRPCNIO/GRPCServer.swift @@ -13,7 +13,7 @@ public final class GRPCServer { port: Int, eventLoopGroup: EventLoopGroup, serviceProviders: [CallHandlerProvider], - errorDelegate: ServerErrorDelegate? = nil + errorDelegate: ServerErrorDelegate? = LoggingServerErrorDelegate() ) -> EventLoopFuture { let servicesByName = Dictionary(uniqueKeysWithValues: serviceProviders.map { ($0.serviceName, $0) }) let bootstrap = ServerBootstrap(group: eventLoopGroup) @@ -53,7 +53,7 @@ public final class GRPCServer { // Maintain a strong reference to ensure it lives as long as the server. self.errorDelegate = errorDelegate - // `BaseCallHandler` holds a weak reference to the delegate; nil out this reference to avoid retain cycles. + // nil out errorDelegate to avoid retain cycles. onClose.whenComplete { self.errorDelegate = nil } diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index 4cc5b214d..ef67a75c7 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -35,7 +35,7 @@ extension GRPCServerCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try RequestMessage(serializedData: messageAsData)))) } catch { - ctx.fireErrorCaught(GRPCStatus.requestProtoParseError) + ctx.fireErrorCaught(GRPCError.requestProtoParseFailure) } case .end: @@ -61,9 +61,9 @@ extension GRPCServerCodec: ChannelOutboundHandler { responseBuffer.write(bytes: messageData) ctx.write(self.wrapOutboundOut(.message(responseBuffer)), promise: promise) } catch { - let status = GRPCStatus.responseProtoSerializationError - promise?.fail(error: status) - ctx.fireErrorCaught(status) + let error = GRPCError.responseProtoSerializationFailure + promise?.fail(error: error) + ctx.fireErrorCaught(error) } case .status(let status): diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index cb1ae07d4..7a023242c 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -2,7 +2,7 @@ import Foundation import NIOHTTP1 /// Encapsulates the result of a gRPC call. -public struct GRPCStatus: Error { +public struct GRPCStatus: Error, Equatable { /// The code to return in the `grpc-status` header. public let code: StatusCode /// The message to return in the `grpc-message` header. @@ -22,24 +22,14 @@ public struct GRPCStatus: Error { public static let ok = GRPCStatus(code: .ok, message: "OK") /// "Internal server error" status. public static let processingError = GRPCStatus(code: .internalError, message: "unknown error processing request") - - /// Status indicating that the given method is not implemented. - public static func unimplemented(method: String) -> GRPCStatus { - return GRPCStatus(code: .unimplemented, message: "unknown method " + method) - } - - // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md - static internal let requestProtoParseError = GRPCStatus(code: .internalError, message: "could not parse request proto") - static internal let responseProtoSerializationError = GRPCStatus(code: .internalError, message: "could not serialize response proto") - static internal let unsupportedCompression = GRPCStatus(code: .unimplemented, message: "compression is not supported on the server") } -protocol GRPCStatusTransformable: Error { +public protocol GRPCStatusTransformable: Error { func asGRPCStatus() -> GRPCStatus } extension GRPCStatus: GRPCStatusTransformable { - func asGRPCStatus() -> GRPCStatus { + public func asGRPCStatus() -> GRPCStatus { return self } } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index 3db609585..022eeea98 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -27,8 +27,8 @@ public enum RawGRPCServerResponsePart { /// /// The translation from HTTP2 to HTTP1 is done by `HTTP2ToHTTP1ServerCodec`. public final class HTTP1ToRawGRPCServerCodec { - internal var inboundState = InboundState.expectingHeaders - internal var outboundState = OutboundState.expectingHeaders + var inboundState = InboundState.expectingHeaders + var outboundState = OutboundState.expectingHeaders private var buffer: NIO.ByteBuffer? @@ -46,7 +46,7 @@ extension HTTP1ToRawGRPCServerCodec { enum Body { case expectingCompressedFlag case expectingMessageLength - case receivedMessageLength(UInt32) + case expectingMoreMessageBytes(UInt32) } } @@ -57,20 +57,6 @@ extension HTTP1ToRawGRPCServerCodec { } } -extension HTTP1ToRawGRPCServerCodec { - struct StateMachineError: Error, GRPCStatusTransformable { - private let message: String - - init(_ message: String) { - self.message = message - } - - func asGRPCStatus() -> GRPCStatus { - return GRPCStatus.processingError - } - } -} - extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { public typealias InboundIn = HTTPServerRequestPart public typealias InboundOut = RawGRPCServerRequestPart @@ -97,7 +83,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processHead(ctx: ChannelHandlerContext, requestHead: HTTPRequestHead) throws -> InboundState { guard case .expectingHeaders = inboundState else { - throw StateMachineError("expecteded state .expectingHeaders, got \(inboundState)") + throw GRPCError.invalidState("expecteded state .expectingHeaders, got \(inboundState)") } ctx.fireChannelRead(self.wrapInboundOut(.head(requestHead))) @@ -107,7 +93,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processBody(ctx: ChannelHandlerContext, body: inout ByteBuffer) throws -> InboundState { guard case .expectingBody(let bodyState) = inboundState else { - throw StateMachineError("expecteded state .expectingBody(_), got \(inboundState)") + throw GRPCError.invalidState("expecteded state .expectingBody(_), got \(inboundState)") } return .expectingBody(try processBodyState(ctx: ctx, initialState: bodyState, messageBuffer: &body)) @@ -124,37 +110,36 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { while true { switch bodyState { case .expectingCompressedFlag: - guard let compressionFlag: Int8 = messageBuffer.readInteger() else { return .expectingCompressedFlag } + guard let compressedFlag: Int8 = messageBuffer.readInteger() else { return .expectingCompressedFlag } // TODO: Add support for compression. - guard compressionFlag == 0 else { throw GRPCStatus.unsupportedCompression } + guard compressedFlag == 0 else { throw GRPCError.unexpectedCompression } bodyState = .expectingMessageLength case .expectingMessageLength: guard let messageLength: UInt32 = messageBuffer.readInteger() else { return .expectingMessageLength } - bodyState = .receivedMessageLength(messageLength) + bodyState = .expectingMoreMessageBytes(messageLength) - case .receivedMessageLength(let messageLength): + case .expectingMoreMessageBytes(let bytesOutstanding): // We need to account for messages being spread across multiple `ByteBuffer`s so buffer them // into `buffer`. Note: when messages are contained within a single `ByteBuffer` we're just // taking a slice so don't incur any extra writes. - guard messageBuffer.readableBytes >= messageLength else { - let remainingBytes = messageLength - numericCast(messageBuffer.readableBytes) + guard messageBuffer.readableBytes >= bytesOutstanding else { + let remainingBytes = bytesOutstanding - numericCast(messageBuffer.readableBytes) if var buffer = buffer { buffer.write(buffer: &messageBuffer) self.buffer = buffer } else { - messageBuffer.reserveCapacity(numericCast(messageLength)) + messageBuffer.reserveCapacity(numericCast(bytesOutstanding)) self.buffer = messageBuffer } - - return .receivedMessageLength(remainingBytes) + return .expectingMoreMessageBytes(remainingBytes) } // We know buffer.readableBytes >= messageLength, so it's okay to force unwrap here. - var slice = messageBuffer.readSlice(length: numericCast(messageLength))! + var slice = messageBuffer.readSlice(length: numericCast(bytesOutstanding))! if var buffer = buffer { buffer.write(buffer: &slice) @@ -170,8 +155,8 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { } private func processEnd(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) throws -> InboundState { - guard trailers == nil else { - throw StateMachineError("unexpected trailers received \(String(describing: trailers))") + if let trailers = trailers { + throw GRPCError.invalidState("unexpected trailers received \(trailers)") } ctx.fireChannelRead(self.wrapInboundOut(.end)) @@ -207,17 +192,19 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { outboundState = .expectingBodyOrStatus case .status(let status): + // If we error before sending the initial headers (e.g. unimplemtned method) then we won't have sent the request head. + // NIOHTTP2 doesn't support sending a single frame as a "Trailers-Only" response so we still need to loop back and + // send the request head first. + if case .expectingHeaders = outboundState { + var headers = HTTPHeaders() + headers.add(name: "content-type", value: "application/grpc") + self.write(ctx: ctx, data: NIOAny(RawGRPCServerResponsePart.headers(headers)), promise: nil) + } + var trailers = status.trailingMetadata trailers.add(name: "grpc-status", value: String(describing: status.code.rawValue)) trailers.add(name: "grpc-message", value: status.message) - // "Trailers-Only" response - if case .expectingHeaders = outboundState { - trailers.add(name: "content-type", value: "application/grpc") - let responseHead = HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok) - ctx.write(self.wrapOutboundOut(.head(responseHead)), promise: nil) - } - ctx.writeAndFlush(self.wrapOutboundOut(.end(trailers)), promise: promise) outboundState = .ignore inboundState = .ignore diff --git a/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift new file mode 100644 index 000000000..86c128d31 --- /dev/null +++ b/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift @@ -0,0 +1,24 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +public class LoggingServerErrorDelegate: ServerErrorDelegate { + public init() {} + + public func observe(_ error: Error) { + print("[grpc-server] \(error)") + } +} diff --git a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift index fee423ceb..15b312959 100644 --- a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift +++ b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift @@ -1,12 +1,29 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import Foundation +import NIO public protocol ServerErrorDelegate: class { - /// Called when an error thrown in the channel pipeline. + //: FIXME: provide more context about where the error was thrown. + /// Called when an error is thrown in the channel pipeline. func observe(_ error: Error) /// Transforms the given error into a new error. /// - /// This allows framework to transform errors which may be out of their control + /// This allows framework users to transform errors which may be out of their control /// due to third-party libraries, for example, into more meaningful errors or /// `GRPCStatus` errors. Errors returned from this protocol are not passed to /// `observe`. diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift index 3d782e9a9..a7de29b47 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Server.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Server.swift @@ -85,7 +85,7 @@ extension Generator { if options.generateNIOImplementation { println("/// Determines, calls and returns the appropriate request handler, depending on the request's method.") println("/// Returns nil for methods not handled by this service.") - println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate? = nil) -> GRPCCallHandler? {") + println("\(access) func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate?) -> GRPCCallHandler? {") indent() println("switch methodName {") for method in service.methods { diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift index 5daaf4eb8..863bb11ae 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift @@ -4,34 +4,6 @@ import NIOHTTP1 @testable import SwiftGRPCNIO import XCTest -internal struct CaseExtractError: Error { - let message: String -} - -@discardableResult -func extractHeaders(_ response: RawGRPCServerResponsePart) throws -> HTTPHeaders { - guard case .headers(let headers) = response else { - throw CaseExtractError(message: "\(response) did not match .headers") - } - return headers -} - -@discardableResult -func extractMessage(_ response: RawGRPCServerResponsePart) throws -> ByteBuffer { - guard case .message(let message) = response else { - throw CaseExtractError(message: "\(response) did not match .message") - } - return message -} - -@discardableResult -func extractStatus(_ response: RawGRPCServerResponsePart) throws -> GRPCStatus { - guard case .status(let status) = response else { - throw CaseExtractError(message: "\(response) did not match .status") - } - return status -} - class CollectingChannelHandler: ChannelOutboundHandler { var responses: [OutboundIn] = [] @@ -40,8 +12,19 @@ class CollectingChannelHandler: ChannelOutboundHandler { } } +class CollectingServerErrorDelegate: ServerErrorDelegate { + var errors: [Error] = [] + + func observe(_ error: Error) { + self.errors.append(error) + } +} + class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { static let echoProvider: [String: CallHandlerProvider] = ["echo.Echo": EchoProvider_NIO()] + class var defaultServiceProvider: [String: CallHandlerProvider] { + return echoProvider + } func configureChannel(withHandlers handlers: [ChannelHandler]) -> EventLoopFuture { let channel = EmbeddedChannel() @@ -49,15 +32,28 @@ class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { .map { _ in channel } } + var errorCollector: CollectingServerErrorDelegate = CollectingServerErrorDelegate() + + override func setUp() { + errorCollector.errors.removeAll() + } + /// Waits for `count` responses to be collected and then returns them. The test fails if the number /// of collected responses does not match the expected. + /// + /// - Parameters: + /// - count: expected number of responses. + /// - servicesByName: service providers keyed by their service name. + /// - callback: a callback called after the channel has been setup, intended to "fill" the channel + /// with messages. The callback is called before this function returns. + /// - Returns: The responses collected from the pipeline. func waitForGRPCChannelHandlerResponses( count: Int, - servicesByName: [String: CallHandlerProvider] = echoProvider, + servicesByName: [String: CallHandlerProvider] = defaultServiceProvider, callback: @escaping (EmbeddedChannel) throws -> Void ) throws -> [RawGRPCServerResponsePart] { let collector = CollectingChannelHandler() - try configureChannel(withHandlers: [collector, GRPCChannelHandler(servicesByName: servicesByName)]) + try configureChannel(withHandlers: [collector, GRPCChannelHandler(servicesByName: servicesByName, errorDelegate: errorCollector)]) .thenThrowing(callback) .wait() diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index 4ecd8b96e..60e1e7afd 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -18,12 +18,15 @@ func gRPCMessage(channel: EmbeddedChannel, compression: Bool = false, message: D class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { func testUnimplementedMethodReturnsUnimplementedStatus() throws { let responses = try waitForGRPCChannelHandlerResponses(count: 1) { channel in - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "unimplemented") + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "unimplementedMethodName") try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) } + let expectedError = GRPCError.unimplementedMethod("unimplementedMethodName") + XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + XCTAssertNoThrow(try extractStatus(responses[0])) { status in - XCTAssertEqual(status.code, .unimplemented) + XCTAssertEqual(status, expectedError.asGRPCStatus()) } } @@ -42,7 +45,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractMessage(responses[1])) XCTAssertNoThrow(try extractStatus(responses[2])) { status in - XCTAssertEqual(status.code, .ok) + XCTAssertEqual(status, .ok) } } @@ -56,28 +59,30 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { try channel.writeInbound(RawGRPCServerRequestPart.message(buffer)) } + let expectedError = GRPCError.requestProtoParseFailure + XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in - let expectedStatus = GRPCStatus.requestProtoParseError - XCTAssertEqual(status.code, expectedStatus.code) - XCTAssertEqual(status.message, expectedStatus.message) + XCTAssertEqual(status, expectedError.asGRPCStatus()) } } } class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCase { - func testUnimplementedStatusReturnedWhenCompressionFlagIsSet() throws { + func testInternalErrorStatusReturnedWhenCompressionFlagIsSet() throws { let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) } + let expectedError = GRPCError.unexpectedCompression + XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in - let expected = GRPCStatus.unsupportedCompression - XCTAssertEqual(status.code, expected.code) - XCTAssertEqual(status.message, expected.message) + XCTAssertEqual(status, expectedError.asGRPCStatus()) } } @@ -106,7 +111,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractMessage(responses[1])) XCTAssertNoThrow(try extractStatus(responses[2])) { status in - XCTAssertEqual(status.code, .ok) + XCTAssertEqual(status, .ok) } } @@ -119,11 +124,12 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.body(buffer)) } + let expectedError = GRPCError.requestProtoParseFailure + XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in - let expected = GRPCStatus.requestProtoParseError - XCTAssertEqual(status.code, expected.code) - XCTAssertEqual(status.message, expected.message) + XCTAssertEqual(status, expectedError.asGRPCStatus()) } } @@ -141,9 +147,15 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.end(trailers)) } + if case .invalidState(let message)? = errorCollector.errors.first as? GRPCError { + XCTAssert(message.contains("trailers")) + } else { + XCTFail("\(String(describing: errorCollector.errors.first)) was not GRPCError.invalidState") + } + XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in - XCTAssertEqual(status.code, .internalError) + XCTAssertEqual(status, .processingError) } } @@ -166,7 +178,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractMessage(responses[1])) XCTAssertNoThrow(try extractStatus(responses[2])) { status in - XCTAssertEqual(status.code, .ok) + XCTAssertEqual(status, .ok) } } @@ -181,16 +193,3 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } } } - -// Assert the given expression does not throw, and validate the return value from that expression. -public func XCTAssertNoThrow( - _ expression: @autoclosure () throws -> T, - _ message: String = "", - file: StaticString = #file, - line: UInt = #line, - validate: (T) -> Void -) { - var value: T? = nil - XCTAssertNoThrow(try value = expression(), message, file: file, line: line) - value.map { validate($0) } -} diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 60fbbe526..5db92c607 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -124,8 +124,10 @@ extension NIOServerTests { func testUnaryWithLargeData() throws { // Default max frame size is: 16,384. We'll exceed this as we also have to send the size and compression flag. - let request = Echo_EchoRequest.with { $0.text = String(repeating: "e", count: 16_384) } - XCTAssertNoThrow(try client.get(request)) + let longMessage = String(repeating: "e", count: 16_384) + XCTAssertNoThrow(try client.get(Echo_EchoRequest(text: longMessage))) { response in + XCTAssertEqual("Swift echo get: \(longMessage)", response.text) + } } func testUnaryLotsOfRequests() { diff --git a/Tests/SwiftGRPCNIOTests/TestHelpers.swift b/Tests/SwiftGRPCNIOTests/TestHelpers.swift new file mode 100644 index 000000000..73be06743 --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/TestHelpers.swift @@ -0,0 +1,61 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import XCTest +import SwiftGRPCNIO +import NIO +import NIOHTTP1 + +// Assert the given expression does not throw, and validate the return value from that expression. +public func XCTAssertNoThrow( + _ expression: @autoclosure () throws -> T, + _ message: String = "", + file: StaticString = #file, + line: UInt = #line, + validate: (T) -> Void +) { + var value: T? = nil + XCTAssertNoThrow(try value = expression(), message, file: file, line: line) + value.map { validate($0) } +} + +struct CaseExtractError: Error { + let message: String +} + +@discardableResult +func extractHeaders(_ response: RawGRPCServerResponsePart) throws -> HTTPHeaders { + guard case .headers(let headers) = response else { + throw CaseExtractError(message: "\(response) did not match .headers") + } + return headers +} + +@discardableResult +func extractMessage(_ response: RawGRPCServerResponsePart) throws -> ByteBuffer { + guard case .message(let message) = response else { + throw CaseExtractError(message: "\(response) did not match .message") + } + return message +} + +@discardableResult +func extractStatus(_ response: RawGRPCServerResponsePart) throws -> GRPCStatus { + guard case .status(let status) = response else { + throw CaseExtractError(message: "\(response) did not match .status") + } + return status +} diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift index 4d39364b3..ecf86a285 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift @@ -40,7 +40,7 @@ extension Echo_EchoProvider_NIO { /// Determines, calls and returns the appropriate request handler, depending on the request's method. /// Returns nil for methods not handled by this service. - internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate? = nil) -> GRPCCallHandler? { + internal func handleMethod(_ methodName: String, request: HTTPRequestHead, serverHandler: GRPCChannelHandler, channel: Channel, errorDelegate: ServerErrorDelegate?) -> GRPCCallHandler? { switch methodName { case "Get": return UnaryCallHandler(channel: channel, request: request, errorDelegate: errorDelegate) { context in From 2c05703561242c9a4dc5d10faba5f1c3a91436c4 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 21 Feb 2019 13:18:32 +0000 Subject: [PATCH 16/30] PR feedback; docs, tidying --- Makefile | 4 +- .../EchoNIO/Generated/echo.grpc.swift | 36 ++++---- .../Examples/EchoNIO/Generated/echo.pb.swift | 2 +- .../EchoNIO/Generated/echo_nio.grpc.swift | 1 - Sources/Examples/EchoNIO/main.swift | 8 +- .../ClientCalls/BaseClientCall.swift | 75 ++++++++------- .../BidirectionalStreamingClientCall.swift | 13 ++- .../SwiftGRPCNIO/ClientCalls/ClientCall.swift | 48 +++++----- .../ClientStreamingClientCall.swift | 20 ++-- .../ClientCalls/ResponseObserver.swift | 12 ++- .../ServerStreamingClientCall.swift | 8 +- .../ClientCalls/UnaryClientCall.swift | 18 ++-- Sources/SwiftGRPCNIO/ClientOptions.swift | 9 +- .../SwiftGRPCNIO/CompressionMechanism.swift | 17 ++-- Sources/SwiftGRPCNIO/GRPCClient.swift | 13 ++- .../GRPCClientChannelHandler.swift | 34 +++++-- Sources/SwiftGRPCNIO/GRPCClientCodec.swift | 21 +++-- Sources/SwiftGRPCNIO/GRPCError.swift | 23 +++++ Sources/SwiftGRPCNIO/GRPCStatus.swift | 4 +- Sources/SwiftGRPCNIO/GRPCTimeout.swift | 74 +++++++++------ .../HTTP1ToRawGRPCClientCodec.swift | 39 ++++---- .../HTTP1ToRawGRPCServerCodec.swift | 10 +- .../LengthPrefixedMessageReader.swift | 31 +++++-- .../LengthPrefixedMessageWriter.swift | 18 ++-- .../Generator-Client.swift | 30 +++--- Tests/SwiftGRPCNIOTests/EchoProviderNIO.swift | 1 + .../NIOBasicEchoTestCase.swift | 6 +- .../NIOClientCancellingTests.swift | 20 +++- .../NIOClientTimeoutTests.swift | 49 +++++----- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 92 ++++--------------- Tests/SwiftGRPCNIOTests/echo.grpc.swift | 2 +- Tests/SwiftGRPCNIOTests/echo.pb.swift | 2 +- 32 files changed, 411 insertions(+), 329 deletions(-) rename Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift => Sources/Examples/EchoNIO/Generated/echo.grpc.swift (74%) delete mode 120000 Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCError.swift create mode 120000 Tests/SwiftGRPCNIOTests/EchoProviderNIO.swift diff --git a/Makefile b/Makefile index b19dbccfd..62709904e 100644 --- a/Makefile +++ b/Makefile @@ -47,8 +47,8 @@ test-plugin: test-plugin-nio: swift build $(CFLAGS) --product protoc-gen-swiftgrpc - protoc Sources/Examples/Echo/echo.proto --proto_path=Sources/Examples/Echo --plugin=.build/debug/protoc-gen-swift --plugin=.build/debug/protoc-gen-swiftgrpc --swiftgrpc_out=/tmp --swiftgrpc_opt=Client=false,NIO=true - diff -u /tmp/echo.grpc.swift Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift + protoc Sources/Examples/Echo/echo.proto --proto_path=Sources/Examples/Echo --plugin=.build/debug/protoc-gen-swift --plugin=.build/debug/protoc-gen-swiftgrpc --swiftgrpc_out=/tmp --swiftgrpc_opt=NIO=true + diff -u /tmp/echo.grpc.swift Sources/Examples/EchoNIO/Generated/echo.grpc.swift xcodebuild: project xcodebuild -project SwiftGRPC.xcodeproj -configuration "Debug" -parallelizeTargets -target SwiftGRPC -target Echo -target Simple -target protoc-gen-swiftgrpc build diff --git a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift b/Sources/Examples/EchoNIO/Generated/echo.grpc.swift similarity index 74% rename from Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift rename to Sources/Examples/EchoNIO/Generated/echo.grpc.swift index f3fca2a41..e073cafe9 100644 --- a/Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift +++ b/Sources/Examples/EchoNIO/Generated/echo.grpc.swift @@ -27,7 +27,7 @@ import SwiftGRPCNIO import SwiftProtobuf -/// Instantiate Echo_EchoService_NIOClient, then call methods of this protocol to make API calls. +/// Usage: instantiate Echo_EchoService_NIOClient, then call methods of this protocol to make API calls. internal protocol Echo_EchoService_NIO { func get(_ request: Echo_EchoRequest, callOptions: CallOptions?) -> UnaryClientCall func expand(_ request: Echo_EchoRequest, callOptions: CallOptions?, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall @@ -38,37 +38,37 @@ internal protocol Echo_EchoService_NIO { internal final class Echo_EchoService_NIOClient: GRPCServiceClient, Echo_EchoService_NIO { internal let client: GRPCClient internal let service = "echo.Echo" - internal var callOptions: CallOptions + internal var defaultCallOptions: CallOptions /// Creates a client for the echo.Echo service. /// /// - Parameters: /// - client: `GRPCClient` with a connection to the service host. - /// - callOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.callOptions`. - internal init(client: GRPCClient, callOptions: CallOptions? = nil) { + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.defaultCallOptions`. + internal init(client: GRPCClient, defaultCallOptions: CallOptions? = nil) { self.client = client - self.callOptions = callOptions ?? client.callOptions + self.defaultCallOptions = defaultCallOptions ?? client.defaultCallOptions } /// Asynchronous unary call to Get. /// /// - Parameters: /// - request: Request to send to Get. - /// - callOptions: Call options; `self.callOptions` is used if `nil`. + /// - callOptions: Call options; `self.defaultCallOptions` is used if `nil`. /// - Returns: A `UnaryClientCall` with futures for the metadata, status and response. - func get(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { - return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? self.callOptions) + internal func get(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil) -> UnaryClientCall { + return UnaryClientCall(client: client, path: path(forMethod: "Get"), request: request, callOptions: callOptions ?? self.defaultCallOptions) } /// Asynchronous server-streaming call to Expand. /// /// - Parameters: /// - request: Request to send to Expand. - /// - callOptions: Call options; `self.callOptions` is used if `nil`. + /// - callOptions: Call options; `self.defaultCallOptions` is used if `nil`. /// - handler: A closure called when each response is received from the server. /// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status. - func expand(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { - return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? self.callOptions, handler: handler) + internal func expand(_ request: Echo_EchoRequest, callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> ServerStreamingClientCall { + return ServerStreamingClientCall(client: client, path: path(forMethod: "Expand"), request: request, callOptions: callOptions ?? self.defaultCallOptions, handler: handler) } /// Asynchronous client-streaming call to Collect. @@ -77,10 +77,10 @@ internal final class Echo_EchoService_NIOClient: GRPCServiceClient, Echo_EchoSer /// to the server. The caller should send an `.end` after the final message has been sent. /// /// - Parameters: - /// - callOptions: Call options; `self.callOptions` is used if `nil`. + /// - callOptions: Call options; `self.defaultCallOptions` is used if `nil`. /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. - func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { - return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? self.callOptions) + internal func collect(callOptions: CallOptions? = nil) -> ClientStreamingClientCall { + return ClientStreamingClientCall(client: client, path: path(forMethod: "Collect"), callOptions: callOptions ?? self.defaultCallOptions) } /// Asynchronous bidirectional-streaming call to Update. @@ -89,11 +89,11 @@ internal final class Echo_EchoService_NIOClient: GRPCServiceClient, Echo_EchoSer /// to the server. The caller should send an `.end` after the final message has been sent. /// /// - Parameters: - /// - callOptions: Call options; `self.callOptions` is used if `nil`. + /// - callOptions: Call options; `self.defaultCallOptions` is used if `nil`. /// - handler: A closure called when each response is received from the server. - /// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response. - func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { - return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? self.callOptions, handler: handler) + /// - Returns: A `ClientStreamingClientCall` with futures for the metadata and status. + internal func update(callOptions: CallOptions? = nil, handler: @escaping (Echo_EchoResponse) -> Void) -> BidirectionalStreamingClientCall { + return BidirectionalStreamingClientCall(client: client, path: path(forMethod: "Update"), callOptions: callOptions ?? self.defaultCallOptions, handler: handler) } } diff --git a/Sources/Examples/EchoNIO/Generated/echo.pb.swift b/Sources/Examples/EchoNIO/Generated/echo.pb.swift index c95f2daee..a313aab3f 120000 --- a/Sources/Examples/EchoNIO/Generated/echo.pb.swift +++ b/Sources/Examples/EchoNIO/Generated/echo.pb.swift @@ -1 +1 @@ -../../../../Tests/SwiftGRPCNIOTests/echo.pb.swift \ No newline at end of file +../../Echo/Generated/echo.pb.swift \ No newline at end of file diff --git a/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift b/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift deleted file mode 120000 index b6bf95ab4..000000000 --- a/Sources/Examples/EchoNIO/Generated/echo_nio.grpc.swift +++ /dev/null @@ -1 +0,0 @@ -../../../../Tests/SwiftGRPCNIOTests/echo_nio.grpc.swift \ No newline at end of file diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift index 9daa476cf..af0cb3817 100644 --- a/Sources/Examples/EchoNIO/main.swift +++ b/Sources/Examples/EchoNIO/main.swift @@ -138,9 +138,9 @@ Group { var requestMessage = Echo_EchoRequest() requestMessage.text = part print("collect sending: \(requestMessage.text)") - collect.send(.message(requestMessage)) + collect.sendMessage(requestMessage) } - collect.send(.end) + collect.sendEnd() collect.response.whenSuccess { respone in print("collect received: \(respone.text)") @@ -177,9 +177,9 @@ Group { var requestMessage = Echo_EchoRequest() requestMessage.text = part print("update sending: \(requestMessage.text)") - update.send(.message(requestMessage)) + update.sendMessage(requestMessage) } - update.send(.end) + update.sendEnd() // wait() on the status to stop the program from exiting. do { diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 20fe09648..3227ea9e8 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -19,14 +19,23 @@ import NIOHTTP1 import NIOHTTP2 import SwiftProtobuf -public class BaseClientCall { +/// This class provides much of the boilerplate for the four types of gRPC call objects returned to framework +/// users. +/// +/// Setup includes: +/// - creation of an HTTP/2 stream for the call to execute on, +/// - configuration of the NIO channel handlers for the stream, and +/// - setting a call timeout, if one is provided. +/// +/// This class also provides much of the framework user facing functionality via conformance to `ClientCall`. +open class BaseClientCall { /// The underlying `GRPCClient` providing the HTTP/2 channel and multiplexer. internal let client: GRPCClient - /// Promise for an HTTP/2 stream. + /// Promise for an HTTP/2 stream to execute the call on. internal let streamPromise: EventLoopPromise - /// Client channel handler. Handles some internal state for reading/writing messages to the channel. + /// Client channel handler. Handles internal state for reading/writing messages to the channel. /// The handler also owns the promises for the futures that this class surfaces to the user (such as /// `initialMetadata` and `status`). internal let clientChannelHandler: GRPCClientChannelHandler @@ -60,34 +69,23 @@ public class BaseClientCall { self.setTimeout(callOptions.timeout) let requestHead = BaseClientCall.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.sendRequestHead(requestHead) + self.sendHead(requestHead) } } extension BaseClientCall: ClientCall { - /// HTTP/2 stream associated with this call. public var subchannel: EventLoopFuture { return self.streamPromise.futureResult } - /// Initial metadata returned from the server. public var initialMetadata: EventLoopFuture { return self.clientChannelHandler.initialMetadataPromise.futureResult } - /// Status of this call which may originate from the server or client. - /// - /// Note: despite `GRPCStatus` being an `Error`, the value will be delievered as a __success__ - /// result even if the status represents a __negative__ outcome. public var status: EventLoopFuture { return self.clientChannelHandler.statusPromise.futureResult } - /// Cancel the current call. - /// - /// Closes the HTTP/2 stream once it becomes available. Additional writes to the channel will be ignored. - /// Any unfulfilled promises will be failed with a cancelled status (excepting `status` which will be - /// succeeded, if not already succeeded). public func cancel() { self.client.channel.eventLoop.execute { self.subchannel.whenSuccess { channel in @@ -99,7 +97,9 @@ extension BaseClientCall: ClientCall { extension BaseClientCall { /// Creates and configures an HTTP/2 stream channel. `subchannel` will contain the stream channel when it is created. - internal func createStreamChannel() { + /// + /// - Important: This should only ever be called once. + private func createStreamChannel() { self.client.channel.eventLoop.execute { self.client.multiplexer.createStreamChannel(promise: self.streamPromise) { (subchannel, streamID) -> EventLoopFuture in subchannel.pipeline.addHandlers([HTTP2ToHTTP1ClientCodec(streamID: streamID, httpProtocol: .http), @@ -112,29 +112,38 @@ extension BaseClientCall { } /// Send the request head once `subchannel` becomes available. - internal func sendRequestHead(_ requestHead: HTTPRequestHead) { + /// + /// - Important: This should only ever be called once. + private func sendHead(_ requestHead: HTTPRequestHead) { self.subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.head(requestHead), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.head(requestHead), promise: nil) } } - /// Send the given request once `subchannel` becomes available. - internal func sendRequest(_ request: RequestMessage) { + /// Send the given message once `subchannel` becomes available. + /// + /// - Note: This is prefixed to allow for classes conforming to StreamingRequestClientCall to have use the same function name. + internal func _sendMessage(_ message: RequestMessage) { self.subchannel.whenSuccess { channel in - channel.write(GRPCClientRequestPart.message(request), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.message(message), promise: nil) } } /// Send `end` once `subchannel` becomes available. - internal func sendEnd() { + /// + /// - Important: This should only ever be called once. + /// - Note: This is prefixed to allow for classes conforming to StreamingRequestClientCall to have use the same function name. + internal func _sendEnd() { self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) } } /// Creates a client-side timeout for this call. - internal func setTimeout(_ timeout: GRPCTimeout?) { - guard let timeout = timeout else { return } + /// + /// - Important: This should only ever be called once. + private func setTimeout(_ timeout: GRPCTimeout) { + if timeout == .infinite { return } let clientChannelHandler = self.clientChannelHandler self.client.channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { @@ -143,7 +152,7 @@ extension BaseClientCall { } } - /// Makes a new `HTTPRequestHead` for this call. + /// Makes a new `HTTPRequestHead` for a call with this signature. /// /// - Parameters: /// - path: path for this RPC method. @@ -157,19 +166,21 @@ extension BaseClientCall { requestHead.headers.add(name: name, value: value) } + // We're dealing with HTTP/1; the NIO HTTP2ToHTTP1Codec replaces "host" with ":authority". requestHead.headers.add(name: "host", value: host) + requestHead.headers.add(name: "content-type", value: "application/grpc") + + // Used to detect incompatible proxies, as per https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#requests requestHead.headers.add(name: "te", value: "trailers") - requestHead.headers.add(name: "user-agent", value: "grpc-swift-nio") - let acceptedEncoding = CompressionMechanism.acceptEncoding - .map { $0.rawValue } - .joined(separator: ",") + //! FIXME: Add a more specific user-agent, see: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#user-agents + requestHead.headers.add(name: "user-agent", value: "grpc-swift-nio") - requestHead.headers.add(name: "grpc-accept-encoding", value: acceptedEncoding) + requestHead.headers.add(name: "grpc-accept-encoding", value: CompressionMechanism.acceptEncodingHeader) - if let timeout = callOptions.timeout { - requestHead.headers.add(name: "grpc-timeout", value: String(describing: timeout)) + if callOptions.timeout != .infinite { + requestHead.headers.add(name: "grpc-timeout", value: String(describing: callOptions.timeout)) } return requestHead diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index 46f92045a..768404b4d 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -17,16 +17,25 @@ import Foundation import SwiftProtobuf import NIO -/// A bidirectional-streaming gRPC call. Each response is passed to the provided observer. +/// A bidirectional-streaming gRPC call. Each response is passed to the provided observer block. /// /// Messages should be sent via the `send` method; an `.end` message should be sent /// to indicate the final message has been sent. /// /// The following futures are available to the caller: /// - `initialMetadata`: the initial metadata returned from the server, -/// - `status`: the status of the gRPC call, +/// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { + + public func sendMessage(_ message: RequestMessage) { + self._sendMessage(message) + } + + public func sendEnd() { + self._sendEnd() + } + public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift index 36ac96af6..90f037ea7 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -19,8 +19,11 @@ import NIOHTTP1 import NIOHTTP2 import SwiftProtobuf +/// Base protocol for a client call to a gRPC service. public protocol ClientCall { + /// The type of the request message for the call. associatedtype RequestMessage: Message + /// The type of the response message for the call. associatedtype ResponseMessage: Message /// HTTP/2 stream that requests and responses are sent and received on. @@ -29,7 +32,13 @@ public protocol ClientCall { /// Initial response metadata. var initialMetadata: EventLoopFuture { get } - /// Response status. + /// Status of this call which may be populated by the server or client. + /// + /// The client may populate the status if, for example, it was not possible to connect to the service. + /// + /// Note: despite `GRPCStatus` being an `Error`, the value will be __always__ delivered as a __success__ + /// result even if the status represents a __negative__ outcome. This future will __never__ be fulfilled + /// with an error. var status: EventLoopFuture { get } /// Trailing response metadata. @@ -37,7 +46,11 @@ public protocol ClientCall { /// This is the same metadata as `GRPCStatus.trailingMetadata` returned by `status`. var trailingMetadata: EventLoopFuture { get } - /// Cancels the current call. + /// Cancel the current call. + /// + /// Closes the HTTP/2 stream once it becomes available. Additional writes to the channel will be ignored. + /// Any unfulfilled promises will be failed with a cancelled status (excepting `status` which will be + /// succeeded, if not already succeeded). func cancel() } @@ -47,32 +60,23 @@ extension ClientCall { } } -/// A `ClientCall` with server-streaming; i.e. server-streaming and bidirectional-streaming. +/// A `ClientCall` with request streaming; i.e. server-streaming and bidirectional-streaming. public protocol StreamingRequestClientCall: ClientCall { - /// Sends a request to the service. Callers must terminate the stream of messages - /// with an `.end` event. + /// Sends a message to the service. /// - /// - Parameter event: event to send. - func send(_ event: StreamEvent) -} + /// - Important: Callers must terminate the stream of messages by calling `sendEnd()`. + /// - Parameter message: request message to send. + func sendMessage(_ message: RequestMessage) -extension StreamingRequestClientCall { - public func send(_ event: StreamEvent) { - switch event { - case .message(let message): - subchannel.whenSuccess { channel in - channel.write(NIOAny(GRPCClientRequestPart.message(message)), promise: nil) - } - - case .end: - subchannel.whenSuccess { channel in - channel.writeAndFlush(NIOAny(GRPCClientRequestPart.end), promise: nil) - } - } - } + /// Indicates to the service that no more messages will be sent by the client. + func sendEnd() } /// A `ClientCall` with a unary response; i.e. unary and client-streaming. public protocol UnaryResponseClientCall: ClientCall { + /// The response message returned from the service if the call is successful. This may be failed + /// if the call encounters an error. + /// + /// Callers should rely on the `status` of the call for the canonical outcome. var response: EventLoopFuture { get } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index ec2614fb5..dbe1d83d5 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -25,19 +25,27 @@ import NIO /// The following futures are available to the caller: /// - `initialMetadata`: the initial metadata returned from the server, /// - `response`: the response from the call, -/// - `status`: the status of the gRPC call, +/// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { - public var response: EventLoopFuture { - // It's okay to force unwrap because we know the handler is holding the response promise. - return self.clientChannelHandler.responsePromise!.futureResult - } + public unowned let response: EventLoopFuture public init(client: GRPCClient, path: String, callOptions: CallOptions) { + let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() + self.response = responsePromise.futureResult + super.init( client: client, path: path, callOptions: callOptions, - responseObserver: .succeedPromise(client.channel.eventLoop.newPromise())) + responseObserver: .succeedPromise(responsePromise)) + } + + public func sendMessage(_ message: RequestMessage) { + self._sendMessage(message) + } + + public func sendEnd() { + self._sendEnd() } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift index a4a437580..a1061d2cd 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ResponseObserver.swift @@ -21,7 +21,7 @@ import SwiftProtobuf /// /// - succeedPromise: succeed the given promise on receipt of a message. /// - callback: calls the given callback for each response observed. -internal enum ResponseObserver { +public enum ResponseObserver { case succeedPromise(EventLoopPromise) case callback((ResponseMessage) -> Void) @@ -35,4 +35,14 @@ internal enum ResponseObserver { promise.succeed(result: message) } } + + var expectsMultipleResponses: Bool { + switch self { + case .callback: + return true + + case .succeedPromise: + return false + } + } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index 5a7795a2a..65e195e02 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -17,16 +17,16 @@ import Foundation import SwiftProtobuf import NIO -/// A server-streaming gRPC call. The request is sent on initialisation, each response is passed to the provided observer. +/// A server-streaming gRPC call. The request is sent on initialization, each response is passed to the provided observer block. /// /// The following futures are available to the caller: /// - `initialMetadata`: the initial metadata returned from the server, -/// - `status`: the status of the gRPC call, +/// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class ServerStreamingClientCall: BaseClientCall { public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) - self.sendRequest(request) - self.sendEnd() + self._sendMessage(request) + self._sendEnd() } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 6c9c038e3..7ac95f144 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -17,27 +17,27 @@ import Foundation import SwiftProtobuf import NIO -/// A unary gRPC call. The request is sent on initialisation. +/// A unary gRPC call. The request is sent on initialization. /// /// The following futures are available to the caller: /// - `initialMetadata`: the initial metadata returned from the server, /// - `response`: the response from the unary call, -/// - `status`: the status of the gRPC call, +/// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { - public var response: EventLoopFuture { - // It's okay to force unwrap because we know the handler is holding the response promise. - return self.clientChannelHandler.responsePromise!.futureResult - } + public unowned let response: EventLoopFuture public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions) { + let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() + self.response = responsePromise.futureResult + super.init( client: client, path: path, callOptions: callOptions, - responseObserver: .succeedPromise(client.channel.eventLoop.newPromise())) + responseObserver: .succeedPromise(responsePromise)) - self.sendRequest(request) - self.sendEnd() + self._sendMessage(request) + self._sendEnd() } } diff --git a/Sources/SwiftGRPCNIO/ClientOptions.swift b/Sources/SwiftGRPCNIO/ClientOptions.swift index 28c439da0..6dc7e0691 100644 --- a/Sources/SwiftGRPCNIO/ClientOptions.swift +++ b/Sources/SwiftGRPCNIO/ClientOptions.swift @@ -16,16 +16,15 @@ import Foundation import NIOHTTP1 +/// Options to use for GRPC calls. public struct CallOptions { - public static let defaultTimeout = GRPCTimeout.minutes(1) - /// Additional metadata to send to the service. public var customMetadata: HTTPHeaders - /// The call timeout; defaults to to 1 minute. - public var timeout: GRPCTimeout? + /// The call timeout. + public var timeout: GRPCTimeout - public init(customMetadata: HTTPHeaders = HTTPHeaders(), timeout: GRPCTimeout? = CallOptions.defaultTimeout) { + public init(customMetadata: HTTPHeaders = HTTPHeaders(), timeout: GRPCTimeout = GRPCTimeout.default) { self.customMetadata = customMetadata self.timeout = timeout } diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift index 768a9d685..d91fb2812 100644 --- a/Sources/SwiftGRPCNIO/CompressionMechanism.swift +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -15,12 +15,12 @@ */ import Foundation -internal enum CompressionError: Error { +public enum CompressionError: Error { case unsupported(CompressionMechanism) } /// The mechanism to use for message compression. -internal enum CompressionMechanism: String { +public enum CompressionMechanism: String { /// No compression was indicated. case none @@ -33,7 +33,9 @@ internal enum CompressionMechanism: String { /// Compression indiciated via a header, but not one listed in the specification. case unknown - /// Whether there should be a corresponding header flag. + /// Whether the compression flag in gRPC length-prefixed messages should be set or not. + /// + /// See `LengthPrefixedMessageReader` for the message format. var requiresFlag: Bool { switch self { case .none: @@ -55,16 +57,19 @@ internal enum CompressionMechanism: String { } } - /// Compression mechanisms we should list in an accept-encoding header. - static var acceptEncoding: [CompressionMechanism] { + /// A string containing the supported compression mechanisms to list in the "grpc-accept-encoding" header. + static let acceptEncodingHeader: String = { return CompressionMechanism .allCases .filter { $0.supported && $0.requiresFlag } - } + .map { $0.rawValue } + .joined(separator: ", ") + }() } #if swift(>=4.2) extension CompressionMechanism: CaseIterable {} +//! FIXME: Remove this code once the CI is updated to 4.2. #else extension CompressionMechanism { public static let allCases: [CompressionMechanism] = [.none, .identity, .gzip, .deflate, .snappy, .unknown] diff --git a/Sources/SwiftGRPCNIO/GRPCClient.swift b/Sources/SwiftGRPCNIO/GRPCClient.swift index bcfc3ea5a..d32684f74 100644 --- a/Sources/SwiftGRPCNIO/GRPCClient.swift +++ b/Sources/SwiftGRPCNIO/GRPCClient.swift @@ -17,6 +17,9 @@ import Foundation import NIO import NIOHTTP2 +/// Underlying channel and HTTP/2 stream multiplexer. +/// +/// Different service clients implementing `GRPCServiceClient` may share an instance of this class. open class GRPCClient { public static func start( host: String, @@ -40,13 +43,13 @@ open class GRPCClient { public let channel: Channel public let multiplexer: HTTP2StreamMultiplexer public let host: String - public var callOptions: CallOptions + public var defaultCallOptions: CallOptions - init(channel: Channel, multiplexer: HTTP2StreamMultiplexer, host: String, callOptions: CallOptions = CallOptions()) { + init(channel: Channel, multiplexer: HTTP2StreamMultiplexer, host: String, defaultCallOptions: CallOptions = CallOptions()) { self.channel = channel self.multiplexer = multiplexer self.host = host - self.callOptions = callOptions + self.defaultCallOptions = defaultCallOptions } /// Fired when the client shuts down. @@ -59,14 +62,16 @@ open class GRPCClient { } } +/// A GRPC client for a given service. public protocol GRPCServiceClient { + /// The client providing the underlying HTTP/2 channel for this client. var client: GRPCClient { get } /// Name of the service this client is for (e.g. "echo.Echo"). var service: String { get } /// The call options to use should the user not provide per-call options. - var callOptions: CallOptions { get set } + var defaultCallOptions: CallOptions { get set } /// Return the path for the given method in the format "/Service-Name/Method-Name". /// diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift index 52d534704..70b646e16 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -20,7 +20,7 @@ import SwiftProtobuf /// The final client-side channel handler. /// -/// This handler holds promises for the initial metadata and status, as well as an observer +/// This handler holds promises for the initial metadata and the status, as well as an observer /// for responses. For unary and client-streaming calls the observer will succeed a response /// promise. For server-streaming and bidirectional-streaming the observer will call the supplied /// callback with each response received. @@ -29,7 +29,7 @@ import SwiftProtobuf /// response (if applicable) are failed with first error received. The status promise is __succeeded__ /// with the error as a `GRPCStatus`. The stream is also closed and any inbound or outbound messages /// are ignored. -internal class GRPCClientChannelHandler { +open class GRPCClientChannelHandler { internal let initialMetadataPromise: EventLoopPromise internal let statusPromise: EventLoopPromise internal let responseObserver: ResponseObserver @@ -47,14 +47,26 @@ internal class GRPCClientChannelHandler! private enum InboundState { - case expectingHeaders + case expectingHeadersOrStatus case expectingMessageOrStatus + case expectingStatus case ignore + + var expectingStatus: Bool { + switch self { + case .expectingHeadersOrStatus, .expectingMessageOrStatus, .expectingStatus: + return true + + case .ignore: + return false + } + } } private enum OutboundState { @@ -63,7 +75,7 @@ internal class GRPCClientChannelHandler, statusPromise: EventLoopPromise, responseObserver: ResponseObserver @@ -115,7 +127,7 @@ extension GRPCClientChannelHandler: ChannelInboundHandler { switch unwrapInboundIn(data) { case .headers(let headers): - guard self.inboundState == .expectingHeaders else { + guard self.inboundState == .expectingHeadersOrStatus else { self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) return } @@ -125,14 +137,15 @@ extension GRPCClientChannelHandler: ChannelInboundHandler { case .message(let message): guard self.inboundState == .expectingMessageOrStatus else { - self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + self.errorCaught(ctx: ctx, error: GRPCError.responseCardinalityViolation) return } self.responseObserver.observe(message) + self.inboundState = self.responseObserver.expectsMultipleResponses ? .expectingMessageOrStatus : .expectingStatus case .status(let status): - guard self.inboundState == .expectingMessageOrStatus else { + guard self.inboundState.expectingStatus else { self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) return } @@ -150,7 +163,7 @@ extension GRPCClientChannelHandler: ChannelOutboundHandler { public typealias OutboundOut = GRPCClientRequestPart public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - guard self.inboundState != .ignore else { return } + guard self.outboundState != .ignore else { return } switch unwrapOutboundIn(data) { case .head: @@ -178,7 +191,7 @@ extension GRPCClientChannelHandler: ChannelOutboundHandler { extension GRPCClientChannelHandler { /// Closes the HTTP/2 stream. Inbound and outbound state are set to ignore. public func close(ctx: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise?) { - self.observeStatus(GRPCStatus.cancelled) + self.observeStatus(GRPCStatus.cancelledByClient) requestHeadSentPromise.futureResult.whenComplete { ctx.close(mode: mode, promise: promise) @@ -191,6 +204,7 @@ extension GRPCClientChannelHandler { /// Observe an error from the pipeline. Errors are cast to `GRPCStatus` or `GRPCStatus.processingError` /// if the cast failed and promises are fulfilled with the status. The channel is also closed. public func errorCaught(ctx: ChannelHandlerContext, error: Error) { + //! TODO: Add an error handling delegate, similar to in the server. let status = (error as? GRPCStatus) ?? .processingError self.observeStatus(status) diff --git a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift index f35ce8739..33f2b8b96 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -32,7 +32,7 @@ public enum GRPCClientResponsePart { case status(GRPCStatus) } -/// This channel handler simply encodes and decodes protobuf messages into into typed messages +/// This channel handler simply encodes and decodes protobuf messages into typed messages /// and `Data`. public final class GRPCClientCodec { public init() {} @@ -43,15 +43,15 @@ extension GRPCClientCodec: ChannelInboundHandler { public typealias InboundOut = GRPCClientResponsePart public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { - let response = unwrapInboundIn(data) + let response = self.unwrapInboundIn(data) switch response { case .headers(let headers): - ctx.fireChannelRead(wrapInboundOut(.headers(headers))) + ctx.fireChannelRead(self.wrapInboundOut(.headers(headers))) - case .message(var message): + case .message(var messageBuffer): // Force unwrapping is okay here; we're reading the readable bytes. - let messageAsData = message.readData(length: message.readableBytes)! + let messageAsData = messageBuffer.readData(length: messageBuffer.readableBytes)! do { ctx.fireChannelRead(self.wrapInboundOut(.message(try ResponseMessage(serializedData: messageAsData)))) } catch { @@ -59,7 +59,7 @@ extension GRPCClientCodec: ChannelInboundHandler { } case .status(let status): - ctx.fireChannelRead(wrapInboundOut(.status(status))) + ctx.fireChannelRead(self.wrapInboundOut(.status(status))) } } } @@ -69,21 +69,22 @@ extension GRPCClientCodec: ChannelOutboundHandler { public typealias OutboundOut = RawGRPCClientRequestPart public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - let request = unwrapOutboundIn(data) + let request = self.unwrapOutboundIn(data) switch request { case .head(let head): - ctx.write(wrapOutboundOut(.head(head)), promise: promise) + ctx.write(self.wrapOutboundOut(.head(head)), promise: promise) case .message(let message): do { - ctx.writeAndFlush(wrapOutboundOut(.message(try message.serializedData())), promise: promise) + ctx.write(self.wrapOutboundOut(.message(try message.serializedData())), promise: promise) } catch { + promise?.fail(error: error) ctx.fireErrorCaught(error) } case .end: - ctx.writeAndFlush(wrapOutboundOut(.end), promise: promise) + ctx.write(self.wrapOutboundOut(.end), promise: promise) } } } diff --git a/Sources/SwiftGRPCNIO/GRPCError.swift b/Sources/SwiftGRPCNIO/GRPCError.swift new file mode 100644 index 000000000..1848e1515 --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCError.swift @@ -0,0 +1,23 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation + +enum GRPCError: Error { + case HTTPStatusNotOk + case unsupportedCompression(CompressionMechanism) + case cancelledByClient + case responseCardinalityViolation +} diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index b8e15834c..fe8fffb4e 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -10,7 +10,7 @@ public struct GRPCStatus: Error { /// Additional HTTP headers to return in the trailers. public let trailingMetadata: HTTPHeaders - public init(code: StatusCode, message: String? = nil, trailingMetadata: HTTPHeaders = HTTPHeaders()) { + public init(code: StatusCode, message: String?, trailingMetadata: HTTPHeaders = HTTPHeaders()) { self.code = code self.message = message self.trailingMetadata = trailingMetadata @@ -23,7 +23,7 @@ public struct GRPCStatus: Error { /// "Internal server error" status. public static let processingError = GRPCStatus(code: .internalError, message: "unknown error processing request") /// Client cancelled the call. - public static let cancelled = GRPCStatus(code: .cancelled, message: "cancelled by the client") + public static let cancelledByClient = GRPCStatus(code: .cancelled, message: "cancelled by the client") /// Status indicating that the given method is not implemented. public static func unimplemented(method: String) -> GRPCStatus { diff --git a/Sources/SwiftGRPCNIO/GRPCTimeout.swift b/Sources/SwiftGRPCNIO/GRPCTimeout.swift index 46c597c7e..013007ed2 100644 --- a/Sources/SwiftGRPCNIO/GRPCTimeout.swift +++ b/Sources/SwiftGRPCNIO/GRPCTimeout.swift @@ -1,25 +1,39 @@ import Foundation import NIO +public enum GRPCTimeoutError: String, Error { + case negative = "GRPCTimeout must be non-negative" + case tooManyDigits = "GRPCTimeout must be at most 8 digits" +} + /// A timeout for a gRPC call. /// /// Timeouts must be positive and at most 8-digits long. -public struct GRPCTimeout: CustomStringConvertible { +public struct GRPCTimeout: CustomStringConvertible, Equatable { + public static let `default`: GRPCTimeout = try! .minutes(1) + /// Creates an infinite timeout. This is a sentinel value which must __not__ be sent to a gRPC service. + public static let infinite: GRPCTimeout = GRPCTimeout(nanoseconds: Int64.max, description: "infinite") + /// A description of the timeout in the format described in the /// [gRPC protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md). public let description: String + public let nanoseconds: Int64 - private let nanoseconds: Int64 + private init(nanoseconds: Int64, description: String) { + self.nanoseconds = nanoseconds + self.description = description + } - /// Creates a new GRPCTimeout with the given `amount` of the `unit`. - /// - /// `amount` must be positive and at most 8-digits. - private init?(_ amount: Int, _ unit: GRPCTimeoutUnit) { + private static func makeTimeout(_ amount: Int, _ unit: GRPCTimeoutUnit) throws -> GRPCTimeout { // Timeouts must be positive and at most 8-digits. - guard amount >= 0, amount < 100_000_000 else { return nil } + if amount < 0 { throw GRPCTimeoutError.negative } + if amount >= 100_000_000 { throw GRPCTimeoutError.tooManyDigits } + + // See "Timeout" in https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#requests + let description = "\(amount) \(unit.rawValue)" + let nanoseconds = Int64(amount) * Int64(unit.asNanoseconds) - self.description = "\(amount) \(unit.rawValue)" - self.nanoseconds = Int64(amount) * Int64(unit.asNanoseconds) + return GRPCTimeout(nanoseconds: nanoseconds, description: description) } /// Creates a new GRPCTimeout for the given amount of hours. @@ -27,9 +41,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of hours this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of hours if the amount was valid, `nil` otherwise. - public static func hours(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .hours) + /// - Returns: A `GRPCTimeout` representing the given number of hours. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func hours(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .hours) } /// Creates a new GRPCTimeout for the given amount of minutes. @@ -37,9 +52,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of minutes this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of minutes if the amount was valid, `nil` otherwise. - public static func minutes(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .minutes) + /// - Returns: A `GRPCTimeout` representing the given number of minutes. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func minutes(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .minutes) } /// Creates a new GRPCTimeout for the given amount of seconds. @@ -47,9 +63,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of seconds this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of seconds if the amount was valid, `nil` otherwise. - public static func seconds(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .seconds) + /// - Returns: A `GRPCTimeout` representing the given number of seconds. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func seconds(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .seconds) } /// Creates a new GRPCTimeout for the given amount of milliseconds. @@ -57,9 +74,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of milliseconds this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of milliseconds if the amount was valid, `nil` otherwise. - public static func milliseconds(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .milliseconds) + /// - Returns: A `GRPCTimeout` representing the given number of milliseconds. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func milliseconds(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .milliseconds) } /// Creates a new GRPCTimeout for the given amount of microseconds. @@ -67,9 +85,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of microseconds this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of microseconds if the amount was valid, `nil` otherwise. - public static func microseconds(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .microseconds) + /// - Returns: A `GRPCTimeout` representing the given number of microseconds. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func microseconds(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .microseconds) } /// Creates a new GRPCTimeout for the given amount of nanoseconds. @@ -77,9 +96,10 @@ public struct GRPCTimeout: CustomStringConvertible { /// `amount` must be positive and at most 8-digits. /// /// - Parameter amount: the amount of nanoseconds this `GRPCTimeout` represents. - /// - Returns: A `GRPCTimeout` representing the given number of nanoseconds if the amount was valid, `nil` otherwise. - public static func nanoseconds(_ amount: Int) -> GRPCTimeout? { - return GRPCTimeout(amount, .nanoseconds) + /// - Returns: A `GRPCTimeout` representing the given number of nanoseconds. + /// - Throws: `GRPCTimeoutError` if the amount was negative or more than 8 digits long. + public static func nanoseconds(_ amount: Int) throws -> GRPCTimeout { + return try makeTimeout(amount, .nanoseconds) } } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index 71ca9871f..77de1a08f 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -31,7 +31,7 @@ public enum RawGRPCClientResponsePart { case status(GRPCStatus) } -/// Codec for translating HTTP/1 resposnes from the server into untyped gRPC packages +/// Codec for translating HTTP/1 responses from the server into untyped gRPC packages /// and vice-versa. /// /// Most of the inbound processing is done by `LengthPrefixedMessageReader`; which @@ -64,7 +64,7 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { if case .ignore = state { return } - switch unwrapInboundIn(data) { + switch self.unwrapInboundIn(data) { case .head(let head): state = processHead(ctx: ctx, head: head) @@ -88,11 +88,21 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { guard case .expectingHeaders = state else { preconditionFailure("received headers while in state \(state)") } + guard head.status == .ok else { + ctx.fireErrorCaught(GRPCError.HTTPStatusNotOk) + return .ignore + } + if let encodingType = head.headers["grpc-encoding"].first { - inboundCompression = CompressionMechanism(rawValue: encodingType) ?? .unknown + self.inboundCompression = CompressionMechanism(rawValue: encodingType) ?? .unknown } - ctx.fireChannelRead(wrapInboundOut(.headers(head.headers))) + guard inboundCompression.supported else { + ctx.fireErrorCaught(GRPCError.unsupportedCompression(inboundCompression)) + return .ignore + } + + ctx.fireChannelRead(self.wrapInboundOut(.headers(head.headers))) return .expectingBodyOrTrailers } @@ -104,10 +114,8 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { guard case .expectingBodyOrTrailers = state else { preconditionFailure("received body while in state \(state)") } - while messageBuffer.readableBytes > 0 { - if let message = try self.messageReader.read(messageBuffer: &messageBuffer, compression: inboundCompression) { - ctx.fireChannelRead(wrapInboundOut(.message(message))) - } + for message in try self.messageReader.consume(messageBuffer: &messageBuffer, compression: inboundCompression) { + ctx.fireChannelRead(self.wrapInboundOut(.message(message))) } return .expectingBodyOrTrailers @@ -134,20 +142,17 @@ extension HTTP1ToRawGRPCClientCodec: ChannelOutboundHandler { public typealias OutboundOut = HTTPClientRequestPart public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - switch unwrapOutboundIn(data) { + switch self.unwrapOutboundIn(data) { case .head(let requestHead): - ctx.write(wrapOutboundOut(.head(requestHead)), promise: promise) + ctx.write(self.wrapOutboundOut(.head(requestHead)), promise: promise) case .message(let message): - do { - let request = try messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) - ctx.write(wrapOutboundOut(.body(.byteBuffer(request))), promise: promise) - } catch { - ctx.fireErrorCaught(error) - } + var request = ctx.channel.allocator.buffer(capacity: LengthPrefixedMessageWriter.metadataLength) + messageWriter.write(message, into: &request, usingCompression: .none) + ctx.write(self.wrapOutboundOut(.body(.byteBuffer(request))), promise: promise) case .end: - ctx.writeAndFlush(wrapOutboundOut(.end(nil)), promise: promise) + ctx.write(self.wrapOutboundOut(.end(nil)), promise: promise) } } } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index f4bbef739..285efc92f 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -87,12 +87,10 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { ctx.write(self.wrapOutboundOut(.head(HTTPResponseHead(version: .init(major: 2, minor: 0), status: .ok, headers: headers))), promise: promise) case .message(let message): - do { - let responseBuffer = try messageWriter.write(allocator: ctx.channel.allocator, compression: .none, message: message) - ctx.write(self.wrapOutboundOut(.body(.byteBuffer(responseBuffer))), promise: promise) - } catch { - ctx.fireErrorCaught(error) - } + //! FIXME: Determine which compression mechanism should be used. + var responseBuffer = ctx.channel.allocator.buffer(capacity: LengthPrefixedMessageWriter.metadataLength) + messageWriter.write(message, into: &responseBuffer, usingCompression: .none) + ctx.write(self.wrapOutboundOut(.body(.byteBuffer(responseBuffer))), promise: promise) case .status(let status): var trailers = status.trailingMetadata diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index 61fb1929a..e790c0a12 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -26,7 +26,7 @@ import NIOHTTP1 /// /// - SeeAlso: /// [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) -internal class LengthPrefixedMessageReader { +public class LengthPrefixedMessageReader { private var buffer: ByteBuffer! private var state: State = .expectingCompressedFlag private let mode: Mode @@ -45,25 +45,40 @@ internal class LengthPrefixedMessageReader { case isBuffering(requiredBytes: Int) } + /// Consumes all readable bytes from given buffer and returns all messages which could be read. + /// + /// - SeeAlso: `read(messageBuffer:compression:)` + public func consume(messageBuffer: inout ByteBuffer, compression: CompressionMechanism) throws -> [ByteBuffer] { + var messages: [ByteBuffer] = [] + + while messageBuffer.readableBytes > 0 { + if let message = try self.read(messageBuffer: &messageBuffer, compression: compression) { + messages.append(message) + } + } + + return messages + } + /// Reads bytes from the given buffer until it is exhausted or a message has been read. /// /// Length prefixed messages may be split across multiple input buffers in any of the /// following places: /// 1. after the compression flag, - /// 2. after the message length flag, + /// 2. after the message length field, /// 3. at any point within the message. /// /// - Note: /// This method relies on state; if a message is _not_ returned then the next time this - /// method is called it expect to read the bytes which follow the most recently read bytes. - /// If a message _is_ returned without exhausting the given buffer then reading a - /// different buffer is not an issue. + /// method is called it expects to read the bytes which follow the most recently read bytes. /// - /// - Parameter messageBuffer: buffer to read from. + /// - Parameters: + /// - messageBuffer: buffer to read from. + /// - compression: compression mechanism to decode message with. /// - Returns: A buffer containing a message if one has been read, or `nil` if not enough /// bytes have been consumed to return a message. /// - Throws: Throws an error if the compression algorithm is not supported. - internal func read(messageBuffer: inout ByteBuffer, compression: CompressionMechanism) throws -> ByteBuffer? { + public func read(messageBuffer: inout ByteBuffer, compression: CompressionMechanism) throws -> ByteBuffer? { while true { switch state { case .expectingCompressedFlag: @@ -79,7 +94,7 @@ internal class LengthPrefixedMessageReader { // If this holds true, we can skip buffering and return a slice. guard messageLength <= messageBuffer.readableBytes else { self.state = .willBuffer(requiredBytes: messageLength) - break + continue } self.state = .expectingCompressedFlag diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift index 44a7925c2..1c6748906 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -16,27 +16,27 @@ import Foundation import NIO -internal class LengthPrefixedMessageWriter { +public class LengthPrefixedMessageWriter { + public static let metadataLength = 5 + /// Writes the data into a `ByteBuffer` as a gRPC length-prefixed message. /// /// - Parameters: /// - allocator: Buffer allocator. - /// - compression: Compression mechanism to use. + /// - compression: Compression mechanism to use; the mechansim must be supported. /// - message: The serialized Protobuf message to write. /// - Returns: A `ByteBuffer` containing a gRPC length-prefixed message. - /// - Throws: `CompressionError` if the compression mechanism is not supported. + /// - Precondition: `compression.supported` returns `true`. /// - Note: See `LengthPrefixedMessageReader` for more details on the format. - func write(allocator: ByteBufferAllocator, compression: CompressionMechanism, message: Data) throws -> ByteBuffer { - guard compression.supported else { throw CompressionError.unsupported(compression) } + func write(_ message: Data, into buffer: inout ByteBuffer, usingCompression compression: CompressionMechanism) { + precondition(compression.supported, "compression mechanism \(compression) is not supported") // 1-byte for compression flag, 4-bytes for the message length. - var buffer = allocator.buffer(capacity: 5 + message.count) + buffer.reserveCapacity(5 + message.count) - //: TODO: Add compression support, use the length and compressed content. + //! TODO: Add compression support, use the length and compressed content. buffer.write(integer: Int8(compression.requiresFlag ? 1 : 0)) buffer.write(integer: UInt32(message.count)) buffer.write(bytes: message) - - return buffer } } diff --git a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift index fbe776956..74ffd28ec 100644 --- a/Sources/protoc-gen-swiftgrpc/Generator-Client.swift +++ b/Sources/protoc-gen-swiftgrpc/Generator-Client.swift @@ -331,7 +331,7 @@ extension Generator { } private func printNIOServiceClientProtocol() { - println("/// Instantiate \(serviceClassName)Client, then call methods of this protocol to make API calls.") + println("/// Usage: instantiate \(serviceClassName)Client, then call methods of this protocol to make API calls.") println("\(options.visibility.sourceSnippet) protocol \(serviceClassName) {") indent() for method in service.methods { @@ -359,17 +359,17 @@ extension Generator { indent() println("\(access) let client: GRPCClient") println("\(access) let service = \"\(servicePath)\"") - println("\(access) var callOptions: CallOptions") + println("\(access) var defaultCallOptions: CallOptions") println() println("/// Creates a client for the \(servicePath) service.") println("///") printParameters() println("/// - client: `GRPCClient` with a connection to the service host.") - println("/// - callOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.callOptions`.") - println("\(access) init(client: GRPCClient, callOptions: CallOptions? = nil) {") + println("/// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. Defaults to `client.defaultCallOptions`.") + println("\(access) init(client: GRPCClient, defaultCallOptions: CallOptions? = nil) {") indent() println("self.client = client") - println("self.callOptions = callOptions ?? client.callOptions") + println("self.defaultCallOptions = defaultCallOptions ?? client.defaultCallOptions") outdent() println("}") println() @@ -384,9 +384,9 @@ extension Generator { printRequestParameter() printCallOptionsParameter() println("/// - Returns: A `UnaryClientCall` with futures for the metadata, status and response.") - println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil) -> UnaryClientCall<\(methodInputName), \(methodOutputName)> {") + println("\(access) func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil) -> UnaryClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return UnaryClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.callOptions)") + println("return UnaryClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.defaultCallOptions)") outdent() println("}") @@ -398,9 +398,9 @@ extension Generator { printCallOptionsParameter() printHandlerParameter() println("/// - Returns: A `ServerStreamingClientCall` with futures for the metadata and status.") - println("func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> ServerStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + println("\(access) func \(methodFunctionName)(_ request: \(methodInputName), callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> ServerStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return ServerStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.callOptions, handler: handler)") + println("return ServerStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), request: request, callOptions: callOptions ?? self.defaultCallOptions, handler: handler)") outdent() println("}") @@ -412,9 +412,9 @@ extension Generator { printParameters() printCallOptionsParameter() println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") - println("func \(methodFunctionName)(callOptions: CallOptions? = nil) -> ClientStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + println("\(access) func \(methodFunctionName)(callOptions: CallOptions? = nil) -> ClientStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return ClientStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.callOptions)") + println("return ClientStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.defaultCallOptions)") outdent() println("}") @@ -426,10 +426,10 @@ extension Generator { printParameters() printCallOptionsParameter() printHandlerParameter() - println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata, status and response.") - println("func \(methodFunctionName)(callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> BidirectionalStreamingClientCall<\(methodInputName), \(methodOutputName)> {") + println("/// - Returns: A `ClientStreamingClientCall` with futures for the metadata and status.") + println("\(access) func \(methodFunctionName)(callOptions: CallOptions? = nil, handler: @escaping (\(methodOutputName)) -> Void) -> BidirectionalStreamingClientCall<\(methodInputName), \(methodOutputName)> {") indent() - println("return BidirectionalStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.callOptions, handler: handler)") + println("return BidirectionalStreamingClientCall(client: client, path: path(forMethod: \"\(method.name)\"), callOptions: callOptions ?? self.defaultCallOptions, handler: handler)") outdent() println("}") } @@ -453,7 +453,7 @@ extension Generator { } private func printCallOptionsParameter() { - println("/// - callOptions: Call options; `self.callOptions` is used if `nil`.") + println("/// - callOptions: Call options; `self.defaultCallOptions` is used if `nil`.") } private func printHandlerParameter() { diff --git a/Tests/SwiftGRPCNIOTests/EchoProviderNIO.swift b/Tests/SwiftGRPCNIOTests/EchoProviderNIO.swift new file mode 120000 index 000000000..81e19ce11 --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/EchoProviderNIO.swift @@ -0,0 +1 @@ +../../Sources/Examples/EchoNIO/EchoProviderNIO.swift \ No newline at end of file diff --git a/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift b/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift index df992f330..861821ea4 100644 --- a/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/NIOBasicEchoTestCase.swift @@ -32,7 +32,7 @@ extension Echo_EchoResponse { } class NIOBasicEchoTestCase: XCTestCase { - var defaultTimeout: TimeInterval = 1.0 + var defaultTestTimeout: TimeInterval = 1.0 var serverEventLoopGroup: EventLoopGroup! var server: GRPCServer! @@ -45,13 +45,13 @@ class NIOBasicEchoTestCase: XCTestCase { self.serverEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) self.server = try! GRPCServer.start( - hostname: "localhost", port: 5050, eventLoopGroup: self.serverEventLoopGroup, serviceProviders: [EchoProvider_NIO()]) + hostname: "localhost", port: 5050, eventLoopGroup: self.serverEventLoopGroup, serviceProviders: [EchoProviderNIO()]) .wait() self.clientEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) self.client = try! GRPCClient.start( host: "localhost", port: 5050, eventLoopGroup: self.clientEventLoopGroup) - .map { Echo_EchoService_NIOClient(client: $0) } + .map { Echo_EchoService_NIOClient(client: $0, defaultCallOptions: CallOptions(timeout: try! .seconds(5))) } .wait() } diff --git a/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift index 77cd1e588..9725c41a4 100644 --- a/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientCancellingTests.swift @@ -31,30 +31,42 @@ class NIOClientCancellingTests: NIOBasicEchoTestCase { extension NIOClientCancellingTests { func testUnary() { let statusReceived = self.expectation(description: "status received") + let responseReceived = self.expectation(description: "response received") let call = client.get(Echo_EchoRequest(text: "foo bar baz")) call.cancel() + call.response.whenFailure { error in + XCTAssertEqual((error as? GRPCStatus)?.code, .cancelled) + responseReceived.fulfill() + } + call.status.whenSuccess { status in XCTAssertEqual(status.code, .cancelled) statusReceived.fulfill() } - waitForExpectations(timeout: self.defaultTimeout) + waitForExpectations(timeout: self.defaultTestTimeout) } func testClientStreaming() throws { let statusReceived = self.expectation(description: "status received") + let responseReceived = self.expectation(description: "response received") let call = client.collect() call.cancel() + call.response.whenFailure { error in + XCTAssertEqual((error as? GRPCStatus)?.code, .cancelled) + responseReceived.fulfill() + } + call.status.whenSuccess { status in XCTAssertEqual(status.code, .cancelled) statusReceived.fulfill() } - waitForExpectations(timeout: self.defaultTimeout) + waitForExpectations(timeout: self.defaultTestTimeout) } func testServerStreaming() { @@ -70,7 +82,7 @@ extension NIOClientCancellingTests { statusReceived.fulfill() } - waitForExpectations(timeout: self.defaultTimeout) + waitForExpectations(timeout: self.defaultTestTimeout) } func testBidirectionalStreaming() { @@ -86,6 +98,6 @@ extension NIOClientCancellingTests { statusReceived.fulfill() } - waitForExpectations(timeout: self.defaultTimeout) + waitForExpectations(timeout: self.defaultTestTimeout) } } diff --git a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift index b21584062..546d0e961 100644 --- a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift @@ -19,6 +19,9 @@ import NIO import XCTest class NIOClientTimeoutTests: NIOBasicEchoTestCase { + let optionsWithShortTimeout = CallOptions(timeout: try! GRPCTimeout.milliseconds(10)) + let atLeastShortTimeout: TimeInterval = 0.011 + static var allTests: [(String, (NIOClientTimeoutTests) -> () throws -> Void)] { return [ ("testUnaryTimeoutAfterSending", testUnaryTimeoutAfterSending), @@ -39,7 +42,7 @@ class NIOClientTimeoutTests: NIOBasicEchoTestCase { } status.whenFailure { error in - XCTFail("unexpectedelty received error for status: \(error)") + XCTFail("unexpectedly received error for status: \(error)") } } @@ -52,7 +55,7 @@ class NIOClientTimeoutTests: NIOBasicEchoTestCase { } response.whenSuccess { response in - XCTFail("response recevied after deadline") + XCTFail("response received after deadline") } } } @@ -60,73 +63,69 @@ class NIOClientTimeoutTests: NIOBasicEchoTestCase { extension NIOClientTimeoutTests { func testUnaryTimeoutAfterSending() { // The request gets fired on call creation, so we need a very short timeout. - let callOptions = CallOptions(timeout: .milliseconds(1)) + let callOptions = CallOptions(timeout: try! .milliseconds(1)) let call = client.get(Echo_EchoRequest(text: "foo"), callOptions: callOptions) self.expectDeadlineExceeded(forStatus: call.status) self.expectDeadlineExceeded(forResponse: call.response) - waitForExpectations(timeout: defaultTimeout) + waitForExpectations(timeout: defaultTestTimeout) } func testServerStreamingTimeoutAfterSending() { // The request gets fired on call creation, so we need a very short timeout. - let callOptions = CallOptions(timeout: .milliseconds(1)) + let callOptions = CallOptions(timeout: try! .milliseconds(1)) let call = client.expand(Echo_EchoRequest(text: "foo bar baz"), callOptions: callOptions) { _ in } self.expectDeadlineExceeded(forStatus: call.status) - waitForExpectations(timeout: defaultTimeout) + waitForExpectations(timeout: defaultTestTimeout) } func testClientStreamingTimeoutBeforeSending() { - let callOptions = CallOptions(timeout: .milliseconds(50)) - let call = client.collect(callOptions: callOptions) + let call = client.collect(callOptions: optionsWithShortTimeout) self.expectDeadlineExceeded(forStatus: call.status) self.expectDeadlineExceeded(forResponse: call.response) - waitForExpectations(timeout: defaultTimeout) + waitForExpectations(timeout: defaultTestTimeout) } func testClientStreamingTimeoutAfterSending() { - let callOptions = CallOptions(timeout: .milliseconds(50)) - let call = client.collect(callOptions: callOptions) + let call = client.collect(callOptions: optionsWithShortTimeout) self.expectDeadlineExceeded(forStatus: call.status) self.expectDeadlineExceeded(forResponse: call.response) - call.send(.message(Echo_EchoRequest(text: "foo"))) + call.sendMessage(Echo_EchoRequest(text: "foo")) // Timeout before sending `.end` - Thread.sleep(forTimeInterval: 0.1) - call.send(.end) + Thread.sleep(forTimeInterval: atLeastShortTimeout) + call.sendEnd() - waitForExpectations(timeout: defaultTimeout) + waitForExpectations(timeout: defaultTestTimeout) } func testBidirectionalStreamingTimeoutBeforeSending() { - let callOptions = CallOptions(timeout: .milliseconds(50)) - let call = client.update(callOptions: callOptions) { _ in } + let call = client.update(callOptions: optionsWithShortTimeout) { _ in } self.expectDeadlineExceeded(forStatus: call.status) - Thread.sleep(forTimeInterval: 0.1) - waitForExpectations(timeout: defaultTimeout) + Thread.sleep(forTimeInterval: atLeastShortTimeout) + waitForExpectations(timeout: defaultTestTimeout) } func testBidirectionalStreamingTimeoutAfterSending() { - let callOptions = CallOptions(timeout: .milliseconds(50)) - let call = client.update(callOptions: callOptions) { _ in } + let call = client.update(callOptions: optionsWithShortTimeout) { _ in } self.expectDeadlineExceeded(forStatus: call.status) - call.send(.message(Echo_EchoRequest(text: "foo"))) + call.sendMessage(Echo_EchoRequest(text: "foo")) // Timeout before sending `.end` - Thread.sleep(forTimeInterval: 0.1) - call.send(.end) + Thread.sleep(forTimeInterval: atLeastShortTimeout) + call.sendEnd() - waitForExpectations(timeout: defaultTimeout) + waitForExpectations(timeout: defaultTestTimeout) } } diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 77d847c59..46f336baf 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -21,63 +21,6 @@ import NIOHTTP2 @testable import SwiftGRPCNIO import XCTest -// This class is what the SwiftGRPC user would actually implement to provide their service. -final class EchoProvider_NIO: Echo_EchoProvider_NIO { - func get(request: Echo_EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture { - var response = Echo_EchoResponse() - response.text = "Swift echo get: " + request.text - return context.eventLoop.newSucceededFuture(result: response) - } - - func collect(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { - var parts: [String] = [] - return context.eventLoop.newSucceededFuture(result: { event in - switch event { - case .message(let message): - parts.append(message.text) - - case .end: - var response = Echo_EchoResponse() - response.text = "Swift echo collect: " + parts.joined(separator: " ") - context.responsePromise.succeed(result: response) - } - }) - } - - func expand(request: Echo_EchoRequest, context: StreamingResponseCallContext) -> EventLoopFuture { - var endOfSendOperationQueue = context.eventLoop.newSucceededFuture(result: ()) - let parts = request.text.components(separatedBy: " ") - for (i, part) in parts.enumerated() { - var response = Echo_EchoResponse() - response.text = "Swift echo expand (\(i)): \(part)" - endOfSendOperationQueue = endOfSendOperationQueue.then { context.sendResponse(response) } - } - return endOfSendOperationQueue.map { GRPCStatus.ok } - } - - func update(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> { - var endOfSendOperationQueue = context.eventLoop.newSucceededFuture(result: ()) - var count = 0 - - return context.eventLoop.newSucceededFuture(result: { event in - switch event { - case .message(let message): - var response = Echo_EchoResponse() - response.text = "Swift echo update (\(count)): \(message.text)" - endOfSendOperationQueue = endOfSendOperationQueue.then { - context.sendResponse(response) - } - count += 1 - - case .end: - endOfSendOperationQueue - .map { GRPCStatus.ok } - .cascade(promise: context.statusPromise) - } - }) - } -} - class NIOServerTests: NIOBasicEchoTestCase { static var allTests: [(String, (NIOServerTests) -> () throws -> Void)] { return [ @@ -95,13 +38,12 @@ class NIOServerTests: NIOBasicEchoTestCase { } static let aFewStrings = ["foo", "bar", "baz"] - static let lotsOfStrings = (0..<10_000).map { String(describing: $0) } + static let lotsOfStrings = (0..<5_000).map { String(describing: $0) } } extension NIOServerTests { func testUnary() throws { - let options = CallOptions(timeout: nil) - XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo" }, callOptions: options).response.wait().text, "Swift echo get: foo") + XCTAssertEqual(try client.get(Echo_EchoRequest(text: "foo")).response.wait().text, "Swift echo get: foo") } func testUnaryLotsOfRequests() throws { @@ -120,16 +62,16 @@ extension NIOServerTests { } extension NIOServerTests { - func doTestClientStreaming(messages: [String]) throws { + func doTestClientStreaming(messages: [String], file: StaticString = #file, line: UInt = #line) throws { let call = client.collect() for message in messages { - call.send(.message(Echo_EchoRequest.with { $0.text = message })) + call.sendMessage(Echo_EchoRequest.with { $0.text = message }) } - call.send(.end) + call.sendEnd() - XCTAssertEqual("Swift echo collect: " + messages.joined(separator: " "), try call.response.wait().text) - XCTAssertEqual(.ok, try call.status.wait().code) + XCTAssertEqual("Swift echo collect: " + messages.joined(separator: " "), try call.response.wait().text, file: file, line: line) + XCTAssertEqual(.ok, try call.status.wait().code, file: file, line: line) } func testClientStreaming() { @@ -142,14 +84,15 @@ extension NIOServerTests { } extension NIOServerTests { - func doTestServerStreaming(messages: [String]) throws { + func doTestServerStreaming(messages: [String], file: StaticString = #file, line: UInt = #line) throws { var index = 0 let call = client.expand(Echo_EchoRequest.with { $0.text = messages.joined(separator: " ") }) { response in - XCTAssertEqual("Swift echo expand (\(index)): \(messages[index])", response.text) + XCTAssertEqual("Swift echo expand (\(index)): \(messages[index])", response.text, file: file, line: line) index += 1 } - XCTAssertEqual(try call.status.wait().code, .ok) + XCTAssertEqual(try call.status.wait().code, .ok, file: file, line: line) + XCTAssertEqual(index, messages.count) } func testServerStreaming() { @@ -162,23 +105,24 @@ extension NIOServerTests { } extension NIOServerTests { - private func doTestBidirectionalStreaming(messages: [String], waitForEachResponse: Bool = false) throws { + private func doTestBidirectionalStreaming(messages: [String], waitForEachResponse: Bool = false, file: StaticString = #file, line: UInt = #line) throws { let responseReceived = waitForEachResponse ? DispatchSemaphore(value: 0) : nil var index = 0 let call = client.update { response in - XCTAssertEqual("Swift echo update (\(index)): \(messages[index])", response.text) + XCTAssertEqual("Swift echo update (\(index)): \(messages[index])", response.text, file: file, line: line) responseReceived?.signal() index += 1 } messages.forEach { part in - call.send(.message(Echo_EchoRequest.with { $0.text = part })) - responseReceived?.wait() + call.sendMessage(Echo_EchoRequest.with { $0.text = part }) + XCTAssertNotEqual(responseReceived?.wait(timeout: .now() + .seconds(1)), .some(.timedOut), file: file, line: line) } - call.send(.end) + call.sendEnd() - XCTAssertEqual(try call.status.wait().code, .ok) + XCTAssertEqual(try call.status.wait().code, .ok, file: file, line: line) + XCTAssertEqual(index, messages.count) } func testBidirectionalStreamingBatched() throws { diff --git a/Tests/SwiftGRPCNIOTests/echo.grpc.swift b/Tests/SwiftGRPCNIOTests/echo.grpc.swift index 9fef35792..29b4d6b86 120000 --- a/Tests/SwiftGRPCNIOTests/echo.grpc.swift +++ b/Tests/SwiftGRPCNIOTests/echo.grpc.swift @@ -1 +1 @@ -../../Sources/Examples/Echo/Generated/echo.grpc.swift \ No newline at end of file +../../Sources/Examples/EchoNIO/Generated/echo.grpc.swift \ No newline at end of file diff --git a/Tests/SwiftGRPCNIOTests/echo.pb.swift b/Tests/SwiftGRPCNIOTests/echo.pb.swift index 9dbe28075..1b8ab0b6a 120000 --- a/Tests/SwiftGRPCNIOTests/echo.pb.swift +++ b/Tests/SwiftGRPCNIOTests/echo.pb.swift @@ -1 +1 @@ -../../Sources/Examples/Echo/Generated/echo.pb.swift \ No newline at end of file +../../Sources/Examples/EchoNIO/Generated/echo.pb.swift \ No newline at end of file From aa0acdcd3c91836c334e799f02a226258f5babb6 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 25 Feb 2019 17:01:45 +0000 Subject: [PATCH 17/30] Renaming, typo fixes --- .../CallHandlers/BaseCallHandler.swift | 4 ++-- .../ServerStreamingCallHandler.swift | 2 +- .../CallHandlers/UnaryCallHandler.swift | 2 +- Sources/SwiftGRPCNIO/GRPCChannelHandler.swift | 2 +- Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 4 ++-- ...{GRPCError.swift => GRPCServerError.swift} | 4 ++-- .../HTTP1ToRawGRPCServerCodec.swift | 10 +++++----- .../LoggingServerErrorDelegate.swift | 2 +- .../SwiftGRPCNIO/ServerErrorDelegate.swift | 2 +- ...nnelHandlerResponseCapturingTestCase.swift | 4 ---- .../GRPCChannelHandlerTests.swift | 20 ++++++++++--------- 11 files changed, 27 insertions(+), 29 deletions(-) rename Sources/SwiftGRPCNIO/{GRPCError.swift => GRPCServerError.swift} (96%) diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift index 718e211b1..ae3986fd5 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift @@ -50,7 +50,7 @@ extension BaseCallHandler: ChannelInboundHandler { switch self.unwrapInboundIn(data) { case .head(let requestHead): // Head should have been handled by `GRPCChannelHandler`. - self.errorCaught(ctx: ctx, error: GRPCError.invalidState("unexpected request head received \(requestHead)")) + self.errorCaught(ctx: ctx, error: GRPCServerError.invalidState("unexpected request head received \(requestHead)")) case .message(let message): do { @@ -71,7 +71,7 @@ extension BaseCallHandler: ChannelOutboundHandler { public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { guard serverCanWrite else { - promise?.fail(error: GRPCError.serverNotWritable) + promise?.fail(error: GRPCServerError.serverNotWritable) return } diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index e282c2964..6374cea58 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -29,7 +29,7 @@ public class ServerStreamingCallHandler public override func processMessage(_ message: RequestMessage) throws { guard let eventObserver = self.eventObserver, let context = self.context else { - throw GRPCError.requestCardinalityViolation + throw GRPCServerError.requestCardinalityViolation } let resultFuture = eventObserver(message) diff --git a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift index 156a44863..466499cff 100644 --- a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift @@ -53,7 +53,7 @@ extension GRPCChannelHandler: ChannelInboundHandler { switch requestPart { case .head(let requestHead): guard let callHandler = getCallHandler(channel: ctx.channel, requestHead: requestHead) else { - errorCaught(ctx: ctx, error: GRPCError.unimplementedMethod(requestHead.uri)) + errorCaught(ctx: ctx, error: GRPCServerError.unimplementedMethod(requestHead.uri)) return } diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index ef67a75c7..9193c9ea9 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -35,7 +35,7 @@ extension GRPCServerCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try RequestMessage(serializedData: messageAsData)))) } catch { - ctx.fireErrorCaught(GRPCError.requestProtoParseFailure) + ctx.fireErrorCaught(GRPCServerError.requestProtoParseFailure) } case .end: @@ -61,7 +61,7 @@ extension GRPCServerCodec: ChannelOutboundHandler { responseBuffer.write(bytes: messageData) ctx.write(self.wrapOutboundOut(.message(responseBuffer)), promise: promise) } catch { - let error = GRPCError.responseProtoSerializationFailure + let error = GRPCServerError.responseProtoSerializationFailure promise?.fail(error: error) ctx.fireErrorCaught(error) } diff --git a/Sources/SwiftGRPCNIO/GRPCError.swift b/Sources/SwiftGRPCNIO/GRPCServerError.swift similarity index 96% rename from Sources/SwiftGRPCNIO/GRPCError.swift rename to Sources/SwiftGRPCNIO/GRPCServerError.swift index 5106feb07..e42848a54 100644 --- a/Sources/SwiftGRPCNIO/GRPCError.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerError.swift @@ -15,7 +15,7 @@ */ import Foundation -public enum GRPCError: Error, Equatable { +public enum GRPCServerError: Error, Equatable { /// The RPC method is not implemented on the server. case unimplementedMethod(String) @@ -41,7 +41,7 @@ public enum GRPCError: Error, Equatable { case invalidState(String) } -extension GRPCError: GRPCStatusTransformable { +extension GRPCServerError: GRPCStatusTransformable { public func asGRPCStatus() -> GRPCStatus { // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md switch self { diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index 022eeea98..1bdd169f3 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -83,7 +83,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processHead(ctx: ChannelHandlerContext, requestHead: HTTPRequestHead) throws -> InboundState { guard case .expectingHeaders = inboundState else { - throw GRPCError.invalidState("expecteded state .expectingHeaders, got \(inboundState)") + throw GRPCServerError.invalidState("expecteded state .expectingHeaders, got \(inboundState)") } ctx.fireChannelRead(self.wrapInboundOut(.head(requestHead))) @@ -93,7 +93,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processBody(ctx: ChannelHandlerContext, body: inout ByteBuffer) throws -> InboundState { guard case .expectingBody(let bodyState) = inboundState else { - throw GRPCError.invalidState("expecteded state .expectingBody(_), got \(inboundState)") + throw GRPCServerError.invalidState("expecteded state .expectingBody(_), got \(inboundState)") } return .expectingBody(try processBodyState(ctx: ctx, initialState: bodyState, messageBuffer: &body)) @@ -113,7 +113,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { guard let compressedFlag: Int8 = messageBuffer.readInteger() else { return .expectingCompressedFlag } // TODO: Add support for compression. - guard compressedFlag == 0 else { throw GRPCError.unexpectedCompression } + guard compressedFlag == 0 else { throw GRPCServerError.unexpectedCompression } bodyState = .expectingMessageLength @@ -156,7 +156,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { private func processEnd(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) throws -> InboundState { if let trailers = trailers { - throw GRPCError.invalidState("unexpected trailers received \(trailers)") + throw GRPCServerError.invalidState("unexpected trailers received \(trailers)") } ctx.fireChannelRead(self.wrapInboundOut(.end)) @@ -192,7 +192,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { outboundState = .expectingBodyOrStatus case .status(let status): - // If we error before sending the initial headers (e.g. unimplemtned method) then we won't have sent the request head. + // If we error before sending the initial headers (e.g. unimplemented method) then we won't have sent the request head. // NIOHTTP2 doesn't support sending a single frame as a "Trailers-Only" response so we still need to loop back and // send the request head first. if case .expectingHeaders = outboundState { diff --git a/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift index 86c128d31..b0a30c178 100644 --- a/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift +++ b/Sources/SwiftGRPCNIO/LoggingServerErrorDelegate.swift @@ -19,6 +19,6 @@ public class LoggingServerErrorDelegate: ServerErrorDelegate { public init() {} public func observe(_ error: Error) { - print("[grpc-server] \(error)") + print("[grpc-server][\(Date())] \(error)") } } diff --git a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift index 15b312959..83521a6e3 100644 --- a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift +++ b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift @@ -17,7 +17,7 @@ import Foundation import NIO public protocol ServerErrorDelegate: class { - //: FIXME: provide more context about where the error was thrown. + //! FIXME: Provide more context about where the error was thrown. /// Called when an error is thrown in the channel pipeline. func observe(_ error: Error) diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift index 863bb11ae..0999801fd 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift @@ -34,10 +34,6 @@ class GRPCChannelHandlerResponseCapturingTestCase: XCTestCase { var errorCollector: CollectingServerErrorDelegate = CollectingServerErrorDelegate() - override func setUp() { - errorCollector.errors.removeAll() - } - /// Waits for `count` responses to be collected and then returns them. The test fails if the number /// of collected responses does not match the expected. /// diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index 60e1e7afd..4d7e190f2 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -22,8 +22,8 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { try channel.writeInbound(RawGRPCServerRequestPart.head(requestHead)) } - let expectedError = GRPCError.unimplementedMethod("unimplementedMethodName") - XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + let expectedError = GRPCServerError.unimplementedMethod("unimplementedMethodName") + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) XCTAssertNoThrow(try extractStatus(responses[0])) { status in XCTAssertEqual(status, expectedError.asGRPCStatus()) @@ -59,8 +59,8 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { try channel.writeInbound(RawGRPCServerRequestPart.message(buffer)) } - let expectedError = GRPCError.requestProtoParseFailure - XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + let expectedError = GRPCServerError.requestProtoParseFailure + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in @@ -77,8 +77,8 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) } - let expectedError = GRPCError.unexpectedCompression - XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + let expectedError = GRPCServerError.unexpectedCompression + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in @@ -124,8 +124,8 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.body(buffer)) } - let expectedError = GRPCError.requestProtoParseFailure - XCTAssertEqual(expectedError, errorCollector.errors.first as? GRPCError) + let expectedError = GRPCServerError.requestProtoParseFailure + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in @@ -147,7 +147,9 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.end(trailers)) } - if case .invalidState(let message)? = errorCollector.errors.first as? GRPCError { + XCTAssertEqual(errorCollector.errors.count, 1) + + if case .invalidState(let message)? = errorCollector.errors.first as? GRPCServerError { XCTAssert(message.contains("trailers")) } else { XCTFail("\(String(describing: errorCollector.errors.first)) was not GRPCError.invalidState") From 2b82ae83025240b48ff27a679c776fd32b157c6c Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 26 Feb 2019 10:52:51 +0000 Subject: [PATCH 18/30] Split out GRPCChannelHandlerTests and HTTPToRawGRPCServerCodecTests --- .../GRPCChannelHandlerTests.swift | 144 +---------------- .../HTTP1ToRawGRPCServerCodecTests.swift | 153 ++++++++++++++++++ 2 files changed, 160 insertions(+), 137 deletions(-) create mode 100644 Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index 4d7e190f2..b97c3f49f 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -4,18 +4,15 @@ import NIO import NIOHTTP1 @testable import SwiftGRPCNIO -func gRPCMessage(channel: EmbeddedChannel, compression: Bool = false, message: Data? = nil) -> ByteBuffer { - let messageLength = message?.count ?? 0 - var buffer = channel.allocator.buffer(capacity: 5 + messageLength) - buffer.write(integer: Int8(compression ? 1 : 0)) - buffer.write(integer: UInt32(messageLength)) - if let bytes = message { - buffer.write(bytes: bytes) +class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { + static var allTests: [(String, (GRPCChannelHandlerTests) -> () throws -> Void)] { + return [ + ("testUnimplementedMethodReturnsUnimplementedStatus", testUnimplementedMethodReturnsUnimplementedStatus), + ("testImplementedMethodReturnsHeadersMessageAndStatus", testImplementedMethodReturnsHeadersMessageAndStatus), + ("testImplementedMethodReturnsStatusForBadlyFormedProto", testImplementedMethodReturnsStatusForBadlyFormedProto), + ] } - return buffer -} -class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { func testUnimplementedMethodReturnsUnimplementedStatus() throws { let responses = try waitForGRPCChannelHandlerResponses(count: 1) { channel in let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "unimplementedMethodName") @@ -68,130 +65,3 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { } } } - -class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCase { - func testInternalErrorStatusReturnedWhenCompressionFlagIsSet() throws { - let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") - try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) - try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) - } - - let expectedError = GRPCServerError.unexpectedCompression - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) - - XCTAssertNoThrow(try extractHeaders(responses[0])) - XCTAssertNoThrow(try extractStatus(responses[1])) { status in - XCTAssertEqual(status, expectedError.asGRPCStatus()) - } - } - - func testMessageCanBeSentAcrossMultipleByteBuffers() throws { - let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") - // Sending the header allocates a buffer. - try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) - - let request = Echo_EchoRequest.with { $0.text = "echo!" } - let requestAsData = try request.serializedData() - - var buffer = channel.allocator.buffer(capacity: 1) - buffer.write(integer: Int8(0)) - try channel.writeInbound(HTTPServerRequestPart.body(buffer)) - - buffer = channel.allocator.buffer(capacity: 4) - buffer.write(integer: Int32(requestAsData.count)) - try channel.writeInbound(HTTPServerRequestPart.body(buffer)) - - buffer = channel.allocator.buffer(capacity: requestAsData.count) - buffer.write(bytes: requestAsData) - try channel.writeInbound(HTTPServerRequestPart.body(buffer)) - } - - XCTAssertNoThrow(try extractHeaders(responses[0])) - XCTAssertNoThrow(try extractMessage(responses[1])) - XCTAssertNoThrow(try extractStatus(responses[2])) { status in - XCTAssertEqual(status, .ok) - } - } - - func testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized() throws { - let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") - try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) - - let buffer = gRPCMessage(channel: channel, message: Data(bytes: [42])) - try channel.writeInbound(HTTPServerRequestPart.body(buffer)) - } - - let expectedError = GRPCServerError.requestProtoParseFailure - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) - - XCTAssertNoThrow(try extractHeaders(responses[0])) - XCTAssertNoThrow(try extractStatus(responses[1])) { status in - XCTAssertEqual(status, expectedError.asGRPCStatus()) - } - } - - func testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest() throws { - let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in - // We have to use "Collect" (client streaming) as the tests rely on `EmbeddedChannel` which runs in this thread. - // In the current server implementation, responses from unary calls send a status immediately after sending the response. - // As such, a unary "Get" would return an "ok" status before the trailers would be sent. - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Collect") - try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) - try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) - - var trailers = HTTPHeaders() - trailers.add(name: "foo", value: "bar") - try channel.writeInbound(HTTPServerRequestPart.end(trailers)) - } - - XCTAssertEqual(errorCollector.errors.count, 1) - - if case .invalidState(let message)? = errorCollector.errors.first as? GRPCServerError { - XCTAssert(message.contains("trailers")) - } else { - XCTFail("\(String(describing: errorCollector.errors.first)) was not GRPCError.invalidState") - } - - XCTAssertNoThrow(try extractHeaders(responses[0])) - XCTAssertNoThrow(try extractStatus(responses[1])) { status in - XCTAssertEqual(status, .processingError) - } - } - - func testOnlyOneStatusIsReturned() throws { - let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in - let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") - try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) - try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) - - // Sending trailers with `.end` should trigger an error. However, writing a message to a unary call - // will trigger a response and status to be sent back. Since we're using `EmbeddedChannel` this will - // be done before the trailers are sent. If a 4th resposne were to be sent (for the error status) then - // the test would fail. - - var trailers = HTTPHeaders() - trailers.add(name: "foo", value: "bar") - try channel.writeInbound(HTTPServerRequestPart.end(trailers)) - } - - XCTAssertNoThrow(try extractHeaders(responses[0])) - XCTAssertNoThrow(try extractMessage(responses[1])) - XCTAssertNoThrow(try extractStatus(responses[2])) { status in - XCTAssertEqual(status, .ok) - } - } - - override func waitForGRPCChannelHandlerResponses( - count: Int, - servicesByName: [String: CallHandlerProvider] = GRPCChannelHandlerResponseCapturingTestCase.echoProvider, - callback: @escaping (EmbeddedChannel) throws -> Void - ) throws -> [RawGRPCServerResponsePart] { - return try super.waitForGRPCChannelHandlerResponses(count: count, servicesByName: servicesByName) { channel in - _ = channel.pipeline.addHandlers(HTTP1ToRawGRPCServerCodec(), first: true) - .thenThrowing { _ in try callback(channel) } - } - } -} diff --git a/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift new file mode 100644 index 000000000..bb17fef8e --- /dev/null +++ b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift @@ -0,0 +1,153 @@ +import Foundation +import XCTest +import NIO +import NIOHTTP1 +@testable import SwiftGRPCNIO + +func gRPCMessage(channel: EmbeddedChannel, compression: Bool = false, message: Data? = nil) -> ByteBuffer { + let messageLength = message?.count ?? 0 + var buffer = channel.allocator.buffer(capacity: 5 + messageLength) + buffer.write(integer: Int8(compression ? 1 : 0)) + buffer.write(integer: UInt32(messageLength)) + if let bytes = message { + buffer.write(bytes: bytes) + } + return buffer +} + +class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCase { + static var allTests: [(String, (HTTP1ToRawGRPCServerCodecTests) -> () throws -> Void)] { + return [ + ("testInternalErrorStatusReturnedWhenCompressionFlagIsSet", testInternalErrorStatusReturnedWhenCompressionFlagIsSet), + ("testMessageCanBeSentAcrossMultipleByteBuffers", testMessageCanBeSentAcrossMultipleByteBuffers), + ("testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized", testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized), + ("testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest", testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest), + ("testOnlyOneStatusIsReturned", testOnlyOneStatusIsReturned), + ] + } + + func testInternalErrorStatusReturnedWhenCompressionFlagIsSet() throws { + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) + } + + let expectedError = GRPCServerError.unexpectedCompression + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + XCTAssertEqual(status, expectedError.asGRPCStatus()) + } + } + + func testMessageCanBeSentAcrossMultipleByteBuffers() throws { + let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + // Sending the header allocates a buffer. + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + + let request = Echo_EchoRequest.with { $0.text = "echo!" } + let requestAsData = try request.serializedData() + + var buffer = channel.allocator.buffer(capacity: 1) + buffer.write(integer: Int8(0)) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + + buffer = channel.allocator.buffer(capacity: 4) + buffer.write(integer: Int32(requestAsData.count)) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + + buffer = channel.allocator.buffer(capacity: requestAsData.count) + buffer.write(bytes: requestAsData) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractMessage(responses[1])) + XCTAssertNoThrow(try extractStatus(responses[2])) { status in + XCTAssertEqual(status, .ok) + } + } + + func testInternalErrorStatusIsReturnedIfMessageCannotBeDeserialized() throws { + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + + let buffer = gRPCMessage(channel: channel, message: Data(bytes: [42])) + try channel.writeInbound(HTTPServerRequestPart.body(buffer)) + } + + let expectedError = GRPCServerError.requestProtoParseFailure + XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + XCTAssertEqual(status, expectedError.asGRPCStatus()) + } + } + + func testInternalErrorStatusIsReturnedWhenSendingTrailersInRequest() throws { + let responses = try waitForGRPCChannelHandlerResponses(count: 2) { channel in + // We have to use "Collect" (client streaming) as the tests rely on `EmbeddedChannel` which runs in this thread. + // In the current server implementation, responses from unary calls send a status immediately after sending the response. + // As such, a unary "Get" would return an "ok" status before the trailers would be sent. + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Collect") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) + + var trailers = HTTPHeaders() + trailers.add(name: "foo", value: "bar") + try channel.writeInbound(HTTPServerRequestPart.end(trailers)) + } + + XCTAssertEqual(errorCollector.errors.count, 1) + + if case .invalidState(let message)? = errorCollector.errors.first as? GRPCServerError { + XCTAssert(message.contains("trailers")) + } else { + XCTFail("\(String(describing: errorCollector.errors.first)) was not GRPCError.invalidState") + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractStatus(responses[1])) { status in + XCTAssertEqual(status, .processingError) + } + } + + func testOnlyOneStatusIsReturned() throws { + let responses = try waitForGRPCChannelHandlerResponses(count: 3) { channel in + let requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: "/echo.Echo/Get") + try channel.writeInbound(HTTPServerRequestPart.head(requestHead)) + try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel))) + + // Sending trailers with `.end` should trigger an error. However, writing a message to a unary call + // will trigger a response and status to be sent back. Since we're using `EmbeddedChannel` this will + // be done before the trailers are sent. If a 4th resposne were to be sent (for the error status) then + // the test would fail. + + var trailers = HTTPHeaders() + trailers.add(name: "foo", value: "bar") + try channel.writeInbound(HTTPServerRequestPart.end(trailers)) + } + + XCTAssertNoThrow(try extractHeaders(responses[0])) + XCTAssertNoThrow(try extractMessage(responses[1])) + XCTAssertNoThrow(try extractStatus(responses[2])) { status in + XCTAssertEqual(status, .ok) + } + } + + override func waitForGRPCChannelHandlerResponses( + count: Int, + servicesByName: [String: CallHandlerProvider] = GRPCChannelHandlerResponseCapturingTestCase.echoProvider, + callback: @escaping (EmbeddedChannel) throws -> Void + ) throws -> [RawGRPCServerResponsePart] { + return try super.waitForGRPCChannelHandlerResponses(count: count, servicesByName: servicesByName) { channel in + _ = channel.pipeline.addHandlers(HTTP1ToRawGRPCServerCodec(), first: true) + .thenThrowing { _ in try callback(channel) } + } + } +} From 344a6e3707f8c963c948235595f9e09a8aab0cab Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 26 Feb 2019 10:54:00 +0000 Subject: [PATCH 19/30] Update LinuxMain --- Tests/LinuxMain.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index b0abb3e49..5c13ee0aa 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -38,4 +38,7 @@ XCTMain([ // SwiftGRPCNIO testCase(NIOServerTests.allTests) + testCase(NIOServerWebTests.allTests) + testCase(GRPCChannelHandlerTests.allTests) + testCase(HTTP1ToRawGRPCServerCodecTests.allTests) ]) From 0fe5dd247c4dd56972a5cad2668c252fcfa497e8 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 26 Feb 2019 12:51:16 +0000 Subject: [PATCH 20/30] Add missing commas to LinuxMain --- Tests/LinuxMain.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 5c13ee0aa..91d0a9807 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -37,8 +37,8 @@ XCTMain([ testCase(ServerTimeoutTests.allTests), // SwiftGRPCNIO - testCase(NIOServerTests.allTests) - testCase(NIOServerWebTests.allTests) - testCase(GRPCChannelHandlerTests.allTests) + testCase(NIOServerTests.allTests), + testCase(NIOServerWebTests.allTests), + testCase(GRPCChannelHandlerTests.allTests), testCase(HTTP1ToRawGRPCServerCodecTests.allTests) ]) From e39d0a20a5d1f36d487341b704c2f6d266901822 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 26 Feb 2019 13:35:16 +0000 Subject: [PATCH 21/30] Fix grpc-web testUnaryLotsOfRequests on Linux --- Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift index 342975796..44f094467 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift @@ -114,13 +114,15 @@ extension NIOServerWebTests { // Sending that many requests at once can sometimes trip things up, it seems. let clockStart = clock() let numberOfRequests = 2_000 + let completionHandlerExpectation = expectation(description: "completion handler called") -#if os(macOS) - // Linux version of Swift doesn't have this API yet. + // Linux version of Swift doesn't have the `expectedFulfillmentCount` API yet. // Implemented in https://github.com/apple/swift-corelibs-xctest/pull/228 but not yet // released. - completionHandlerExpectation.expectedFulfillmentCount = numberOfRequests -#endif + // + // Wait for the expected number of responses (i.e. `numberOfRequests`) instead. + var responses = 0 + for i in 0.. Date: Tue, 26 Feb 2019 14:38:12 +0000 Subject: [PATCH 22/30] Disable broken Linux test --- Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift index 44f094467..ce87064b5 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerWebTests.swift @@ -25,7 +25,8 @@ class NIOServerWebTests: NIOServerTestCase { static var allTests: [(String, (NIOServerWebTests) -> () throws -> Void)] { return [ ("testUnary", testUnary), - ("testUnaryLotsOfRequests", testUnaryLotsOfRequests), + //! FIXME: Broken on Linux: https://github.com/grpc/grpc-swift/issues/382 + // ("testUnaryLotsOfRequests", testUnaryLotsOfRequests), ("testServerStreaming", testServerStreaming), ] } From e7b074616eef1c3d17da6f9ef650a3f7397f6a35 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 26 Feb 2019 17:33:29 +0000 Subject: [PATCH 23/30] Split errors into server and client enums. --- .../CallHandlers/BaseCallHandler.swift | 4 +- .../ServerStreamingCallHandler.swift | 2 +- .../CallHandlers/UnaryCallHandler.swift | 2 +- Sources/SwiftGRPCNIO/GRPCChannelHandler.swift | 2 +- .../GRPCClientChannelHandler.swift | 7 +- Sources/SwiftGRPCNIO/GRPCClientError.swift | 22 --- Sources/SwiftGRPCNIO/GRPCError.swift | 170 ++++++++++++++++++ Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 4 +- Sources/SwiftGRPCNIO/GRPCServerError.swift | 76 -------- Sources/SwiftGRPCNIO/GRPCStatus.swift | 2 - .../HTTP1ToRawGRPCClientCodec.swift | 47 ++--- .../HTTP1ToRawGRPCServerCodec.swift | 8 +- .../LengthPrefixedMessageReader.swift | 18 +- .../LengthPrefixedMessageWriter.swift | 2 +- ...nnelHandlerResponseCapturingTestCase.swift | 12 ++ .../GRPCChannelHandlerTests.swift | 4 +- .../HTTP1ToRawGRPCServerCodecTests.swift | 10 +- 17 files changed, 236 insertions(+), 156 deletions(-) delete mode 100644 Sources/SwiftGRPCNIO/GRPCClientError.swift create mode 100644 Sources/SwiftGRPCNIO/GRPCError.swift delete mode 100644 Sources/SwiftGRPCNIO/GRPCServerError.swift diff --git a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift index ae3986fd5..5aae56950 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/BaseCallHandler.swift @@ -50,7 +50,7 @@ extension BaseCallHandler: ChannelInboundHandler { switch self.unwrapInboundIn(data) { case .head(let requestHead): // Head should have been handled by `GRPCChannelHandler`. - self.errorCaught(ctx: ctx, error: GRPCServerError.invalidState("unexpected request head received \(requestHead)")) + self.errorCaught(ctx: ctx, error: GRPCError.server(.invalidState("unexpected request head received \(requestHead)"))) case .message(let message): do { @@ -71,7 +71,7 @@ extension BaseCallHandler: ChannelOutboundHandler { public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { guard serverCanWrite else { - promise?.fail(error: GRPCServerError.serverNotWritable) + promise?.fail(error: GRPCError.server(.serverNotWritable)) return } diff --git a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift index 8087f42e7..d3cfe5d61 100644 --- a/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift +++ b/Sources/SwiftGRPCNIO/CallHandlers/ServerStreamingCallHandler.swift @@ -28,7 +28,7 @@ public class ServerStreamingCallHandler public override func processMessage(_ message: RequestMessage) throws { guard let eventObserver = self.eventObserver, let context = self.context else { - throw GRPCServerError.requestCardinalityViolation + throw GRPCError.server(.requestCardinalityViolation) } let resultFuture = eventObserver(message) diff --git a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift index d18b7a4dc..de586e60e 100644 --- a/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCChannelHandler.swift @@ -53,7 +53,7 @@ extension GRPCChannelHandler: ChannelInboundHandler { switch requestPart { case .head(let requestHead): guard let callHandler = getCallHandler(channel: ctx.channel, requestHead: requestHead) else { - errorCaught(ctx: ctx, error: GRPCServerError.unimplementedMethod(requestHead.uri)) + errorCaught(ctx: ctx, error: GRPCError.server(.unimplementedMethod(requestHead.uri))) return } diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift index 3219fa4a6..aa445bc02 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -137,7 +137,7 @@ extension GRPCClientChannelHandler: ChannelInboundHandler { case .message(let message): guard self.inboundState == .expectingMessageOrStatus else { - self.errorCaught(ctx: ctx, error: GRPCClientError.responseCardinalityViolation) + self.errorCaught(ctx: ctx, error: GRPCError.client(.responseCardinalityViolation)) return } @@ -191,7 +191,7 @@ extension GRPCClientChannelHandler: ChannelOutboundHandler { extension GRPCClientChannelHandler { /// Closes the HTTP/2 stream. Inbound and outbound state are set to ignore. public func close(ctx: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise?) { - self.observeStatus(GRPCStatus.cancelledByClient) + self.observeStatus(GRPCError.client(.cancelledByClient).asGRPCStatus()) requestHeadSentPromise.futureResult.whenComplete { ctx.close(mode: mode, promise: promise) @@ -207,8 +207,5 @@ extension GRPCClientChannelHandler { //! TODO: Add an error handling delegate, similar to in the server. let status = (error as? GRPCStatus) ?? .processingError self.observeStatus(status) - - // We don't expect any more requests/responses beyond this point. - self.close(ctx: ctx, mode: .all, promise: nil) } } diff --git a/Sources/SwiftGRPCNIO/GRPCClientError.swift b/Sources/SwiftGRPCNIO/GRPCClientError.swift deleted file mode 100644 index d2558a009..000000000 --- a/Sources/SwiftGRPCNIO/GRPCClientError.swift +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2019, gRPC Authors All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Foundation - -enum GRPCClientError: Error { - case HTTPStatusNotOk - case cancelledByClient - case responseCardinalityViolation -} diff --git a/Sources/SwiftGRPCNIO/GRPCError.swift b/Sources/SwiftGRPCNIO/GRPCError.swift new file mode 100644 index 000000000..e0cab3251 --- /dev/null +++ b/Sources/SwiftGRPCNIO/GRPCError.swift @@ -0,0 +1,170 @@ +/* + * Copyright 2019, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Foundation +import NIOHTTP1 + +/// Wraps a gRPC error to provide contextual information about where it was thrown. +public struct GRPCError: Error, GRPCStatusTransformable { + public enum Origin { case client, server } + + /// The underlying error thrown by framework. + public let error: GRPCStatusTransformable + + /// The origin of the error. + public let origin: Origin + + /// The file in which the error was thrown. + public let file: StaticString + + /// The line number in the `file` where the error was thrown. + public let line: Int + + public func asGRPCStatus() -> GRPCStatus { + return error.asGRPCStatus() + } + + private init(_ error: GRPCStatusTransformable, origin: Origin, file: StaticString, line: Int) { + self.error = error + self.origin = origin + self.file = file + self.line = line + } + + /// Creates a `GRPCError` which may only be thrown from the client. + public static func client(_ error: GRPCClientError, file: StaticString = #file, line: Int = #line) -> GRPCError { + return GRPCError(error, origin: .client, file: file, line: line) + } + + /// Creates a `GRPCError` which was thrown from the client. + public static func client(_ error: GRPCCommonError, file: StaticString = #file, line: Int = #line) -> GRPCError { + return GRPCError(error, origin: .client, file: file, line: line) + } + + /// Creates a `GRPCError` which may only be thrown from the server. + public static func server(_ error: GRPCServerError, file: StaticString = #file, line: Int = #line) -> GRPCError { + return GRPCError(error, origin: .server, file: file, line: line) + } + + /// Creates a `GRPCError` which was thrown from the server. + public static func server(_ error: GRPCCommonError, file: StaticString = #file, line: Int = #line) -> GRPCError { + return GRPCError(error, origin: .server, file: file, line: line) + } + + /// Creates a `GRPCError` which was may be thrown by either the server or the client. + public static func common(_ error: GRPCCommonError, origin: Origin, file: StaticString = #file, line: Int = #line) -> GRPCError { + return GRPCError(error, origin: origin, file: file, line: line) + } +} + +/// An error which should only be thrown by the server. +public enum GRPCServerError: Error, Equatable { + /// The RPC method is not implemented on the server. + case unimplementedMethod(String) + + /// It was not possible to decode a base64 message (gRPC-Web only). + case base64DecodeError + + /// It was not possible to parse the request protobuf. + case requestProtoParseFailure + + /// It was not possible to serialize the response protobuf. + case responseProtoSerializationFailure + + /// More than one request was sent for a unary-request call. + case requestCardinalityViolation + + /// The server received a message when it was not in a writable state. + case serverNotWritable +} + +/// An error which should only be thrown by the client. +public enum GRPCClientError: Error, Equatable { + /// The response status was not "200 OK". + case HTTPStatusNotOk(HTTPResponseStatus) + + /// The call was cancelled by the client. + case cancelledByClient + + /// More than one response was received for a unary-response call. + case responseCardinalityViolation +} + +/// An error which should be thrown by either the client or server. +public enum GRPCCommonError: Error, Equatable { + /// An invalid state has been reached; something has gone very wrong. + case invalidState(String) + + /// Compression was indicated in the "grpc-message-encoding" header but not in the gRPC message compression flag, or vice versa. + case unexpectedCompression + + /// The given compression mechanism is not supported. + case unsupportedCompressionMechanism(String) +} + +extension GRPCServerError: GRPCStatusTransformable { + public func asGRPCStatus() -> GRPCStatus { + // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md + switch self { + case .unimplementedMethod(let method): + return GRPCStatus(code: .unimplemented, message: "unknown method \(method)") + + case .base64DecodeError: + return GRPCStatus(code: .internalError, message: "could not decode base64 message") + + case .requestProtoParseFailure: + return GRPCStatus(code: .internalError, message: "could not parse request proto") + + case .responseProtoSerializationFailure: + return GRPCStatus(code: .internalError, message: "could not serialize response proto") + + case .requestCardinalityViolation: + return GRPCStatus(code: .unimplemented, message: "request cardinality violation; method requires exactly one request but client sent more") + + case .serverNotWritable: + return GRPCStatus.processingError + } + } +} + +extension GRPCClientError: GRPCStatusTransformable { + public func asGRPCStatus() -> GRPCStatus { + switch self { + case .HTTPStatusNotOk(let status): + return GRPCStatus(code: .unknown, message: "server returned \(status.code) \(status.reasonPhrase)") + + case .cancelledByClient: + return GRPCStatus(code: .cancelled, message: "client cancelled the call") + + case .responseCardinalityViolation: + return GRPCStatus(code: .unimplemented, message: "response cardinality violation; method requires exactly one response but server sent more") + } + } +} + +extension GRPCCommonError: GRPCStatusTransformable { + public func asGRPCStatus() -> GRPCStatus { + switch self { + case .invalidState: + return GRPCStatus.processingError + + case .unexpectedCompression: + return GRPCStatus(code: .unimplemented, message: "compression was enabled for this gRPC message but not for this call") + + case .unsupportedCompressionMechanism(let mechanism): + return GRPCStatus(code: .unimplemented, message: "unsupported compression mechanism \(mechanism)") + } + } +} diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index be0fe4990..bc9f4e9ec 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -35,7 +35,7 @@ extension GRPCServerCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try RequestMessage(serializedData: messageAsData)))) } catch { - ctx.fireErrorCaught(GRPCServerError.requestProtoParseFailure) + ctx.fireErrorCaught(GRPCError.server(.requestProtoParseFailure)) } case .end: @@ -59,7 +59,7 @@ extension GRPCServerCodec: ChannelOutboundHandler { let messageData = try message.serializedData() ctx.write(self.wrapOutboundOut(.message(messageData)), promise: promise) } catch { - let error = GRPCServerError.responseProtoSerializationFailure + let error = GRPCError.server(.responseProtoSerializationFailure) promise?.fail(error: error) ctx.fireErrorCaught(error) } diff --git a/Sources/SwiftGRPCNIO/GRPCServerError.swift b/Sources/SwiftGRPCNIO/GRPCServerError.swift deleted file mode 100644 index 309fe3ad7..000000000 --- a/Sources/SwiftGRPCNIO/GRPCServerError.swift +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2019, gRPC Authors All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Foundation - -public enum GRPCServerError: Error, Equatable { - /// The RPC method is not implemented on the server. - case unimplementedMethod(String) - - /// It was not possible to decode a base64 message (gRPC-Web only). - case base64DecodeError - - /// It was not possible to parse the request protobuf. - case requestProtoParseFailure - - /// It was not possible to serialize the response protobuf. - case responseProtoSerializationFailure - - /// The given compression mechanism is not supported. - case unsupportedCompressionMechanism(String) - - /// Compression was indicated in the gRPC message, but not for the call. - case unexpectedCompression - - /// More than one request was sent for a unary-request call. - case requestCardinalityViolation - - /// The server received a message when it was not in a writable state. - case serverNotWritable - - /// An invalid state has been reached; something has gone very wrong. - case invalidState(String) -} - -extension GRPCServerError: GRPCStatusTransformable { - public func asGRPCStatus() -> GRPCStatus { - // These status codes are informed by: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md - switch self { - case .unimplementedMethod(let method): - return GRPCStatus(code: .unimplemented, message: "unknown method \(method)") - - case .base64DecodeError: - return GRPCStatus(code: .internalError, message: "could not decode base64 message") - - case .requestProtoParseFailure: - return GRPCStatus(code: .internalError, message: "could not parse request proto") - - case .responseProtoSerializationFailure: - return GRPCStatus(code: .internalError, message: "could not serialize response proto") - - case .unsupportedCompressionMechanism(let mechanism): - return GRPCStatus(code: .unimplemented, message: "unsupported compression mechanism \(mechanism)") - - case .unexpectedCompression: - return GRPCStatus(code: .unimplemented, message: "compression was enabled for this gRPC message but not for this call") - - case .requestCardinalityViolation: - return GRPCStatus(code: .unimplemented, message: "request cardinality violation; method requires exactly one request but client sent more") - - case .serverNotWritable, .invalidState: - return GRPCStatus.processingError - } - } -} diff --git a/Sources/SwiftGRPCNIO/GRPCStatus.swift b/Sources/SwiftGRPCNIO/GRPCStatus.swift index 8da5aa611..7a7322051 100644 --- a/Sources/SwiftGRPCNIO/GRPCStatus.swift +++ b/Sources/SwiftGRPCNIO/GRPCStatus.swift @@ -22,8 +22,6 @@ public struct GRPCStatus: Error, Equatable { public static let ok = GRPCStatus(code: .ok, message: "OK") /// "Internal server error" status. public static let processingError = GRPCStatus(code: .internalError, message: "unknown error processing request") - /// Client cancelled the call. - public static let cancelledByClient = GRPCStatus(code: .cancelled, message: "cancelled by the client") } public protocol GRPCStatusTransformable: Error { diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift index 58ab69ffa..1886ddc7c 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCClientCodec.swift @@ -64,33 +64,33 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { if case .ignore = state { return } - switch self.unwrapInboundIn(data) { - case .head(let head): - state = processHead(ctx: ctx, head: head) + do { + switch self.unwrapInboundIn(data) { + case .head(let head): + state = try processHead(ctx: ctx, head: head) - case .body(var message): - do { + case .body(var message): state = try processBody(ctx: ctx, messageBuffer: &message) - } catch { - ctx.fireErrorCaught(error) - state = .ignore - } - case .end(let trailers): - state = processTrailers(ctx: ctx, trailers: trailers) + case .end(let trailers): + state = try processTrailers(ctx: ctx, trailers: trailers) + } + } catch { + ctx.fireErrorCaught(error) + state = .ignore } } /// Forwards the headers from the request head to the next handler. /// /// - note: Requires the `.expectingHeaders` state. - private func processHead(ctx: ChannelHandlerContext, head: HTTPResponseHead) -> State { - guard case .expectingHeaders = state - else { preconditionFailure("received headers while in state \(state)") } + private func processHead(ctx: ChannelHandlerContext, head: HTTPResponseHead) throws -> State { + guard case .expectingHeaders = state else { + throw GRPCError.client(.invalidState("received headers while in state \(state)")) + } guard head.status == .ok else { - ctx.fireErrorCaught(GRPCClientError.HTTPStatusNotOk) - return .ignore + throw GRPCError.client(.HTTPStatusNotOk(head.status)) } if let encodingType = head.headers["grpc-encoding"].first { @@ -98,8 +98,7 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { } guard inboundCompression.supported else { - ctx.fireErrorCaught(GRPCServerError.unsupportedCompressionMechanism(inboundCompression.rawValue)) - return .ignore + throw GRPCError.client(.unsupportedCompressionMechanism(inboundCompression.rawValue)) } ctx.fireChannelRead(self.wrapInboundOut(.headers(head.headers))) @@ -111,8 +110,9 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { /// /// - note: Requires the `.expectingBodyOrTrailers` state. private func processBody(ctx: ChannelHandlerContext, messageBuffer: inout ByteBuffer) throws -> State { - guard case .expectingBodyOrTrailers = state - else { preconditionFailure("received body while in state \(state)") } + guard case .expectingBodyOrTrailers = state else { + throw GRPCError.client(.invalidState("received body while in state \(state)")) + } for message in try self.messageReader.consume(messageBuffer: &messageBuffer, compression: inboundCompression) { ctx.fireChannelRead(self.wrapInboundOut(.message(message))) @@ -123,9 +123,10 @@ extension HTTP1ToRawGRPCClientCodec: ChannelInboundHandler { /// Forwards a `GRPCStatus` to the next handler. The status and message are extracted /// from the trailers if they exist; the `.unknown` status code is used if no status exists. - private func processTrailers(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) -> State { - guard case .expectingBodyOrTrailers = state - else { preconditionFailure("received trailers while in state \(state)") } + private func processTrailers(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) throws -> State { + guard case .expectingBodyOrTrailers = state else { + throw GRPCError.client(.invalidState("received trailers while in state \(state)")) + } let statusCode = trailers?["grpc-status"].first .flatMap { Int($0) } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index fd6fc230b..a60d86864 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -116,7 +116,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processHead(ctx: ChannelHandlerContext, requestHead: HTTPRequestHead) throws -> InboundState { guard case .expectingHeaders = inboundState else { - throw GRPCServerError.invalidState("expecteded state .expectingHeaders, got \(inboundState)") + throw GRPCError.server(.invalidState("expecteded state .expectingHeaders, got \(inboundState)")) } if let contentTypeHeader = requestHead.headers["content-type"].first { @@ -136,7 +136,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { func processBody(ctx: ChannelHandlerContext, body: inout ByteBuffer) throws -> InboundState { guard case .expectingBody = inboundState else { - throw GRPCServerError.invalidState("expecteded state .expectingBody(_), got \(inboundState)") + throw GRPCError.server(.invalidState("expecteded state .expectingBody, got \(inboundState)")) } // If the contentType is text, then decode the incoming bytes as base64 encoded, and append @@ -151,7 +151,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { let readyBytes = requestTextBuffer.readableBytes - (requestTextBuffer.readableBytes % 4) guard let base64Encoded = requestTextBuffer.readString(length: readyBytes), let decodedData = Data(base64Encoded: base64Encoded) else { - throw GRPCServerError.base64DecodeError + throw GRPCError.server(.base64DecodeError) } body.write(bytes: decodedData) @@ -166,7 +166,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { private func processEnd(ctx: ChannelHandlerContext, trailers: HTTPHeaders?) throws -> InboundState { if let trailers = trailers { - throw GRPCServerError.invalidState("unexpected trailers received \(trailers)") + throw GRPCError.server(.invalidState("unexpected trailers received \(trailers)")) } ctx.fireChannelRead(self.wrapInboundOut(.end)) diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index ee5851800..3a030dd55 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -27,15 +27,11 @@ import NIOHTTP1 /// - SeeAlso: /// [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) public class LengthPrefixedMessageReader { + public typealias Mode = GRPCError.Origin + + private let mode: Mode private var buffer: ByteBuffer! private var state: State = .expectingCompressedFlag - private let mode: Mode - - internal init(mode: Mode) { - self.mode = mode - } - - internal enum Mode { case client, server } private enum State { case expectingCompressedFlag @@ -45,6 +41,10 @@ public class LengthPrefixedMessageReader { case isBuffering(requiredBytes: Int) } + public init(mode: Mode) { + self.mode = mode + } + /// Consumes all readable bytes from given buffer and returns all messages which could be read. /// /// - SeeAlso: `read(messageBuffer:compression:)` @@ -131,11 +131,11 @@ public class LengthPrefixedMessageReader { private func handleCompressionFlag(enabled flagEnabled: Bool, mechanism: CompressionMechanism) throws { guard flagEnabled == mechanism.requiresFlag else { - throw GRPCServerError.unexpectedCompression + throw GRPCError.common(.unexpectedCompression, origin: mode) } guard mechanism.supported else { - throw GRPCServerError.unsupportedCompressionMechanism(mechanism.rawValue) + throw GRPCError.common(.unsupportedCompressionMechanism(mechanism.rawValue), origin: mode) } } } diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift index 1c6748906..b5c449b34 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -26,7 +26,7 @@ public class LengthPrefixedMessageWriter { /// - compression: Compression mechanism to use; the mechansim must be supported. /// - message: The serialized Protobuf message to write. /// - Returns: A `ByteBuffer` containing a gRPC length-prefixed message. - /// - Precondition: `compression.supported` returns `true`. + /// - Precondition: `compression.supported` is `true`. /// - Note: See `LengthPrefixedMessageReader` for more details on the format. func write(_ message: Data, into buffer: inout ByteBuffer, usingCompression compression: CompressionMechanism) { precondition(compression.supported, "compression mechanism \(compression) is not supported") diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift index 8cbb763fb..e1db681cd 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerResponseCapturingTestCase.swift @@ -15,6 +15,18 @@ class CollectingChannelHandler: ChannelOutboundHandler { class CollectingServerErrorDelegate: ServerErrorDelegate { var errors: [Error] = [] + var asGRPCErrors: [GRPCError]? { + return self.errors as? [GRPCError] + } + + var asGRPCServerErrors: [GRPCServerError]? { + return (self.asGRPCErrors?.map { $0.error }) as? [GRPCServerError] + } + + var asGRPCCommonErrors: [GRPCCommonError]? { + return (self.asGRPCErrors?.map { $0.error }) as? [GRPCCommonError] + } + func observe(_ error: Error) { self.errors.append(error) } diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index b97c3f49f..04e86041f 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -20,7 +20,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { } let expectedError = GRPCServerError.unimplementedMethod("unimplementedMethodName") - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + XCTAssertEqual([expectedError], errorCollector.asGRPCServerErrors) XCTAssertNoThrow(try extractStatus(responses[0])) { status in XCTAssertEqual(status, expectedError.asGRPCStatus()) @@ -57,7 +57,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { } let expectedError = GRPCServerError.requestProtoParseFailure - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + XCTAssertEqual([expectedError], errorCollector.asGRPCServerErrors) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in diff --git a/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift index bb17fef8e..7bd87ef58 100644 --- a/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift +++ b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift @@ -33,8 +33,8 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.body(gRPCMessage(channel: channel, compression: true))) } - let expectedError = GRPCServerError.unexpectedCompression - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + let expectedError = GRPCCommonError.unexpectedCompression + XCTAssertEqual([expectedError], errorCollector.asGRPCCommonErrors) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in @@ -81,7 +81,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas } let expectedError = GRPCServerError.requestProtoParseFailure - XCTAssertEqual([expectedError], errorCollector.errors as? [GRPCServerError]) + XCTAssertEqual([expectedError], errorCollector.asGRPCServerErrors) XCTAssertNoThrow(try extractHeaders(responses[0])) XCTAssertNoThrow(try extractStatus(responses[1])) { status in @@ -105,10 +105,10 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas XCTAssertEqual(errorCollector.errors.count, 1) - if case .invalidState(let message)? = errorCollector.errors.first as? GRPCServerError { + if case .some(.invalidState(let message)) = errorCollector.asGRPCCommonErrors?.first { XCTAssert(message.contains("trailers")) } else { - XCTFail("\(String(describing: errorCollector.errors.first)) was not GRPCError.invalidState") + XCTFail("\(String(describing: errorCollector.errors.first)) was not .invalidState") } XCTAssertNoThrow(try extractHeaders(responses[0])) From 79040a42e538353acf4aaab3973780e104d0c6dd Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 27 Feb 2019 15:14:16 +0000 Subject: [PATCH 24/30] Add more client-specific errors --- .../ClientCalls/BaseClientCall.swift | 3 +- .../GRPCClientChannelHandler.swift | 28 +++++++++------ Sources/SwiftGRPCNIO/GRPCClientCodec.swift | 3 +- Sources/SwiftGRPCNIO/GRPCError.swift | 34 +++++++++++++++---- Sources/SwiftGRPCNIO/GRPCServerCodec.swift | 2 +- .../SwiftGRPCNIO/ServerErrorDelegate.swift | 2 +- .../GRPCChannelHandlerTests.swift | 2 +- .../HTTP1ToRawGRPCServerCodecTests.swift | 2 +- 8 files changed, 53 insertions(+), 23 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 3227ea9e8..bbe6684de 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -147,8 +147,7 @@ extension BaseClientCall { let clientChannelHandler = self.clientChannelHandler self.client.channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { - let status = GRPCStatus(code: .deadlineExceeded, message: "client timed out after \(timeout)") - clientChannelHandler.observeStatus(status) + clientChannelHandler.observeError(.client(.deadlineExceeded(timeout))) } } diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift index aa445bc02..29caaff61 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -109,6 +109,15 @@ open class GRPCClientChannelHandler?) { guard self.outboundState != .ignore else { return } - switch unwrapOutboundIn(data) { + switch self.unwrapOutboundIn(data) { case .head: guard self.outboundState == .expectingHead else { - self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + self.errorCaught(ctx: ctx, error: GRPCError.client(.invalidState("received headers while in state \(self.outboundState)"))) return } @@ -179,7 +188,7 @@ extension GRPCClientChannelHandler: ChannelOutboundHandler { default: guard self.outboundState == .expectingMessageOrEnd else { - self.errorCaught(ctx: ctx, error: GRPCStatus.processingError) + self.errorCaught(ctx: ctx, error: GRPCError.client(.invalidState("received message or end while in state \(self.outboundState)"))) return } @@ -191,7 +200,7 @@ extension GRPCClientChannelHandler: ChannelOutboundHandler { extension GRPCClientChannelHandler { /// Closes the HTTP/2 stream. Inbound and outbound state are set to ignore. public func close(ctx: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise?) { - self.observeStatus(GRPCError.client(.cancelledByClient).asGRPCStatus()) + self.observeError(GRPCError.client(.cancelledByClient)) requestHeadSentPromise.futureResult.whenComplete { ctx.close(mode: mode, promise: promise) @@ -201,11 +210,10 @@ extension GRPCClientChannelHandler { self.outboundState = .ignore } - /// Observe an error from the pipeline. Errors are cast to `GRPCStatus` or `GRPCStatus.processingError` - /// if the cast failed and promises are fulfilled with the status. The channel is also closed. + /// Observe an error from the pipeline and close the channel. public func errorCaught(ctx: ChannelHandlerContext, error: Error) { //! TODO: Add an error handling delegate, similar to in the server. - let status = (error as? GRPCStatus) ?? .processingError - self.observeStatus(status) + self.observeError((error as? GRPCError) ?? GRPCError.unknown(error, origin: .client)) + ctx.close(mode: .all, promise: nil) } } diff --git a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift index 33f2b8b96..266f90b67 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientCodec.swift @@ -55,7 +55,7 @@ extension GRPCClientCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try ResponseMessage(serializedData: messageAsData)))) } catch { - ctx.fireErrorCaught(error) + ctx.fireErrorCaught(GRPCError.client(.responseProtoDeserializationFailure)) } case .status(let status): @@ -79,6 +79,7 @@ extension GRPCClientCodec: ChannelOutboundHandler { do { ctx.write(self.wrapOutboundOut(.message(try message.serializedData())), promise: promise) } catch { + let error = GRPCError.client(.requestProtoSerializationFailure) promise?.fail(error: error) ctx.fireErrorCaught(error) } diff --git a/Sources/SwiftGRPCNIO/GRPCError.swift b/Sources/SwiftGRPCNIO/GRPCError.swift index e0cab3251..d45feea89 100644 --- a/Sources/SwiftGRPCNIO/GRPCError.swift +++ b/Sources/SwiftGRPCNIO/GRPCError.swift @@ -21,7 +21,7 @@ public struct GRPCError: Error, GRPCStatusTransformable { public enum Origin { case client, server } /// The underlying error thrown by framework. - public let error: GRPCStatusTransformable + public let error: Error /// The origin of the error. public let origin: Origin @@ -33,10 +33,10 @@ public struct GRPCError: Error, GRPCStatusTransformable { public let line: Int public func asGRPCStatus() -> GRPCStatus { - return error.asGRPCStatus() + return (error as? GRPCStatusTransformable)?.asGRPCStatus() ?? GRPCStatus.processingError } - private init(_ error: GRPCStatusTransformable, origin: Origin, file: StaticString, line: Int) { + private init(_ error: Error, origin: Origin, file: StaticString, line: Int) { self.error = error self.origin = origin self.file = file @@ -67,6 +67,10 @@ public struct GRPCError: Error, GRPCStatusTransformable { public static func common(_ error: GRPCCommonError, origin: Origin, file: StaticString = #file, line: Int = #line) -> GRPCError { return GRPCError(error, origin: origin, file: file, line: line) } + + public static func unknown(_ error: Error, origin: Origin) -> GRPCError { + return GRPCError(error, origin: origin, file: "", line: 0) + } } /// An error which should only be thrown by the server. @@ -77,8 +81,8 @@ public enum GRPCServerError: Error, Equatable { /// It was not possible to decode a base64 message (gRPC-Web only). case base64DecodeError - /// It was not possible to parse the request protobuf. - case requestProtoParseFailure + /// It was not possible to deserialize the request protobuf. + case requestProtoDeserializationFailure /// It was not possible to serialize the response protobuf. case responseProtoSerializationFailure @@ -98,8 +102,17 @@ public enum GRPCClientError: Error, Equatable { /// The call was cancelled by the client. case cancelledByClient + /// It was not possible to deserialize the response protobuf. + case responseProtoDeserializationFailure + + /// It was not possible to serialize the request protobuf. + case requestProtoSerializationFailure + /// More than one response was received for a unary-response call. case responseCardinalityViolation + + /// The call deadline was exceeded. + case deadlineExceeded(GRPCTimeout) } /// An error which should be thrown by either the client or server. @@ -124,7 +137,7 @@ extension GRPCServerError: GRPCStatusTransformable { case .base64DecodeError: return GRPCStatus(code: .internalError, message: "could not decode base64 message") - case .requestProtoParseFailure: + case .requestProtoDeserializationFailure: return GRPCStatus(code: .internalError, message: "could not parse request proto") case .responseProtoSerializationFailure: @@ -150,6 +163,15 @@ extension GRPCClientError: GRPCStatusTransformable { case .responseCardinalityViolation: return GRPCStatus(code: .unimplemented, message: "response cardinality violation; method requires exactly one response but server sent more") + + case .responseProtoDeserializationFailure: + return GRPCStatus(code: .internalError, message: "could not parse response proto") + + case .requestProtoSerializationFailure: + return GRPCStatus(code: .internalError, message: "could not serialize request proto") + + case .deadlineExceeded(let timeout): + return GRPCStatus(code: .deadlineExceeded, message: "call exceeded timeout of \(timeout)") } } } diff --git a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift index bc9f4e9ec..5cc6e2104 100644 --- a/Sources/SwiftGRPCNIO/GRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/GRPCServerCodec.swift @@ -35,7 +35,7 @@ extension GRPCServerCodec: ChannelInboundHandler { do { ctx.fireChannelRead(self.wrapInboundOut(.message(try RequestMessage(serializedData: messageAsData)))) } catch { - ctx.fireErrorCaught(GRPCError.server(.requestProtoParseFailure)) + ctx.fireErrorCaught(GRPCError.server(.requestProtoDeserializationFailure)) } case .end: diff --git a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift index 83521a6e3..efb3f0ccb 100644 --- a/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift +++ b/Sources/SwiftGRPCNIO/ServerErrorDelegate.swift @@ -17,7 +17,7 @@ import Foundation import NIO public protocol ServerErrorDelegate: class { - //! FIXME: Provide more context about where the error was thrown. + //! FIXME: Provide more context about where the error was thrown, i.e. using `GRPCError`. /// Called when an error is thrown in the channel pipeline. func observe(_ error: Error) diff --git a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift index 04e86041f..a0f4f68ac 100644 --- a/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift +++ b/Tests/SwiftGRPCNIOTests/GRPCChannelHandlerTests.swift @@ -56,7 +56,7 @@ class GRPCChannelHandlerTests: GRPCChannelHandlerResponseCapturingTestCase { try channel.writeInbound(RawGRPCServerRequestPart.message(buffer)) } - let expectedError = GRPCServerError.requestProtoParseFailure + let expectedError = GRPCServerError.requestProtoDeserializationFailure XCTAssertEqual([expectedError], errorCollector.asGRPCServerErrors) XCTAssertNoThrow(try extractHeaders(responses[0])) diff --git a/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift index 7bd87ef58..39b92fee0 100644 --- a/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift +++ b/Tests/SwiftGRPCNIOTests/HTTP1ToRawGRPCServerCodecTests.swift @@ -80,7 +80,7 @@ class HTTP1ToRawGRPCServerCodecTests: GRPCChannelHandlerResponseCapturingTestCas try channel.writeInbound(HTTPServerRequestPart.body(buffer)) } - let expectedError = GRPCServerError.requestProtoParseFailure + let expectedError = GRPCServerError.requestProtoDeserializationFailure XCTAssertEqual([expectedError], errorCollector.asGRPCServerErrors) XCTAssertNoThrow(try extractHeaders(responses[0])) From 605d033ed9276053f7754c07ae32bb4663ab21d7 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 27 Feb 2019 15:45:34 +0000 Subject: [PATCH 25/30] Fixup comments, documentation --- Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift | 9 ++++----- Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift | 9 --------- Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift | 3 +++ Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift | 7 +++---- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index bbe6684de..62c2da31a 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -122,7 +122,7 @@ extension BaseClientCall { /// Send the given message once `subchannel` becomes available. /// - /// - Note: This is prefixed to allow for classes conforming to StreamingRequestClientCall to have use the same function name. + /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. internal func _sendMessage(_ message: RequestMessage) { self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.message(message), promise: nil) @@ -132,7 +132,7 @@ extension BaseClientCall { /// Send `end` once `subchannel` becomes available. /// /// - Important: This should only ever be called once. - /// - Note: This is prefixed to allow for classes conforming to StreamingRequestClientCall to have use the same function name. + /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. internal func _sendEnd() { self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) @@ -145,9 +145,8 @@ extension BaseClientCall { private func setTimeout(_ timeout: GRPCTimeout) { if timeout == .infinite { return } - let clientChannelHandler = self.clientChannelHandler - self.client.channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { - clientChannelHandler.observeError(.client(.deadlineExceeded(timeout))) + self.client.channel.eventLoop.scheduleTask(in: timeout.asNIOTimeAmount) { [weak self] in + self?.clientChannelHandler.observeError(.client(.deadlineExceeded(timeout))) } } diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index a60d86864..f8cdb5b9f 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -94,15 +94,6 @@ extension HTTP1ToRawGRPCServerCodec: ChannelInboundHandler { inboundState = try processHead(ctx: ctx, requestHead: requestHead) case .body(var body): - // do { - // while body.readableBytes > 0 { - // if let message = try messageReader.read(messageBuffer: &body, compression: .none) { - // ctx.fireChannelRead(wrapInboundOut(.message(message))) - // } - // } - // } catch { - // ctx.fireErrorCaught(error) - // } inboundState = try processBody(ctx: ctx, body: &body) case .end(let trailers): diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index 3a030dd55..eca2ec7e8 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -24,6 +24,9 @@ import NIOHTTP1 /// - message length: length of the message as a 4-byte unsigned integer, /// - message: `message_length` bytes. /// +/// Messages may span multiple `ByteBuffer`s, and `ByteBuffer`s may contain multiple +/// length-prefixed messages. +/// /// - SeeAlso: /// [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md) public class LengthPrefixedMessageReader { diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift index b5c449b34..77ee4bd8b 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageWriter.swift @@ -22,17 +22,16 @@ public class LengthPrefixedMessageWriter { /// Writes the data into a `ByteBuffer` as a gRPC length-prefixed message. /// /// - Parameters: - /// - allocator: Buffer allocator. - /// - compression: Compression mechanism to use; the mechansim must be supported. /// - message: The serialized Protobuf message to write. + /// - buffer: The buffer to write the message into. + /// - compression: Compression mechanism to use; the mechansim must be supported. /// - Returns: A `ByteBuffer` containing a gRPC length-prefixed message. /// - Precondition: `compression.supported` is `true`. /// - Note: See `LengthPrefixedMessageReader` for more details on the format. func write(_ message: Data, into buffer: inout ByteBuffer, usingCompression compression: CompressionMechanism) { precondition(compression.supported, "compression mechanism \(compression) is not supported") - // 1-byte for compression flag, 4-bytes for the message length. - buffer.reserveCapacity(5 + message.count) + buffer.reserveCapacity(LengthPrefixedMessageWriter.metadataLength + message.count) //! TODO: Add compression support, use the length and compressed content. buffer.write(integer: Int8(compression.requiresFlag ? 1 : 0)) From 4c85d7701d6ab796d74292d8a80336f0903af73f Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 28 Feb 2019 14:00:06 +0000 Subject: [PATCH 26/30] Fix typos and clarify documentation --- .../BidirectionalStreamingClientCall.swift | 1 - Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift | 2 +- Sources/SwiftGRPCNIO/CompressionMechanism.swift | 12 ++++++++---- .../SwiftGRPCNIO/LengthPrefixedMessageReader.swift | 4 ++++ Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift | 8 ++++---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index 768404b4d..d62d011e4 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -27,7 +27,6 @@ import NIO /// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { - public func sendMessage(_ message: RequestMessage) { self._sendMessage(message) } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift index 90f037ea7..3e762a5a5 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -60,7 +60,7 @@ extension ClientCall { } } -/// A `ClientCall` with request streaming; i.e. server-streaming and bidirectional-streaming. +/// A `ClientCall` with request streaming; i.e. client-streaming and bidirectional-streaming. public protocol StreamingRequestClientCall: ClientCall { /// Sends a message to the service. /// diff --git a/Sources/SwiftGRPCNIO/CompressionMechanism.swift b/Sources/SwiftGRPCNIO/CompressionMechanism.swift index d91fb2812..f7a755bd9 100644 --- a/Sources/SwiftGRPCNIO/CompressionMechanism.swift +++ b/Sources/SwiftGRPCNIO/CompressionMechanism.swift @@ -21,16 +21,20 @@ public enum CompressionError: Error { /// The mechanism to use for message compression. public enum CompressionMechanism: String { - /// No compression was indicated. + // No compression was indicated. case none - /// Compression indicated via a header. - case identity // no compression + // Compression indicated via a header. case gzip case deflate case snappy + // This is the same as `.none` but was indicated via a "grpc-encoding" and may be listed + // in the "grpc-accept-encoding" header. If this is the compression mechanism being used + // then the compression flag should be indicated in length-prefxied messages (see + // `LengthPrefixedMessageReader`). + case identity - /// Compression indiciated via a header, but not one listed in the specification. + // Compression indicated via a header, but not one listed in the specification. case unknown /// Whether the compression flag in gRPC length-prefixed messages should be set or not. diff --git a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift index eca2ec7e8..b477eec08 100644 --- a/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift +++ b/Sources/SwiftGRPCNIO/LengthPrefixedMessageReader.swift @@ -71,6 +71,9 @@ public class LengthPrefixedMessageReader { /// 2. after the message length field, /// 3. at any point within the message. /// + /// It is possible for the message length field to be split across multiple `ByteBuffer`s, + /// this is unlikely to happen in practice. + /// /// - Note: /// This method relies on state; if a message is _not_ returned then the next time this /// method is called it expects to read the bytes which follow the most recently read bytes. @@ -90,6 +93,7 @@ public class LengthPrefixedMessageReader { self.state = .expectingMessageLength case .expectingMessageLength: + //! FIXME: Support the message length being split across multiple byte buffers. guard let messageLength: UInt32 = messageBuffer.readInteger() else { return nil } self.state = .receivedMessageLength(numericCast(messageLength)) diff --git a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift index 546d0e961..d0e1f488f 100644 --- a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift @@ -20,7 +20,7 @@ import XCTest class NIOClientTimeoutTests: NIOBasicEchoTestCase { let optionsWithShortTimeout = CallOptions(timeout: try! GRPCTimeout.milliseconds(10)) - let atLeastShortTimeout: TimeInterval = 0.011 + let moreThanShortTimeout: TimeInterval = 0.011 static var allTests: [(String, (NIOClientTimeoutTests) -> () throws -> Void)] { return [ @@ -100,7 +100,7 @@ extension NIOClientTimeoutTests { call.sendMessage(Echo_EchoRequest(text: "foo")) // Timeout before sending `.end` - Thread.sleep(forTimeInterval: atLeastShortTimeout) + Thread.sleep(forTimeInterval: moreThanShortTimeout) call.sendEnd() waitForExpectations(timeout: defaultTestTimeout) @@ -111,7 +111,7 @@ extension NIOClientTimeoutTests { self.expectDeadlineExceeded(forStatus: call.status) - Thread.sleep(forTimeInterval: atLeastShortTimeout) + Thread.sleep(forTimeInterval: moreThanShortTimeout) waitForExpectations(timeout: defaultTestTimeout) } @@ -123,7 +123,7 @@ extension NIOClientTimeoutTests { call.sendMessage(Echo_EchoRequest(text: "foo")) // Timeout before sending `.end` - Thread.sleep(forTimeInterval: atLeastShortTimeout) + Thread.sleep(forTimeInterval: moreThanShortTimeout) call.sendEnd() waitForExpectations(timeout: defaultTestTimeout) From 918dc7cfd82f11fc2f95d729364151fcf3adaac8 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Thu, 28 Feb 2019 15:05:22 +0000 Subject: [PATCH 27/30] Enqueue messages to be sent --- Sources/Examples/EchoNIO/main.swift | 10 +-- .../ClientCalls/BaseClientCall.swift | 64 ++++++++++++++++--- .../BidirectionalStreamingClientCall.swift | 31 +++++++-- .../SwiftGRPCNIO/ClientCalls/ClientCall.swift | 43 +++++++++++-- .../ClientStreamingClientCall.swift | 28 ++++++-- .../ServerStreamingClientCall.swift | 7 +- .../ClientCalls/UnaryClientCall.swift | 8 ++- .../GRPCClientChannelHandler.swift | 2 +- .../HTTP1ToRawGRPCServerCodec.swift | 2 +- .../NIOClientTimeoutTests.swift | 8 +-- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 10 +-- 11 files changed, 169 insertions(+), 44 deletions(-) diff --git a/Sources/Examples/EchoNIO/main.swift b/Sources/Examples/EchoNIO/main.swift index 003714212..3d62df24e 100644 --- a/Sources/Examples/EchoNIO/main.swift +++ b/Sources/Examples/EchoNIO/main.swift @@ -132,13 +132,14 @@ Group { let collect = echo.collect() + var queue = collect.newMessageQueue() for part in message.components(separatedBy: " ") { var requestMessage = Echo_EchoRequest() requestMessage.text = part print("collect sending: \(requestMessage.text)") - collect.sendMessage(requestMessage) + queue = queue.then { collect.sendMessage(requestMessage) } } - collect.sendEnd() + queue.whenSuccess { collect.sendEnd(promise: nil) } collect.response.whenSuccess { respone in print("collect received: \(respone.text)") @@ -171,13 +172,14 @@ Group { print("update received: \(response.text)") } + var queue = update.newMessageQueue() for part in message.components(separatedBy: " ") { var requestMessage = Echo_EchoRequest() requestMessage.text = part print("update sending: \(requestMessage.text)") - update.sendMessage(requestMessage) + queue = queue.then { update.sendMessage(requestMessage) } } - update.sendEnd() + queue.whenSuccess { update.sendEnd(promise: nil) } // wait() on the status to stop the program from exiting. do { diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 62c2da31a..997929a18 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -67,9 +67,6 @@ open class BaseClientCall { self.createStreamChannel() self.setTimeout(callOptions.timeout) - - let requestHead = BaseClientCall.makeRequestHead(path: path, host: client.host, callOptions: callOptions) - self.sendHead(requestHead) } } @@ -114,31 +111,80 @@ extension BaseClientCall { /// Send the request head once `subchannel` becomes available. /// /// - Important: This should only ever be called once. - private func sendHead(_ requestHead: HTTPRequestHead) { + /// + /// - Parameters: + /// - requestHead: The request head to send. + /// - promise: A promise to fulfill once the request head has been sent. + internal func sendHead(_ requestHead: HTTPRequestHead, promise: EventLoopPromise?) { + // The nghttp2 implementation of NIOHTTP2 has a known defect where "promises on control frame + // writes do not work and will be leaked. Promises on DATA frame writes work just fine and will + // be fulfilled correctly." Succeed the promise here as a temporary workaround. + promise?.succeed(result: ()) self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.head(requestHead), promise: nil) } } + /// Send the request head once `subchannel` becomes available. + /// + /// - Important: This should only ever be called once. + /// + /// - Parameter requestHead: The request head to send. + /// - Returns: A future which will be succeeded once the request head has been sent. + internal func sendHead(_ requestHead: HTTPRequestHead) -> EventLoopFuture { + let promise = client.channel.eventLoop.newPromise(of: Void.self) + self.sendHead(requestHead, promise: promise) + return promise.futureResult + } + /// Send the given message once `subchannel` becomes available. /// /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. - internal func _sendMessage(_ message: RequestMessage) { + /// - Parameters: + /// - message: The message to send. + /// - promise: A promise to fulfil when the message reaches the network. + internal func _sendMessage(_ message: RequestMessage, promise: EventLoopPromise?) { self.subchannel.whenSuccess { channel in - channel.writeAndFlush(GRPCClientRequestPart.message(message), promise: nil) + channel.writeAndFlush(GRPCClientRequestPart.message(message), promise: promise) } } + /// Send the given message once `subchannel` becomes available. + /// + /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. + /// - Returns: A future which will be fullfilled when the message reaches the network. + internal func _sendMessage(_ message: RequestMessage) -> EventLoopFuture { + let promise = client.channel.eventLoop.newPromise(of: Void.self) + self._sendMessage(message, promise: promise) + return promise.futureResult + } + /// Send `end` once `subchannel` becomes available. /// - /// - Important: This should only ever be called once. /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. - internal func _sendEnd() { + /// - Important: This should only ever be called once. + /// - Parameter promise: A promise to succeed once then end has been sent. + internal func _sendEnd(promise: EventLoopPromise?) { + // The nghttp2 implementation of NIOHTTP2 has a known defect where "promises on control frame + // writes do not work and will be leaked. Promises on DATA frame writes work just fine and will + // be fulfilled correctly." Succeed the promise here as a temporary workaround. + promise?.succeed(result: ()) self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) } } + /// Send `end` once `subchannel` becomes available. + /// + /// - Note: This is prefixed to allow for classes conforming to `StreamingRequestClientCall` to use the non-underbarred name. + /// - Important: This should only ever be called once. + ///- Returns: A future which will be succeeded once the end has been sent. + internal func _sendEnd() -> EventLoopFuture { + let promise = client.channel.eventLoop.newPromise(of: Void.self) + self._sendEnd(promise: promise) + return promise.futureResult + } + /// Creates a client-side timeout for this call. /// /// - Important: This should only ever be called once. @@ -157,7 +203,7 @@ extension BaseClientCall { /// - host: the address of the host we are connected to. /// - callOptions: options to use when configuring this call. /// - Returns: `HTTPRequestHead` configured for this call. - internal class func makeRequestHead(path: String, host: String, callOptions: CallOptions) -> HTTPRequestHead { + internal func makeRequestHead(path: String, host: String, callOptions: CallOptions) -> HTTPRequestHead { var requestHead = HTTPRequestHead(version: .init(major: 2, minor: 0), method: .POST, uri: path) callOptions.customMetadata.forEach { name, value in diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index d62d011e4..6f5ca9eba 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -27,15 +27,34 @@ import NIO /// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class BidirectionalStreamingClientCall: BaseClientCall, StreamingRequestClientCall { - public func sendMessage(_ message: RequestMessage) { - self._sendMessage(message) + private var messageQueue: EventLoopFuture + + public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { + self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) + super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) + + let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) + self.messageQueue = self.messageQueue.then { self.sendHead(requestHead) } } - public func sendEnd() { - self._sendEnd() + public func sendMessage(_ message: RequestMessage) -> EventLoopFuture { + return self._sendMessage(message) } - public init(client: GRPCClient, path: String, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { - super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) + public func sendMessage(_ message: RequestMessage, promise: EventLoopPromise?) { + self._sendMessage(message, promise: promise) + } + + public func sendEnd() -> EventLoopFuture { + return self._sendEnd() + } + + public func sendEnd(promise: EventLoopPromise?) { + self._sendEnd(promise: promise) + } + + public func newMessageQueue() -> EventLoopFuture { + defer { self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) } + return self.messageQueue } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift index 3e762a5a5..efe5cb8e0 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -64,12 +64,45 @@ extension ClientCall { public protocol StreamingRequestClientCall: ClientCall { /// Sends a message to the service. /// - /// - Important: Callers must terminate the stream of messages by calling `sendEnd()`. - /// - Parameter message: request message to send. - func sendMessage(_ message: RequestMessage) + /// - Important: Callers must terminate the stream of messages by calling `sendEnd()` or `sendEnd(promise:)`. + /// + /// - Parameter message: The message to send. + /// - Returns: A future which will be fullfilled when the message has been sent. + func sendMessage(_ message: RequestMessage) -> EventLoopFuture + + /// Sends a message to the service. + /// + /// - Important: Callers must terminate the stream of messages by calling `sendEnd()` or `sendEnd(promise:)`. + /// + /// - Parameters: + /// - message: The message to send. + /// - promise: A promise to be fulfilled when the message has been sent. + func sendMessage(_ message: RequestMessage, promise: EventLoopPromise?) - /// Indicates to the service that no more messages will be sent by the client. - func sendEnd() + /// Returns a new succeeded future. + /// + /// Callers may use this to create a message queue as such: + /// ``` + /// var queue = call.newMessageQueue() + /// for message in messagesToSend { + /// queue = queue.then { call.sendMessage(message) } + /// } + /// ``` + /// + /// - Returns: A succeeded future which may be used as the head of a message queue. + func newMessageQueue() -> EventLoopFuture + + /// Terminates a stream of messages sent to the service. + /// + /// - Important: This should only ever be called once. + /// - Returns: A future which will be fullfilled when the end has been sent. + func sendEnd() -> EventLoopFuture + + /// Terminates a stream of messages sent to the service. + /// + /// - Important: This should only ever be called once. + /// - Parameter promise: A promise to be fulfilled when the end has been sent. + func sendEnd(promise: EventLoopPromise?) } /// A `ClientCall` with a unary response; i.e. unary and client-streaming. diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index dbe1d83d5..60722cd76 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -28,24 +28,42 @@ import NIO /// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class ClientStreamingClientCall: BaseClientCall, StreamingRequestClientCall, UnaryResponseClientCall { - public unowned let response: EventLoopFuture + public let response: EventLoopFuture + private var messageQueue: EventLoopFuture public init(client: GRPCClient, path: String, callOptions: CallOptions) { let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() self.response = responsePromise.futureResult + self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) super.init( client: client, path: path, callOptions: callOptions, responseObserver: .succeedPromise(responsePromise)) + + let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) + self.messageQueue = self.messageQueue.then { self.sendHead(requestHead) } + } + + public func sendMessage(_ message: RequestMessage) -> EventLoopFuture { + return self._sendMessage(message) + } + + public func sendMessage(_ message: RequestMessage, promise: EventLoopPromise?) { + self._sendMessage(message, promise: promise) + } + + public func sendEnd() -> EventLoopFuture { + return self._sendEnd() } - public func sendMessage(_ message: RequestMessage) { - self._sendMessage(message) + public func sendEnd(promise: EventLoopPromise?) { + self._sendEnd(promise: promise) } - public func sendEnd() { - self._sendEnd() + public func newMessageQueue() -> EventLoopFuture { + defer { self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) } + return self.messageQueue } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift index 65e195e02..0d3027714 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ServerStreamingClientCall.swift @@ -26,7 +26,10 @@ import NIO public class ServerStreamingClientCall: BaseClientCall { public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions, handler: @escaping (ResponseMessage) -> Void) { super.init(client: client, path: path, callOptions: callOptions, responseObserver: .callback(handler)) - self._sendMessage(request) - self._sendEnd() + + let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) + self.sendHead(requestHead) + .then { self._sendMessage(request) } + .whenSuccess { self._sendEnd(promise: nil) } } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift index 7ac95f144..fbcb75316 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/UnaryClientCall.swift @@ -25,7 +25,7 @@ import NIO /// - `status`: the status of the gRPC call after it has ended, /// - `trailingMetadata`: any metadata returned from the server alongside the `status`. public class UnaryClientCall: BaseClientCall, UnaryResponseClientCall { - public unowned let response: EventLoopFuture + public let response: EventLoopFuture public init(client: GRPCClient, path: String, request: RequestMessage, callOptions: CallOptions) { let responsePromise: EventLoopPromise = client.channel.eventLoop.newPromise() @@ -37,7 +37,9 @@ public class UnaryClientCall: callOptions: callOptions, responseObserver: .succeedPromise(responsePromise)) - self._sendMessage(request) - self._sendEnd() + let requestHead = self.makeRequestHead(path: path, host: client.host, callOptions: callOptions) + self.sendHead(requestHead) + .then { self._sendMessage(request) } + .whenSuccess { self._sendEnd(promise: nil) } } } diff --git a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift index 29caaff61..141066cc9 100644 --- a/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift +++ b/Sources/SwiftGRPCNIO/GRPCClientChannelHandler.swift @@ -29,7 +29,7 @@ import SwiftProtobuf /// response (if applicable) are failed with first error received. The status promise is __succeeded__ /// with the error as a `GRPCStatus`. The stream is also closed and any inbound or outbound messages /// are ignored. -open class GRPCClientChannelHandler { +internal class GRPCClientChannelHandler { internal let initialMetadataPromise: EventLoopPromise internal let statusPromise: EventLoopPromise internal let responseObserver: ResponseObserver diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index f8cdb5b9f..186ced8df 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -200,7 +200,7 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { // Store the response into an independent buffer. We can't return the message directly as // it needs to be aggregated with all the responses plus the trailers, in order to have // the base64 response properly encoded in a single byte stream. - messageWriter.write(messageBytes, into: &self.responseTextBuffer, usingCompression: .none) + messageWriter.write(messageBytes, into: &responseTextBuffer, usingCompression: .none) // Since we stored the written data, mark the write promise as successful so that the // ServerStreaming provider continues sending the data. diff --git a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift index d0e1f488f..7d18c2d52 100644 --- a/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOClientTimeoutTests.swift @@ -97,11 +97,11 @@ extension NIOClientTimeoutTests { self.expectDeadlineExceeded(forStatus: call.status) self.expectDeadlineExceeded(forResponse: call.response) - call.sendMessage(Echo_EchoRequest(text: "foo")) + call.sendMessage(Echo_EchoRequest(text: "foo"), promise: nil) // Timeout before sending `.end` Thread.sleep(forTimeInterval: moreThanShortTimeout) - call.sendEnd() + call.sendEnd(promise: nil) waitForExpectations(timeout: defaultTestTimeout) } @@ -120,11 +120,11 @@ extension NIOClientTimeoutTests { self.expectDeadlineExceeded(forStatus: call.status) - call.sendMessage(Echo_EchoRequest(text: "foo")) + call.sendMessage(Echo_EchoRequest(text: "foo"), promise: nil) // Timeout before sending `.end` Thread.sleep(forTimeInterval: moreThanShortTimeout) - call.sendEnd() + call.sendEnd(promise: nil) waitForExpectations(timeout: defaultTestTimeout) } diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 8146d9102..16d7b8abd 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -52,10 +52,11 @@ extension NIOServerTests { let numberOfRequests = 2_000 for i in 0.. 0 { print("\(i) requests sent so far, elapsed time: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } - XCTAssertEqual(try client.get(Echo_EchoRequest.with { $0.text = "foo \(i)" }).response.wait().text, "Swift echo get: foo \(i)") + XCTAssertEqual(try client.get(Echo_EchoRequest(text: "foo \(i)")).response.wait().text, "Swift echo get: foo \(i)") } print("total time for \(numberOfRequests) requests: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } @@ -75,10 +76,11 @@ extension NIOServerTests { func doTestClientStreaming(messages: [String], file: StaticString = #file, line: UInt = #line) throws { let call = client.collect() + var queue = call.newMessageQueue() for message in messages { - call.sendMessage(Echo_EchoRequest.with { $0.text = message }) + queue = queue.then { call.sendMessage(Echo_EchoRequest(text: message)) } } - call.sendEnd() + queue.whenSuccess { call.sendEnd(promise: nil) } XCTAssertEqual("Swift echo collect: " + messages.joined(separator: " "), try call.response.wait().text, file: file, line: line) XCTAssertEqual(.ok, try call.status.wait().code, file: file, line: line) @@ -126,7 +128,7 @@ extension NIOServerTests { } messages.forEach { part in - call.sendMessage(Echo_EchoRequest.with { $0.text = part }) + call.sendMessage(Echo_EchoRequest(text: part), promise: nil) XCTAssertNotEqual(responseReceived?.wait(timeout: .now() + .seconds(1)), .some(.timedOut), file: file, line: line) } call.sendEnd() From 5e30ed3c0a5e7fc2c8be22063c37abb9c90252ba Mon Sep 17 00:00:00 2001 From: George Barnett Date: Fri, 1 Mar 2019 14:41:19 +0000 Subject: [PATCH 28/30] Workaround compile error for swift<4.2 --- Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift | 9 ++++++++- Tests/LinuxMain.swift | 2 +- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 3 +-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift index 186ced8df..bcf202c33 100644 --- a/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift +++ b/Sources/SwiftGRPCNIO/HTTP1ToRawGRPCServerCodec.swift @@ -200,7 +200,14 @@ extension HTTP1ToRawGRPCServerCodec: ChannelOutboundHandler { // Store the response into an independent buffer. We can't return the message directly as // it needs to be aggregated with all the responses plus the trailers, in order to have // the base64 response properly encoded in a single byte stream. - messageWriter.write(messageBytes, into: &responseTextBuffer, usingCompression: .none) + #if swift(>=4.2) + messageWriter.write(messageBytes, into: &self.responseTextBuffer, usingCompression: .none) + #else + // Write into a temporary buffer to avoid: "error: cannot pass immutable value as inout argument: 'self' is immutable" + var responseBuffer = ctx.channel.allocator.buffer(capacity: LengthPrefixedMessageWriter.metadataLength) + messageWriter.write(messageBytes, into: &responseBuffer, usingCompression: .none) + responseTextBuffer.write(buffer: &responseBuffer) + #endif // Since we stored the written data, mark the write promise as successful so that the // ServerStreaming provider continues sending the data. diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 4f2a7ecaf..f1c83e9d0 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -39,7 +39,7 @@ XCTMain([ // SwiftGRPCNIO testCase(NIOServerTests.allTests), testCase(NIOClientCancellingTests.allTests), - testCase(NIOClientTimeoutTests.allTests) + testCase(NIOClientTimeoutTests.allTests), testCase(NIOServerWebTests.allTests), testCase(GRPCChannelHandlerTests.allTests), testCase(HTTP1ToRawGRPCServerCodecTests.allTests) diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 16d7b8abd..8d94bb9b8 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -52,7 +52,6 @@ extension NIOServerTests { let numberOfRequests = 2_000 for i in 0.. 0 { print("\(i) requests sent so far, elapsed time: \(Double(clock() - clockStart) / Double(CLOCKS_PER_SEC))") } @@ -131,7 +130,7 @@ extension NIOServerTests { call.sendMessage(Echo_EchoRequest(text: part), promise: nil) XCTAssertNotEqual(responseReceived?.wait(timeout: .now() + .seconds(1)), .some(.timedOut), file: file, line: line) } - call.sendEnd() + call.sendEnd(promise: nil) XCTAssertEqual(try call.status.wait().code, .ok, file: file, line: line) XCTAssertEqual(index, messages.count) From 941e15ac3f8f3e69f1d5d764a867734e41a2f0a7 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Fri, 1 Mar 2019 15:17:37 +0000 Subject: [PATCH 29/30] Fix documentation, add TODOs --- Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift | 2 ++ .../ClientCalls/BidirectionalStreamingClientCall.swift | 1 - Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift | 6 +++--- .../ClientCalls/ClientStreamingClientCall.swift | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift index 997929a18..d47f76f4c 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BaseClientCall.swift @@ -119,6 +119,7 @@ extension BaseClientCall { // The nghttp2 implementation of NIOHTTP2 has a known defect where "promises on control frame // writes do not work and will be leaked. Promises on DATA frame writes work just fine and will // be fulfilled correctly." Succeed the promise here as a temporary workaround. + //! TODO: remove this and pass the promise to `writeAndFlush` when NIOHTTP2 supports it. promise?.succeed(result: ()) self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.head(requestHead), promise: nil) @@ -168,6 +169,7 @@ extension BaseClientCall { // The nghttp2 implementation of NIOHTTP2 has a known defect where "promises on control frame // writes do not work and will be leaked. Promises on DATA frame writes work just fine and will // be fulfilled correctly." Succeed the promise here as a temporary workaround. + //! TODO: remove this and pass the promise to `writeAndFlush` when NIOHTTP2 supports it. promise?.succeed(result: ()) self.subchannel.whenSuccess { channel in channel.writeAndFlush(GRPCClientRequestPart.end, promise: nil) diff --git a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift index 6f5ca9eba..deb5a1f5f 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/BidirectionalStreamingClientCall.swift @@ -54,7 +54,6 @@ public class BidirectionalStreamingClientCall EventLoopFuture { - defer { self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) } return self.messageQueue } } diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift index efe5cb8e0..49dc52c81 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientCall.swift @@ -79,9 +79,9 @@ public protocol StreamingRequestClientCall: ClientCall { /// - promise: A promise to be fulfilled when the message has been sent. func sendMessage(_ message: RequestMessage, promise: EventLoopPromise?) - /// Returns a new succeeded future. + /// Returns a future which can be used as a message queue. /// - /// Callers may use this to create a message queue as such: + /// Callers may use this as such: /// ``` /// var queue = call.newMessageQueue() /// for message in messagesToSend { @@ -89,7 +89,7 @@ public protocol StreamingRequestClientCall: ClientCall { /// } /// ``` /// - /// - Returns: A succeeded future which may be used as the head of a message queue. + /// - Returns: A future which may be used as the head of a message queue. func newMessageQueue() -> EventLoopFuture /// Terminates a stream of messages sent to the service. diff --git a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift index 60722cd76..6d40a6fa6 100644 --- a/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift +++ b/Sources/SwiftGRPCNIO/ClientCalls/ClientStreamingClientCall.swift @@ -63,7 +63,6 @@ public class ClientStreamingClientCall EventLoopFuture { - defer { self.messageQueue = client.channel.eventLoop.newSucceededFuture(result: ()) } return self.messageQueue } } From d58519a3451e6a154a9a43bc9b2afad36d3014fa Mon Sep 17 00:00:00 2001 From: George Barnett Date: Fri, 1 Mar 2019 15:48:47 +0000 Subject: [PATCH 30/30] Increase timeout for bidi tests --- Tests/SwiftGRPCNIOTests/NIOServerTests.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift index 8d94bb9b8..33c64a9b2 100644 --- a/Tests/SwiftGRPCNIOTests/NIOServerTests.swift +++ b/Tests/SwiftGRPCNIOTests/NIOServerTests.swift @@ -116,11 +116,12 @@ extension NIOServerTests { } extension NIOServerTests { - private func doTestBidirectionalStreaming(messages: [String], waitForEachResponse: Bool = false, file: StaticString = #file, line: UInt = #line) throws { + private func doTestBidirectionalStreaming(messages: [String], waitForEachResponse: Bool = false, timeout: GRPCTimeout? = nil, file: StaticString = #file, line: UInt = #line) throws { let responseReceived = waitForEachResponse ? DispatchSemaphore(value: 0) : nil var index = 0 - let call = client.update { response in + let callOptions = timeout.map { CallOptions(timeout: $0) } + let call = client.update(callOptions: callOptions) { response in XCTAssertEqual("Swift echo update (\(index)): \(messages[index])", response.text, file: file, line: line) responseReceived?.signal() index += 1 @@ -145,10 +146,10 @@ extension NIOServerTests { } func testBidirectionalStreamingLotsOfMessagesBatched() throws { - XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings)) + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings, timeout: try .seconds(15))) } func testBidirectionalStreamingLotsOfMessagesPingPong() throws { - XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings, waitForEachResponse: true)) + XCTAssertNoThrow(try doTestBidirectionalStreaming(messages: NIOServerTests.lotsOfStrings, waitForEachResponse: true, timeout: try .seconds(15))) } }