Skip to content

Commit

Permalink
Add a dev-tool subpackage (#2167)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
glbrntt authored Jan 31, 2025
1 parent 2b17298 commit f163392
Show file tree
Hide file tree
Showing 8 changed files with 483 additions and 0 deletions.
2 changes: 2 additions & 0 deletions dev/format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ if "$lint"; then
"${repo}/Tests" \
"${repo}/Examples" \
"${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
"${repo}/dev" \
&& SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?

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

if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then
Expand Down
8 changes: 8 additions & 0 deletions dev/grpc-dev-tool/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
36 changes: 36 additions & 0 deletions dev/grpc-dev-tool/Package.swift
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions dev/grpc-dev-tool/Sources/grpc-dev-tool/GRPCDevTool.swift
Original file line number Diff line number Diff line change
@@ -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]
)
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit f163392

Please sign in to comment.