Browse Source

Add a dev-tool subpackage (#2167)

Motivation:

A number of test in this package and others rely on ad-hoc services
using Codable. This is less overhead than using protobuf as you it's not
always available. It also means the messages are defined in Swift so
they're easy to change without needing to regenerate. However, the
service glue code is hand rolled. We can avoid this by having a little
adapter sit on top of the code gen lib.

Modifications:

- Add a grpc-dev-tool package to dev. We can use this as a place to add
tooling and other helpers without worrying about worsening the
experience for end users (because of additional dependencies, more
public API and so on).
- For now this has a single executable for generating code from a JSON
config file. The schema for the services is limited, but that's fine,
it's not a general purpose tool.

Result:

- We have a tool which can generate grpc code from a JSON definition
which uses Codable message types.
George Barnett 11 months ago
parent
commit
f163392838

+ 2 - 0
dev/format.sh

@@ -61,6 +61,7 @@ if "$lint"; then
     "${repo}/Tests" \
     "${repo}/Tests" \
     "${repo}/Examples" \
     "${repo}/Examples" \
     "${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
     "${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
+    "${repo}/dev" \
     && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
     && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
 
 
   if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then
   if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then
@@ -80,6 +81,7 @@ elif "$format"; then
     "${repo}/Tests" \
     "${repo}/Tests" \
     "${repo}/Examples" \
     "${repo}/Examples" \
     "${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
     "${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
+    "${repo}/dev" \
     && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
     && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
 
 
   if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then
   if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then

+ 8 - 0
dev/grpc-dev-tool/.gitignore

@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc

+ 36 - 0
dev/grpc-dev-tool/Package.swift

@@ -0,0 +1,36 @@
+// swift-tools-version:6.0
+/*
+ * 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 PackageDescription
+
+let package = Package(
+  name: "grpc-dev-tool",
+  platforms: [.macOS(.v15)],
+  dependencies: [
+    .package(path: "../.."),
+    .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
+  ],
+  targets: [
+    .executableTarget(
+      name: "grpc-dev-tool",
+      dependencies: [
+        .product(name: "GRPCCodeGen", package: "grpc-swift"),
+        .product(name: "ArgumentParser", package: "swift-argument-parser"),
+      ]
+    )
+  ]
+)

+ 25 - 0
dev/grpc-dev-tool/Sources/grpc-dev-tool/GRPCDevTool.swift

@@ -0,0 +1,25 @@
+/*
+ * 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
+
+@main
+struct GRPCDevTool: AsyncParsableCommand {
+  static let configuration = CommandConfiguration(
+    commandName: "grpc-dev-tool",
+    subcommands: [GenerateJSON.self]
+  )
+}

+ 75 - 0
dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCCodeGen+Conversions.swift

@@ -0,0 +1,75 @@
+/*
+ * 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 GRPCCodeGen
+
+/// Creates a `ServiceDescriptor` from a JSON `ServiceSchema`.
+extension ServiceDescriptor {
+  init(_ service: ServiceSchema) {
+    self.init(
+      documentation: "",
+      name: .init(
+        identifyingName: service.name,
+        typeName: service.name,
+        propertyName: service.name
+      ),
+      methods: service.methods.map {
+        MethodDescriptor($0)
+      }
+    )
+  }
+}
+
+extension MethodDescriptor {
+  /// Creates a `MethodDescriptor` from a JSON `ServiceSchema.Method`.
+  init(_ method: ServiceSchema.Method) {
+    self.init(
+      documentation: "",
+      name: .init(
+        identifyingName: method.name,
+        typeName: method.name,
+        functionName: method.name
+      ),
+      isInputStreaming: method.kind.streamsInput,
+      isOutputStreaming: method.kind.streamsOutput,
+      inputType: method.input,
+      outputType: method.output
+    )
+  }
+}
+
+extension CodeGenerator.Config.AccessLevel {
+  init(_ level: GeneratorConfig.AccessLevel) {
+    switch level {
+    case .internal:
+      self = .internal
+    case .package:
+      self = .package
+    }
+  }
+}
+
+extension CodeGenerator.Config {
+  init(_ config: GeneratorConfig) {
+    self.init(
+      accessLevel: CodeGenerator.Config.AccessLevel(config.accessLevel),
+      accessLevelOnImports: config.accessLevelOnImports,
+      client: config.generateClient,
+      server: config.generateServer,
+      indentation: 2
+    )
+  }
+}

+ 83 - 0
dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCDevUtils+GenerateJSON.swift

@@ -0,0 +1,83 @@
+/*
+ * 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 Foundation
+
+struct GenerateJSON: ParsableCommand {
+  static let configuration = CommandConfiguration(
+    commandName: "generate-json",
+    subcommands: [Generate.self, DumpConfig.self],
+    defaultSubcommand: Generate.self
+  )
+}
+
+extension GenerateJSON {
+  struct Generate: ParsableCommand {
+    @Argument(help: "The path to a JSON input file.")
+    var input: String
+
+    func run() throws {
+      // Decode the input file.
+      let url = URL(filePath: self.input)
+      let data = try Data(contentsOf: url)
+      let json = JSONDecoder()
+      let config = try json.decode(JSONCodeGeneratorRequest.self, from: data)
+
+      // Generate the output and dump it to stdout.
+      let generator = JSONCodeGenerator()
+      let sourceFile = try generator.generate(request: config)
+      print(sourceFile.contents)
+    }
+  }
+}
+
+extension GenerateJSON {
+  struct DumpConfig: ParsableCommand {
+    func run() throws {
+      // Create a request for the code generator using all four RPC kinds.
+      var request = JSONCodeGeneratorRequest(
+        service: ServiceSchema(name: "Echo", methods: []),
+        config: .defaults
+      )
+
+      let methodNames = ["get", "collect", "expand", "update"]
+      let methodKinds: [ServiceSchema.Method.Kind] = [
+        .unary,
+        .clientStreaming,
+        .serverStreaming,
+        .bidiStreaming,
+      ]
+
+      for (name, kind) in zip(methodNames, methodKinds) {
+        let method = ServiceSchema.Method(
+          name: name,
+          input: "EchoRequest",
+          output: "EchoResponse",
+          kind: kind
+        )
+        request.service.methods.append(method)
+      }
+
+      // Encoding the config to JSON and dump it to stdout.
+      let encoder = JSONEncoder()
+      encoder.outputFormatting = [.prettyPrinted]
+      let data = try encoder.encode(request)
+      let json = String(decoding: data, as: UTF8.self)
+      print(json)
+    }
+  }
+}

+ 119 - 0
dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGenerator.swift

@@ -0,0 +1,119 @@
+/*
+ * 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 Foundation
+import GRPCCodeGen
+
+struct JSONCodeGenerator {
+  private static let currentYear: Int = {
+    let now = Date()
+    let year = Calendar.current.component(.year, from: Date())
+    return year
+  }()
+
+  private static let header = """
+    /*
+     * Copyright \(Self.currentYear), 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.
+     */
+    """
+
+  private static let jsonSerializers: String = """
+    fileprivate struct JSONSerializer<Message: Codable>: MessageSerializer {
+      fileprivate func serialize<Bytes: GRPCContiguousBytes>(
+        _ message: Message
+      ) throws -> Bytes {
+        do {
+          let jsonEncoder = JSONEncoder()
+          let data = try jsonEncoder.encode(message)
+          return Bytes(data)
+        } catch {
+          throw RPCError(
+            code: .internalError,
+            message: "Can't serialize message to JSON.",
+            cause: error
+          )
+        }
+      }
+    }
+
+    fileprivate struct JSONDeserializer<Message: Codable>: MessageDeserializer {
+      fileprivate func deserialize<Bytes: GRPCContiguousBytes>(
+        _ serializedMessageBytes: Bytes
+      ) throws -> Message {
+        do {
+          let jsonDecoder = JSONDecoder()
+          let data = serializedMessageBytes.withUnsafeBytes { Data($0) }
+          return try jsonDecoder.decode(Message.self, from: data)
+        } catch {
+          throw RPCError(
+            code: .internalError,
+            message: "Can't deserialize message from JSON.",
+            cause: error
+          )
+        }
+      }
+    }
+    """
+
+  func generate(request: JSONCodeGeneratorRequest) throws -> SourceFile {
+    let generator = CodeGenerator(config: CodeGenerator.Config(request.config))
+
+    let codeGenRequest = CodeGenerationRequest(
+      fileName: request.service.name + ".swift",
+      leadingTrivia: Self.header,
+      dependencies: [
+        Dependency(
+          item: Dependency.Item(kind: .struct, name: "Data"),
+          module: "Foundation",
+          accessLevel: .internal
+        ),
+        Dependency(
+          item: Dependency.Item(kind: .class, name: "JSONEncoder"),
+          module: "Foundation",
+          accessLevel: .internal
+        ),
+        Dependency(
+          item: Dependency.Item(kind: .class, name: "JSONDecoder"),
+          module: "Foundation",
+          accessLevel: .internal
+        ),
+      ],
+      services: [ServiceDescriptor(request.service)],
+      makeSerializerCodeSnippet: { type in "JSONSerializer<\(type)>()" },
+      makeDeserializerCodeSnippet: { type in "JSONDeserializer<\(type)>()" }
+    )
+
+    var sourceFile = try generator.generate(codeGenRequest)
+
+    // Insert a fileprivate serializer/deserializer for JSON at the bottom of each file.
+    sourceFile.contents += "\n\n"
+    sourceFile.contents += Self.jsonSerializers
+
+    return sourceFile
+  }
+}

+ 135 - 0
dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGeneratorRequest.swift

@@ -0,0 +1,135 @@
+/*
+ * 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 Foundation
+
+struct JSONCodeGeneratorRequest: Codable {
+  /// The service to generate.
+  var service: ServiceSchema
+
+  /// Configuration for the generation.
+  var config: GeneratorConfig
+
+  init(service: ServiceSchema, config: GeneratorConfig) {
+    self.service = service
+    self.config = config
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    self.service = try container.decode(ServiceSchema.self, forKey: .service)
+    self.config = try container.decodeIfPresent(GeneratorConfig.self, forKey: .config) ?? .defaults
+  }
+}
+
+struct ServiceSchema: Codable {
+  var name: String
+  var methods: [Method]
+
+  struct Method: Codable {
+    var name: String
+    var input: String
+    var output: String
+    var kind: Kind
+
+    enum Kind: String, Codable {
+      case unary = "unary"
+      case clientStreaming = "client_streaming"
+      case serverStreaming = "server_streaming"
+      case bidiStreaming = "bidi_streaming"
+
+      var streamsInput: Bool {
+        switch self {
+        case .unary, .serverStreaming:
+          return false
+        case .clientStreaming, .bidiStreaming:
+          return true
+        }
+      }
+
+      var streamsOutput: Bool {
+        switch self {
+        case .unary, .clientStreaming:
+          return false
+        case .serverStreaming, .bidiStreaming:
+          return true
+        }
+      }
+    }
+  }
+}
+
+struct GeneratorConfig: Codable {
+  enum AccessLevel: String, Codable {
+    case `internal`
+    case `package`
+
+    var capitalized: String {
+      switch self {
+      case .internal:
+        return "Internal"
+      case .package:
+        return "Package"
+      }
+    }
+  }
+
+  var generateClient: Bool
+  var generateServer: Bool
+  var accessLevel: AccessLevel
+  var accessLevelOnImports: Bool
+
+  static var defaults: Self {
+    GeneratorConfig(
+      generateClient: true,
+      generateServer: true,
+      accessLevel: .internal,
+      accessLevelOnImports: false
+    )
+  }
+
+  init(
+    generateClient: Bool,
+    generateServer: Bool,
+    accessLevel: AccessLevel,
+    accessLevelOnImports: Bool
+  ) {
+    self.generateClient = generateClient
+    self.generateServer = generateServer
+    self.accessLevel = accessLevel
+    self.accessLevelOnImports = accessLevelOnImports
+  }
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    let defaults = Self.defaults
+
+    let generateClient = try container.decodeIfPresent(Bool.self, forKey: .generateClient)
+    self.generateClient = generateClient ?? defaults.generateClient
+
+    let generateServer = try container.decodeIfPresent(Bool.self, forKey: .generateServer)
+    self.generateServer = generateServer ?? defaults.generateServer
+
+    let accessLevel = try container.decodeIfPresent(AccessLevel.self, forKey: .accessLevel)
+    self.accessLevel = accessLevel ?? defaults.accessLevel
+
+    let accessLevelOnImports = try container.decodeIfPresent(
+      Bool.self,
+      forKey: .accessLevelOnImports
+    )
+    self.accessLevelOnImports = accessLevelOnImports ?? defaults.accessLevelOnImports
+  }
+}