Skip to content

Commit f163392

Browse files
authored
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.
1 parent 2b17298 commit f163392

File tree

8 files changed

+483
-0
lines changed

8 files changed

+483
-0
lines changed

dev/format.sh

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ if "$lint"; then
6161
"${repo}/Tests" \
6262
"${repo}/Examples" \
6363
"${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
64+
"${repo}/dev" \
6465
&& SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
6566

6667
if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then
@@ -80,6 +81,7 @@ elif "$format"; then
8081
"${repo}/Tests" \
8182
"${repo}/Examples" \
8283
"${repo}/IntegrationTests/Benchmarks/Benchmarks/GRPCSwiftBenchmark" \
84+
"${repo}/dev" \
8385
&& SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$?
8486

8587
if [[ "${SWIFT_FORMAT_RC}" -ne 0 ]]; then

dev/grpc-dev-tool/.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

dev/grpc-dev-tool/Package.swift

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// swift-tools-version:6.0
2+
/*
3+
* Copyright 2025, gRPC Authors All rights reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import PackageDescription
19+
20+
let package = Package(
21+
name: "grpc-dev-tool",
22+
platforms: [.macOS(.v15)],
23+
dependencies: [
24+
.package(path: "../.."),
25+
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
26+
],
27+
targets: [
28+
.executableTarget(
29+
name: "grpc-dev-tool",
30+
dependencies: [
31+
.product(name: "GRPCCodeGen", package: "grpc-swift"),
32+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
33+
]
34+
)
35+
]
36+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2025, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
19+
@main
20+
struct GRPCDevTool: AsyncParsableCommand {
21+
static let configuration = CommandConfiguration(
22+
commandName: "grpc-dev-tool",
23+
subcommands: [GenerateJSON.self]
24+
)
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2025, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import GRPCCodeGen
18+
19+
/// Creates a `ServiceDescriptor` from a JSON `ServiceSchema`.
20+
extension ServiceDescriptor {
21+
init(_ service: ServiceSchema) {
22+
self.init(
23+
documentation: "",
24+
name: .init(
25+
identifyingName: service.name,
26+
typeName: service.name,
27+
propertyName: service.name
28+
),
29+
methods: service.methods.map {
30+
MethodDescriptor($0)
31+
}
32+
)
33+
}
34+
}
35+
36+
extension MethodDescriptor {
37+
/// Creates a `MethodDescriptor` from a JSON `ServiceSchema.Method`.
38+
init(_ method: ServiceSchema.Method) {
39+
self.init(
40+
documentation: "",
41+
name: .init(
42+
identifyingName: method.name,
43+
typeName: method.name,
44+
functionName: method.name
45+
),
46+
isInputStreaming: method.kind.streamsInput,
47+
isOutputStreaming: method.kind.streamsOutput,
48+
inputType: method.input,
49+
outputType: method.output
50+
)
51+
}
52+
}
53+
54+
extension CodeGenerator.Config.AccessLevel {
55+
init(_ level: GeneratorConfig.AccessLevel) {
56+
switch level {
57+
case .internal:
58+
self = .internal
59+
case .package:
60+
self = .package
61+
}
62+
}
63+
}
64+
65+
extension CodeGenerator.Config {
66+
init(_ config: GeneratorConfig) {
67+
self.init(
68+
accessLevel: CodeGenerator.Config.AccessLevel(config.accessLevel),
69+
accessLevelOnImports: config.accessLevelOnImports,
70+
client: config.generateClient,
71+
server: config.generateServer,
72+
indentation: 2
73+
)
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2025, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
import Foundation
19+
20+
struct GenerateJSON: ParsableCommand {
21+
static let configuration = CommandConfiguration(
22+
commandName: "generate-json",
23+
subcommands: [Generate.self, DumpConfig.self],
24+
defaultSubcommand: Generate.self
25+
)
26+
}
27+
28+
extension GenerateJSON {
29+
struct Generate: ParsableCommand {
30+
@Argument(help: "The path to a JSON input file.")
31+
var input: String
32+
33+
func run() throws {
34+
// Decode the input file.
35+
let url = URL(filePath: self.input)
36+
let data = try Data(contentsOf: url)
37+
let json = JSONDecoder()
38+
let config = try json.decode(JSONCodeGeneratorRequest.self, from: data)
39+
40+
// Generate the output and dump it to stdout.
41+
let generator = JSONCodeGenerator()
42+
let sourceFile = try generator.generate(request: config)
43+
print(sourceFile.contents)
44+
}
45+
}
46+
}
47+
48+
extension GenerateJSON {
49+
struct DumpConfig: ParsableCommand {
50+
func run() throws {
51+
// Create a request for the code generator using all four RPC kinds.
52+
var request = JSONCodeGeneratorRequest(
53+
service: ServiceSchema(name: "Echo", methods: []),
54+
config: .defaults
55+
)
56+
57+
let methodNames = ["get", "collect", "expand", "update"]
58+
let methodKinds: [ServiceSchema.Method.Kind] = [
59+
.unary,
60+
.clientStreaming,
61+
.serverStreaming,
62+
.bidiStreaming,
63+
]
64+
65+
for (name, kind) in zip(methodNames, methodKinds) {
66+
let method = ServiceSchema.Method(
67+
name: name,
68+
input: "EchoRequest",
69+
output: "EchoResponse",
70+
kind: kind
71+
)
72+
request.service.methods.append(method)
73+
}
74+
75+
// Encoding the config to JSON and dump it to stdout.
76+
let encoder = JSONEncoder()
77+
encoder.outputFormatting = [.prettyPrinted]
78+
let data = try encoder.encode(request)
79+
let json = String(decoding: data, as: UTF8.self)
80+
print(json)
81+
}
82+
}
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2025, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
import GRPCCodeGen
19+
20+
struct JSONCodeGenerator {
21+
private static let currentYear: Int = {
22+
let now = Date()
23+
let year = Calendar.current.component(.year, from: Date())
24+
return year
25+
}()
26+
27+
private static let header = """
28+
/*
29+
* Copyright \(Self.currentYear), gRPC Authors All rights reserved.
30+
*
31+
* Licensed under the Apache License, Version 2.0 (the "License");
32+
* you may not use this file except in compliance with the License.
33+
* You may obtain a copy of the License at
34+
*
35+
* http://www.apache.org/licenses/LICENSE-2.0
36+
*
37+
* Unless required by applicable law or agreed to in writing, software
38+
* distributed under the License is distributed on an "AS IS" BASIS,
39+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
40+
* See the License for the specific language governing permissions and
41+
* limitations under the License.
42+
*/
43+
"""
44+
45+
private static let jsonSerializers: String = """
46+
fileprivate struct JSONSerializer<Message: Codable>: MessageSerializer {
47+
fileprivate func serialize<Bytes: GRPCContiguousBytes>(
48+
_ message: Message
49+
) throws -> Bytes {
50+
do {
51+
let jsonEncoder = JSONEncoder()
52+
let data = try jsonEncoder.encode(message)
53+
return Bytes(data)
54+
} catch {
55+
throw RPCError(
56+
code: .internalError,
57+
message: "Can't serialize message to JSON.",
58+
cause: error
59+
)
60+
}
61+
}
62+
}
63+
64+
fileprivate struct JSONDeserializer<Message: Codable>: MessageDeserializer {
65+
fileprivate func deserialize<Bytes: GRPCContiguousBytes>(
66+
_ serializedMessageBytes: Bytes
67+
) throws -> Message {
68+
do {
69+
let jsonDecoder = JSONDecoder()
70+
let data = serializedMessageBytes.withUnsafeBytes { Data($0) }
71+
return try jsonDecoder.decode(Message.self, from: data)
72+
} catch {
73+
throw RPCError(
74+
code: .internalError,
75+
message: "Can't deserialize message from JSON.",
76+
cause: error
77+
)
78+
}
79+
}
80+
}
81+
"""
82+
83+
func generate(request: JSONCodeGeneratorRequest) throws -> SourceFile {
84+
let generator = CodeGenerator(config: CodeGenerator.Config(request.config))
85+
86+
let codeGenRequest = CodeGenerationRequest(
87+
fileName: request.service.name + ".swift",
88+
leadingTrivia: Self.header,
89+
dependencies: [
90+
Dependency(
91+
item: Dependency.Item(kind: .struct, name: "Data"),
92+
module: "Foundation",
93+
accessLevel: .internal
94+
),
95+
Dependency(
96+
item: Dependency.Item(kind: .class, name: "JSONEncoder"),
97+
module: "Foundation",
98+
accessLevel: .internal
99+
),
100+
Dependency(
101+
item: Dependency.Item(kind: .class, name: "JSONDecoder"),
102+
module: "Foundation",
103+
accessLevel: .internal
104+
),
105+
],
106+
services: [ServiceDescriptor(request.service)],
107+
makeSerializerCodeSnippet: { type in "JSONSerializer<\(type)>()" },
108+
makeDeserializerCodeSnippet: { type in "JSONDeserializer<\(type)>()" }
109+
)
110+
111+
var sourceFile = try generator.generate(codeGenRequest)
112+
113+
// Insert a fileprivate serializer/deserializer for JSON at the bottom of each file.
114+
sourceFile.contents += "\n\n"
115+
sourceFile.contents += Self.jsonSerializers
116+
117+
return sourceFile
118+
}
119+
}

0 commit comments

Comments
 (0)