diff --git a/dev/format.sh b/dev/format.sh index 7e4e46519..1201d6861 100755 --- a/dev/format.sh +++ b/dev/format.sh @@ -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 @@ -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 diff --git a/dev/grpc-dev-tool/.gitignore b/dev/grpc-dev-tool/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/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 diff --git a/dev/grpc-dev-tool/Package.swift b/dev/grpc-dev-tool/Package.swift new file mode 100644 index 000000000..8f8d3f892 --- /dev/null +++ b/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"), + ] + ) + ] +) diff --git a/dev/grpc-dev-tool/Sources/grpc-dev-tool/GRPCDevTool.swift b/dev/grpc-dev-tool/Sources/grpc-dev-tool/GRPCDevTool.swift new file mode 100644 index 000000000..5cf08ea27 --- /dev/null +++ b/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] + ) +} diff --git a/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCCodeGen+Conversions.swift b/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCCodeGen+Conversions.swift new file mode 100644 index 000000000..1888aa866 --- /dev/null +++ b/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 + ) + } +} diff --git a/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCDevUtils+GenerateJSON.swift b/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/GRPCDevUtils+GenerateJSON.swift new file mode 100644 index 000000000..d7821d0ca --- /dev/null +++ b/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) + } + } +} diff --git a/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGenerator.swift b/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGenerator.swift new file mode 100644 index 000000000..3c827b93d --- /dev/null +++ b/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: MessageSerializer { + fileprivate func serialize( + _ 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: MessageDeserializer { + fileprivate func deserialize( + _ 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 + } +} diff --git a/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGeneratorRequest.swift b/dev/grpc-dev-tool/Sources/grpc-dev-tool/Subcommands/GenerateJSON/JSONCodeGeneratorRequest.swift new file mode 100644 index 000000000..5f26c4050 --- /dev/null +++ b/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 + } +}