Parcourir la source

[CodeGenLib] Translator for enums containing type aliases and static properties (#1733)

Motivation:

In the generated code we want to have:

- enums for each namespace containing enums for all their services
- enums for each service (inside the corresponding namespace enum) containing
enums for all their methods, an array of all corresponding MethodDescriptors and type aliases for streaming and non-streaming protocols
- enums for each method (inside the corresponding service enum) containing
type aliases for the input and output types and a method descriptor (GRPCCore)

Modifications:

- Created the Translator protocol defining the 'translate()' function.
- Implemented the IDLToStructuredSwiftTranslator struct which conforms to the Translator protocol and creates the StructuredSwiftRepresentation for a CodeGenerationRequest calling the Specialized translators' functions.
- Created the SpecializedTranslator protocol.
- Implemented the TypeAliasTranslator, the first of the 3 SpecializedTranslators that takes in a CodeGenerationRequest object and creates all the enums, type aliases and properties mentioned in the Motivation, in StructuredSwiftRepresentation format.
- Created CodeGenError.
- Wrote SnippetTests.

Result:

The generated code will contain useful type aliases for message types and
protocol names, organized in namespace, service and method specific enums.
Stefana-Ioana Dranca il y a 2 ans
Parent
commit
e9273f1d54

+ 1 - 1
NOTICES.txt

@@ -62,7 +62,7 @@ This product uses derivations of swift-extras/swift-extras-base64 'Base64.swift'
 
 This product uses derivations of apple/swift-openapi-generator 'StructuredSwiftRepresentation.swift',
 'TypeName.swift', 'TypeUsage.swift', 'Builtins.swift', 'RendererProtocol.swift', 'TextBasedProtocol',
-and 'Test_TextBasedRenderer'.
+'Test_TextBasedRenderer', and 'SnippetBasedReferenceTests.swift'.
     
     * LICENSE (Apache License 2.0):
       * https://github.com/apple/swift-openapi-generator/blob/main/LICENSE.txt

+ 64 - 0
Sources/GRPCCodeGen/CodeGenError.swift

@@ -0,0 +1,64 @@
+/*
+ * Copyright 2023, 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.
+ */
+
+/// A error thrown by the ``SourceGenerator`` to signal errors in the ``CodeGenerationRequest`` object.
+public struct CodeGenError: Error, Hashable, Sendable {
+  /// The code indicating the domain of the error.
+  public var code: Code
+  /// A message providing more details about the error which may include details specific to this
+  /// instance of the error.
+  public var message: String
+
+  /// Creates a new error.
+  ///
+  /// - Parameters:
+  ///   - code: The error code.
+  ///   - message: A description of the error.
+  public init(code: Code, message: String) {
+    self.code = code
+    self.message = message
+  }
+}
+
+extension CodeGenError {
+  public struct Code: Hashable, Sendable {
+    private enum Value {
+      case nonUniqueServiceName
+      case nonUniqueMethodName
+    }
+
+    private var value: Value
+    private init(_ value: Value) {
+      self.value = value
+    }
+
+    /// The same name is used for two services that are either in the same namespace or don't have a namespace.
+    public static var nonUniqueServiceName: Self {
+      Self(.nonUniqueServiceName)
+    }
+
+    /// The same name is used for two methods of the same service.
+    public static var nonUniqueMethodName: Self {
+      Self(.nonUniqueMethodName)
+    }
+  }
+}
+
+extension CodeGenError: CustomStringConvertible {
+  public var description: String {
+    return "\(self.code): \"\(self.message)\""
+  }
+}

+ 4 - 4
Sources/GRPCCodeGen/CodeGenerationRequest.swift

@@ -36,7 +36,7 @@ public struct CodeGenerationRequest {
   public var services: [ServiceDescriptor]
 
   /// Closure that receives a message type as a `String` and returns a code snippet to
-  /// initialize a `MessageSerializer` for that type as a `String`.
+  /// initialise a `MessageSerializer` for that type as a `String`.
   ///
   /// The result is inserted in the generated code, where clients serialize RPC inputs and
   /// servers serialize RPC outputs.
@@ -217,7 +217,7 @@ public struct CodeGenerationRequest {
       public var inputType: String
 
       /// The generated output type for the described method.
-      public var ouputType: String
+      public var outputType: String
 
       public init(
         documentation: String,
@@ -225,14 +225,14 @@ public struct CodeGenerationRequest {
         isInputStreaming: Bool,
         isOutputStreaming: Bool,
         inputType: String,
-        ouputType: String
+        outputType: String
       ) {
         self.documentation = documentation
         self.name = name
         self.isInputStreaming = isInputStreaming
         self.isOutputStreaming = isOutputStreaming
         self.inputType = inputType
-        self.ouputType = ouputType
+        self.outputType = outputType
       }
     }
   }

+ 0 - 1
Sources/GRPCCodeGen/Internal/StructuredSwiftRepresentation.swift

@@ -66,7 +66,6 @@ struct ImportDescription: Equatable, Codable {
 ///
 /// For example: `public`.
 internal enum AccessModifier: String, Sendable, Equatable, Codable {
-
   /// A declaration accessible outside of the module.
   case `public`
 

+ 43 - 0
Sources/GRPCCodeGen/Internal/Translator/IDLToStructuredSwiftTranslator.swift

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023, 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.
+ */
+
+struct IDLToStructuredSwiftTranslator: Translator {
+  private let typealiasTranslator = TypealiasTranslator()
+
+  func translate(
+    codeGenerationRequest: CodeGenerationRequest,
+    client: Bool,
+    server: Bool
+  ) throws -> StructuredSwiftRepresentation {
+    let topComment = Comment.doc(codeGenerationRequest.leadingTrivia)
+    let imports: [ImportDescription] = [
+      ImportDescription(moduleName: "GRPCCore")
+    ]
+    var codeBlocks: [CodeBlock] = []
+    codeBlocks.append(
+      contentsOf: try self.typealiasTranslator.translate(from: codeGenerationRequest)
+    )
+
+    let fileDescription = FileDescription(
+      topComment: topComment,
+      imports: imports,
+      codeBlocks: codeBlocks
+    )
+    let fileName = String(codeGenerationRequest.fileName.split(separator: ".")[0])
+    let file = NamedFileDescription(name: fileName, contents: fileDescription)
+    return StructuredSwiftRepresentation(file: file)
+  }
+}

+ 29 - 0
Sources/GRPCCodeGen/Internal/Translator/SpecializedTranslator.swift

@@ -0,0 +1,29 @@
+/*
+ * Copyright 2023, 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.
+ */
+
+/// Represents one responsibility of the ``Translator``: either the type aliases translation,
+/// the server code translation or the client code translation.
+protocol SpecializedTranslator {
+  /// Generates an array of ``CodeBlock`` elements that will be part of the ``StructuredSwiftRepresentation`` object
+  /// created by the ``Translator``.
+  ///
+  /// - Parameters:
+  ///   - codeGenerationRequest: The ``CodeGenerationRequest`` object used to represent a Source IDL description of RPCs.
+  /// - Returns: An array of ``CodeBlock`` elements.
+  ///
+  /// - SeeAlso: ``CodeGenerationRequest``, ``Translator``,  ``CodeBlock``.
+  func translate(from codeGenerationRequest: CodeGenerationRequest) throws -> [CodeBlock]
+}

+ 33 - 0
Sources/GRPCCodeGen/Internal/Translator/Translator.swift

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023, 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.
+ */
+
+/// Transforms ``CodeGenerationRequest`` objects into ``StructuredSwiftRepresentation`` objects.
+///
+/// It represents the first step of the code generation process for IDL described RPCs.
+protocol Translator {
+  /// Translates the provided ``CodeGenerationRequest`` object, into Swift code representation.
+  /// - Parameters:
+  ///   - codeGenerationRequest: The IDL described RPCs representation.
+  ///   - client: Whether or not client code should be generated from the IDL described RPCs representation.
+  ///   - server: Whether or not server code should be generated from the IDL described RPCs representation.
+  /// - Returns: A structured Swift representation of the generated code.
+  /// - Throws: An error if there are issues translating the codeGenerationRequest.
+  func translate(
+    codeGenerationRequest: CodeGenerationRequest,
+    client: Bool,
+    server: Bool
+  ) throws -> StructuredSwiftRepresentation
+}

+ 310 - 0
Sources/GRPCCodeGen/Internal/Translator/TypealiasTranslator.swift

@@ -0,0 +1,310 @@
+/*
+ * Copyright 2023, 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.
+ */
+
+/// Creates enums containing useful type aliases and static properties for the methods, services and
+/// namespaces described in a ``CodeGenerationRequest`` object, using types from
+/// ``StructuredSwiftRepresentation``.
+///
+/// For example, in the case of the ``Echo`` service, the ``TypealiasTranslator`` will create
+/// a representation for the following generated code:
+/// ```swift
+/// public enum echo {
+///   public enum Echo {
+///     public enum Methods {
+///       public enum Get {
+///         public typealias Input = Echo_EchoRequest
+///         public typealias Output = Echo_EchoResponse
+///         public static let descriptor = MethodDescriptor(service: "echo.Echo", method: "Get")
+///       }
+///
+///       public enum Collect {
+///         public typealias Input = Echo_EchoRequest
+///         public typealias Output = Echo_EchoResponse
+///         public static let descriptor = MethodDescriptor(service: "echo.Echo", method: "Collect")
+///       }
+///       // ...
+///     }
+///     public static let methods: [MethodDescriptor] = [
+///       echo.Echo.Get.descriptor,
+///       echo.Echo.Collect.descriptor,
+///       // ...
+///     ]
+///
+///     public typealias StreamingServiceProtocol = echo_EchoServiceStreamingProtocol
+///     public typealias ServiceProtocol = echo_EchoServiceProtocol
+///
+///   }
+/// }
+/// ```
+///
+/// A ``CodeGenerationRequest`` can contain multiple namespaces, so the TypealiasTranslator will create a ``CodeBlock``
+/// for each namespace.
+struct TypealiasTranslator: SpecializedTranslator {
+  func translate(from codeGenerationRequest: CodeGenerationRequest) throws -> [CodeBlock] {
+    var codeBlocks: [CodeBlock] = []
+    let services = codeGenerationRequest.services
+    let servicesByNamespace = Dictionary(grouping: services, by: { $0.namespace })
+
+    // Verify service names are unique within each namespace and that services with no namespace
+    // don't have the same names as any of the namespaces.
+    try self.checkServiceNamesAreUnique(for: servicesByNamespace)
+
+    // Sorting the keys and the services in each list of the dictionary is necessary
+    // so that the generated enums are deterministically ordered.
+    for (namespace, services) in servicesByNamespace.sorted(by: { $0.key < $1.key }) {
+      let namespaceCodeBlocks = try self.makeNamespaceEnum(
+        for: namespace,
+        containing: services.sorted(by: { $0.name < $1.name })
+      )
+      codeBlocks.append(contentsOf: namespaceCodeBlocks)
+    }
+
+    return codeBlocks
+  }
+}
+
+extension TypealiasTranslator {
+  private func checkServiceNamesAreUnique(
+    for servicesByNamespace: [String: [CodeGenerationRequest.ServiceDescriptor]]
+  ) throws {
+    // Check that if there are services in an empty namespace, none have names which match other namespaces
+    let noNamespaceServices = servicesByNamespace["", default: []]
+    let namespaces = servicesByNamespace.keys
+    for service in noNamespaceServices {
+      if namespaces.contains(service.name) {
+        throw CodeGenError(
+          code: .nonUniqueServiceName,
+          message: """
+            Services with no namespace must not have the same names as the namespaces. \
+            \(service.name) is used as a name for a service with no namespace and a namespace.
+            """
+        )
+      }
+    }
+
+    // Check that service names are unique within each namespace.
+    for (namespace, services) in servicesByNamespace {
+      var serviceNames: Set<String> = []
+      for service in services {
+        if serviceNames.contains(service.name) {
+          let errorMessage: String
+          if namespace.isEmpty {
+            errorMessage = """
+              Services in an empty namespace must have unique names. \
+              \(service.name) is used as a name for multiple services without namespaces.
+              """
+          } else {
+            errorMessage = """
+              Services within the same namespace must have unique names. \
+              \(service.name) is used as a name for multiple services in the \(service.namespace) namespace.
+              """
+          }
+          throw CodeGenError(
+            code: .nonUniqueServiceName,
+            message: errorMessage
+          )
+        }
+        serviceNames.insert(service.name)
+      }
+    }
+  }
+
+  private func makeNamespaceEnum(
+    for namespace: String,
+    containing services: [CodeGenerationRequest.ServiceDescriptor]
+  ) throws -> [CodeBlock] {
+    var serviceDeclarations = [Declaration]()
+
+    // Create the service specific enums.
+    for service in services {
+      let serviceEnum = try self.makeServiceEnum(from: service)
+      serviceDeclarations.append(serviceEnum)
+    }
+
+    // If there is no namespace, the service enums are independent CodeBlocks.
+    // If there is a namespace, the associated enum will contain the service enums and will
+    // be represented as a single CodeBlock element.
+    if namespace.isEmpty {
+      return serviceDeclarations.map {
+        CodeBlock(item: .declaration($0))
+      }
+    } else {
+      var namespaceEnum = EnumDescription(name: namespace)
+      namespaceEnum.members = serviceDeclarations
+      return [CodeBlock(item: .declaration(.enum(namespaceEnum)))]
+    }
+  }
+
+  private func makeServiceEnum(
+    from service: CodeGenerationRequest.ServiceDescriptor
+  ) throws -> Declaration {
+    var serviceEnum = EnumDescription(name: service.name)
+    var methodsEnum = EnumDescription(name: "Methods")
+    let methods = service.methods
+
+    // Verify method names are unique for the service.
+    try self.checkMethodNamesAreUnique(in: service)
+
+    // Create the method specific enums.
+    for method in methods {
+      let methodEnum = self.makeMethodEnum(from: method, in: service)
+      methodsEnum.members.append(methodEnum)
+    }
+    serviceEnum.members.append(.enum(methodsEnum))
+
+    // Create the method descriptor array.
+    let methodDescriptorsDeclaration = self.makeMethodDescriptors(for: service)
+    serviceEnum.members.append(methodDescriptorsDeclaration)
+
+    // Create the streaming and non-streaming service protocol type aliases.
+    let serviceProtocols = self.makeServiceProtocolsTypealiases(for: service)
+    serviceEnum.members.append(contentsOf: serviceProtocols)
+
+    return .enum(serviceEnum)
+  }
+
+  private func checkMethodNamesAreUnique(
+    in service: CodeGenerationRequest.ServiceDescriptor
+  ) throws {
+    let methodNames = service.methods.map { $0.name }
+    var seenNames = Set<String>()
+
+    for methodName in methodNames {
+      if seenNames.contains(methodName) {
+        throw CodeGenError(
+          code: .nonUniqueMethodName,
+          message: """
+            Methods of a service must have unique names. \
+            \(methodName) is used as a name for multiple methods of the \(service.name) service.
+            """
+        )
+      }
+      seenNames.insert(methodName)
+    }
+  }
+
+  private func makeMethodEnum(
+    from method: CodeGenerationRequest.ServiceDescriptor.MethodDescriptor,
+    in service: CodeGenerationRequest.ServiceDescriptor
+  ) -> Declaration {
+    var methodEnum = EnumDescription(name: method.name)
+
+    let inputTypealias = Declaration.typealias(
+      name: "Input",
+      existingType: .member([method.inputType])
+    )
+    let outputTypealias = Declaration.typealias(
+      name: "Output",
+      existingType: .member([method.outputType])
+    )
+    let descriptorVariable = self.makeMethodDescriptor(
+      from: method,
+      in: service
+    )
+    methodEnum.members.append(inputTypealias)
+    methodEnum.members.append(outputTypealias)
+    methodEnum.members.append(descriptorVariable)
+
+    return .enum(methodEnum)
+  }
+
+  private func makeMethodDescriptor(
+    from method: CodeGenerationRequest.ServiceDescriptor.MethodDescriptor,
+    in service: CodeGenerationRequest.ServiceDescriptor
+  ) -> Declaration {
+    let descriptorDeclarationLeft = Expression.identifier(.pattern("descriptor"))
+
+    let fullyQualifiedServiceName: String
+    if service.namespace.isEmpty {
+      fullyQualifiedServiceName = service.name
+    } else {
+      fullyQualifiedServiceName = "\(service.namespace).\(service.name)"
+    }
+
+    let descriptorDeclarationRight = Expression.functionCall(
+      FunctionCallDescription(
+        calledExpression: .identifierType(.member(["MethodDescriptor"])),
+        arguments: [
+          FunctionArgumentDescription(
+            label: "service",
+            expression: .literal(fullyQualifiedServiceName)
+          ),
+          FunctionArgumentDescription(
+            label: "method",
+            expression: .literal(method.name)
+          ),
+        ]
+      )
+    )
+    return .variable(
+      isStatic: true,
+      kind: .let,
+      left: descriptorDeclarationLeft,
+      right: descriptorDeclarationRight
+    )
+  }
+
+  private func makeMethodDescriptors(
+    for service: CodeGenerationRequest.ServiceDescriptor
+  ) -> Declaration {
+    var methodDescriptors = [Expression]()
+    let methodNames = service.methods.map { $0.name }
+
+    for methodName in methodNames {
+      let methodDescriptorPath = Expression.memberAccess(
+        MemberAccessDescription(
+          left: .identifierType(.member(["Methods", methodName])),
+          right: "descriptor"
+        )
+      )
+      methodDescriptors.append(methodDescriptorPath)
+    }
+
+    return .variable(
+      isStatic: true,
+      kind: .let,
+      left: .identifier(.pattern("methods")),
+      type: .array(.member(["MethodDescriptor"])),
+      right: .literal(.array(methodDescriptors))
+    )
+  }
+
+  private func makeServiceProtocolsTypealiases(
+    for service: CodeGenerationRequest.ServiceDescriptor
+  ) -> [Declaration] {
+    let namespacedPrefix: String
+
+    if service.namespace.isEmpty {
+      namespacedPrefix = service.name
+    } else {
+      namespacedPrefix = "\(service.namespace)_\(service.name)"
+    }
+
+    let streamingServiceProtocolName = "\(namespacedPrefix)ServiceStreamingProtocol"
+    let streamingServiceProtocolTypealias = Declaration.typealias(
+      name: "StreamingServiceProtocol",
+      existingType: .member([streamingServiceProtocolName])
+    )
+
+    let serviceProtocolName = "\(namespacedPrefix)ServiceProtocol"
+    let serviceProtocolTypealias = Declaration.typealias(
+      name: "ServiceProtocol",
+      existingType: .member([serviceProtocolName])
+    )
+
+    return [streamingServiceProtocolTypealias, serviceProtocolTypealias]
+  }
+}

+ 519 - 0
Tests/GRPCCodeGenTests/Internal/Translator/SnippetBasedTranslatorTests.swift

@@ -0,0 +1,519 @@
+/*
+ * Copyright 2023, 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 XCTest
+
+@testable import GRPCCodeGen
+
+final class SnippetBasedTranslatorTests: XCTestCase {
+  typealias MethodDescriptor = GRPCCodeGen.CodeGenerationRequest.ServiceDescriptor.MethodDescriptor
+  typealias ServiceDescriptor = GRPCCodeGen.CodeGenerationRequest.ServiceDescriptor
+
+  func testTypealiasTranslator() throws {
+    let method = MethodDescriptor(
+      documentation: "Documentation for MethodA",
+      name: "MethodA",
+      isInputStreaming: false,
+      isOutputStreaming: false,
+      inputType: "NamespaceA_ServiceARequest",
+      outputType: "NamespaceA_ServiceAResponse"
+    )
+    let service = ServiceDescriptor(
+      documentation: "Documentation for ServiceA",
+      name: "ServiceA",
+      namespace: "namespaceA",
+      methods: [method]
+    )
+    let expectedSwift =
+      """
+      enum namespaceA {
+          enum ServiceA {
+              enum Methods {
+                  enum MethodA {
+                      typealias Input = NamespaceA_ServiceARequest
+                      typealias Output = NamespaceA_ServiceAResponse
+                      static let descriptor = MethodDescriptor(
+                          service: "namespaceA.ServiceA",
+                          method: "MethodA"
+                      )
+                  }
+              }
+              static let methods: [MethodDescriptor] = [
+                  Methods.MethodA.descriptor
+              ]
+              typealias StreamingServiceProtocol = namespaceA_ServiceAServiceStreamingProtocol
+              typealias ServiceProtocol = namespaceA_ServiceAServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [service]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorEmptyNamespace() throws {
+    let method = MethodDescriptor(
+      documentation: "Documentation for MethodA",
+      name: "MethodA",
+      isInputStreaming: false,
+      isOutputStreaming: false,
+      inputType: "ServiceARequest",
+      outputType: "ServiceAResponse"
+    )
+    let service = ServiceDescriptor(
+      documentation: "Documentation for ServiceA",
+      name: "ServiceA",
+      namespace: "",
+      methods: [method]
+    )
+    let expectedSwift =
+      """
+      enum ServiceA {
+          enum Methods {
+              enum MethodA {
+                  typealias Input = ServiceARequest
+                  typealias Output = ServiceAResponse
+                  static let descriptor = MethodDescriptor(
+                      service: "ServiceA",
+                      method: "MethodA"
+                  )
+              }
+          }
+          static let methods: [MethodDescriptor] = [
+              Methods.MethodA.descriptor
+          ]
+          typealias StreamingServiceProtocol = ServiceAServiceStreamingProtocol
+          typealias ServiceProtocol = ServiceAServiceProtocol
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [service]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorCheckMethodsOrder() throws {
+    let methodA = MethodDescriptor(
+      documentation: "Documentation for MethodA",
+      name: "MethodA",
+      isInputStreaming: false,
+      isOutputStreaming: false,
+      inputType: "NamespaceA_ServiceARequest",
+      outputType: "NamespaceA_ServiceAResponse"
+    )
+    let methodB = MethodDescriptor(
+      documentation: "Documentation for MethodB",
+      name: "MethodB",
+      isInputStreaming: false,
+      isOutputStreaming: false,
+      inputType: "NamespaceA_ServiceARequest",
+      outputType: "NamespaceA_ServiceAResponse"
+    )
+    let service = ServiceDescriptor(
+      documentation: "Documentation for ServiceA",
+      name: "ServiceA",
+      namespace: "namespaceA",
+      methods: [methodA, methodB]
+    )
+    let expectedSwift =
+      """
+      enum namespaceA {
+          enum ServiceA {
+              enum Methods {
+                  enum MethodA {
+                      typealias Input = NamespaceA_ServiceARequest
+                      typealias Output = NamespaceA_ServiceAResponse
+                      static let descriptor = MethodDescriptor(
+                          service: "namespaceA.ServiceA",
+                          method: "MethodA"
+                      )
+                  }
+                  enum MethodB {
+                      typealias Input = NamespaceA_ServiceARequest
+                      typealias Output = NamespaceA_ServiceAResponse
+                      static let descriptor = MethodDescriptor(
+                          service: "namespaceA.ServiceA",
+                          method: "MethodB"
+                      )
+                  }
+              }
+              static let methods: [MethodDescriptor] = [
+                  Methods.MethodA.descriptor,
+                  Methods.MethodB.descriptor
+              ]
+              typealias StreamingServiceProtocol = namespaceA_ServiceAServiceStreamingProtocol
+              typealias ServiceProtocol = namespaceA_ServiceAServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [service]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorNoMethodsService() throws {
+    let service = ServiceDescriptor(
+      documentation: "Documentation for ServiceA",
+      name: "ServiceA",
+      namespace: "namespaceA",
+      methods: []
+    )
+    let expectedSwift =
+      """
+      enum namespaceA {
+          enum ServiceA {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = namespaceA_ServiceAServiceStreamingProtocol
+              typealias ServiceProtocol = namespaceA_ServiceAServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [service]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorServiceAlphabeticalOrder() throws {
+    let serviceB = ServiceDescriptor(
+      documentation: "Documentation for BService",
+      name: "BService",
+      namespace: "namespacea",
+      methods: []
+    )
+
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "namespacea",
+      methods: []
+    )
+
+    let expectedSwift =
+      """
+      enum namespacea {
+          enum AService {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = namespacea_AServiceServiceStreamingProtocol
+              typealias ServiceProtocol = namespacea_AServiceServiceProtocol
+          }
+          enum BService {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = namespacea_BServiceServiceStreamingProtocol
+              typealias ServiceProtocol = namespacea_BServiceServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [serviceB, serviceA]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorServiceAlphabeticalOrderNoNamespace() throws {
+    let serviceB = ServiceDescriptor(
+      documentation: "Documentation for BService",
+      name: "BService",
+      namespace: "",
+      methods: []
+    )
+
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "",
+      methods: []
+    )
+
+    let expectedSwift =
+      """
+      enum AService {
+          enum Methods {}
+          static let methods: [MethodDescriptor] = []
+          typealias StreamingServiceProtocol = AServiceServiceStreamingProtocol
+          typealias ServiceProtocol = AServiceServiceProtocol
+      }
+      enum BService {
+          enum Methods {}
+          static let methods: [MethodDescriptor] = []
+          typealias StreamingServiceProtocol = BServiceServiceStreamingProtocol
+          typealias ServiceProtocol = BServiceServiceProtocol
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [serviceB, serviceA]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorNamespaceAlphabeticalOrder() throws {
+    let serviceB = ServiceDescriptor(
+      documentation: "Documentation for BService",
+      name: "BService",
+      namespace: "bnamespace",
+      methods: []
+    )
+
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "anamespace",
+      methods: []
+    )
+
+    let expectedSwift =
+      """
+      enum anamespace {
+          enum AService {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = anamespace_AServiceServiceStreamingProtocol
+              typealias ServiceProtocol = anamespace_AServiceServiceProtocol
+          }
+      }
+      enum bnamespace {
+          enum BService {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = bnamespace_BServiceServiceStreamingProtocol
+              typealias ServiceProtocol = bnamespace_BServiceServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [serviceB, serviceA]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorNamespaceNoNamespaceOrder() throws {
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "anamespace",
+      methods: []
+    )
+    let serviceB = ServiceDescriptor(
+      documentation: "Documentation for BService",
+      name: "BService",
+      namespace: "",
+      methods: []
+    )
+    let expectedSwift =
+      """
+      enum BService {
+          enum Methods {}
+          static let methods: [MethodDescriptor] = []
+          typealias StreamingServiceProtocol = BServiceServiceStreamingProtocol
+          typealias ServiceProtocol = BServiceServiceProtocol
+      }
+      enum anamespace {
+          enum AService {
+              enum Methods {}
+              static let methods: [MethodDescriptor] = []
+              typealias StreamingServiceProtocol = anamespace_AServiceServiceStreamingProtocol
+              typealias ServiceProtocol = anamespace_AServiceServiceProtocol
+          }
+      }
+      """
+
+    try self.assertTypealiasTranslation(
+      codeGenerationRequest: self.makeCodeGenerationRequest(services: [serviceA, serviceB]),
+      expectedSwift: expectedSwift
+    )
+  }
+
+  func testTypealiasTranslatorSameNameServicesNoNamespaceError() throws {
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "",
+      methods: []
+    )
+
+    let codeGenerationRequest = self.makeCodeGenerationRequest(services: [serviceA, serviceA])
+    let translator = TypealiasTranslator()
+    self.assertThrowsError(
+      ofType: CodeGenError.self,
+      try translator.translate(from: codeGenerationRequest)
+    ) {
+      error in
+      XCTAssertEqual(
+        error as CodeGenError,
+        CodeGenError(
+          code: .nonUniqueServiceName,
+          message: """
+            Services in an empty namespace must have unique names. \
+            AService is used as a name for multiple services without namespaces.
+            """
+        )
+      )
+    }
+  }
+
+  func testTypealiasTranslatorSameNameServicesSameNamespaceError() throws {
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "namespacea",
+      methods: []
+    )
+
+    let codeGenerationRequest = self.makeCodeGenerationRequest(services: [serviceA, serviceA])
+    let translator = TypealiasTranslator()
+    self.assertThrowsError(
+      ofType: CodeGenError.self,
+      try translator.translate(from: codeGenerationRequest)
+    ) {
+      error in
+      XCTAssertEqual(
+        error as CodeGenError,
+        CodeGenError(
+          code: .nonUniqueServiceName,
+          message: """
+            Services within the same namespace must have unique names. \
+            AService is used as a name for multiple services in the namespacea namespace.
+            """
+        )
+      )
+    }
+  }
+
+  func testTypealiasTranslatorSameNameMethodsSameServiceError() throws {
+    let methodA = MethodDescriptor(
+      documentation: "Documentation for MethodA",
+      name: "MethodA",
+      isInputStreaming: false,
+      isOutputStreaming: false,
+      inputType: "NamespaceA_ServiceARequest",
+      outputType: "NamespaceA_ServiceAResponse"
+    )
+    let service = ServiceDescriptor(
+      documentation: "Documentation for AService",
+      name: "AService",
+      namespace: "namespacea",
+      methods: [methodA, methodA]
+    )
+
+    let codeGenerationRequest = self.makeCodeGenerationRequest(services: [service])
+    let translator = TypealiasTranslator()
+    self.assertThrowsError(
+      ofType: CodeGenError.self,
+      try translator.translate(from: codeGenerationRequest)
+    ) {
+      error in
+      XCTAssertEqual(
+        error as CodeGenError,
+        CodeGenError(
+          code: .nonUniqueMethodName,
+          message: """
+            Methods of a service must have unique names. \
+            MethodA is used as a name for multiple methods of the AService service.
+            """
+        )
+      )
+    }
+  }
+
+  func testTypealiasTranslatorSameNameNoNamespaceServiceAndNamespaceError() throws {
+    let serviceA = ServiceDescriptor(
+      documentation: "Documentation for SameName service with no namespace",
+      name: "SameName",
+      namespace: "",
+      methods: []
+    )
+    let serviceB = ServiceDescriptor(
+      documentation: "Documentation for BService",
+      name: "BService",
+      namespace: "SameName",
+      methods: []
+    )
+    let codeGenerationRequest = self.makeCodeGenerationRequest(services: [serviceA, serviceB])
+    let translator = TypealiasTranslator()
+    self.assertThrowsError(
+      ofType: CodeGenError.self,
+      try translator.translate(from: codeGenerationRequest)
+    ) {
+      error in
+      XCTAssertEqual(
+        error as CodeGenError,
+        CodeGenError(
+          code: .nonUniqueServiceName,
+          message: """
+            Services with no namespace must not have the same names as the namespaces. \
+            SameName is used as a name for a service with no namespace and a namespace.
+            """
+        )
+      )
+    }
+  }
+}
+
+extension SnippetBasedTranslatorTests {
+  private func assertTypealiasTranslation(
+    codeGenerationRequest: CodeGenerationRequest,
+    expectedSwift: String
+  ) throws {
+    let translator = TypealiasTranslator()
+    let codeBlocks = try translator.translate(from: codeGenerationRequest)
+    let renderer = TextBasedRenderer.default
+    renderer.renderCodeBlocks(codeBlocks)
+    let contents = renderer.renderedContents()
+    try XCTAssertEqualWithDiff(contents, expectedSwift)
+  }
+
+  private func assertThrowsError<T, E: Error>(
+    ofType: E.Type,
+    _ expression: @autoclosure () throws -> T,
+    _ errorHandler: (E) -> Void
+  ) {
+    XCTAssertThrowsError(try expression()) { error in
+      guard let error = error as? E else {
+        return XCTFail("Error had unexpected type '\(type(of: error))'")
+      }
+      errorHandler(error)
+    }
+  }
+}
+
+extension SnippetBasedTranslatorTests {
+  private func makeCodeGenerationRequest(services: [ServiceDescriptor]) -> CodeGenerationRequest {
+    return CodeGenerationRequest(
+      fileName: "test.grpc",
+      leadingTrivia: "Some really exciting license header 2023.",
+      dependencies: [],
+      services: services,
+      lookupSerializer: {
+        "ProtobufSerializer<\($0)>()"
+      },
+      lookupDeserializer: {
+        "ProtobufDeserializer<\($0)>()"
+      }
+    )
+  }
+}

+ 67 - 0
Tests/GRPCCodeGenTests/Internal/Translator/TestFunctions.swift

@@ -0,0 +1,67 @@
+/*
+ * Copyright 2023, 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.
+ */
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the SwiftOpenAPIGenerator open source project
+//
+// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+import XCTest
+
+private func diff(expected: String, actual: String) throws -> String {
+  let process = Process()
+  process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+  process.arguments = [
+    "bash", "-c",
+    "diff -U5 --label=expected <(echo '\(expected)') --label=actual <(echo '\(actual)')",
+  ]
+  let pipe = Pipe()
+  process.standardOutput = pipe
+  try process.run()
+  process.waitUntilExit()
+  let pipeData = try XCTUnwrap(
+    pipe.fileHandleForReading.readToEnd(),
+    """
+    No output from command:
+    \(process.executableURL!.path) \(process.arguments!.joined(separator: " "))
+    """
+  )
+  return String(decoding: pipeData, as: UTF8.self)
+}
+
+internal func XCTAssertEqualWithDiff(
+  _ actual: String,
+  _ expected: String,
+  file: StaticString = #filePath,
+  line: UInt = #line
+) throws {
+  if actual == expected { return }
+  XCTFail(
+    """
+    XCTAssertEqualWithDiff failed (click for diff)
+    \(try diff(expected: expected, actual: actual))
+    """,
+    file: file,
+    line: line
+  )
+}