Browse Source

Add `echo-metadata` example (#2182)

This PR adds an example showcasing how to set/read request and response
metadata.

---------

Co-authored-by: George Barnett <gbarnett@apple.com>
Gus Cairo 11 months ago
parent
commit
f20f91669b

+ 8 - 0
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

+ 43 - 0
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")
+      ]
+    )
+  ]
+)

+ 58 - 0
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

+ 35 - 0
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)
+  }
+}

+ 27 - 0
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]
+  )
+}

+ 73 - 0
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<Echo_EchoRequest>,
+    context: ServerContext
+  ) async throws -> ServerResponse<Echo_EchoResponse> {
+    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<Echo_EchoRequest>,
+    context: ServerContext
+  ) async throws -> ServerResponse<Echo_EchoResponse> {
+    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<Echo_EchoRequest>,
+    context: ServerContext
+  ) async throws -> StreamingServerResponse<Echo_EchoResponse> {
+    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<Echo_EchoRequest>,
+    context: ServerContext
+  ) async throws -> StreamingServerResponse<Echo_EchoResponse> {
+    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
+    }
+  }
+}

+ 1 - 0
Examples/echo-metadata/Sources/Protos/echo

@@ -0,0 +1 @@
+../../../../dev/protos/examples/echo/

+ 7 - 0
Examples/echo-metadata/Sources/Protos/grpc-swift-proto-generator-config.json

@@ -0,0 +1,7 @@
+{
+  "generate": {
+    "clients": true,
+    "servers": true,
+    "messages": true
+  }
+}

+ 58 - 0
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)")
+      }
+    }
+  }
+}

+ 65 - 0
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)")
+          }
+        }
+      }
+    }
+  }
+}

+ 53 - 0
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)")
+      }
+    }
+  }
+}

+ 43 - 0
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)")
+      }
+    }
+  }
+}

+ 67 - 0
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)")
+          }
+        }
+      }
+    }
+  }
+}

+ 1 - 1
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