diff --git a/Examples/echo-metadata/.gitignore b/Examples/echo-metadata/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Examples/echo-metadata/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/echo-metadata/Package.swift b/Examples/echo-metadata/Package.swift new file mode 100644 index 000000000..9cad1a0ff --- /dev/null +++ b/Examples/echo-metadata/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version:6.0 +/* + * Copyright 2024, 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 PackageDescription + +let package = Package( + name: "echo-metadata", + platforms: [.macOS("15.0")], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "2.0.0-rc.1"), + .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", exact: "1.0.0-rc.1"), + .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", exact: "1.0.0-rc.1"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + ], + targets: [ + .executableTarget( + name: "echo-metadata", + dependencies: [ + .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + plugins: [ + .plugin(name: "GRPCProtobufGenerator", package: "grpc-swift-protobuf") + ] + ) + ] +) diff --git a/Examples/echo-metadata/README.md b/Examples/echo-metadata/README.md new file mode 100644 index 000000000..fc9f17fc8 --- /dev/null +++ b/Examples/echo-metadata/README.md @@ -0,0 +1,58 @@ +# Echo-Metadata + +This example demonstrates how to interact with `Metadata` on RPCs: how to set and read it on unary +and streaming requests, as well as how to set and read both initial and trailing metadata on unary +and streaming responses. This is done using a simple 'echo' server and client and the SwiftNIO +based HTTP/2 transport. + +## Overview + +An `echo-metadata` command line tool that uses generated stubs for an 'echo-metadata' service +which allows you to start a server and to make requests against it. + +You can use any of the client's subcommands (`get`, `collect`, `expand` and `update`) to send the +provided `message` as both the request's message, and as the value for the `echo-message` key in +the request's metadata. + +The server will then echo back the message and the metadata's `echo-message` key-value pair sent +by the client. The request's metadata will be echoed both in the initial and the trailing metadata. + +The tool uses the [SwiftNIO](https://github.com/grpc/grpc-swift-nio-transport) HTTP/2 transport. + +## Prerequisites + +You must have the Protocol Buffers compiler (`protoc`) installed. You can find +the instructions for doing this in the [gRPC Swift Protobuf documentation][0]. +The `swift` commands below are all prefixed with `PROTOC_PATH=$(which protoc)`, +this is to let the build system know where `protoc` is located so that it can +generate stubs for you. You can read more about it in the [gRPC Swift Protobuf +documentation][1]. + +## Usage + +Build and run the server using the CLI: + +```console +$ PROTOC_PATH=$(which protoc) swift run echo-metadata serve +Echo-Metadata listening on [ipv4]127.0.0.1:1234 +``` + +Use the CLI to run the client and make a `get` (unary) request: + +```console +$ PROTOC_PATH=$(which protoc) swift run echo-metadata get --message "hello" +get → metadata: [("echo-message", "hello")] +get → message: hello +get ← initial metadata: [("echo-message", "hello")] +get ← message: hello +get ← trailing metadata: [("echo-message", "hello")] +``` + +Get help with the CLI by running: + +```console +$ PROTOC_PATH=$(which protoc) swift run echo-metadata --help +``` + +[0]: https://swiftpackageindex.com/grpc/grpc-swift-protobuf/documentation/grpcprotobuf/installing-protoc +[1]: https://swiftpackageindex.com/grpc/grpc-swift-protobuf/documentation/grpcprotobuf/generating-stubs diff --git a/Examples/echo-metadata/Sources/ClientArguments.swift b/Examples/echo-metadata/Sources/ClientArguments.swift new file mode 100644 index 000000000..9aa237f67 --- /dev/null +++ b/Examples/echo-metadata/Sources/ClientArguments.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCNIOTransportHTTP2 + +struct ClientArguments: ParsableArguments { + @Option(help: "The server's listening port") + var port: Int = 1234 + + @Option( + help: + "Message to send to the server. It will also be sent in the request's metadata as the value for `echo-message`." + ) + var message: String +} + +extension ClientArguments { + var target: any ResolvableTarget { + return .ipv4(host: "127.0.0.1", port: self.port) + } +} diff --git a/Examples/echo-metadata/Sources/EchoMetadata.swift b/Examples/echo-metadata/Sources/EchoMetadata.swift new file mode 100644 index 000000000..4624f16c7 --- /dev/null +++ b/Examples/echo-metadata/Sources/EchoMetadata.swift @@ -0,0 +1,27 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore + +@main +struct EchoMetadata: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "echo-metadata", + abstract: "A multi-tool to run an echo-metadata server and execute RPCs against it.", + subcommands: [Serve.self, Get.self, Collect.self, Update.self, Expand.self] + ) +} diff --git a/Examples/echo-metadata/Sources/EchoService.swift b/Examples/echo-metadata/Sources/EchoService.swift new file mode 100644 index 000000000..ebfe56de2 --- /dev/null +++ b/Examples/echo-metadata/Sources/EchoService.swift @@ -0,0 +1,73 @@ +/* + * Copyright 2025, 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 GRPCCore + +struct EchoService: Echo_Echo.ServiceProtocol { + func get( + request: ServerRequest, + context: ServerContext + ) async throws -> ServerResponse { + let responseMetadata = Metadata(request.metadata.filter({ $0.key.starts(with: "echo-") })) + return ServerResponse( + message: .with { $0.text = request.message.text }, + metadata: responseMetadata, + trailingMetadata: responseMetadata + ) + } + + func collect( + request: StreamingServerRequest, + context: ServerContext + ) async throws -> ServerResponse { + let responseMetadata = Metadata(request.metadata.filter({ $0.key.starts(with: "echo-") })) + let messages = try await request.messages.reduce(into: []) { $0.append($1.text) } + let joined = messages.joined(separator: " ") + + return ServerResponse( + message: .with { $0.text = joined }, + metadata: responseMetadata, + trailingMetadata: responseMetadata + ) + } + + func expand( + request: ServerRequest, + context: ServerContext + ) async throws -> StreamingServerResponse { + let responseMetadata = Metadata(request.metadata.filter({ $0.key.starts(with: "echo-") })) + let parts = request.message.text.split(separator: " ") + let messages = parts.map { part in Echo_EchoResponse.with { $0.text = String(part) } } + + return StreamingServerResponse(metadata: responseMetadata) { writer in + try await writer.write(contentsOf: messages) + return responseMetadata + } + } + + func update( + request: StreamingServerRequest, + context: ServerContext + ) async throws -> StreamingServerResponse { + let responseMetadata = Metadata(request.metadata.filter({ $0.key.starts(with: "echo-") })) + return StreamingServerResponse(metadata: responseMetadata) { writer in + for try await message in request.messages { + try await writer.write(.with { $0.text = message.text }) + } + return responseMetadata + } + } +} diff --git a/Examples/echo-metadata/Sources/Protos/echo b/Examples/echo-metadata/Sources/Protos/echo new file mode 120000 index 000000000..66fa3f5f5 --- /dev/null +++ b/Examples/echo-metadata/Sources/Protos/echo @@ -0,0 +1 @@ +../../../../dev/protos/examples/echo/ \ No newline at end of file diff --git a/Examples/echo-metadata/Sources/Protos/grpc-swift-proto-generator-config.json b/Examples/echo-metadata/Sources/Protos/grpc-swift-proto-generator-config.json new file mode 100644 index 000000000..e6dda31fb --- /dev/null +++ b/Examples/echo-metadata/Sources/Protos/grpc-swift-proto-generator-config.json @@ -0,0 +1,7 @@ +{ + "generate": { + "clients": true, + "servers": true, + "messages": true + } +} diff --git a/Examples/echo-metadata/Sources/Subcommands/Collect.swift b/Examples/echo-metadata/Sources/Subcommands/Collect.swift new file mode 100644 index 000000000..a523a809e --- /dev/null +++ b/Examples/echo-metadata/Sources/Subcommands/Collect.swift @@ -0,0 +1,58 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore +import GRPCNIOTransportHTTP2 + +struct Collect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Makes a client streaming RPC to the echo-metadata server." + ) + + @OptionGroup + var arguments: ClientArguments + + func run() async throws { + try await withGRPCClient( + transport: .http2NIOPosix( + target: self.arguments.target, + transportSecurity: .plaintext + ) + ) { client in + let echo = Echo_Echo.Client(wrapping: client) + let requestMetadata: Metadata = ["echo-message": "\(arguments.message)"] + + print("collect → metadata: \(requestMetadata)") + try await echo.collect(metadata: requestMetadata) { writer in + for part in self.arguments.message.split(separator: " ") { + print("collect → \(part)") + try await writer.write(.with { $0.text = String(part) }) + } + } onResponse: { response in + let initialMetadata = Metadata(response.metadata.filter({ $0.key.starts(with: "echo-") })) + print("collect ← initial metadata: \(initialMetadata)") + + print("collect ← message: \(try response.message.text)") + + let trailingMetadata = Metadata( + response.trailingMetadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("collect ← trailing metadata: \(trailingMetadata)") + } + } + } +} diff --git a/Examples/echo-metadata/Sources/Subcommands/Expand.swift b/Examples/echo-metadata/Sources/Subcommands/Expand.swift new file mode 100644 index 000000000..134a2ac66 --- /dev/null +++ b/Examples/echo-metadata/Sources/Subcommands/Expand.swift @@ -0,0 +1,65 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore +import GRPCNIOTransportHTTP2 + +struct Expand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Makes a server streaming RPC to the echo-metadata server." + ) + + @OptionGroup + var arguments: ClientArguments + + func run() async throws { + try await withGRPCClient( + transport: .http2NIOPosix( + target: self.arguments.target, + transportSecurity: .plaintext + ) + ) { client in + let echo = Echo_Echo.Client(wrapping: client) + let requestMetadata: Metadata = ["echo-message": "\(arguments.message)"] + let message = Echo_EchoRequest.with { $0.text = self.arguments.message } + + print("expand → metadata: \(requestMetadata)") + print("expand → message: \(message.text)") + + try await echo.expand(message, metadata: requestMetadata) { response in + let responseContents = try response.accepted.get() + + let initialMetadata = Metadata( + responseContents.metadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("expand ← initial metadata: \(initialMetadata)") + for try await part in responseContents.bodyParts { + switch part { + case .message(let message): + print("expand ← message: \(message.text)") + + case .trailingMetadata(let trailingMetadata): + let trailingMetadata = Metadata( + trailingMetadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("expand ← trailing metadata: \(trailingMetadata)") + } + } + } + } + } +} diff --git a/Examples/echo-metadata/Sources/Subcommands/Get.swift b/Examples/echo-metadata/Sources/Subcommands/Get.swift new file mode 100644 index 000000000..443da6914 --- /dev/null +++ b/Examples/echo-metadata/Sources/Subcommands/Get.swift @@ -0,0 +1,53 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore +import GRPCNIOTransportHTTP2 + +struct Get: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Makes a unary RPC to the echo-metadata server." + ) + + @OptionGroup + var arguments: ClientArguments + + func run() async throws { + try await withGRPCClient( + transport: .http2NIOPosix( + target: self.arguments.target, + transportSecurity: .plaintext + ) + ) { client in + let echo = Echo_Echo.Client(wrapping: client) + let requestMetadata: Metadata = ["echo-message": "\(arguments.message)"] + let message = Echo_EchoRequest.with { $0.text = self.arguments.message } + + print("get → metadata: \(requestMetadata)") + print("get → message: \(message.text)") + try await echo.get(message, metadata: requestMetadata) { response in + let initialMetadata = Metadata(response.metadata.filter({ $0.key.starts(with: "echo-") })) + print("get ← initial metadata: \(initialMetadata)") + print("get ← message: \(try response.message.text)") + let trailingMetadata = Metadata( + response.trailingMetadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("get ← trailing metadata: \(trailingMetadata)") + } + } + } +} diff --git a/Examples/echo-metadata/Sources/Subcommands/Serve.swift b/Examples/echo-metadata/Sources/Subcommands/Serve.swift new file mode 100644 index 000000000..36f4616d0 --- /dev/null +++ b/Examples/echo-metadata/Sources/Subcommands/Serve.swift @@ -0,0 +1,43 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore +import GRPCNIOTransportHTTP2 + +struct Serve: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Starts an echo-metadata server.") + + @Option(help: "The port to listen on") + var port: Int = 1234 + + func run() async throws { + let server = GRPCServer( + transport: .http2NIOPosix( + address: .ipv4(host: "127.0.0.1", port: self.port), + transportSecurity: .plaintext + ), + services: [EchoService()] + ) + + try await withThrowingDiscardingTaskGroup { group in + group.addTask { try await server.serve() } + if let address = try await server.listeningAddress { + print("Echo-Metadata listening on \(address)") + } + } + } +} diff --git a/Examples/echo-metadata/Sources/Subcommands/Update.swift b/Examples/echo-metadata/Sources/Subcommands/Update.swift new file mode 100644 index 000000000..f357fa901 --- /dev/null +++ b/Examples/echo-metadata/Sources/Subcommands/Update.swift @@ -0,0 +1,67 @@ +/* + * Copyright 2025, 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 ArgumentParser +import GRPCCore +import GRPCNIOTransportHTTP2 + +struct Update: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Makes a bidirectional server streaming RPC to the echo-metadata server." + ) + + @OptionGroup + var arguments: ClientArguments + + func run() async throws { + try await withGRPCClient( + transport: .http2NIOPosix( + target: self.arguments.target, + transportSecurity: .plaintext + ) + ) { client in + let echo = Echo_Echo.Client(wrapping: client) + let requestMetadata: Metadata = ["echo-message": "\(arguments.message)"] + + print("update → metadata: \(requestMetadata)") + try await echo.update(metadata: requestMetadata) { writer in + for part in self.arguments.message.split(separator: " ") { + print("update → message: \(part)") + try await writer.write(.with { $0.text = String(part) }) + } + } onResponse: { response in + let responseContents = try response.accepted.get() + + let initialMetadata = Metadata( + responseContents.metadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("update ← initial metadata: \(initialMetadata)") + for try await part in responseContents.bodyParts { + switch part { + case .message(let message): + print("update ← message: \(message.text)") + + case .trailingMetadata(let trailingMetadata): + let trailingMetadata = Metadata( + trailingMetadata.filter({ $0.key.starts(with: "echo-") }) + ) + print("update ← trailing metadata: \(trailingMetadata)") + } + } + } + } + } +} diff --git a/Examples/echo/README.md b/Examples/echo/README.md index da5049731..7753d3e1e 100644 --- a/Examples/echo/README.md +++ b/Examples/echo/README.md @@ -1,7 +1,7 @@ # Echo This example demonstrates all four RPC types using a simple 'echo' service and -client and the Swift NIO based HTTP/2 transport. +client and the SwiftNIO based HTTP/2 transport. ## Overview