diff --git a/.agents/languages/swift.md b/.agents/languages/swift.md index 3f9f81cd76..1df5b89d3d 100644 --- a/.agents/languages/swift.md +++ b/.agents/languages/swift.md @@ -13,6 +13,7 @@ Load this file when changing `swift/` or Swift xlang behavior. - Swift formatting uses `swift/.swift-format`; do not rely on SwiftLint for indentation or source formatting. - Use `ENABLE_FORY_DEBUG_OUTPUT=1` when debugging Swift tests. +- Generated Swift gRPC companions are compiler-owned files targeting grpc-swift 1.x. Keep grpc-swift out of the `swift/` runtime package; it belongs only to generated user code and the compiler build fixture. - Prefer the user-requested or existing Foundation public value type when it is the intended Swift surface; do not invent Fory-prefixed wrappers only to avoid import ambiguity. - Preserve distinct temporal semantics. Timestamp values and day-only local dates should have protocol-accurate helper names and no stale aliases after a refactor. - When temporal or public-type refactors touch generated Swift code, sweep message fields, union payloads, macros, xlang harnesses, and integration fixtures together. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7a407b43a..a6320d2bef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,24 @@ jobs: cd compiler pytest -q fory_compiler/tests + compiler_import: + name: Compiler Import ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9"] + steps: + - uses: actions/checkout@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + - name: Import the compiler CLI + run: | + cd compiler + python -c "import fory_compiler.cli" + java: name: Java CI needs: changes @@ -1056,6 +1074,45 @@ jobs: cd integration_tests/grpc_tests/java mvn -T16 --no-transfer-progress -Dtest=DartGrpcTest test + grpc_java_swift_tests: + name: Java/Swift gRPC Tests + needs: changes + if: needs.changes.outputs.grpc_tests == 'true' || needs.changes.outputs.swift == 'true' + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: "temurin" + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 + cache: "pip" + - name: Cache Maven local repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Install Java artifacts for gRPC tests + run: | + cd java + mvn -T16 --no-transfer-progress clean install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true + - name: Generate gRPC test sources + run: python integration_tests/grpc_tests/generate_grpc.py + - name: Run Swift gRPC package tests + run: | + cd integration_tests/grpc_tests/swift/interop + swift test + - name: Run Java/Swift gRPC Tests + run: | + cd integration_tests/grpc_tests/java + mvn -T16 --no-transfer-progress -Dtest=SwiftGrpcTest test + javascript: name: JavaScript CI needs: changes diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py index 561910a58f..2556c71a62 100644 --- a/compiler/fory_compiler/cli.py +++ b/compiler/fory_compiler/cli.py @@ -26,11 +26,8 @@ from fory_compiler.frontend.base import FrontendError from fory_compiler.frontend.utils import parse_idl_file, resolve_import_path -from fory_compiler.ir.ast import Schema -from fory_compiler.ir.emitter import FDLEmitter -from fory_compiler.ir.validator import SchemaValidator -from fory_compiler.generators.base import GeneratorOptions from fory_compiler.generators import GENERATORS +from fory_compiler.generators.base import GeneratorOptions from fory_compiler.generators.csharp import validate_csharp_generation from fory_compiler.generators.kotlin import ( kotlin_output_paths, @@ -40,13 +37,15 @@ scala_output_paths, scala_package_for_schema, ) +from fory_compiler.generators.swift import validate_swift_generation +from fory_compiler.ir.ast import Schema +from fory_compiler.ir.emitter import FDLEmitter +from fory_compiler.ir.validator import SchemaValidator class ImportError(Exception): """Error during import resolution.""" - pass - GENERATED_MARKER = "This file is generated by Apache Fory compiler." SKIP_DIR_NAMES = {"build", "target"} @@ -342,6 +341,27 @@ def validate_csharp_files( return False +def validate_swift_files( + files: List[Path], + import_paths: List[Path], + namespace_style: Optional[str] = None, + grpc: bool = False, +) -> bool: + """Preflight Swift output paths and top-level symbols before writing output.""" + cache: Dict[Path, Schema] = {} + graph: List[Tuple[Path, Schema]] = [] + for file_path in files: + file_graph = collect_schema_graph(file_path, import_paths, cache, set()) + if file_graph is None: + return False + graph.extend(file_graph) + try: + return validate_swift_generation(graph, namespace_style, grpc=grpc) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return False + + def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: """Check package combinations that Scala source cannot compile.""" packages = {scala_package_for_schema(schema) for _, schema in graph} @@ -1023,7 +1043,7 @@ def cmd_compile(args: argparse.Namespace) -> int: return 1 # Validate that all languages are supported - invalid = [lang for lang in lang_output_dirs.keys() if lang not in GENERATORS] + invalid = [lang for lang in lang_output_dirs if lang not in GENERATORS] if invalid: print(f"Error: Unknown language(s): {', '.join(invalid)}", file=sys.stderr) print(f"Available: {', '.join(GENERATORS.keys())}", file=sys.stderr) @@ -1044,15 +1064,22 @@ def cmd_compile(args: argparse.Namespace) -> int: ) import_paths.append(resolved) - if "kotlin" in lang_output_dirs: - if not validate_kotlin_generation(args.files, import_paths, grpc=args.grpc): - return 1 - if "csharp" in lang_output_dirs: - if not validate_csharp_files(args.files, import_paths, grpc=args.grpc): - return 1 - if "scala" in lang_output_dirs: - if not validate_scala_generation(args.files, import_paths, grpc=args.grpc): - return 1 + if "kotlin" in lang_output_dirs and not validate_kotlin_generation( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "csharp" in lang_output_dirs and not validate_csharp_files( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "scala" in lang_output_dirs and not validate_scala_generation( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "swift" in lang_output_dirs and not validate_swift_files( + args.files, import_paths, args.swift_namespace_style, grpc=args.grpc + ): + return 1 if args.grpc_web and "javascript" not in lang_output_dirs: print( diff --git a/compiler/fory_compiler/frontend/fbs/lexer.py b/compiler/fory_compiler/frontend/fbs/lexer.py index a392ab983b..dd5eeef80f 100644 --- a/compiler/fory_compiler/frontend/fbs/lexer.py +++ b/compiler/fory_compiler/frontend/fbs/lexer.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from enum import Enum, auto -from typing import List +from typing import List, Tuple class TokenType(Enum): @@ -217,7 +217,7 @@ def read_string(self) -> str: self.advance() # closing quote return value - def read_number(self) -> tuple[TokenType, str]: + def read_number(self) -> Tuple[TokenType, str]: value = "" if self.peek() == "-": value += self.advance() diff --git a/compiler/fory_compiler/frontend/fdl/parser.py b/compiler/fory_compiler/frontend/fdl/parser.py index 90771dcf98..31216ff77e 100644 --- a/compiler/fory_compiler/frontend/fdl/parser.py +++ b/compiler/fory_compiler/frontend/fdl/parser.py @@ -18,7 +18,7 @@ """Recursive descent parser for FDL.""" import warnings -from typing import List, Set, Optional +from typing import List, Set, Optional, Tuple from fory_compiler.ir.ast import ( Schema, @@ -258,7 +258,7 @@ def make_location(self, token: Token) -> SourceLocation: source_format=self.source_format, ) - def parse_package(self) -> tuple[str, Optional[str]]: + def parse_package(self) -> Tuple[str, Optional[str]]: """Parse a package declaration: package foo.bar [alias baz];""" self.consume(TokenType.PACKAGE) diff --git a/compiler/fory_compiler/frontend/proto/parser.py b/compiler/fory_compiler/frontend/proto/parser.py index a5c69b4079..8b853d0f14 100644 --- a/compiler/fory_compiler/frontend/proto/parser.py +++ b/compiler/fory_compiler/frontend/proto/parser.py @@ -17,7 +17,7 @@ """Recursive descent parser for proto3.""" -from typing import List +from typing import List, Tuple from fory_compiler.frontend.proto.ast import ( ProtoSchema, @@ -162,7 +162,7 @@ def parse_import(self) -> str: self.consume(TokenType.SEMI, "Expected ';' after import") return path - def parse_option_statement(self) -> tuple[str, object]: + def parse_option_statement(self) -> Tuple[str, object]: self.consume(TokenType.OPTION, "Expected 'option'") name = self.parse_option_name() self.consume(TokenType.EQUALS, "Expected '=' after option name") diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py new file mode 100644 index 0000000000..76567997bf --- /dev/null +++ b/compiler/fory_compiler/generators/services/swift.py @@ -0,0 +1,637 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Swift gRPC service companion generator (grpc-swift v1).""" + +from typing import Dict, List, Set + +from fory_compiler.generators.base import GeneratedFile +from fory_compiler.generators.services.base import StreamingMode, streaming_mode +from fory_compiler.ir.ast import RpcMethod, Service + +# Availability gate matching grpc-swift's async/await APIs. +_ASYNC_AVAILABLE = "@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)" + +# Members the generated provider and client expose through their protocols and +# base types; an rpc whose Swift name matches one would override or clash with it. +_SWIFT_GRPC_RESERVED_MEMBERS = { + "handle", + "serviceName", + "channel", + "defaultCallOptions", +} + + +class SwiftServiceMixin: + """Generates Swift gRPC service companions backed by Fory serialization.""" + + def generate_services(self) -> List[GeneratedFile]: + services = [s for s in self.schema.services if not self.is_imported_type(s)] + if not services: + return [] + self._check_swift_grpc_method_names(services) + return [self._generate_swift_service(service) for service in services] + + def _grpc_prefix(self) -> str: + return "_".join(self._package_components_for_schema(self.schema)) + + def _service_symbol(self, service: Service) -> str: + name = self.to_pascal_case(service.name) + prefix = self._grpc_prefix() + return f"{prefix}_{name}" if prefix else name + + def swift_grpc_output_path(self, service: Service) -> str: + package = self.schema.package + package_path = package.replace(".", "/") if package else "" + file_name = f"{self.to_pascal_case(service.name)}Grpc.swift" + return f"{package_path}/{file_name}" if package_path else file_name + + def swift_grpc_service_symbols(self, service: Service) -> List[str]: + base = self._service_symbol(service) + modes = {streaming_mode(m) for m in service.methods} + symbols = [ + f"{base}Metadata", + f"{base}Provider", + f"{base}AsyncProvider", + f"{base}AsyncClient", + ] + if service.methods: + symbols.append(f"{base}Message") + if modes & {StreamingMode.SERVER_STREAMING, StreamingMode.BIDIRECTIONAL}: + symbols += [ + f"{base}StreamingResponseContext", + f"{base}AsyncResponseStream", + f"{base}ResponseStream", + ] + if StreamingMode.CLIENT_STREAMING in modes: + symbols.append(f"{base}UnaryResponseContext") + if modes & {StreamingMode.CLIENT_STREAMING, StreamingMode.BIDIRECTIONAL}: + symbols.append(f"{base}AsyncRequestStream") + return symbols + + def _swift_grpc_method_name(self, method: RpcMethod) -> str: + return self.safe_member_name(method.name) + + def _request_type(self, method: RpcMethod) -> str: + return self._named_type_reference(method.request_type) + + def _response_type(self, method: RpcMethod) -> str: + return self._named_type_reference(method.response_type) + + def _check_swift_grpc_method_names(self, services: List[Service]) -> None: + for service in services: + seen: Dict[str, str] = {} + for method in service.methods: + swift_name = self._swift_grpc_method_name(method).strip("`") + if swift_name == "_": + raise ValueError( + f"Swift gRPC method {service.name}.{method.name} generates " + "_, which Swift cannot use as a member name; rename the rpc" + ) + if swift_name in _SWIFT_GRPC_RESERVED_MEMBERS: + raise ValueError( + f"Swift gRPC method {service.name}.{method.name} generates " + f"{swift_name}, which collides with a generated provider or " + "client member; rename the rpc" + ) + if swift_name in seen: + raise ValueError( + f"Swift gRPC method name collision in service {service.name}: " + f"{seen[swift_name]} and {method.name} both generate {swift_name}" + ) + seen[swift_name] = method.name + + def _generate_swift_service(self, service: Service) -> GeneratedFile: + base = self._service_symbol(service) + module = self.module_type_path() + methods = service.methods + modes = {streaming_mode(m) for m in methods} + + lines: List[str] = [] + lines.append(self.get_license_header("//")) + lines.append("") + # gRPC symbols are package-prefixed with underscores, matching grpc-swift. + lines.append("// swiftlint:disable type_name") + lines.append("") + lines.append("import Foundation") + lines.append("import GRPC") + lines.append("import NIOCore") + lines.append("import Fory") + lines.append("") + + if methods: + lines.extend(self._marshaller(base, module)) + lines.append("") + lines.extend(self._metadata(base, service)) + lines.append("") + lines.extend(self._adapters(base, modes)) + lines.extend(self._provider(base, service)) + lines.append("") + lines.extend(self._async_provider(base, service)) + lines.append("") + lines.extend(self._async_client(base, service)) + lines.append("") + lines.append("// swiftlint:enable type_name") + + content = "\n".join(lines).rstrip() + "\n" + package_path = ( + self.schema.package.replace(".", "/") if self.schema.package else "" + ) + file_name = f"{self.to_pascal_case(service.name)}Grpc.swift" + path = f"{package_path}/{file_name}" if package_path else file_name + return GeneratedFile(path=path, content=content) + + def _marshaller(self, base: str, module: str) -> List[str]: + # The Swift Fory instance is single-threaded, so keep one per thread. + return [ + "private enum ForyRuntime {", + f' private static let key = "org.apache.fory.grpc." ' + f"+ String(reflecting: {module}.self)", + " static func fory() throws -> Fory {", + " let storage = Thread.current.threadDictionary", + " if let existing = storage[key] as? Fory { return existing }", + f" let local = Fory(config: {module}.getFory().config)", + f" try {module}.install(local)", + " storage[key] = local", + " return local", + " }", + "}", + "", + "// Internal Fory wire wrapper for gRPC request and response messages.", + "// NIOCore.ByteBuffer is qualified because `import Fory` also exposes one.", + "// grpc-swift transfers this carrier between the calling task and the event", + "// loop, so the payload must be Sendable. The carrier itself only stores that", + "// payload, so @unchecked covers the wrapper while Value carries the guarantee.", + ( + f"struct {base}Message: GRPCPayload," + " @unchecked Sendable where Value.Target == Value {" + ), + " let value: Value", + " init(_ value: Value) { self.value = value }", + " init(serializedByteBuffer buffer: inout NIOCore.ByteBuffer) throws {", + " let bytes = buffer.readBytes(length: buffer.readableBytes) ?? []", + " self.value = try ForyRuntime.fory().deserialize(Data(bytes))", + " }", + " func serialize(into buffer: inout NIOCore.ByteBuffer) throws {", + " buffer.writeBytes(try ForyRuntime.fory().serialize(value))", + " }", + "}", + ] + + def _metadata(self, base: str, service: Service) -> List[str]: + full_name = self.get_grpc_service_name(service) + lines = [f"enum {base}Metadata {{"] + lines.append(" enum Methods {") + for method in service.methods: + name = self._swift_grpc_method_name(method) + lines.append(f" static let {name} = GRPCMethodDescriptor(") + lines.append(f' name: "{method.name}",') + lines.append( + f' path: "{self.get_grpc_method_path(service, method)}",' + ) + lines.append(f" type: {self._call_type(method)})") + lines.append(" }") + lines.append(" static let serviceDescriptor = GRPCServiceDescriptor(") + lines.append(f' name: "{service.name}",') + lines.append(f' fullName: "{full_name}",') + if service.methods: + lines.append(" methods: [") + for index, method in enumerate(service.methods): + comma = "," if index < len(service.methods) - 1 else "" + lines.append( + f" Methods.{self._swift_grpc_method_name(method)}{comma}" + ) + lines.append(" ])") + else: + lines.append(" methods: [])") + lines.append("}") + return lines + + def _call_type(self, method: RpcMethod) -> str: + return { + StreamingMode.UNARY: ".unary", + StreamingMode.SERVER_STREAMING: ".serverStreaming", + StreamingMode.CLIENT_STREAMING: ".clientStreaming", + StreamingMode.BIDIRECTIONAL: ".bidirectionalStreaming", + }[streaming_mode(method)] + + def _adapters(self, base: str, modes: Set[StreamingMode]) -> List[str]: + streamed_response = bool( + modes & {StreamingMode.SERVER_STREAMING, StreamingMode.BIDIRECTIONAL} + ) + streamed_request = bool( + modes & {StreamingMode.CLIENT_STREAMING, StreamingMode.BIDIRECTIONAL} + ) + lines: List[str] = [] + if streamed_response: + lines += self._streaming_response_context(base) + lines.append("") + lines += self._async_response_stream(base) + lines.append("") + lines += self._client_response_stream(base) + lines.append("") + if StreamingMode.CLIENT_STREAMING in modes: + lines += self._unary_response_context(base) + lines.append("") + if streamed_request: + lines += self._async_request_stream(base) + lines.append("") + return lines + + def _streaming_response_context(self, base: str) -> List[str]: + return [ + ( + f"public struct {base}StreamingResponseContext" + " where Response.Target == Response {" + ), + f" fileprivate let base: StreamingResponseCallContext<{base}Message>", + " public var eventLoop: EventLoop { base.eventLoop }", + " @discardableResult", + " public func sendResponse(_ response: Response) -> EventLoopFuture {", + f" base.sendResponse({base}Message(response))", + " }", + "}", + ] + + def _unary_response_context(self, base: str) -> List[str]: + return [ + ( + f"public struct {base}UnaryResponseContext" + " where Response.Target == Response {" + ), + f" fileprivate let base: UnaryResponseCallContext<{base}Message>", + " public var eventLoop: EventLoop { base.eventLoop }", + " public func respond(_ response: Response) {", + f" base.responsePromise.succeed({base}Message(response))", + " }", + "}", + ] + + def _async_response_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + ( + f"public struct {base}AsyncResponseStream" + " where Response.Target == Response {" + ), + f" fileprivate let base: GRPCAsyncResponseStreamWriter<{base}Message>", + " public func send(_ response: Response) async throws {", + f" try await base.send({base}Message(response))", + " }", + "}", + ] + + def _async_request_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + ( + f"public struct {base}AsyncRequestStream: AsyncSequence" + " where Request.Target == Request {" + ), + " public typealias Element = Request", + f" fileprivate let base: GRPCAsyncRequestStream<{base}Message>", + " public struct AsyncIterator: AsyncIteratorProtocol {", + f" fileprivate var base: GRPCAsyncRequestStream<{base}Message>.AsyncIterator", + " public mutating func next() async throws -> Request? {", + " try await base.next()?.value", + " }", + " }", + " public func makeAsyncIterator() -> AsyncIterator {", + " AsyncIterator(base: base.makeAsyncIterator())", + " }", + "}", + ] + + def _client_response_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + ( + f"public struct {base}ResponseStream: AsyncSequence" + " where Response.Target == Response {" + ), + " public typealias Element = Response", + f" fileprivate let base: GRPCAsyncResponseStream<{base}Message>", + " public struct AsyncIterator: AsyncIteratorProtocol {", + f" fileprivate var base: GRPCAsyncResponseStream<{base}Message>.AsyncIterator", + " public mutating func next() async throws -> Response? {", + " try await base.next()?.value", + " }", + " }", + " public func makeAsyncIterator() -> AsyncIterator {", + " AsyncIterator(base: base.makeAsyncIterator())", + " }", + "}", + ] + + def _provider(self, base: str, service: Service) -> List[str]: + lines = [f"public protocol {base}Provider: CallHandlerProvider {{"] + for method in service.methods: + lines.extend(self._provider_requirement(base, method)) + lines.append("}") + lines.append("") + lines.append(f"extension {base}Provider {{") + lines.append( + f" public var serviceName: Substring " + f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" + ) + lines.append("") + lines.extend(self._handle_signature()) + lines.append(" switch name {") + for method in service.methods: + lines.extend(self._provider_handler_case(base, method)) + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("}") + return lines + + def _handle_signature(self) -> List[str]: + return [ + " public func handle(", + " method name: Substring,", + " context: CallHandlerContext", + " ) -> GRPCServerHandlerProtocol? {", + ] + + def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + f" func {name}(request: {req}, context: StatusOnlyCallContext)", + f" -> EventLoopFuture<{res}>", + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + ( + f" func {name}(request: {req}, " + f"context: {base}StreamingResponseContext<{res}>)" + ), + " -> EventLoopFuture", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + f" func {name}(context: {base}UnaryResponseContext<{res}>)", + f" -> EventLoopFuture<(StreamEvent<{req}>) -> Void>", + ] + return [ + f" func {name}(context: {base}StreamingResponseContext<{res}>)", + f" -> EventLoopFuture<(StreamEvent<{req}>) -> Void>", + ] + + def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + head = [ + f' case "{method.name}":', + f" return {self._server_handler(mode)}(", + " context: context,", + f" requestDeserializer: GRPCPayloadDeserializer<{base}Message<{req}>>(),", + f" responseSerializer: GRPCPayloadSerializer<{base}Message<{res}>>(),", + " interceptors: [],", + ] + if mode is StreamingMode.UNARY: + head.append( + f" userFunction: {{ req, ctx in " + f"self.{name}(request: req.value, context: ctx).map {{ {base}Message($0) }} }})" + ) + elif mode is StreamingMode.SERVER_STREAMING: + head += [ + " userFunction: { req, ctx in", + f" self.{name}(", + " request: req.value,", + f" context: {base}StreamingResponseContext(base: ctx))", + " })", + ] + elif mode is StreamingMode.CLIENT_STREAMING: + head.extend( + self._client_stream_observer(base, name, req, "UnaryResponseContext") + ) + else: + head.extend( + self._client_stream_observer( + base, name, req, "StreamingResponseContext" + ) + ) + return head + + def _client_stream_observer( + self, base: str, name: str, req: str, ctx_kind: str + ) -> List[str]: + return [ + " observerFactory: { ctx in", + ( + f" self.{name}(context: {base}{ctx_kind}(base: ctx))" + ".map { observer in" + ), + f" {{ (event: StreamEvent<{base}Message<{req}>>) in", + " switch event {", + ( + " case .message(let wrapped): " + "observer(.message(wrapped.value))" + ), + " case .end: observer(.end)", + " @unknown default: break", + " }", + " }", + " }", + " })", + ] + + def _server_handler(self, mode: StreamingMode) -> str: + return { + StreamingMode.UNARY: "UnaryServerHandler", + StreamingMode.SERVER_STREAMING: "ServerStreamingServerHandler", + StreamingMode.CLIENT_STREAMING: "ClientStreamingServerHandler", + StreamingMode.BIDIRECTIONAL: "BidirectionalStreamingServerHandler", + }[mode] + + def _async_provider(self, base: str, service: Service) -> List[str]: + lines = [ + _ASYNC_AVAILABLE, + f"public protocol {base}AsyncProvider: CallHandlerProvider, Sendable {{", + ] + for method in service.methods: + lines.extend(self._async_provider_requirement(base, method)) + lines.append("}") + lines.append("") + lines.append(_ASYNC_AVAILABLE) + lines.append(f"extension {base}AsyncProvider {{") + lines.append( + f" public var serviceName: Substring " + f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" + ) + lines.append("") + lines.extend(self._handle_signature()) + lines.append(" switch name {") + for method in service.methods: + lines.extend(self._async_provider_handler_case(base, method)) + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("}") + return lines + + def _async_provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + ( + f" func {name}(request: {req}, context: GRPCAsyncServerCallContext)" + f" async throws -> {res}" + ), + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + f" func {name}(", + f" request: {req},", + f" responseStream: {base}AsyncResponseStream<{res}>,", + " context: GRPCAsyncServerCallContext", + " ) async throws", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + f" func {name}(", + f" requestStream: {base}AsyncRequestStream<{req}>,", + " context: GRPCAsyncServerCallContext", + f" ) async throws -> {res}", + ] + return [ + f" func {name}(", + f" requestStream: {base}AsyncRequestStream<{req}>,", + f" responseStream: {base}AsyncResponseStream<{res}>,", + " context: GRPCAsyncServerCallContext", + " ) async throws", + ] + + def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + head = [ + f' case "{method.name}":', + " return GRPCAsyncServerHandler(", + " context: context,", + f" requestDeserializer: GRPCPayloadDeserializer<{base}Message<{req}>>(),", + f" responseSerializer: GRPCPayloadSerializer<{base}Message<{res}>>(),", + " interceptors: [],", + ] + if mode is StreamingMode.UNARY: + head.append( + f" wrapping: {{ {base}Message(" + f"try await self.{name}(request: $0.value, context: $1)) }})" + ) + elif mode is StreamingMode.SERVER_STREAMING: + head += [ + " wrapping: {", + f" try await self.{name}(", + " request: $0.value,", + f" responseStream: {base}AsyncResponseStream(base: $1),", + " context: $2)", + " })", + ] + elif mode is StreamingMode.CLIENT_STREAMING: + head += [ + " wrapping: {", + f" {base}Message(try await self.{name}(", + f" requestStream: {base}AsyncRequestStream(base: $0),", + " context: $1))", + " })", + ] + else: + head += [ + " wrapping: {", + f" try await self.{name}(", + f" requestStream: {base}AsyncRequestStream(base: $0),", + f" responseStream: {base}AsyncResponseStream(base: $1),", + " context: $2)", + " })", + ] + return head + + def _async_client(self, base: str, service: Service) -> List[str]: + lines = [ + _ASYNC_AVAILABLE, + f"public struct {base}AsyncClient: GRPCClient {{", + " public var channel: GRPCChannel", + " public var defaultCallOptions: CallOptions", + " public init(channel: GRPCChannel, defaultCallOptions: CallOptions = CallOptions()) {", + " self.channel = channel", + " self.defaultCallOptions = defaultCallOptions", + " }", + ] + for method in service.methods: + lines.append("") + lines.extend(self._async_client_method(base, method)) + lines.append("}") + return lines + + def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + path = f"{base}Metadata.Methods.{name}.path" + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + f" public func {name}(_ request: {req}) async throws -> {res} {{", + f" let response: {base}Message<{res}> = try await performAsyncUnaryCall(", + f" path: {path},", + f" request: {base}Message(request), callOptions: defaultCallOptions)", + " return response.value", + " }", + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + f" public func {name}(_ request: {req}) -> {base}ResponseStream<{res}> {{", + f" {base}ResponseStream(base: performAsyncServerStreamingCall(", + f" path: {path},", + f" request: {base}Message(request), callOptions: defaultCallOptions))", + " }", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + ( + f" public func {name}(_ requests: S)" + f" async throws -> {res}" + ), + f" where S.Element == {req} {{", + f" let response: {base}Message<{res}> = try await performAsyncClientStreamingCall(", + f" path: {path},", + f" requests: requests.map {{ {base}Message($0) }}, callOptions: defaultCallOptions)", + " return response.value", + " }", + ] + return [ + ( + f" public func {name}(_ requests: S)" + f" -> {base}ResponseStream<{res}>" + ), + f" where S.Element == {req} {{", + f" {base}ResponseStream(base: performAsyncBidirectionalStreamingCall(", + f" path: {path},", + f" requests: requests.map {{ {base}Message($0) }}, callOptions: defaultCallOptions))", + " }", + ] diff --git a/compiler/fory_compiler/generators/swift.py b/compiler/fory_compiler/generators/swift.py index 12dd063b5c..99ebfa3902 100644 --- a/compiler/fory_compiler/generators/swift.py +++ b/compiler/fory_compiler/generators/swift.py @@ -24,7 +24,8 @@ from fory_compiler.frontend.base import FrontendError from fory_compiler.frontend.utils import parse_idl_file -from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.generators.base import BaseGenerator, GeneratedFile, GeneratorOptions +from fory_compiler.generators.services.swift import SwiftServiceMixin from fory_compiler.ir.ast import ( ArrayType, Enum, @@ -41,7 +42,7 @@ from fory_compiler.ir.types import PrimitiveKind -class SwiftGenerator(BaseGenerator): +class SwiftGenerator(SwiftServiceMixin, BaseGenerator): """Generates Swift types using Fory Swift model macros.""" language_name = "swift" @@ -201,6 +202,48 @@ def output_file_path(self) -> str: return f"{package_path}/{file_name}" return file_name + def swift_declared_symbols(self) -> list[str]: + # Every declaration is reported with the Swift scope that contains it, and + # duplicates are kept so preflight can see a name claimed twice. Enum style + # nests types under a namespace enum, so it also owns that enum itself. + components = self._namespace_components_for_schema(self.schema) + scope = ".".join(components) if self.get_namespace_style() == "enum" else "" + symbols: list[str] = [] + if components and self.get_namespace_style() == "enum": + symbols.append(components[0]) + for type_def in self.schema.enums + self.schema.unions + self.schema.messages: + if self.is_imported_type(type_def): + continue + self._collect_declared_symbols(type_def, scope, [], symbols) + symbols.append( + self._scoped_symbol(scope, self._module_helper_name_for_schema(self.schema)) + ) + return symbols + + def _collect_declared_symbols( + self, + type_def: Message | Enum | Union, + scope: str, + parent_stack: list[Message], + symbols: list[str], + ) -> None: + name = self._declared_type_name(type_def.name, parent_stack or None) + symbols.append(self._scoped_symbol(scope, name)) + if not isinstance(type_def, Message): + return + nested_scope = self._scoped_symbol(scope, name) + nested_stack = parent_stack + [type_def] + for nested in ( + list(type_def.nested_enums) + + list(type_def.nested_unions) + + list(type_def.nested_messages) + ): + self._collect_declared_symbols(nested, nested_scope, nested_stack, symbols) + + @staticmethod + def _scoped_symbol(scope: str, name: str) -> str: + return f"{scope}.{name}" if scope else name + def module_file_name(self) -> str: if self.schema.source_file and not self.schema.source_file.startswith("<"): stem = Path(self.schema.source_file).stem @@ -1469,3 +1512,56 @@ def generate_module_type(self, indent: int = 0) -> list[str]: lines.append(f"{ind}" + "}") return lines + + +def validate_swift_generation( + graph: list[tuple[Path, Schema]], + namespace_style: str | None = None, + grpc: bool = False, +) -> bool: + """Preflight Swift output paths and top-level symbol owners before writing.""" + output_owners: dict[str, list[str]] = {} + symbol_owners: dict[str, list[str]] = {} + for path, schema in graph: + options = GeneratorOptions( + output_dir=Path("."), swift_namespace_style=namespace_style + ) + generator = SwiftGenerator(schema, options) + output_owners.setdefault(generator.output_file_path(), []).append( + f"{path} schema module" + ) + for symbol in generator.swift_declared_symbols(): + symbol_owners.setdefault(symbol, []).append(f"{path} schema type") + if grpc: + for service in schema.services: + if generator.is_imported_type(service): + continue + output_owners.setdefault( + generator.swift_grpc_output_path(service), [] + ).append(f"{path} service {service.name}") + for symbol in generator.swift_grpc_service_symbols(service): + symbol_owners.setdefault(symbol, []).append( + f"{path} service {service.name}" + ) + + _raise_swift_collision( + output_owners, + "Swift generated file path collision; rename schema files or services, " + "or use distinct packages", + ) + _raise_swift_collision( + symbol_owners, + "Swift top-level symbol collision; rename schema types or services, " + "or use distinct packages", + ) + return True + + +def _raise_swift_collision(owners: dict[str, list[str]], message: str) -> None: + collisions = {key: names for key, names in owners.items() if len(names) > 1} + if not collisions: + return + details = ", ".join( + f"{key}: {', '.join(names)}" for key, names in sorted(collisions.items()) + ) + raise ValueError(f"{message}. Collisions: {details}") diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index e2798aa5b5..5eaeeef91c 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -17,10 +17,10 @@ """Codegen smoke tests for schemas that contain service definitions.""" -from pathlib import Path import re import shutil import subprocess +from pathlib import Path from textwrap import dedent from typing import Dict, Tuple, Type @@ -29,16 +29,19 @@ from fory_compiler.cli import ( cmd_compile, compile_file, - main as foryc_main, parse_args, resolve_imports, validate_scala_generation, + validate_swift_files, +) +from fory_compiler.cli import ( + main as foryc_main, ) -from fory_compiler.frontend.fdl.lexer import Lexer -from fory_compiler.frontend.fdl.parser import Parser from fory_compiler.frontend.fbs.lexer import Lexer as FbsLexer from fory_compiler.frontend.fbs.parser import Parser as FbsParser from fory_compiler.frontend.fbs.translator import FbsTranslator +from fory_compiler.frontend.fdl.lexer import Lexer +from fory_compiler.frontend.fdl.parser import Parser from fory_compiler.frontend.proto.lexer import Lexer as ProtoLexer from fory_compiler.frontend.proto.parser import Parser as ProtoParser from fory_compiler.frontend.proto.translator import ProtoTranslator @@ -53,11 +56,10 @@ from fory_compiler.generators.python import PythonGenerator from fory_compiler.generators.rust import RustGenerator from fory_compiler.generators.scala import ScalaGenerator -from fory_compiler.generators.swift import SwiftGenerator +from fory_compiler.generators.swift import SwiftGenerator, validate_swift_generation from fory_compiler.ir.ast import Schema from fory_compiler.ir.validator import SchemaValidator - GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = ( JavaGenerator, PythonGenerator, @@ -158,6 +160,7 @@ def test_unsupported_generators_no_services(): ScalaGenerator, KotlinGenerator, JavaScriptGenerator, + SwiftGenerator, DartGenerator, CppGenerator, ): @@ -972,6 +975,232 @@ def test_scala_grpc_marshaller(): assert "org.apache.fory.scala.grpc" not in content +def test_swift_empty_service_reserves_no_marshaller_symbol(): + schema = parse_fdl( + "message GreeterMessage { string a = 1; }\nservice Greeter { }\n" + ) + assert validate_swift_generation([(Path("empty.fdl"), schema)], grpc=True) + + +@pytest.mark.parametrize( + "schema_source, has_methods", + [ + ("message R { string a = 1; }\nservice Greeter { }\n", False), + ( + "message R { string a = 1; }\n" + "service Greeter { rpc Call (R) returns (R); }\n", + True, + ), + ( + "message R { string a = 1; }\n" + "service Greeter { rpc Stream (R) returns (stream R); }\n", + True, + ), + ], +) +def test_swift_grpc_declared_symbols_match_emitted(schema_source, has_methods): + schema = parse_fdl(schema_source) + options = GeneratorOptions(output_dir=Path("/tmp"), grpc=True) + generator = SwiftGenerator(schema, options) + service = schema.services[0] + declared = set(generator.swift_grpc_service_symbols(service)) + emitted = set( + re.findall( + r"^(?:public )?(?:struct|enum|protocol) ([A-Za-z_0-9]+)", + "".join(item.content for item in generator.generate_services()), + re.M, + ) + ) + assert declared == emitted + assert any(name.endswith("Message") for name in declared) is has_methods + + +def test_swift_grpc_fory_marshaller(): + schema = parse_fdl(_GREETER_WITH_SERVICE) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"demo/greeter/GreeterGrpc.swift"} + content = files["demo/greeter/GreeterGrpc.swift"] + assert ( + "struct Demo_Greeter_GreeterMessage: GRPCPayload" + in content + ) + assert "enum ForyRuntime {" in content + assert "Thread.current.threadDictionary" in content + assert "Demo.Greeter.ForyModule.getFory()" in content + assert "enum Demo_Greeter_GreeterMetadata" in content + assert 'fullName: "demo.greeter.Greeter"' in content + assert ( + "public protocol Demo_Greeter_GreeterProvider: CallHandlerProvider" in content + ) + assert ( + "public protocol Demo_Greeter_GreeterAsyncProvider: CallHandlerProvider, Sendable" + in content + ) + assert "public struct Demo_Greeter_GreeterAsyncClient: GRPCClient" in content + assert "return UnaryServerHandler(" in content + assert "func sayHello(" in content + # Fory carries the bytes; no protobuf and no Java/C# transliteration. + assert "ProtobufSerializer" not in content + assert "import SwiftProtobuf" not in content + assert "enum GreeterGrpc" not in content + + +def test_swift_grpc_default_package(): + schema = parse_fdl( + dedent( + """ + message Req {} + message Res {} + service Greeter { rpc SayHello (Req) returns (Res); } + """ + ) + ) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"GreeterGrpc.swift"} + content = files["GreeterGrpc.swift"] + assert "enum GreeterMetadata" in content + assert "public protocol GreeterProvider: CallHandlerProvider" in content + assert "public struct GreeterAsyncClient: GRPCClient" in content + assert "ForyModule.getFory()" in content + # No package means no name prefix. + assert "Demo_" not in content + + +def test_swift_grpc_preflight_collision(tmp_path: Path, capsys): + # In flatten style a service provider and a like-named message both land at + # file scope, so the preflight must reject the clash. + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.collision; + + message GreeterProvider {} + message Req {} + message Res {} + + service Greeter { + rpc Call (Req) returns (Res); + } + """ + ) + ) + assert validate_swift_files([main], [tmp_path], "flatten", grpc=True) is False + err = capsys.readouterr().err + assert "Swift top-level symbol collision" in err + assert "Demo_Collision_GreeterProvider" in err + + +def test_swift_grpc_imported_types(tmp_path: Path): + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + + message SharedRequest { string name = 1; } + message SharedReply { string text = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.greeter; + + import "common.fdl"; + + service Greeter { + rpc Call (SharedRequest) returns (SharedReply); + } + """ + ) + ) + schema = resolve_imports(main, [tmp_path]) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"demo/greeter/GreeterGrpc.swift"} + content = files["demo/greeter/GreeterGrpc.swift"] + # Imported request and response types are referenced in their own namespace. + assert "Demo.Shared.SharedRequest" in content + assert "Demo.Shared.SharedReply" in content + assert "Demo.Greeter.ForyModule.getFory()" in content + + +@pytest.mark.parametrize( + "rpc_name", ["Handle", "ServiceName", "Channel", "DefaultCallOptions"] +) +def test_swift_grpc_reserved_member_collision(rpc_name): + schema = parse_fdl( + dedent( + f""" + package demo.naming; + + message Req {{}} + message Res {{}} + + service Greeter {{ rpc {rpc_name} (Req) returns (Res); }} + """ + ) + ) + with pytest.raises( + ValueError, match="collides with a generated provider or client member" + ): + generate_service_files(schema, SwiftGenerator) + + +@pytest.mark.parametrize("rpc_name", ["_", "__", "___"]) +def test_swift_grpc_underscore_only_method(rpc_name): + schema = parse_fdl( + dedent( + f""" + package demo.naming; + + message Req {{}} + message Res {{}} + + service Greeter {{ rpc {rpc_name} (Req) returns (Res); }} + """ + ) + ) + with pytest.raises(ValueError, match="Swift cannot use as a member name"): + generate_service_files(schema, SwiftGenerator) + + +def test_swift_grpc_nested_and_imported_payloads(tmp_path: Path): + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + message Outer { message Inner { string v = 1; } Inner inner = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.api; + import "common.fdl"; + message Local { message Deep { string v = 1; } Deep deep = 1; } + service S { + rpc Echo (Local) returns (Outer); + rpc DeepEcho (Local.Deep) returns (Outer.Inner); + } + """ + ) + ) + schema = resolve_imports(main, [tmp_path]) + content = generate_service_files(schema, SwiftGenerator)["demo/api/SGrpc.swift"] + # Nested local and imported nested types resolve to their full namespace paths. + assert "request: Demo.Api.Local," in content + assert "EventLoopFuture" in content + assert "request: Demo.Api.Local.Deep," in content + assert "Demo.Shared.Outer.Inner" in content + assert "Demo_Api_SMessage" in content + + def test_grpc_streaming_method_shapes(): schema = parse_fdl( dedent( @@ -1131,6 +1360,39 @@ def test_grpc_streaming_method_shapes(): assert "call.sendMessage(request)" in scala assert "call.halfClose()" in scala + swift = next(iter(generate_service_files(schema, SwiftGenerator).values())) + assert "type: .unary)" in swift + assert "type: .serverStreaming)" in swift + assert "type: .clientStreaming)" in swift + assert "type: .bidirectionalStreaming)" in swift + assert "return UnaryServerHandler(" in swift + assert "return ServerStreamingServerHandler(" in swift + assert "return ClientStreamingServerHandler(" in swift + assert "return BidirectionalStreamingServerHandler(" in swift + assert ( + "func unary(request: Demo.Streams.Req, context: GRPCAsyncServerCallContext)" + " async throws -> Demo.Streams.Res" in swift + ) + assert ( + "responseStream: Demo_Streams_StreamerAsyncResponseStream" + in swift + ) + assert ( + "requestStream: Demo_Streams_StreamerAsyncRequestStream" + in swift + ) + assert ( + "public func unary(_ request: Demo.Streams.Req) async throws -> Demo.Streams.Res" + in swift + ) + assert ( + "public func server(_ request: Demo.Streams.Req)" + " -> Demo_Streams_StreamerResponseStream" in swift + ) + assert "performAsyncClientStreamingCall(" in swift + assert "performAsyncBidirectionalStreamingCall(" in swift + assert "ProtobufSerializer" not in swift + def test_go_grpc_service_codegen(): schema = parse_fdl(_GREETER_WITH_SERVICE) @@ -1849,6 +2111,12 @@ def test_grpc_method_keywords_safe(): assert "def `class`(request: Req, responseObserver:" in scala assert 'SERVICE_NAME,\n "Class"' in scala + swift = next(iter(generate_service_files(schema, SwiftGenerator).values())) + assert "func `class`(request: Demo.Keywords.Req" in swift + assert "public func `class`(_ request: Demo.Keywords.Req)" in swift + assert 'case "Class":' in swift + assert "Demo_Keywords_GreeterMetadata.Methods.`class`" in swift + def test_python_grpc_registration_collision(): schema = parse_fdl( @@ -1938,13 +2206,16 @@ def test_proto_and_fbs_grpc_service_codegen(): proto_java = generate_service_files(proto_schema, JavaGenerator) proto_python = generate_service_files(proto_schema, PythonGenerator) proto_scala = generate_service_files(proto_schema, ScalaGenerator) + proto_swift = generate_service_files(proto_schema, SwiftGenerator) assert "demo/proto/ProtoSvcGrpc.java" in proto_java assert "demo_proto_grpc.py" in proto_python assert "demo/proto/ProtoSvcGrpc.scala" in proto_scala + assert "demo/proto/ProtoSvcGrpc.swift" in proto_swift assert "MethodType.SERVER_STREAMING" in proto_java["demo/proto/ProtoSvcGrpc.java"] assert "channel.unary_stream(" in proto_python["demo_proto_grpc.py"] assert "MethodType.SERVER_STREAMING" in proto_scala["demo/proto/ProtoSvcGrpc.scala"] assert "RpcIterator[Res]" in proto_scala["demo/proto/ProtoSvcGrpc.scala"] + assert "type: .serverStreaming)" in proto_swift["demo/proto/ProtoSvcGrpc.swift"] fbs_schema = parse_fbs( dedent( @@ -1963,9 +2234,12 @@ def test_proto_and_fbs_grpc_service_codegen(): fbs_java = generate_service_files(fbs_schema, JavaGenerator) fbs_python = generate_service_files(fbs_schema, PythonGenerator) fbs_scala = generate_service_files(fbs_schema, ScalaGenerator) + fbs_swift = generate_service_files(fbs_schema, SwiftGenerator) assert "demo/fbs/FbsSvcGrpc.java" in fbs_java assert "demo_fbs_grpc.py" in fbs_python assert "demo/fbs/FbsSvcGrpc.scala" in fbs_scala + assert "demo/fbs/FbsSvcGrpc.swift" in fbs_swift + assert 'fullName: "demo.fbs.FbsSvc"' in fbs_swift["demo/fbs/FbsSvcGrpc.swift"] assert 'SERVICE_NAME = "demo.fbs.FbsSvc"' in fbs_java["demo/fbs/FbsSvcGrpc.java"] assert '"/demo.fbs.FbsSvc/Call"' in fbs_python["demo_fbs_grpc.py"] assert ( @@ -2278,6 +2552,102 @@ def test_csharp_grpc_dotnet_fixture(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr +def test_swift_common_root_package_emits_shared_namespace_enum(): + # Two schemas that share a top-level package component each emit `public enum + # Demo`, an invalid redeclaration when compiled into one Swift module. Preflight + # records that namespace enum so generation fails before the files are written. + shared_schema = parse_fdl( + "package demo.shared;\nmessage SharedRequest { string name = 1; }\n" + ) + greeter_schema = parse_fdl( + "package demo.greeter;\nmessage LocalRequest { string name = 1; }\n" + ) + shared = generate_files(shared_schema, SwiftGenerator) + greeter = generate_files(greeter_schema, SwiftGenerator) + assert "public enum Demo {" in next(iter(shared.values())) + assert "public enum Demo {" in next(iter(greeter.values())) + + with pytest.raises(ValueError, match="Demo"): + validate_swift_generation( + [ + (Path("shared.fdl"), shared_schema), + (Path("greeter.fdl"), greeter_schema), + ] + ) + + +def test_swift_helper_name_collision_fails_preflight(): + schema = parse_fdl("message ForyModule { string a = 1; }\n") + with pytest.raises(ValueError, match="ForyModule"): + validate_swift_generation([(Path("helper.fdl"), schema)]) + + +def test_swift_normalized_name_collision_fails_preflight(): + schema = parse_fdl( + "message my_type { string a = 1; }\nmessage MyType { string b = 1; }\n" + ) + with pytest.raises(ValueError, match="MyType"): + validate_swift_generation([(Path("normalized.fdl"), schema)]) + + +def test_swift_packaged_normalized_name_collision_fails_preflight(): + schema = parse_fdl( + "package demo.api;\n" + "message my_type { string a = 1; }\n" + "message MyType { string b = 1; }\n" + ) + with pytest.raises(ValueError, match="Demo.Api.MyType"): + validate_swift_generation([(Path("packaged.fdl"), schema)]) + + +def test_swift_nested_type_collision_fails_preflight(): + schema = parse_fdl( + "message Parent {\n" + " message my_type { string a = 1; }\n" + " message MyType { string b = 1; }\n" + "}\n" + ) + with pytest.raises(ValueError, match=r"Parent\.MyType"): + validate_swift_generation([(Path("nested.fdl"), schema)]) + + +def test_swift_deeply_nested_type_collision_fails_preflight(): + schema = parse_fdl( + "message L1 {\n" + " message L2 {\n" + " message my_type { string a = 1; }\n" + " message MyType { string b = 1; }\n" + " }\n" + "}\n" + ) + with pytest.raises(ValueError, match=r"L1\.L2\.MyType"): + validate_swift_generation([(Path("deep.fdl"), schema)]) + + +def test_swift_same_nested_name_under_distinct_parents_passes_preflight(): + schema = parse_fdl( + "message A { message Inner { string a = 1; } }\n" + "message B { message Inner { string b = 1; } }\n" + ) + assert validate_swift_generation([(Path("siblings.fdl"), schema)]) + + +def test_swift_flattened_helper_collision_fails_preflight(): + schema = parse_fdl("package demo.api;\nmessage ForyModule { string a = 1; }\n") + with pytest.raises(ValueError, match="Demo_Api_ForyModule"): + validate_swift_generation( + [(Path("flat.fdl"), schema)], namespace_style="flatten" + ) + + +def test_swift_distinct_root_packages_pass_preflight(): + alpha = parse_fdl("package alpha.one;\nmessage A { string x = 1; }\n") + beta = parse_fdl("package beta.two;\nmessage B { string y = 1; }\n") + assert validate_swift_generation( + [(Path("alpha.fdl"), alpha), (Path("beta.fdl"), beta)] + ) + + def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) @@ -3518,10 +3888,10 @@ def test_dart_grpc_method_aliases(tmp_path: Path): def test_dart_grpc_reserved_methods(): - from fory_compiler.generators.dart import DartGenerator - import pytest + from fory_compiler.generators.dart import DartGenerator + for rpc_name, emitted in [("ToString", "toString"), ("HashCode", "hashCode")]: schema = parse_fdl( dedent( diff --git a/docs/compiler/cli.md b/docs/compiler/cli.md index 56e6059c80..fc8c81bbef 100644 --- a/docs/compiler/cli.md +++ b/docs/compiler/cli.md @@ -132,14 +132,14 @@ foryc user.fdl order.fdl product.fdl --output ./generated foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --csharp_out=./generated/csharp --dart_out=./generated/dart --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript ``` -**Generate Java, Python, Go, Rust, C++, C#, Dart, Scala, Kotlin, and Node.js JavaScript gRPC service companions:** +**Generate Java, Python, Go, Rust, C++, C#, Dart, Scala, Kotlin, Node.js JavaScript, and Swift gRPC service companions:** ```bash -foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --cpp_out=./generated/cpp --csharp_out=./generated/csharp --dart_out=./generated/dart --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --grpc +foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --cpp_out=./generated/cpp --csharp_out=./generated/csharp --dart_out=./generated/dart --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --swift_out=./generated/swift --grpc ``` The generated gRPC service code uses Fory to serialize request and response -payloads. Java output imports grpc-java APIs, Python output defaults to +bodies. Java output imports grpc-java APIs, Python output defaults to `grpc.aio`, Go output imports grpc-go, Rust output imports `tonic` and `bytes`, C++ output includes gRPC C++ `grpcpp` headers; targets compiling it must add the generated output directory to their include path and link `fory::serialization` @@ -148,7 +148,9 @@ Scala output imports grpc-java APIs, and Kotlin output imports grpc-java and grpc-kotlin APIs and uses coroutine stubs. C# output imports `Grpc.Core.Api` types and can be hosted with normal .NET gRPC packages such as `Grpc.AspNetCore` or called through `Grpc.Net.Client`. Dart output imports `package:grpc`. -JavaScript output imports `@grpc/grpc-js`. +JavaScript output imports `@grpc/grpc-js`. Swift output targets grpc-swift 1.x +and emits `async`/`await` providers and clients alongside `EventLoopFuture` +providers. Applications that compile or run those generated service files must provide their own gRPC dependencies. Fory packages do not add a hard gRPC dependency for this feature. @@ -411,6 +413,8 @@ generated/ - Each schema includes a schema-file module owner and `toBytes`/`fromBytes` helpers - Imported schemas are installed transitively by generated module helpers +- With `--grpc`, one `Grpc.swift` companion per service is generated + next to the model, targeting grpc-swift 1.x ### Dart diff --git a/docs/compiler/flatbuffers-idl.md b/docs/compiler/flatbuffers-idl.md index caa7f3fea8..de9c1190d0 100644 --- a/docs/compiler/flatbuffers-idl.md +++ b/docs/compiler/flatbuffers-idl.md @@ -126,7 +126,7 @@ message Container { FlatBuffers `rpc_service` definitions are translated to Fory services. With `--grpc`, the compiler emits gRPC service companions for supported outputs such -as Java, Python, Go, Rust, C#, Dart, Scala, Kotlin, and JavaScript. JavaScript +as Java, Python, Go, Rust, C#, Swift, Dart, Scala, Kotlin, and JavaScript. JavaScript browser clients are generated with `--grpc-web`. These companions use Fory serialization for request and response payloads. diff --git a/docs/compiler/generated-code/swift.md b/docs/compiler/generated-code/swift.md index f53f0cec74..7b3598f771 100644 --- a/docs/compiler/generated-code/swift.md +++ b/docs/compiler/generated-code/swift.md @@ -106,3 +106,11 @@ With non-empty package and `flatten` style, the helper is prefixed too (for exam For schemas without explicit `[id=...]`, installation uses computed numeric IDs. If `option enable_auto_type_id = false;` is set, generated code uses name-based registration APIs. + +Generated models declare `Equatable` where every field supports it, but they do +not declare `Sendable`. Compile them in Swift 5 language mode; Swift 6 strict +concurrency rejects passing them across an isolation boundary. + +## gRPC Service Companions + +With `--grpc`, Swift emits one `Grpc.swift` per service containing `Provider`, `AsyncProvider`, `AsyncClient`, and `Metadata`, where `` carries the package prefix. See [Swift gRPC](../../grpc/swift.md) for dependencies, streaming shapes, and usage. diff --git a/docs/compiler/index.md b/docs/compiler/index.md index 15dac27c4f..bc5d74f148 100644 --- a/docs/compiler/index.md +++ b/docs/compiler/index.md @@ -23,8 +23,8 @@ Fory IDL is a schema definition language for Apache Fory that enables type-safe cross-language serialization. Define your data structures once and generate native data structure code for Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, and Kotlin. Fory IDL can also -describe RPC services; for Java, Python, Go, Rust, C++, C#, Dart, Scala, Kotlin, -and JavaScript, the compiler can generate gRPC service companions that use +describe RPC services; for Java, Python, Go, Rust, C++, C#, Swift, Dart, Scala, +Kotlin, and JavaScript, the compiler can generate gRPC service companions that use Fory serialization for request and response payloads. ## Example Schema @@ -88,11 +88,11 @@ service AnimalService { } ``` -Generate Java, Python, Go, Rust, C++, C#, Dart, Scala, Kotlin, and JavaScript -models plus gRPC service companions with: +Generate Java, Python, Go, Rust, C++, C#, Swift, Dart, Scala, Kotlin, and +JavaScript models plus gRPC service companions with: ```bash -foryc animals.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --cpp_out=./generated/cpp --csharp_out=./generated/csharp --dart_out=./generated/dart --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --grpc +foryc animals.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --cpp_out=./generated/cpp --csharp_out=./generated/csharp --swift_out=./generated/swift --dart_out=./generated/dart --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --grpc ``` The generated service code uses normal gRPC APIs, but request and response diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md index d9f8bd942e..4291e9b649 100644 --- a/docs/compiler/schema-idl.md +++ b/docs/compiler/schema-idl.md @@ -908,7 +908,8 @@ union_field := ['repeated'] field_type IDENTIFIER '=' INTEGER [field_options] '; Services define RPC method contracts in Fory IDL. They are optional: schemas with services still generate the normal data model types, and gRPC service code is generated only when the compiler is run with `--grpc` for supported language -outputs such as Java, Python, Go, Rust, C#, Dart, Scala, Kotlin, and JavaScript. +outputs such as Java, Python, Go, Rust, C#, Swift, Dart, Scala, Kotlin, and +JavaScript. JavaScript browser gRPC-Web clients are generated with `--grpc-web`. ```protobuf diff --git a/docs/grpc/index.md b/docs/grpc/index.md index 0aae10ce69..5dcb86f9d8 100644 --- a/docs/grpc/index.md +++ b/docs/grpc/index.md @@ -96,7 +96,7 @@ different generated service contract. ## Language Guides -Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Dart, Scala, and Kotlin have documented gRPC +Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, and Kotlin have documented gRPC companions. Use the [support matrix](../introduction/support-matrix.md) and the selected language page for current dependencies and streaming support. @@ -112,3 +112,4 @@ for current dependencies and streaming support. | Dart | [Dart](dart.md) | | Scala | [Scala](scala.md) | | Kotlin | [Kotlin](kotlin.md) | +| Swift | [Swift](swift.md) | diff --git a/docs/grpc/swift.md b/docs/grpc/swift.md new file mode 100644 index 0000000000..34702f9fdd --- /dev/null +++ b/docs/grpc/swift.md @@ -0,0 +1,243 @@ +--- +title: Swift gRPC +sidebar_position: 14 +id: swift +license: | + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You 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. +--- + +Fory can generate Swift gRPC service companions for schemas that define +services. The companion provides the usual gRPC service providers, clients, +method descriptors, and service metadata, while request and response objects are +serialized with Fory instead of protobuf. + +Use this mode when both RPC peers are generated from the same Fory IDL, protobuf +IDL, or FlatBuffers IDL and both sides expect Fory-encoded message bodies. Use +normal protobuf gRPC generation for APIs that must be consumed by generic +protobuf clients, reflection tools, or components that expect protobuf bytes. + +The companion targets [grpc-swift](https://github.com/grpc/grpc-swift) 1.x. That +line keeps the same platform floor as the Fory Swift package (macOS 13, iOS 16); +grpc-swift 2.x requires a newer floor. + +## Add Dependencies + +The `Fory` package does not depend on grpc-swift. Add grpc-swift in the package +that compiles or runs the generated companions: + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/apache/fory.git", exact: "$version"), + .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.23.0"), +], +targets: [ + .target( + name: "App", + dependencies: [ + .product(name: "Fory", package: "fory"), + .product(name: "GRPC", package: "grpc-swift"), + ] + ) +] +``` + +## Define a Service + +Service definitions can come from Fory IDL, protobuf IDL, or FlatBuffers +`rpc_service` definitions. A Fory IDL service looks like this: + +```protobuf +package demo.greeter; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string reply = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} +``` + +Generate Swift model and gRPC companion code with `--grpc`: + +```bash +foryc service.fdl --swift_out=./Sources/App --grpc +``` + +For this schema the Swift generator emits: + +| File | Purpose | +| -------------------------------- | -------------------------------------------- | +| `demo/greeter/greeter.swift` | Fory model types and the `ForyModule` helper | +| `demo/greeter/GreeterGrpc.swift` | gRPC providers, client, and service metadata | + +Generated gRPC symbols are prefixed with the package, so the schema above emits +`Demo_Greeter_GreeterAsyncProvider`, `Demo_Greeter_GreeterAsyncClient`, and +`Demo_Greeter_GreeterProvider`. A schema with no package drops the prefix +(`GreeterAsyncProvider`). + +## Implement a Server + +Conform a type to the generated `async`/`await` provider and host it with a +normal grpc-swift `Server`: + +```swift +import Fory +import GRPC +import NIOPosix + +final class GreeterService: Demo_Greeter_GreeterAsyncProvider { + func sayHello( + request: Demo.Greeter.HelloRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Demo.Greeter.HelloReply { + Demo.Greeter.HelloReply(reply: "Hello, " + request.name) + } +} + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +let server = try await Server.insecure(group: group) + .withServiceProviders([GreeterService()]) + .bind(host: "127.0.0.1", port: 1234) + .get() +``` + +Request and response types are registered by the generated schema module that +the companion uses, so server code does not register serializers by hand. An +`EventLoopFuture`-based `Demo_Greeter_GreeterProvider` is also emitted for +servers that do not use `async`/`await`. + +## Create a Client + +Use the generated async client over a grpc-swift channel: + +```swift +import Fory +import GRPC +import NIOPosix + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +let channel = try GRPCChannelPool.with( + target: .host("127.0.0.1", port: 1234), + transportSecurity: .plaintext, + eventLoopGroup: group) + +let client = Demo_Greeter_GreeterAsyncClient(channel: channel) +let reply = try await client.sayHello(Demo.Greeter.HelloRequest(name: "Fory")) +print(reply.reply) +``` + +## Streaming RPCs + +Fory service definitions can use the four gRPC streaming shapes: + +```protobuf +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); + rpc LotsOfReplies (HelloRequest) returns (stream HelloReply); + rpc LotsOfGreetings (stream HelloRequest) returns (HelloReply); + rpc BidiHello (stream HelloRequest) returns (stream HelloReply); +} +``` + +Streaming methods present clean request and response types. The provider receives +a response writer (`send(_:)`) for server output and an `AsyncSequence` for client +input; the client returns an `AsyncSequence` of responses for server-streamed +replies: + +```swift +// Server side +func lotsOfReplies( + request: Demo.Greeter.HelloRequest, + responseStream: Demo_Greeter_GreeterAsyncResponseStream, + context: GRPCAsyncServerCallContext +) async throws { + try await responseStream.send(Demo.Greeter.HelloReply(reply: "Hi " + request.name)) +} + +// Client side +for try await reply in client.lotsOfReplies(Demo.Greeter.HelloRequest(name: "Fory")) { + print(reply.reply) +} +``` + +## gRPC Runtime Behavior + +Generated companions carry Fory-encoded bytes inside a private `GRPCPayload` +wrapper. The Swift `Fory` instance is single-threaded, so the wrapper uses one +`Fory` per thread, built from the schema module's configuration and registrations, +which makes concurrent RPCs safe without sharing a single instance. Imported +request and response types resolve to their own namespace and are registered +transitively through the owning module, so a service that crosses an import +boundary works without extra registration. + +## Swift Language Mode + +Compile generated companions in Swift 5 language mode (use +`swift-tools-version:5.9`, or set `swiftLanguageMode(.v5)` on the target in a +6.x manifest). grpc-swift moves each request and response between the calling +task and the event loop, so the wire wrapper requires a `Sendable` payload, and +generated Fory Swift models do not declare that conformance. This applies to +every call shape, including unary calls, not only the streaming ones. + +## Known Limitations + +The generated client is async/await only. grpc-swift's `EventLoopFuture` client +returns call objects parameterized by the on-the-wire message type, which would +expose the internal Fory wrapper, so it is not emitted. Both providers (async and +`EventLoopFuture`) are generated. + +Interceptors are not generated. grpc-swift interceptors are typed on the +on-the-wire message, which is the internal Fory wrapper; emitting interceptor +hooks would expose that wrapper. Use a custom channel or server configuration for +cross-cutting concerns instead. + +RPC names must produce a usable Swift member. The compiler rejects an rpc whose +name is only underscores, because it normalizes to `_`, which Swift reserves for +discards. It also rejects `handle`, `serviceName`, `channel`, and +`defaultCallOptions`, which collide with members of the generated provider and +client. Rename the rpc in the schema. + +Swift models put each package under a nested `enum` namespace, so two schemas that +share a top-level package component (for example `demo.shared` and `demo.greeter`) +both emit `public enum Demo`. The compiler rejects that with a top-level symbol +collision before it writes either file. This is a model-generation behavior, not +specific to gRPC, but it also affects a service that imports across such packages. +Give the schemas disjoint top-level packages (for example `shared.models` and +`greeter.api`). Generating into separate Swift modules with one `foryc` invocation +each only helps unrelated schemas, because the preflight collects imports +recursively: compiling `demo.greeter` still includes `demo.shared` in the graph and +rejects the duplicate `Demo` even if the shared schema was generated in another +invocation. An import graph needs disjoint top-level packages. + +## Troubleshooting + +### Missing grpc-swift Types + +If the build cannot find `GRPCAsyncServerCallContext`, `Server`, or +`GRPCChannelPool`, add the grpc-swift dependency and the `GRPC` product to the +target that compiles the generated companion. + +### Protobuf Clients Cannot Decode the Service + +Generated companions exchange Fory-encoded bodies, not protobuf bytes. A generic +protobuf client cannot decode them. Both peers must be generated from the same +Fory IDL and use the generated Fory companions. diff --git a/docs/introduction/support-matrix.md b/docs/introduction/support-matrix.md index 16e8151445..d8fcbdb138 100644 --- a/docs/introduction/support-matrix.md +++ b/docs/introduction/support-matrix.md @@ -30,7 +30,7 @@ does not imply support for every Fory capability. | Compact Row Format | Java | Java-only compact layout | | Fory JSON | Java, Kotlin, Scala | Standard JSON text | | Fory compiler output | Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, Kotlin | Generated models use supported Fory APIs | -| Fory gRPC | Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Dart, Scala, Kotlin | Peers must use matching generated Fory service contracts | +| Fory gRPC | Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, Kotlin | Peers must use matching generated Fory service contracts | Platform constraints such as [Android](../object-serialization/java/android.md) and [GraalVM Native Image](../object-serialization/java/graalvm.md) are documented in the Java Object diff --git a/docs/object-serialization/swift/index.md b/docs/object-serialization/swift/index.md index 135c02a5e6..c121b60b03 100644 --- a/docs/object-serialization/swift/index.md +++ b/docs/object-serialization/swift/index.md @@ -60,6 +60,7 @@ targets: [ - [Shared and Circular References](references.md) - [Polymorphism and Dynamic Types](polymorphism.md) - [Schema Evolution](schema-evolution.md) +- [gRPC Support](../../grpc/swift.md) - [Troubleshooting](troubleshooting.md) ## Quick Example diff --git a/integration_tests/grpc_tests/generate_grpc.py b/integration_tests/grpc_tests/generate_grpc.py old mode 100644 new mode 100755 index 931fe9b285..320913323a --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -39,16 +39,27 @@ "cpp": TEST_DIR / "cpp/generated", "csharp": TEST_DIR / "csharp/generated", "kotlin": TEST_DIR / "kotlin/src/main/kotlin/generated", + "swift": TEST_DIR / "swift/interop/Sources/Generated", "dart": TEST_DIR / "dart/lib/generated", } +SWIFT_SOURCES = TEST_DIR / "swift/interop/Sources" + +# Package-less schemas, one Swift module each, so both emit a bare `ForyModule`. +# One invocation apiece: a single invocation rejects the duplicate declaration. +SWIFT_MODULE_SCHEMAS = [ + (TEST_DIR / "idl" / "grpc_default_package_one.fdl", "GeneratedDefaultPackageOne"), + (TEST_DIR / "idl" / "grpc_default_package_two.fdl", "GeneratedDefaultPackageTwo"), +] + def main() -> int: env = os.environ.copy() compiler_path = str(REPO_ROOT / "compiler") env["PYTHONPATH"] = compiler_path + os.pathsep + env.get("PYTHONPATH", "") - for root in OUTPUTS.values(): + swift_module_roots = [SWIFT_SOURCES / name for _, name in SWIFT_MODULE_SCHEMAS] + for root in [*OUTPUTS.values(), *swift_module_roots]: root.mkdir(parents=True, exist_ok=True) subprocess.check_call( [ @@ -84,6 +95,7 @@ def main() -> int: f"--cpp_out={OUTPUTS['cpp']}", f"--csharp_out={OUTPUTS['csharp']}", f"--kotlin_out={OUTPUTS['kotlin']}", + f"--swift_out={OUTPUTS['swift']}", f"--dart_out={OUTPUTS['dart']}", "--grpc", ], @@ -103,6 +115,20 @@ def main() -> int: env=env, ) + for schema, module_name in SWIFT_MODULE_SCHEMAS: + subprocess.check_call( + [ + sys.executable, + "-m", + "fory_compiler", + "compile", + str(schema), + f"--swift_out={SWIFT_SOURCES / module_name}", + "--grpc", + ], + env=env, + ) + return 0 diff --git a/integration_tests/grpc_tests/idl/grpc_default_package_one.fdl b/integration_tests/grpc_tests/idl/grpc_default_package_one.fdl new file mode 100644 index 0000000000..631dfe0d81 --- /dev/null +++ b/integration_tests/grpc_tests/idl/grpc_default_package_one.fdl @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +// No package, so the Swift helper is a top-level ForyModule, textually +// identical to the one from grpc_default_package_two.fdl. + +message DefaultPackageOneRequest { + string id = 1; + int32 count = 2; +} + +message DefaultPackageOneResponse { + string id = 1; + int32 count = 2; +} + +service DefaultPackageOneService { + rpc UnaryMessage (DefaultPackageOneRequest) returns (DefaultPackageOneResponse); +} diff --git a/integration_tests/grpc_tests/idl/grpc_default_package_two.fdl b/integration_tests/grpc_tests/idl/grpc_default_package_two.fdl new file mode 100644 index 0000000000..05eba213e3 --- /dev/null +++ b/integration_tests/grpc_tests/idl/grpc_default_package_two.fdl @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +// No package, so the Swift helper is a top-level ForyModule, textually +// identical to the one from grpc_default_package_one.fdl. + +message DefaultPackageTwoRequest { + string id = 1; + int32 count = 2; +} + +message DefaultPackageTwoResponse { + string id = 1; + int32 count = 2; +} + +service DefaultPackageTwoService { + rpc UnaryMessage (DefaultPackageTwoRequest) returns (DefaultPackageTwoResponse); +} diff --git a/integration_tests/grpc_tests/java/src/test/java/org/apache/fory/grpc_tests/SwiftGrpcTest.java b/integration_tests/grpc_tests/java/src/test/java/org/apache/fory/grpc_tests/SwiftGrpcTest.java new file mode 100644 index 0000000000..fe2eff2dae --- /dev/null +++ b/integration_tests/grpc_tests/java/src/test/java/org/apache/fory/grpc_tests/SwiftGrpcTest.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.fory.grpc_tests; + +import io.grpc.Server; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.testng.annotations.Test; + +public class SwiftGrpcTest extends GrpcTestBase { + + @Test + public void testJavaServerSwiftClient() throws Exception { + Server server = startJavaAllSchemasServer(); + try { + runPeer( + "swift-grpc-client", + swiftCommand("client", "--target", "127.0.0.1:" + server.getPort())); + } finally { + server.shutdownNow(); + server.awaitTermination(10, TimeUnit.SECONDS); + } + } + + @Test + public void testSwiftServerJavaClient() throws Exception { + exercisePeerServer( + "swift-grpc", "Swift", "fory-grpc-swift-", swiftCommand("server"), this::exerciseAllSchemas); + } + + private PeerCommand swiftCommand(String... args) { + Path swiftRoot = grpcRoot().resolve("swift").resolve("interop"); + List command = new ArrayList<>(); + command.add(swiftRoot.resolve(".build").resolve("debug").resolve("interop").toString()); + command.addAll(Arrays.asList(args)); + PeerCommand peerCommand = newPeerCommand(swiftRoot, command); + putEnv(peerCommand, "ENABLE_FORY_DEBUG_OUTPUT", "1"); + setLocalhostNoProxy(peerCommand); + clearProxyEnv(peerCommand); + return peerCommand; + } +} diff --git a/integration_tests/grpc_tests/run_tests.sh b/integration_tests/grpc_tests/run_tests.sh index 8afabb374c..947b2fab56 100755 --- a/integration_tests/grpc_tests/run_tests.sh +++ b/integration_tests/grpc_tests/run_tests.sh @@ -21,12 +21,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" -TEST_CLASSES="${1:-PythonAsyncGrpcTest,PythonSyncGrpcTest,RustGrpcTest,GoGrpcTest,CppGrpcTest,KotlinGrpcTest,DartGrpcTest}" +TEST_CLASSES="${1:-PythonAsyncGrpcTest,PythonSyncGrpcTest,RustGrpcTest,GoGrpcTest,CppGrpcTest,KotlinGrpcTest,DartGrpcTest,SwiftGrpcTest}" +SWIFT_INTEROP_DIR="${SCRIPT_DIR}/swift/interop" has_test_class() { [[ ",${TEST_CLASSES}," == *",$1,"* ]] } +if has_test_class "SwiftGrpcTest" && ! command -v swift >/dev/null 2>&1; then + echo "Error: SwiftGrpcTest requires the Swift toolchain" >&2 + exit 1 +fi + if has_test_class "PythonAsyncGrpcTest" || has_test_class "PythonSyncGrpcTest"; then python -m pip install "grpcio>=1.62.2,<1.71" python -m pip install -v -e "${ROOT_DIR}/python" @@ -56,6 +62,12 @@ if has_test_class "DartGrpcTest"; then dart analyze bin lib/generated/*/*_grpc.dart dart format --output=none --set-exit-if-changed bin lib/generated/*/*_grpc.dart fi +# Swift toolchain tests (generated marshaller round-trip and concurrency). These +# need the Swift toolchain rather than the JVM, so they run in their own package. +if has_test_class "SwiftGrpcTest"; then + cd "${SWIFT_INTEROP_DIR}" + swift test +fi cd "${ROOT_DIR}/integration_tests/grpc_tests/java" mvn -T16 --no-transfer-progress \ -Dtest="${TEST_CLASSES}" \ diff --git a/integration_tests/grpc_tests/swift/interop/.gitignore b/integration_tests/grpc_tests/swift/interop/.gitignore new file mode 100644 index 0000000000..9cdc5fe0d4 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/.gitignore @@ -0,0 +1,5 @@ +.build/ +Package.resolved +Sources/Generated/ +Sources/GeneratedDefaultPackageOne/ +Sources/GeneratedDefaultPackageTwo/ diff --git a/integration_tests/grpc_tests/swift/interop/Package.swift b/integration_tests/grpc_tests/swift/interop/Package.swift new file mode 100644 index 0000000000..a85bf93397 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Package.swift @@ -0,0 +1,60 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ForyGrpcInterop", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), + .package(path: "../../../../swift"), + ], + targets: [ + .target( + name: "ForyGrpcGenerated", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/Generated" + ), + // Package-less schemas, one module each. Both emit a bare `ForyModule`, so + // together they cover generated helpers whose textual paths are identical + // across modules. + .target( + name: "ForyGrpcDefaultPackageOne", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/GeneratedDefaultPackageOne" + ), + .target( + name: "ForyGrpcDefaultPackageTwo", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/GeneratedDefaultPackageTwo" + ), + .executableTarget( + name: "interop", + dependencies: [ + "ForyGrpcGenerated", + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/Interop" + ), + .testTarget( + name: "ForyGrpcTests", + dependencies: [ + "ForyGrpcGenerated", + "ForyGrpcDefaultPackageOne", + "ForyGrpcDefaultPackageTwo", + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Tests/ForyGrpcTests" + ), + ] +) diff --git a/integration_tests/grpc_tests/swift/interop/Sources/Interop/main.swift b/integration_tests/grpc_tests/swift/interop/Sources/Interop/main.swift new file mode 100644 index 0000000000..59aa368304 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Sources/Interop/main.swift @@ -0,0 +1,473 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +// Swift peer for the Java-driven gRPC interop tests. Mirrors the Go and Rust +// peers and the Java GrpcTestBase transforms. +// +// server --port-file start a server for all schemas, write the port +// client --target host:port connect and exercise all schemas, both ways + +import Foundation +import ForyGrpcGenerated +import GRPC +import NIOPosix + +// MARK: - Shared values + +private func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) +} + +private func expect(_ got: T, _ want: T, _ what: String) { + if got != want { fail("\(what): got \(got), want \(want)") } +} + +private func stream(_ values: [T]) -> AsyncStream { + AsyncStream { continuation in + for value in values { continuation.yield(value) } + continuation.finish() + } +} + +// MARK: - FDL + +private func fdlResponse( + _ request: GrpcFdl.GrpcFdlRequest, _ tag: String, _ offset: Int32 +) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "\(tag):\(request.id)", count: request.count + offset, payload: "\(tag):\(request.payload)") +} + +private func fdlAggregate(_ requests: [GrpcFdl.GrpcFdlRequest]) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "client:" + requests.map(\.id).joined(separator: "+"), + count: requests.reduce(0) { $0 + $1.count }, + payload: "client:" + requests.map(\.payload).joined(separator: "+")) +} + +private func fdlRequest(_ union: GrpcFdl.GrpcFdlUnion) -> GrpcFdl.GrpcFdlRequest { + guard case .request(let request) = union else { fail("fdl: expected request union") } + return request +} + +private func fdlUnionResponse( + _ request: GrpcFdl.GrpcFdlRequest, _ tag: String, _ offset: Int32 +) -> GrpcFdl.GrpcFdlUnion { + .response(fdlResponse(request, tag, offset)) +} + +private final class FdlService: GrpcFdl_FdlGrpcServiceAsyncProvider { + func unaryMessage(request: GrpcFdl.GrpcFdlRequest, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlResponse + { fdlResponse(request, "unary", 10) } + + func serverStreamMessage( + request: GrpcFdl.GrpcFdlRequest, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for i in 0..<3 { try await responseStream.send(fdlResponse(request, "server-\(i)", Int32(i))) } + } + + func clientStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlResponse { + var requests: [GrpcFdl.GrpcFdlRequest] = [] + for try await request in requestStream { requests.append(request) } + return fdlAggregate(requests) + } + + func bidiStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await request in requestStream { + try await responseStream.send(fdlResponse(request, "bidi-\(index)", Int32(index))) + index += 1 + } + } + + func unaryUnion(request: GrpcFdl.GrpcFdlUnion, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlUnion + { fdlUnionResponse(fdlRequest(request), "unary", 10) } + + func serverStreamUnion( + request: GrpcFdl.GrpcFdlUnion, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + let value = fdlRequest(request) + for i in 0..<3 { try await responseStream.send(fdlUnionResponse(value, "server-\(i)", Int32(i))) } + } + + func clientStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlUnion { + var requests: [GrpcFdl.GrpcFdlRequest] = [] + for try await union in requestStream { requests.append(fdlRequest(union)) } + return .response(fdlAggregate(requests)) + } + + func bidiStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await union in requestStream { + try await responseStream.send(fdlUnionResponse(fdlRequest(union), "bidi-\(index)", Int32(index))) + index += 1 + } + } +} + +private func exerciseFdl(_ channel: GRPCChannel) async throws { + let client = GrpcFdl_FdlGrpcServiceAsyncClient(channel: channel) + let requests = [ + GrpcFdl.GrpcFdlRequest(id: "fdl-a", count: 1, payload: "alpha"), + GrpcFdl.GrpcFdlRequest(id: "fdl-b", count: 2, payload: "beta"), + ] + let first = requests[0] + expect(try await client.unaryMessage(first), fdlResponse(first, "unary", 10), "fdl.unary") + + var served: [GrpcFdl.GrpcFdlResponse] = [] + for try await m in client.serverStreamMessage(first) { served.append(m) } + expect( + served, + [fdlResponse(first, "server-0", 0), fdlResponse(first, "server-1", 1), fdlResponse(first, "server-2", 2)], + "fdl.serverStream") + + expect(try await client.clientStreamMessage(stream(requests)), fdlAggregate(requests), "fdl.clientStream") + + var bidi: [GrpcFdl.GrpcFdlResponse] = [] + for try await m in client.bidiStreamMessage(stream(requests)) { bidi.append(m) } + expect(bidi, [fdlResponse(requests[0], "bidi-0", 0), fdlResponse(requests[1], "bidi-1", 1)], "fdl.bidi") + + let unions = [ + GrpcFdl.GrpcFdlUnion.request(GrpcFdl.GrpcFdlRequest(id: "fdl-u-a", count: 3, payload: "union-alpha")), + GrpcFdl.GrpcFdlUnion.request(GrpcFdl.GrpcFdlRequest(id: "fdl-u-b", count: 4, payload: "union-beta")), + ] + let firstReq = fdlRequest(unions[0]) + expect(try await client.unaryUnion(unions[0]), fdlUnionResponse(firstReq, "unary", 10), "fdl.unaryUnion") + + var servedU: [GrpcFdl.GrpcFdlUnion] = [] + for try await m in client.serverStreamUnion(unions[0]) { servedU.append(m) } + expect( + servedU, + [fdlUnionResponse(firstReq, "server-0", 0), fdlUnionResponse(firstReq, "server-1", 1), fdlUnionResponse(firstReq, "server-2", 2)], + "fdl.serverStreamUnion") + + let aggU = GrpcFdl.GrpcFdlUnion.response(fdlAggregate(unions.map(fdlRequest))) + expect(try await client.clientStreamUnion(stream(unions)), aggU, "fdl.clientStreamUnion") + + var bidiU: [GrpcFdl.GrpcFdlUnion] = [] + for try await m in client.bidiStreamUnion(stream(unions)) { bidiU.append(m) } + expect( + bidiU, + [fdlUnionResponse(fdlRequest(unions[0]), "bidi-0", 0), fdlUnionResponse(fdlRequest(unions[1]), "bidi-1", 1)], + "fdl.bidiUnion") +} + +// MARK: - FBS + +private func fbsResponse( + _ request: GrpcFbs.GrpcFbsRequest, _ tag: String, _ offset: Int32 +) -> GrpcFbs.GrpcFbsResponse { + GrpcFbs.GrpcFbsResponse( + id: "\(tag):\(request.id)", count: request.count + offset, payload: "\(tag):\(request.payload)") +} + +private func fbsAggregate(_ requests: [GrpcFbs.GrpcFbsRequest]) -> GrpcFbs.GrpcFbsResponse { + GrpcFbs.GrpcFbsResponse( + id: "client:" + requests.map(\.id).joined(separator: "+"), + count: requests.reduce(0) { $0 + $1.count }, + payload: "client:" + requests.map(\.payload).joined(separator: "+")) +} + +private func fbsRequest(_ union: GrpcFbs.GrpcFbsUnion) -> GrpcFbs.GrpcFbsRequest { + guard case .grpcFbsRequest(let request) = union else { fail("fbs: expected request union") } + return request +} + +private func fbsUnionResponse( + _ request: GrpcFbs.GrpcFbsRequest, _ tag: String, _ offset: Int32 +) -> GrpcFbs.GrpcFbsUnion { + .grpcFbsResponse(fbsResponse(request, tag, offset)) +} + +private final class FbsService: GrpcFbs_FbsGrpcServiceAsyncProvider { + func unaryMessage(request: GrpcFbs.GrpcFbsRequest, context: GRPCAsyncServerCallContext) + async throws -> GrpcFbs.GrpcFbsResponse + { fbsResponse(request, "unary", 10) } + + func serverStreamMessage( + request: GrpcFbs.GrpcFbsRequest, + responseStream: GrpcFbs_FbsGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for i in 0..<3 { try await responseStream.send(fbsResponse(request, "server-\(i)", Int32(i))) } + } + + func clientStreamMessage( + requestStream: GrpcFbs_FbsGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFbs.GrpcFbsResponse { + var requests: [GrpcFbs.GrpcFbsRequest] = [] + for try await request in requestStream { requests.append(request) } + return fbsAggregate(requests) + } + + func bidiStreamMessage( + requestStream: GrpcFbs_FbsGrpcServiceAsyncRequestStream, + responseStream: GrpcFbs_FbsGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await request in requestStream { + try await responseStream.send(fbsResponse(request, "bidi-\(index)", Int32(index))) + index += 1 + } + } + + func unaryUnion(request: GrpcFbs.GrpcFbsUnion, context: GRPCAsyncServerCallContext) + async throws -> GrpcFbs.GrpcFbsUnion + { fbsUnionResponse(fbsRequest(request), "unary", 10) } + + func serverStreamUnion( + request: GrpcFbs.GrpcFbsUnion, + responseStream: GrpcFbs_FbsGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + let value = fbsRequest(request) + for i in 0..<3 { try await responseStream.send(fbsUnionResponse(value, "server-\(i)", Int32(i))) } + } + + func clientStreamUnion( + requestStream: GrpcFbs_FbsGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFbs.GrpcFbsUnion { + var requests: [GrpcFbs.GrpcFbsRequest] = [] + for try await union in requestStream { requests.append(fbsRequest(union)) } + return .grpcFbsResponse(fbsAggregate(requests)) + } + + func bidiStreamUnion( + requestStream: GrpcFbs_FbsGrpcServiceAsyncRequestStream, + responseStream: GrpcFbs_FbsGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await union in requestStream { + try await responseStream.send(fbsUnionResponse(fbsRequest(union), "bidi-\(index)", Int32(index))) + index += 1 + } + } +} + +private func exerciseFbs(_ channel: GRPCChannel) async throws { + let client = GrpcFbs_FbsGrpcServiceAsyncClient(channel: channel) + let requests = [ + GrpcFbs.GrpcFbsRequest(id: "fbs-a", count: 5, payload: "alpha"), + GrpcFbs.GrpcFbsRequest(id: "fbs-b", count: 6, payload: "beta"), + ] + let first = requests[0] + expect(try await client.unaryMessage(first), fbsResponse(first, "unary", 10), "fbs.unary") + + var served: [GrpcFbs.GrpcFbsResponse] = [] + for try await m in client.serverStreamMessage(first) { served.append(m) } + expect( + served, + [fbsResponse(first, "server-0", 0), fbsResponse(first, "server-1", 1), fbsResponse(first, "server-2", 2)], + "fbs.serverStream") + + expect(try await client.clientStreamMessage(stream(requests)), fbsAggregate(requests), "fbs.clientStream") + + var bidi: [GrpcFbs.GrpcFbsResponse] = [] + for try await m in client.bidiStreamMessage(stream(requests)) { bidi.append(m) } + expect(bidi, [fbsResponse(requests[0], "bidi-0", 0), fbsResponse(requests[1], "bidi-1", 1)], "fbs.bidi") + + let unions = [ + GrpcFbs.GrpcFbsUnion.grpcFbsRequest(GrpcFbs.GrpcFbsRequest(id: "fbs-u-a", count: 7, payload: "union-alpha")), + GrpcFbs.GrpcFbsUnion.grpcFbsRequest(GrpcFbs.GrpcFbsRequest(id: "fbs-u-b", count: 8, payload: "union-beta")), + ] + let firstReq = fbsRequest(unions[0]) + expect(try await client.unaryUnion(unions[0]), fbsUnionResponse(firstReq, "unary", 10), "fbs.unaryUnion") + + var servedU: [GrpcFbs.GrpcFbsUnion] = [] + for try await m in client.serverStreamUnion(unions[0]) { servedU.append(m) } + expect( + servedU, + [fbsUnionResponse(firstReq, "server-0", 0), fbsUnionResponse(firstReq, "server-1", 1), fbsUnionResponse(firstReq, "server-2", 2)], + "fbs.serverStreamUnion") + + let aggU = GrpcFbs.GrpcFbsUnion.grpcFbsResponse(fbsAggregate(unions.map(fbsRequest))) + expect(try await client.clientStreamUnion(stream(unions)), aggU, "fbs.clientStreamUnion") + + var bidiU: [GrpcFbs.GrpcFbsUnion] = [] + for try await m in client.bidiStreamUnion(stream(unions)) { bidiU.append(m) } + expect( + bidiU, + [fbsUnionResponse(fbsRequest(unions[0]), "bidi-0", 0), fbsUnionResponse(fbsRequest(unions[1]), "bidi-1", 1)], + "fbs.bidiUnion") +} + +// MARK: - PB + +private func pbResponsePayload( + _ payload: GrpcPb.GrpcPbRequest.Payload?, _ tag: String, _ offset: UInt32 +) -> GrpcPb.GrpcPbResponse.Payload? { + switch payload { + case .text(let text): return .text("\(tag):\(text)") + case .number(let number): return .number(number + offset) + default: return nil + } +} + +private func pbResponse( + _ request: GrpcPb.GrpcPbRequest, _ tag: String, _ offset: UInt32 +) -> GrpcPb.GrpcPbResponse { + GrpcPb.GrpcPbResponse( + id: "\(tag):\(request.id)", + count: request.count + offset, + payload: pbResponsePayload(request.payload, tag, offset)) +} + +private func pbAggregate(_ requests: [GrpcPb.GrpcPbRequest]) -> GrpcPb.GrpcPbResponse { + let ids = requests.map(\.id).joined(separator: "+") + return GrpcPb.GrpcPbResponse( + id: "client:" + ids, + count: requests.reduce(0) { $0 + $1.count }, + payload: .text("client:" + ids)) +} + +private final class PbService: GrpcPb_PbGrpcServiceAsyncProvider { + func unaryMessage(request: GrpcPb.GrpcPbRequest, context: GRPCAsyncServerCallContext) + async throws -> GrpcPb.GrpcPbResponse + { pbResponse(request, "unary", 10) } + + func serverStreamMessage( + request: GrpcPb.GrpcPbRequest, + responseStream: GrpcPb_PbGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for i in 0..<3 { try await responseStream.send(pbResponse(request, "server-\(i)", UInt32(i))) } + } + + func clientStreamMessage( + requestStream: GrpcPb_PbGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcPb.GrpcPbResponse { + var requests: [GrpcPb.GrpcPbRequest] = [] + for try await request in requestStream { requests.append(request) } + return pbAggregate(requests) + } + + func bidiStreamMessage( + requestStream: GrpcPb_PbGrpcServiceAsyncRequestStream, + responseStream: GrpcPb_PbGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await request in requestStream { + try await responseStream.send(pbResponse(request, "bidi-\(index)", UInt32(index))) + index += 1 + } + } +} + +private func exercisePb(_ channel: GRPCChannel) async throws { + let client = GrpcPb_PbGrpcServiceAsyncClient(channel: channel) + let requests = [ + GrpcPb.GrpcPbRequest(id: "pb-a", count: 9, payload: .text("alpha")), + GrpcPb.GrpcPbRequest(id: "pb-b", count: 10, payload: .number(42)), + ] + let first = requests[0] + expect(try await client.unaryMessage(first), pbResponse(first, "unary", 10), "pb.unary") + + var served: [GrpcPb.GrpcPbResponse] = [] + for try await m in client.serverStreamMessage(first) { served.append(m) } + expect( + served, + [pbResponse(first, "server-0", 0), pbResponse(first, "server-1", 1), pbResponse(first, "server-2", 2)], + "pb.serverStream") + + expect(try await client.clientStreamMessage(stream(requests)), pbAggregate(requests), "pb.clientStream") + + var bidi: [GrpcPb.GrpcPbResponse] = [] + for try await m in client.bidiStreamMessage(stream(requests)) { bidi.append(m) } + expect(bidi, [pbResponse(requests[0], "bidi-0", 0), pbResponse(requests[1], "bidi-1", 1)], "pb.bidi") +} + +// MARK: - Driver + +private func portFileArgument() -> String? { + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--port-file"), i + 1 < args.count { return args[i + 1] } + return nil +} + +private func targetArgument() -> String? { + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--target"), i + 1 < args.count { return args[i + 1] } + return nil +} + +private func runServer() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await Server.insecure(group: group) + .withServiceProviders([FdlService(), FbsService(), PbService()]) + .bind(host: "127.0.0.1", port: 0) + .get() + let port = server.channel.localAddress!.port! + if let path = portFileArgument() { + try "\(port)\n".write(toFile: path, atomically: true, encoding: .utf8) + } + try await server.onClose.get() +} + +private func runClient() async throws { + guard let target = targetArgument() else { fail("client: missing --target host:port") } + let parts = target.split(separator: ":") + guard parts.count == 2, let port = Int(parts[1]) else { fail("client: bad --target \(target)") } + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let channel = try GRPCChannelPool.with( + target: .host(String(parts[0]), port: port), + transportSecurity: .plaintext, + eventLoopGroup: group) + do { + try await exerciseFdl(channel) + try await exerciseFbs(channel) + try await exercisePb(channel) + } catch { + try? await channel.close().get() + throw error + } + try await channel.close().get() + print("swift interop ok") +} + +let mode = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "" +switch mode { +case "server": try await runServer() +case "client": try await runClient() +default: fail("usage: interop server --port-file | client --target host:port") +} diff --git a/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift new file mode 100644 index 0000000000..47a10c605b --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift @@ -0,0 +1,125 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 NIOCore +import Testing + +@testable import ForyGrpcDefaultPackageOne +@testable import ForyGrpcDefaultPackageTwo +@testable import ForyGrpcGenerated + +// Exercises the generated marshaller across threads to show that its per-thread +// Fory carries no data race, and that each module keeps its own instance. +// +// Written with swift-testing because ThreadSanitizer cannot load into the +// platform-signed `xctest` runner that XCTest bundles use on macOS. +@Suite struct MarshallerThreadSafetyTests { + @Test func concurrentRoundTrip() { + DispatchQueue.concurrentPerform(iterations: 2000) { i in + do { + let allocator = ByteBufferAllocator() + let request = GrpcFdl.GrpcFdlRequest(id: "n\(i)", count: Int32(i), payload: "p\(i)") + var buffer = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(request).serialize(into: &buffer) + let back = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &buffer) + #expect(back.value == request) + } catch { + Issue.record("marshaller round-trip failed: \(error)") + } + } + } + + @Test func wireCompatibleWithModuleFory() throws { + let allocator = ByteBufferAllocator() + let probe = GrpcFdl.GrpcFdlRequest(id: "probe", count: 7, payload: "x") + + let sharedBytes = try GrpcFdl.ForyModule.getFory().serialize(probe) + var inbound = allocator.buffer(capacity: sharedBytes.count) + inbound.writeBytes(sharedBytes) + let fromShared = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &inbound) + #expect(fromShared.value == probe) + + var outbound = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(probe).serialize(into: &outbound) + let fromMarshaller: GrpcFdl.GrpcFdlRequest = + try GrpcFdl.ForyModule.getFory().deserialize(Data(outbound.readableBytesView)) + #expect(fromMarshaller == probe) + } + + // Both schemas are packaged, so this only covers one module. + @Test func packagedSchemasKeepOwnRuntimeOnOneThread() throws { + let allocator = ByteBufferAllocator() + + let fdlRequest = GrpcFdl.GrpcFdlRequest(id: "fdl", count: 1, payload: "p") + var fdlBuffer = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(fdlRequest).serialize(into: &fdlBuffer) + let fdlBack = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &fdlBuffer) + #expect(fdlBack.value == fdlRequest) + + let fbsRequest = GrpcFbs.GrpcFbsRequest(id: "fbs", count: 2, payload: "q") + var fbsBuffer = allocator.buffer(capacity: 64) + try GrpcFbs_FbsGrpcServiceMessage(fbsRequest).serialize(into: &fbsBuffer) + let fbsBack = try GrpcFbs_FbsGrpcServiceMessage( + serializedByteBuffer: &fbsBuffer) + #expect(fbsBack.value == fbsRequest) + + var fdlAgain = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(fdlRequest).serialize(into: &fdlAgain) + let fdlSecondPass = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &fdlAgain) + #expect(fdlSecondPass.value == fdlRequest) + } + + // Both modules emit a bare `ForyModule`, so their generated key expressions + // are identical and only runtime module qualification separates them. + @Test func defaultPackageModulesKeepOwnRuntimeOnOneThread() throws { + #expect( + String(reflecting: ForyGrpcDefaultPackageOne.ForyModule.self) + != String(reflecting: ForyGrpcDefaultPackageTwo.ForyModule.self)) + #expect( + String(describing: ForyGrpcDefaultPackageOne.ForyModule.self) + == String(describing: ForyGrpcDefaultPackageTwo.ForyModule.self)) + + let allocator = ByteBufferAllocator() + + let one = DefaultPackageOneRequest(id: "one", count: 1) + var oneBuffer = allocator.buffer(capacity: 64) + try DefaultPackageOneServiceMessage(one).serialize(into: &oneBuffer) + let oneBack = try DefaultPackageOneServiceMessage( + serializedByteBuffer: &oneBuffer) + #expect(oneBack.value == one) + + // Runs on the same thread, where a shared key would return the other + // module's Fory. + let two = DefaultPackageTwoRequest(id: "two", count: 2) + var twoBuffer = allocator.buffer(capacity: 64) + try DefaultPackageTwoServiceMessage(two).serialize(into: &twoBuffer) + let twoBack = try DefaultPackageTwoServiceMessage( + serializedByteBuffer: &twoBuffer) + #expect(twoBack.value == two) + + var oneAgain = allocator.buffer(capacity: 64) + try DefaultPackageOneServiceMessage(one).serialize(into: &oneAgain) + let oneSecondPass = try DefaultPackageOneServiceMessage( + serializedByteBuffer: &oneAgain) + #expect(oneSecondPass.value == one) + } +} diff --git a/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift new file mode 100644 index 0000000000..44345141c4 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift @@ -0,0 +1,184 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 GRPC +import NIOPosix +import XCTest + +import ForyGrpcGenerated + +private func response( + _ request: GrpcFdl.GrpcFdlRequest, _ tag: String, _ offset: Int +) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "\(tag):\(request.id)", + count: request.count + Int32(offset), + payload: "\(tag):\(request.payload)") +} + +private func aggregate(_ requests: [GrpcFdl.GrpcFdlRequest]) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "client:" + requests.map(\.id).joined(separator: "+"), + count: requests.reduce(0) { $0 + $1.count }, + payload: "client:" + requests.map(\.payload).joined(separator: "+")) +} + +private func stream(_ requests: [GrpcFdl.GrpcFdlRequest]) -> AsyncStream { + AsyncStream { continuation in + for request in requests { continuation.yield(request) } + continuation.finish() + } +} + +private final class FdlService: GrpcFdl_FdlGrpcServiceAsyncProvider { + func unaryMessage(request: GrpcFdl.GrpcFdlRequest, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlResponse + { + response(request, "unary", 10) + } + func serverStreamMessage( + request: GrpcFdl.GrpcFdlRequest, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for i in 0..<3 { try await responseStream.send(response(request, "server-\(i)", i)) } + } + func clientStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlResponse { + var requests: [GrpcFdl.GrpcFdlRequest] = [] + for try await request in requestStream { requests.append(request) } + return aggregate(requests) + } + func bidiStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await request in requestStream { + try await responseStream.send(response(request, "bidi-\(index)", index)) + index += 1 + } + } + func unaryUnion(request: GrpcFdl.GrpcFdlUnion, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlUnion + { + request + } + func serverStreamUnion( + request: GrpcFdl.GrpcFdlUnion, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + try await responseStream.send(request) + } + func clientStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlUnion { + var last = GrpcFdl.GrpcFdlUnion.response(GrpcFdl.GrpcFdlResponse()) + for try await union in requestStream { last = union } + return last + } + func bidiStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for try await union in requestStream { try await responseStream.send(union) } + } +} + +/// Runs `body` against a channel served by an in-process gRPC server, tearing down +/// the channel, server, and event loop group in reverse order of creation on every +/// exit path. +private func withInProcessChannel( + _ body: (GRPCChannel) async throws -> T +) async throws -> T { + var teardown: [() async throws -> Void] = [] + func unwind() async throws { + var firstError: Error? + // Setup locals are out of scope before unwind. Clear each closure after it + // runs so its transport owner is released before the event loop shuts down. + while !teardown.isEmpty { + var step: (() async throws -> Void)? = teardown.removeLast() + do { + try await step?() + } catch { + if firstError == nil { firstError = error } + } + step = nil + } + if let firstError { throw firstError } + } + let operation: Result + do { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + teardown.append { try await group.shutdownGracefully() } + let server = try await Server.insecure(group: group) + .withServiceProviders([FdlService()]) + .bind(host: "127.0.0.1", port: 0) + .get() + teardown.append { try await server.close().get() } + let channel = try GRPCChannelPool.with( + target: .host("127.0.0.1", port: server.channel.localAddress!.port!), + transportSecurity: .plaintext, + eventLoopGroup: group) + teardown.append { try await channel.close().get() } + + operation = .success(try await body(channel)) + } catch { + operation = .failure(error) + } + + do { + try await unwind() + } catch { + if case .success = operation { throw error } + } + return try operation.get() +} + +final class RoundTripTests: XCTestCase { + func testInProcessAllStreamingModes() async throws { + try await withInProcessChannel { channel in + try await exerciseAllStreamingModes(channel) + } + } + + private func exerciseAllStreamingModes(_ channel: GRPCChannel) async throws { + let client = GrpcFdl_FdlGrpcServiceAsyncClient(channel: channel) + let first = GrpcFdl.GrpcFdlRequest(id: "a", count: 1, payload: "alpha") + let requests = [first, GrpcFdl.GrpcFdlRequest(id: "b", count: 2, payload: "beta")] + + let unary = try await client.unaryMessage(first) + XCTAssertEqual(unary, response(first, "unary", 10)) + + var served: [GrpcFdl.GrpcFdlResponse] = [] + for try await message in client.serverStreamMessage(first) { served.append(message) } + XCTAssertEqual(served, [response(first, "server-0", 0), response(first, "server-1", 1), response(first, "server-2", 2)]) + + let aggregated = try await client.clientStreamMessage(stream(requests)) + XCTAssertEqual(aggregated, aggregate(requests)) + + var bidi: [GrpcFdl.GrpcFdlResponse] = [] + for try await message in client.bidiStreamMessage(stream(requests)) { bidi.append(message) } + XCTAssertEqual(bidi, [response(requests[0], "bidi-0", 0), response(requests[1], "bidi-1", 1)]) + } +} diff --git a/swift/Sources/Fory/FieldCodecs.swift b/swift/Sources/Fory/FieldCodecs.swift index 59e6b031c5..f8f8a1c299 100644 --- a/swift/Sources/Fory/FieldCodecs.swift +++ b/swift/Sources/Fory/FieldCodecs.swift @@ -206,10 +206,16 @@ public extension FieldCodec { hasDeclaredChildren: Bool ) throws { if refMode != .none { - if refMode == .tracking, isRefType { - let object = value as AnyObject - if context.refWriter.tryWriteRef(buffer: context.buffer, object: object) { - return + if refMode == .tracking { + if isRefType { + let object = value as AnyObject + if context.refWriter.tryWriteRef(buffer: context.buffer, object: object) { + return + } + } else { + // Peers that track every object assign this value a ref id too. + context.buffer.writeInt8(RefFlag.refValue.rawValue) + context.refWriter.reserveRefID() } } else { context.buffer.writeInt8(RefFlag.notNullValue.rawValue) diff --git a/swift/Sources/Fory/RefResolver.swift b/swift/Sources/Fory/RefResolver.swift index f7d0823afd..e3b16cfef9 100644 --- a/swift/Sources/Fory/RefResolver.swift +++ b/swift/Sources/Fory/RefResolver.swift @@ -36,6 +36,14 @@ public final class RefWriter { return false } + /// Claims a ref id slot without tracking a pointer, keeping ids aligned with peers. + @discardableResult + public func reserveRefID() -> UInt32 { + let id = nextRefID + nextRefID &+= 1 + return id + } + public func reset() { if !refs.isEmpty { refs.removeAll(keepingCapacity: true) diff --git a/swift/Sources/Fory/Serializer.swift b/swift/Sources/Fory/Serializer.swift index ca47fa7432..6e8359f5df 100644 --- a/swift/Sources/Fory/Serializer.swift +++ b/swift/Sources/Fory/Serializer.swift @@ -106,10 +106,16 @@ public extension Serializer { writeTypeInfo: Bool ) throws { if refMode != .none { - if refMode == .tracking, isRefType { - let object = value as AnyObject - if context.refWriter.tryWriteRef(buffer: context.buffer, object: object) { - return + if refMode == .tracking { + if isRefType { + let object = value as AnyObject + if context.refWriter.tryWriteRef(buffer: context.buffer, object: object) { + return + } + } else { + // Peers that track every object assign this value a ref id too. + context.buffer.writeInt8(RefFlag.refValue.rawValue) + context.refWriter.reserveRefID() } } else { context.buffer.writeInt8(RefFlag.notNullValue.rawValue) diff --git a/swift/Sources/ForyMacro/ForyObjectMacro.swift b/swift/Sources/ForyMacro/ForyObjectMacro.swift index f2547983e7..022cf052f5 100644 --- a/swift/Sources/ForyMacro/ForyObjectMacro.swift +++ b/swift/Sources/ForyMacro/ForyObjectMacro.swift @@ -2904,9 +2904,15 @@ private func buildWriteWrapperDecl(accessPrefix: String) -> String { ) throws { let __buffer = context.buffer if refMode != .none { - if refMode == .tracking, Self.isRefType, let object = value as AnyObject? { - if context.refWriter.tryWriteRef(buffer: __buffer, object: object) { - return + if refMode == .tracking { + if Self.isRefType, let object = value as AnyObject? { + if context.refWriter.tryWriteRef(buffer: __buffer, object: object) { + return + } + } else { + // Peers that track every object assign this value a ref id too. + __buffer.writeInt8(RefFlag.refValue.rawValue) + context.refWriter.reserveRefID() } } else { __buffer.writeInt8(RefFlag.notNullValue.rawValue)