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 3895ac645b..8cb1225061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1016,6 +1016,49 @@ 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: Build Swift gRPC peer + run: | + cd integration_tests/grpc_tests/swift/interop + swift build -c release --product interop + - 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..355c1c8193 100644 --- a/compiler/fory_compiler/cli.py +++ b/compiler/fory_compiler/cli.py @@ -22,15 +22,11 @@ import os import sys from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple 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 +36,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"} @@ -66,9 +64,9 @@ def is_generated_file(path: Path) -> bool: return GENERATED_MARKER in content -def scan_generated_files(root: Path, relative: bool) -> List[Path]: +def scan_generated_files(root: Path, relative: bool) -> list[Path]: """Scan for generated files under root.""" - matches: List[Path] = [] + matches: list[Path] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if not should_skip_dir(d)] for filename in filenames: @@ -89,9 +87,9 @@ def scan_generated_files(root: Path, relative: bool) -> List[Path]: def resolve_imports( file_path: Path, - import_paths: Optional[List[Path]] = None, - visited: Optional[Set[Path]] = None, - cache: Optional[Dict[Path, Schema]] = None, + import_paths: list[Path] | None = None, + visited: set[Path] | None = None, + cache: dict[Path, Schema] | None = None, ) -> Schema: """ Recursively resolve imports and merge all types into a single schema. @@ -134,7 +132,7 @@ def resolve_imports( imported_messages = [] imported_unions = [] resolved_import_files = [] - source_packages: Dict[str, Optional[str]] = {str(file_path): schema.package} + source_packages: dict[str, str | None] = {str(file_path): schema.package} for imp in schema.imports: # Resolve import path using search paths @@ -194,7 +192,7 @@ def annotate_source_package(schema: Schema) -> None: type_def.source_package = schema.package -def go_package_info(schema: Schema) -> Tuple[Optional[str], str]: +def go_package_info(schema: Schema) -> tuple[str | None, str]: """Return (import_path, package_name) for Go.""" go_package = schema.get_option("go_package") if go_package: @@ -211,10 +209,10 @@ def go_package_info(schema: Schema) -> Tuple[Optional[str], str]: def collect_schema_graph( file_path: Path, - import_paths: List[Path], - cache: Dict[Path, Schema], - visiting: Set[Path], -) -> Optional[List[Tuple[Path, Schema]]]: + import_paths: list[Path], + cache: dict[Path, Schema], + visiting: set[Path], +) -> list[tuple[Path, Schema]] | None: """Parse a schema and its imports before generated files are written.""" file_path = file_path.resolve() if file_path in visiting: @@ -260,7 +258,7 @@ def collect_schema_graph( return entries -def validate_kotlin_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: +def validate_kotlin_import_packages(graph: list[tuple[Path, Schema]]) -> bool: """Check package combinations that Kotlin source cannot compile.""" packages = {kotlin_package_for_schema(schema) for _, schema in graph} if None not in packages or len(packages) <= 1: @@ -279,11 +277,11 @@ def validate_kotlin_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: def validate_kotlin_output_paths( - graph: List[Tuple[Path, Schema]], + graph: list[tuple[Path, Schema]], grpc: bool = False, ) -> bool: """Check Kotlin output paths for the current generation run.""" - outputs: Dict[str, List[str]] = {} + outputs: dict[str, list[str]] = {} for path, schema in graph: for output_path, owner in kotlin_output_paths(schema, include_services=grpc): outputs.setdefault(output_path, []).append(f"{path} {owner}") @@ -305,13 +303,13 @@ def validate_kotlin_output_paths( def validate_kotlin_generation( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight Kotlin package and helper paths before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -323,13 +321,13 @@ def validate_kotlin_generation( def validate_csharp_files( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight C# generated paths and module owners before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -342,7 +340,28 @@ def validate_csharp_files( return False -def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: +def validate_swift_files( + files: list[Path], + import_paths: list[Path], + namespace_style: str | None = 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} if None not in packages or len(packages) <= 1: @@ -361,11 +380,11 @@ def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: def validate_scala_output_paths( - graph: List[Tuple[Path, Schema]], + graph: list[tuple[Path, Schema]], grpc: bool = False, ) -> bool: """Check Scala output paths for the current generation run.""" - outputs: Dict[str, List[str]] = {} + outputs: dict[str, list[str]] = {} for path, schema in graph: for output_path, owner in scala_output_paths(schema, include_services=grpc): outputs.setdefault(output_path, []).append(f"{path} {owner}") @@ -387,13 +406,13 @@ def validate_scala_output_paths( def validate_scala_generation( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight Scala package and helper paths before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -404,7 +423,7 @@ def validate_scala_generation( return validate_scala_output_paths(graph, grpc=grpc) -def _find_go_module_root(base_go_out: Path) -> Optional[Path]: +def _find_go_module_root(base_go_out: Path) -> Path | None: base_go_out = base_go_out.resolve() for candidate in (base_go_out, *base_go_out.parents): if (candidate / "go.mod").is_file(): @@ -412,7 +431,7 @@ def _find_go_module_root(base_go_out: Path) -> Optional[Path]: return None -def _read_go_module_path(go_module_root: Path) -> Optional[str]: +def _read_go_module_path(go_module_root: Path) -> str | None: module_file = go_module_root / "go.mod" if not module_file.is_file(): return None @@ -461,7 +480,7 @@ def resolve_go_output_dir(base_go_out: Path, schema: Schema) -> Path: return base_go_out -def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: +def parse_args(args: list[str] | None = None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( prog="foryc", @@ -670,7 +689,7 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: return parser.parse_args(args) -def normalize_args(args: Optional[List[str]]) -> List[str]: +def normalize_args(args: list[str] | None) -> list[str]: """Normalize args so compile is the default command.""" if args is None: args = sys.argv[1:] @@ -687,7 +706,7 @@ def normalize_args(args: Optional[List[str]]) -> List[str]: return args -def get_languages(lang_arg: str) -> List[str]: +def get_languages(lang_arg: str) -> list[str]: """Parse the language argument into a list of languages.""" if lang_arg == "all": return list(GENERATORS.keys()) @@ -706,19 +725,19 @@ def get_languages(lang_arg: str) -> List[str]: def compile_file( file_path: Path, - lang_output_dirs: Dict[str, Path], - import_paths: Optional[List[Path]] = None, - go_nested_type_style: Optional[str] = None, - swift_namespace_style: Optional[str] = None, + lang_output_dirs: dict[str, Path], + import_paths: list[Path] | None = None, + go_nested_type_style: str | None = None, + swift_namespace_style: str | None = None, emit_fdl: bool = False, - emit_fdl_path: Optional[Path] = None, - resolve_cache: Optional[Dict[Path, Schema]] = None, + emit_fdl_path: Path | None = None, + resolve_cache: dict[Path, Schema] | None = None, grpc: bool = False, grpc_web: bool = False, grpc_python_mode: str = "async", *, - generated_outputs: Optional[Dict[Path, Path]] = None, - generated_cpp_namespaces: Optional[Dict[str, Tuple[Path, str]]] = None, + generated_outputs: dict[Path, Path] | None = None, + generated_cpp_namespaces: dict[str, tuple[Path, str]] | None = None, ) -> bool: """Compile a single IDL file with import resolution. @@ -837,7 +856,7 @@ def compile_file( "cpp": "C++", "javascript": "JavaScript", }.get(lang, lang.capitalize()) - output_targets: List[Path] = [] + output_targets: list[Path] = [] for f in files: target = (lang_output / f.path).resolve() # Reject overwriting existing non-generated files @@ -874,18 +893,18 @@ def compile_file( def compile_file_recursive( file_path: Path, - lang_output_dirs: Dict[str, Path], - import_paths: List[Path], - go_nested_type_style: Optional[str], - swift_namespace_style: Optional[str], + lang_output_dirs: dict[str, Path], + import_paths: list[Path], + go_nested_type_style: str | None, + swift_namespace_style: str | None, emit_fdl: bool, - emit_fdl_path: Optional[Path], - generated: Set[Path], - stack: Set[Path], - resolve_cache: Dict[Path, Schema], - go_module_root: Optional[Path], - generated_outputs: Dict[Path, Path], - generated_cpp_namespaces: Dict[str, Tuple[Path, str]], + emit_fdl_path: Path | None, + generated: set[Path], + stack: set[Path], + resolve_cache: dict[Path, Schema], + go_module_root: Path | None, + generated_outputs: dict[Path, Path], + generated_cpp_namespaces: dict[str, tuple[Path, str]], grpc: bool = False, grpc_web: bool = False, grpc_python_mode: str = "async", @@ -1001,7 +1020,7 @@ def cmd_compile(args: argparse.Namespace) -> int: } # Determine which languages to generate - lang_output_dirs: Dict[str, Path] = {} + lang_output_dirs: dict[str, Path] = {} # First, add languages specified via --{lang}_out (these use direct paths) for lang, out_dir in lang_specific_outputs.items(): @@ -1023,7 +1042,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 +1063,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( @@ -1076,10 +1102,10 @@ def cmd_compile(args: argparse.Namespace) -> int: out_dir.mkdir(parents=True, exist_ok=True) success = True - generated: Set[Path] = set() - resolve_cache: Dict[Path, Schema] = {} - generated_outputs: Dict[Path, Path] = {} - generated_cpp_namespaces: Dict[str, Tuple[Path, str]] = {} + generated: set[Path] = set() + resolve_cache: dict[Path, Schema] = {} + generated_outputs: dict[Path, Path] = {} + generated_cpp_namespaces: dict[str, tuple[Path, str]] = {} for file_path in args.files: if not file_path.exists(): print(f"Error: File not found: {file_path}", file=sys.stderr) @@ -1134,7 +1160,7 @@ def cmd_scan_generated(args: argparse.Namespace) -> int: return 0 -def main(args: Optional[List[str]] = None) -> int: +def main(args: list[str] | None = None) -> int: """Main entry point.""" parsed = parse_args(normalize_args(args)) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py new file mode 100644 index 0000000000..76eab1efd8 --- /dev/null +++ b/compiler/fory_compiler/generators/services/swift.py @@ -0,0 +1,627 @@ +# 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 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_collisions(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 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_collisions(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 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.{module}"', + " 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.", + "// @unchecked Sendable: a transient per-message carrier handed to a single", + "// consumer and (de)serialized synchronously, never shared across threads,", + "// so grpc-swift's async APIs can move it under Swift 6 strict concurrency.", + ( + 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..0f5753f71e 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,21 @@ def output_file_path(self) -> str: return f"{package_path}/{file_name}" return file_name + def swift_model_top_level_symbols(self) -> set[str]: + # In enum style with a package, types live nested under a shared namespace + # enum, so there are no collidable top-level names. Flatten and default + # packages put each type and the module helper at file scope. + components = self._package_components_for_schema(self.schema) + if self.get_namespace_style() == "enum" and components: + return set() + symbols: set[str] = set() + for type_def in self.schema.enums + self.schema.unions + self.schema.messages: + if self.is_imported_type(type_def): + continue + symbols.add(self._declared_type_name(type_def.name)) + symbols.add(self._module_helper_name_for_schema(self.schema)) + return symbols + 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 +1485,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_model_top_level_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..161a8888a0 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -17,28 +17,30 @@ """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 import pytest 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 @@ -57,8 +59,7 @@ from fory_compiler.ir.ast import Schema from fory_compiler.ir.validator import SchemaValidator - -GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = ( +GENERATOR_CLASSES: tuple[type[BaseGenerator], ...] = ( JavaGenerator, PythonGenerator, CppGenerator, @@ -120,16 +121,16 @@ def parse_fbs(source: str) -> Schema: def generate_files( - schema: Schema, generator_cls: Type[BaseGenerator] -) -> Dict[str, str]: + schema: Schema, generator_cls: type[BaseGenerator] +) -> dict[str, str]: options = GeneratorOptions(output_dir=Path("/tmp")) generator = generator_cls(schema, options) return {item.path: item.content for item in generator.generate()} def generate_service_files( - schema: Schema, generator_cls: Type[BaseGenerator] -) -> Dict[str, str]: + schema: Schema, generator_cls: type[BaseGenerator] +) -> dict[str, str]: options = GeneratorOptions(output_dir=Path("/tmp"), grpc=True) generator = generator_cls(schema, options) return {item.path: item.content for item in generator.generate_services()} @@ -158,6 +159,7 @@ def test_unsupported_generators_no_services(): ScalaGenerator, KotlinGenerator, JavaScriptGenerator, + SwiftGenerator, DartGenerator, CppGenerator, ): @@ -972,6 +974,173 @@ def test_scala_grpc_marshaller(): assert "org.apache.fory.scala.grpc" not in content +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) + + +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 +1300,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 +2051,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 +2146,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 +2174,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 +2492,24 @@ def test_csharp_grpc_dotnet_fixture(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr +def test_swift_grpc_common_root_package_collision(): + # Two schemas that share a top-level package component each emit `public enum + # Demo`, an invalid redeclaration when compiled into one Swift module. This is a + # model-generator limitation, not gRPC specific; gRPC companions inherit it, and + # the workaround is disjoint top-level packages. The build-level proof lives in + # the Swift toolchain tests under integration_tests/grpc_tests/swift. + shared = generate_files( + parse_fdl("package demo.shared;\nmessage SharedRequest { string name = 1; }\n"), + SwiftGenerator, + ) + greeter = generate_files( + parse_fdl("package demo.greeter;\nmessage LocalRequest { string name = 1; }\n"), + SwiftGenerator, + ) + assert "public enum Demo {" in next(iter(shared.values())) + assert "public enum Demo {" in next(iter(greeter.values())) + + def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) @@ -3518,10 +3750,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 f2c2994232..4258e00634 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..11b606e9b2 100644 --- a/docs/compiler/generated-code/swift.md +++ b/docs/compiler/generated-code/swift.md @@ -106,3 +106,7 @@ 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. + +## 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..dece001baf --- /dev/null +++ b/docs/grpc/swift.md @@ -0,0 +1,235 @@ +--- +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", from: "1.2.0"), + .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). The Fory wire wrapper is `Sendable`, but the async client's +client-streaming and bidirectional methods pass your request and response model +types through grpc-swift's `Sendable` streaming APIs, and the generated Fory +Swift models are not yet `Sendable`. Server providers, the unary client, and the +server-streaming client build under Swift 6 strict concurrency; full Swift 6 +support for the streaming client follows once generated models are `Sendable`. + +## 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. + +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`. Swift treats that as an invalid redeclaration when +both compile into one module. 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`), or compile them as separate Swift modules. + +## 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 953619cd93..2fb324c6c2 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 | 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..514e5add1b --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -39,6 +39,7 @@ "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", } @@ -84,6 +85,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", ], 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..9c405fc016 --- /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("release").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..50b7e6423d 100755 --- a/integration_tests/grpc_tests/run_tests.sh +++ b/integration_tests/grpc_tests/run_tests.sh @@ -21,7 +21,7 @@ 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}" has_test_class() { [[ ",${TEST_CLASSES}," == *",$1,"* ]] @@ -56,6 +56,19 @@ 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 command -v swift >/dev/null 2>&1 && [ -d "${SCRIPT_DIR}/swift/interop" ]; then + cd "${SCRIPT_DIR}/swift/interop" + swift test + if [[ "${FORY_SWIFT_TSAN:-}" == "1" ]]; then + swift test --sanitize=thread + fi +fi +if has_test_class "SwiftGrpcTest"; then + cd "${SCRIPT_DIR}/swift/interop" + swift build -c release --product interop +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..a720271441 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/.gitignore @@ -0,0 +1,3 @@ +.build/ +Package.resolved +Sources/Generated/ 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..9037abb980 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Package.swift @@ -0,0 +1,39 @@ +// 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" + ), + .executableTarget( + name: "interop", + dependencies: [ + "ForyGrpcGenerated", + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/Interop" + ), + .testTarget( + name: "ForyGrpcTests", + dependencies: [ + "ForyGrpcGenerated", + .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..4363cb6a19 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Sources/Interop/main.swift @@ -0,0 +1,468 @@ +// 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) + defer { try? channel.close().wait() } + try await exerciseFdl(channel) + try await exerciseFbs(channel) + try await exercisePb(channel) + 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..9d6f78685f --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift @@ -0,0 +1,61 @@ +// 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 XCTest + +@testable import ForyGrpcGenerated + +// The generated marshaller uses one Fory per thread. Run it from many threads +// (under `swift test --sanitize=thread` in CI) to prove there is no data race, +// and confirm it stays wire-compatible with the schema module's shared Fory. +final class MarshallerThreadSafetyTests: XCTestCase { + func testConcurrentRoundTrip() { + 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) + XCTAssertEqual(back.value, request) + } catch { + XCTFail("marshaller round-trip failed: \(error)") + } + } + } + + func testWireCompatibleWithModuleFory() 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) + XCTAssertEqual(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)) + XCTAssertEqual(fromMarshaller, probe) + } +} 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..170f43b257 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift @@ -0,0 +1,144 @@ +// 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) } + } +} + +// Hosts the generated provider in-process and exercises the generated async client +// across all four streaming modes (the relocated SwiftPM build-and-run fixture). +final class RoundTripTests: XCTestCase { + func testInProcessAllStreamingModes() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + let server = try await Server.insecure(group: group) + .withServiceProviders([FdlService()]) + .bind(host: "127.0.0.1", port: 0) + .get() + defer { try? server.close().wait() } + let port = server.channel.localAddress!.port! + let channel = try GRPCChannelPool.with( + target: .host("127.0.0.1", port: port), + transportSecurity: .plaintext, + eventLoopGroup: group) + defer { try? channel.close().wait() } + + 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 c82f2f4ece..b806c0370f 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 d0b9e52a29..25eb643445 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 5156024a48..1e06e71ca6 100644 --- a/swift/Sources/ForyMacro/ForyObjectMacro.swift +++ b/swift/Sources/ForyMacro/ForyObjectMacro.swift @@ -2901,9 +2901,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)