Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for gRPC using SwiftNIO #121

Merged
merged 25 commits into from Mar 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Examples/ElizaCocoaPodsApp/Podfile.lock
Expand Up @@ -15,7 +15,7 @@ EXTERNAL SOURCES:
:path: "../.."

SPEC CHECKSUMS:
Connect-Swift: 2d7a0c3315232381dcb94edf57a594d66bf8ebba
Connect-Swift: 73d976064e788c49ef8112b9c4a37d807bed593f
SwiftProtobuf: afced68785854575756db965e9da52bbf3dc45e7

PODFILE CHECKSUM: b598f373a6ab5add976b09c2ac79029bf2200d48
Expand Down
7 changes: 5 additions & 2 deletions Examples/ElizaCocoaPodsApp/README.md
Expand Up @@ -9,8 +9,11 @@ the Connect library:

- [Connect](https://connect.build) + unary
- [Connect](https://connect.build) + streaming
- [gRPC-Web](https://grpc.io) + unary
- [gRPC-Web](https://grpc.io) + streaming
- [gRPC-Web](https://github.com/grpc/grpc-web) + unary
- [gRPC-Web](https://github.com/grpc/grpc-web) + streaming

**Note that vanilla gRPC support is not available in this example because
[SwiftNIO does not support CocoaPods](https://github.com/apple/swift-nio/issues/2393).**

## Try it out

Expand Down
51 changes: 50 additions & 1 deletion Examples/ElizaSharedSources/AppSources/MenuView.swift
Expand Up @@ -13,11 +13,18 @@
// limitations under the License.

import Connect
#if !COCOAPODS
// SwiftNIO (and gRPC) support is not available via CocoaPods since SwiftNIO does not support it.
// This import is only necessary if using gRPC, not for Connect or gRPC-Web.
import ConnectNIO
#endif
import SwiftUI

private enum MessagingConnectionType: Int, CaseIterable {
case connectUnary
case connectStreaming
case grpcUnary
case grpcStreaming
case grpcWebUnary
case grpcWebStreaming
}
Expand All @@ -34,14 +41,26 @@ struct MenuView: View {
private func createClient(withProtocol networkProtocol: NetworkProtocol)
-> Buf_Connect_Demo_Eliza_V1_ElizaServiceClient
{
let host = "https://demo.connect.build"
#if COCOAPODS
let protocolClient = ProtocolClient(
httpClient: URLSessionHTTPClient(),
config: ProtocolClientConfig(
host: "https://demo.connect.build",
host: host,
networkProtocol: networkProtocol,
codec: ProtoCodec() // Protobuf binary, or JSONCodec() for JSON
)
)
#else
let protocolClient = ProtocolClient(
httpClient: NIOHTTPClient(host: host), // Or URLSessionHTTPClient()
config: ProtocolClientConfig(
host: host,
networkProtocol: networkProtocol,
codec: ProtoCodec() // Protobuf binary, or JSONCodec() for JSON
)
)
#endif
return Buf_Connect_Demo_Eliza_V1_ElizaServiceClient(client: protocolClient)
}

Expand Down Expand Up @@ -85,6 +104,21 @@ struct MenuView: View {
.navigationTitle("Eliza Chat (Streaming)")
)

case .grpcUnary:
#if !COCOAPODS
NavigationLink(
"gRPC (Unary)",
destination: LazyNavigationView {
MessagingView(
viewModel: UnaryMessagingViewModel(
client: self.createClient(withProtocol: .grpc)
)
)
}
.navigationTitle("Eliza Chat (gRPC Unary)")
)
#endif

case .grpcWebUnary:
NavigationLink(
"gRPC Web (Unary)",
Expand All @@ -98,6 +132,21 @@ struct MenuView: View {
.navigationTitle("Eliza Chat (gRPC-W Unary)")
)

case .grpcStreaming:
#if !COCOAPODS
NavigationLink(
"gRPC (Streaming)",
destination: LazyNavigationView {
MessagingView(
viewModel: BidirectionalStreamingMessagingViewModel(
client: self.createClient(withProtocol: .grpc)
)
)
}
.navigationTitle("Eliza Chat (gRPC Streaming)")
)
#endif

case .grpcWebStreaming:
NavigationLink(
"gRPC Web (Streaming)",
Expand Down
Expand Up @@ -17,6 +17,7 @@
B216FC1129723F65003AB294 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = B216FC0929723F65003AB294 /* README.md */; };
B236EE8C295F564900DDCDA9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B236EE8B295F564900DDCDA9 /* Assets.xcassets */; };
B236EEA4295F569700DDCDA9 /* Connect in Frameworks */ = {isa = PBXBuildFile; productRef = B236EEA3295F569700DDCDA9 /* Connect */; };
B2C1986529CA282B00C3D327 /* ConnectNIO in Frameworks */ = {isa = PBXBuildFile; productRef = B2C1986429CA282B00C3D327 /* ConnectNIO */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -39,6 +40,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B2C1986529CA282B00C3D327 /* ConnectNIO in Frameworks */,
B236EEA4295F569700DDCDA9 /* Connect in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -139,6 +141,7 @@
name = ElizaSwiftPackageApp;
packageProductDependencies = (
B236EEA3295F569700DDCDA9 /* Connect */,
B2C1986429CA282B00C3D327 /* ConnectNIO */,
);
productName = ElizaSwiftPackageApp;
productReference = B236EE84295F564800DDCDA9 /* ElizaSwiftPackageApp.app */;
Expand Down Expand Up @@ -415,6 +418,10 @@
isa = XCSwiftPackageProductDependency;
productName = Connect;
};
B2C1986429CA282B00C3D327 /* ConnectNIO */ = {
isa = XCSwiftPackageProductDependency;
productName = ConnectNIO;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = B236EE7C295F564800DDCDA9 /* Project object */;
Expand Down
@@ -1,5 +1,50 @@
{
"pins" : [
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036",
"version" : "1.0.3"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
"version" : "1.0.4"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "45167b8006448c79dda4b7bd604e07a034c15c49",
"version" : "2.48.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "38feec96bcd929028939107684073554bf01abeb",
"version" : "1.25.2"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3",
"version" : "2.23.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
Expand Down
6 changes: 4 additions & 2 deletions Examples/ElizaSwiftPackageApp/README.md
Expand Up @@ -9,8 +9,10 @@ the Connect library:

- [Connect](https://connect.build) + unary
- [Connect](https://connect.build) + streaming
- [gRPC-Web](https://grpc.io) + unary
- [gRPC-Web](https://grpc.io) + streaming
- [gRPC](https://grpc.io) + unary (using `ConnectGRPC` + `SwiftNIO`)
- [gRPC](https://grpc.io) + streaming (using `ConnectGRPC` + `SwiftNIO`)
- [gRPC-Web](https://github.com/grpc/grpc-web) + unary
- [gRPC-Web](https://github.com/grpc/grpc-web) + streaming

## Try it out

Expand Down
110 changes: 110 additions & 0 deletions Libraries/Connect/Implementation/GRPC/ConnectError+GRPC.swift
@@ -0,0 +1,110 @@
// Copyright 2022-2023 Buf Technologies, Inc.
//
// 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

extension ConnectError {
/// Creates an error using gRPC trailers.
///
/// - parameter trailers: The trailers (or headers, for gRPC-Web) from which to parse the error.
/// - parameter code: The status code received from the server.
///
/// - returns: An error, if the status indicated an error.
public static func fromGRPCTrailers(_ trailers: Trailers, code: Code) -> Self? {
if code == .ok {
return nil
}

return .init(
code: code,
message: trailers.grpcMessage(),
exception: nil,
details: trailers.connectErrorDetailsFromGRPC(),
metadata: [:]
)
}
}

private extension Trailers {
func grpcMessage() -> String? {
return self[HeaderConstants.grpcMessage]?.first?.grpcPercentDecoded()
}

func connectErrorDetailsFromGRPC() -> [ConnectError.Detail] {
return self[HeaderConstants.grpcStatusDetails]?
.first
.flatMap { Data(base64Encoded: $0) }
.flatMap { data -> Grpc_Status_V1_Status? in
return try? ProtoCodec().deserialize(source: data)
}?
.details
.compactMap { protoDetail in
return ConnectError.Detail(
// Include only the type name (last component of the type URL)
// to be compatible with SwiftProtobuf's `Google_Protobuf_Any`.
type: String(protoDetail.typeURL.split(separator: "/").last!),
payload: protoDetail.value
)
}
?? []
}
}

private extension String {
/// grpcPercentEncode/grpcPercentDecode follows RFC 3986 Section 2.1 and the gRPC HTTP/2 spec.
/// It's a variant of URL-encoding with fewer reserved characters. It's intended
/// to take UTF-8 encoded text and escape non-ASCII bytes so that they're valid
/// HTTP/1 headers, while still maximizing readability of the data on the wire.
///
/// The grpc-message trailer (used for human-readable error messages) should be
/// percent-encoded.
///
/// References:
///
/// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#responses
/// https://datatracker.ietf.org/doc/html/rfc3986#section-2.1
///
/// - returns: A decoded string using the above format.
func grpcPercentDecoded() -> Self {
let utf8 = self.utf8
let endIndex = utf8.endIndex
var characters = [UInt8]()
let utf8Percent = UInt8(ascii: "%")
var index = utf8.startIndex

while index < endIndex {
let character = utf8[index]
if character == utf8Percent {
let secondIndex = utf8.index(index, offsetBy: 2)
if secondIndex >= endIndex {
return self // Decoding failed
}

if let decoded = String(
utf8[utf8.index(index, offsetBy: 1) ... secondIndex]
).flatMap({ UInt8($0, radix: 16) }) {
characters.append(decoded)
index = utf8.index(after: secondIndex)
} else {
return self // Decoding failed
}
} else {
characters.append(character)
index = utf8.index(after: index)
}
}

return String(decoding: characters, as: Unicode.UTF8.self)
}
}
40 changes: 40 additions & 0 deletions Libraries/Connect/Implementation/GRPC/Headers+GRPC.swift
@@ -0,0 +1,40 @@
// Copyright 2022-2023 Buf Technologies, Inc.
//
// 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

extension Headers {
/// Adds required headers to gRPC and gRPC-Web requests/streams.
///
/// - parameter config: The configuration to use for adding headers (i.e., for compression
/// headers).
///
/// - returns: A set of updated headers.
public func addingGRPCHeaders(using config: ProtocolClientConfig) -> Self {
var headers = self
headers[HeaderConstants.grpcAcceptEncoding] = config
.acceptCompressionPoolNames()
headers[HeaderConstants.grpcContentEncoding] = config.requestCompression
.map { [$0.pool.name()] }
headers[HeaderConstants.grpcTE] = ["trailers"]

// Note that we do not comply with the recommended structure for user-agent:
// https://github.com/grpc/grpc/blob/v1.51.1/doc/PROTOCOL-HTTP2.md#user-agents
// But this behavior matches connect-web:
// https://github.com/bufbuild/connect-web/blob/v0.4.0/packages/connect-core/src/grpc-web-create-request-header.ts#L33-L36
// swiftlint:disable:previous line_length
headers[HeaderConstants.xUserAgent] = ["@bufbuild/connect-swift"]
return headers
}
}
25 changes: 25 additions & 0 deletions Libraries/Connect/Implementation/GRPC/Trailers+GRPC.swift
@@ -0,0 +1,25 @@
// Copyright 2022-2023 Buf Technologies, Inc.
//
// 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.

extension Trailers {
/// Identifies the status code from gRPC and gRPC-Web trailers.
///
/// - returns: The gRPC status code, if specified.
public func grpcStatus() -> Code? {
return self[HeaderConstants.grpcStatus]?
.first
.flatMap(Int.init)
.flatMap { Code(rawValue: $0) }
}
}