diff --git a/.agents/languages/swift.md b/.agents/languages/swift.md index c4cf4030bc..8db24d1715 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/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..fa56ea0f1e --- /dev/null +++ b/compiler/fory_compiler/generators/services/swift.py @@ -0,0 +1,609 @@ +# 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 {{", + " 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 {{", + 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 {{", + 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 {{", + 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 {{", + " 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 {{", + " 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 2d63b2ecee..4be96ae6fd 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -17,27 +17,29 @@ """Codegen smoke tests for schemas that contain service definitions.""" -from pathlib import Path 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 @@ -56,8 +58,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, @@ -119,16 +120,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()} @@ -157,6 +158,7 @@ def test_unsupported_generators_no_services(): ScalaGenerator, KotlinGenerator, JavaScriptGenerator, + SwiftGenerator, DartGenerator, ): continue @@ -971,6 +973,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( @@ -1104,6 +1273,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) @@ -1822,6 +2024,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( @@ -1911,13 +2119,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( @@ -1936,9 +2147,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 ( @@ -2248,6 +2462,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) @@ -3578,10 +3810,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/compiler-guide.md b/docs/compiler/compiler-guide.md index f5a77692af..0840d1a25a 100644 --- a/docs/compiler/compiler-guide.md +++ b/docs/compiler/compiler-guide.md @@ -149,20 +149,22 @@ 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#, Dart, Scala, Kotlin, and Node.js JavaScript gRPC service companions:** +**Generate Java, Python, Go, Rust, 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 --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 --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`, 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. @@ -425,6 +427,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/guide/swift/grpc-support.md b/docs/guide/swift/grpc-support.md new file mode 100644 index 0000000000..af7307809f --- /dev/null +++ b/docs/guide/swift/grpc-support.md @@ -0,0 +1,235 @@ +--- +title: gRPC Support +sidebar_position: 12 +id: grpc_support +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/guide/swift/index.md b/docs/guide/swift/index.md index 721864fc64..e5fcaee353 100644 --- a/docs/guide/swift/index.md +++ b/docs/guide/swift/index.md @@ -61,6 +61,7 @@ targets: [ - [Shared and Circular References](references.md) - [Polymorphism and Dynamic Types](polymorphism.md) - [Schema Evolution](schema-evolution.md) +- [gRPC Support](grpc-support.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 f2950c1fe5..91684da80c --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -38,6 +38,7 @@ "rust": TEST_DIR / "rust/generated/src", "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", } @@ -82,6 +83,7 @@ def main() -> int: f"--rust_out={OUTPUTS['rust']}", 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/run_tests.sh b/integration_tests/grpc_tests/run_tests.sh index 784f2f05ee..01f688efd9 100755 --- a/integration_tests/grpc_tests/run_tests.sh +++ b/integration_tests/grpc_tests/run_tests.sh @@ -52,6 +52,15 @@ 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 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..a95a41d884 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Package.swift @@ -0,0 +1,30 @@ +// 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" + ), + .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/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/java/fory-core/src/main/java/org/apache/fory/Fory.java b/java/fory-core/src/main/java/org/apache/fory/Fory.java index a7916400ae..4ec96eb9df 100644 --- a/java/fory-core/src/main/java/org/apache/fory/Fory.java +++ b/java/fory-core/src/main/java/org/apache/fory/Fory.java @@ -561,6 +561,7 @@ private T deserializeByType(MemoryBuffer buffer, Class type) { return (T) refReader.getReadRef(); } TypeInfo typeInfo = typeResolver.readTypeInfo(readContext, type); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object value = readContext.readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, value); return (T) value; diff --git a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java index 7bdda7394c..23b613dafc 100644 --- a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java +++ b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java @@ -2178,13 +2178,18 @@ private Expression readRef( // indicates that the object is first read. Expression needDeserialize = ExpressionUtils.egt(refId, new Literal(Fory.NOT_NULL_VALUE_FLAG, PRIMITIVE_BYTE_TYPE)); + // a peer without ref tracking for this type sends NOT_NULL with no id preserved; reserve a + // placeholder so the nested read's `readContext.reference` stays balanced + Expression preserveNotNullRefId = invokeReadContext("preserveNotNullValueRefId", refId); Expression deserializedValue = deserializeForNotNull.get(); Expression setReadRef = invokeReadContext("setReadRef", refId, deserializedValue); Expression readValue = inlineInvokeReadContext("getReadRef", OBJECT_TYPE); // use false to ignore null return new If( needDeserialize, - callback.apply(new ListExpression(refId, deserializedValue, setReadRef, deserializedValue)), + callback.apply( + new ListExpression( + refId, preserveNotNullRefId, deserializedValue, setReadRef, deserializedValue)), callback.apply(readValue), false); } diff --git a/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java b/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java index 75e23df68e..d428c1d4bc 100644 --- a/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java +++ b/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java @@ -384,6 +384,34 @@ public int tryPreserveRefId() { return refReader.tryPreserveRefId(buffer); } + /** + * Reserves a no-op ref id when a {@link Fory#NOT_NULL_VALUE_FLAG} header precedes a serializer + * whose read binds its instance through {@link #reference(Object)}. + * + *

A peer may disable reference tracking globally or per type and send {@code NOT_NULL} where + * this runtime preserved no id, so tracked reads must reserve a placeholder to keep the + * serializer's {@code reference} pop balanced. {@link #setReadRef(int, Object)} ignores the + * negative id. Must be called only after the value's serializer is resolved; a {@code REF_VALUE} + * header already preserved the real id and pushing again would shadow it, breaking circular + * references. + */ + public void preserveNotNullValueRefId(int nextReadRefId, Serializer serializer) { + if (nextReadRefId == Fory.NOT_NULL_VALUE_FLAG && serializer.needToWriteRef()) { + refReader.preserveRefId(-1); + } + } + + /** + * Variant of {@link #preserveNotNullValueRefId(int, Serializer)} for generated codecs, which only + * emit ref headers for declared types that participate in ref tracking. Kept to a single int + * argument so the generated call stays small enough for method-size budgets. + */ + public void preserveNotNullValueRefId(int nextReadRefId) { + if (nextReadRefId == Fory.NOT_NULL_VALUE_FLAG) { + refReader.preserveRefId(-1); + } + } + /** Returns the last ref id preserved by the active {@link RefReader}. */ public int lastPreservedRefId() { return refReader.lastPreservedRefId(); @@ -569,6 +597,7 @@ public Object readRef() { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { TypeInfo typeInfo = typeResolver.readTypeInfo(this); + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -580,6 +609,7 @@ public Object readRef() { public Object readRef(TypeInfo typeInfo) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -592,6 +622,7 @@ public Object readRef(TypeInfoHolder classInfoHolder) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { TypeInfo typeInfo = typeResolver.readTypeInfo(this, classInfoHolder); + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -604,6 +635,7 @@ public T readRef(Serializer serializer) { if (serializer.needToWriteRef()) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + preserveNotNullValueRefId(nextReadRefId, serializer); Object o = readNonRef(serializer); refReader.setReadRef(nextReadRefId, o); return (T) o; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java index 0681127faf..b0ca7899c4 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java @@ -190,11 +190,10 @@ static Object readField( if (refMode == RefMode.TRACKING) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - Object value = - typeResolver - .readTypeInfo(readContext, fieldInfo.type) - .getSerializer() - .read(readContext); + Serializer serializer = + typeResolver.readTypeInfo(readContext, fieldInfo.type).getSerializer(); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); + Object value = serializer.read(readContext); refReader.setReadRef(nextReadRefId, value); return value; } @@ -554,11 +553,16 @@ private static Object readContainerFieldValueRef( if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { Object value; if (fieldInfo.containerSerializerOverride != null) { + readContext.preserveNotNullValueRefId(nextReadRefId, fieldInfo.containerSerializerOverride); value = readContext.readNonRef(fieldInfo.containerSerializerOverride); } else if (readContext.getConfig().isXlang()) { + readContext.preserveNotNullValueRefId( + nextReadRefId, fieldInfo.containerTypeInfo.getSerializer()); value = readContext.readNonRef(fieldInfo.containerTypeInfo); } else { - value = readContext.readData(typeResolver.readTypeInfo(readContext)); + TypeInfo typeInfo = typeResolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); + value = readContext.readData(typeInfo); } refReader.setReadRef(nextReadRefId, value); return value; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java b/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java index 039ac96c5c..08f178ef69 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java @@ -579,8 +579,11 @@ private static Object readRefElement( Class declaredElementType) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - Object element = - readNonRefElement(readContext, typeResolver, elementTypeInfoHolder, declaredElementType); + TypeInfo elementTypeInfo = typeResolver.readTypeInfo(readContext, elementTypeInfoHolder); + Serializer serializer = + resolveElementSerializer(typeResolver, declaredElementType, elementTypeInfo); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); + Object element = readElement(readContext, elementTypeInfo, serializer); readContext.setReadRef(nextReadRefId, element); return element; } @@ -595,6 +598,16 @@ private static Object readNonRefElement( TypeInfo elementTypeInfo = typeResolver.readTypeInfo(readContext, elementTypeInfoHolder); Serializer serializer = resolveElementSerializer(typeResolver, declaredElementType, elementTypeInfo); + if (serializer.needToWriteRef()) { + // elements written without ref flags may still bind through `readContext.reference`; + // reserve a placeholder so the pop stays balanced + readContext.preserveRefId(-1); + } + return readElement(readContext, elementTypeInfo, serializer); + } + + private static Object readElement( + ReadContext readContext, TypeInfo elementTypeInfo, Serializer serializer) { if (serializer == elementTypeInfo.getSerializer()) { return readContext.readNonRef(elementTypeInfo); } diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java index c07ae31326..b95331dee1 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java @@ -398,7 +398,9 @@ public Object read(ReadContext readContext) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { // ref value or not-null value - Object o = readContext.readData(classResolver.readTypeInfo(readContext)); + TypeInfo typeInfo = classResolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); + Object o = readContext.readData(typeInfo); readContext.setReadRef(nextReadRefId, o); readContext.setReadRef(outerRefId, o); return o; @@ -409,7 +411,9 @@ public Object read(ReadContext readContext) { int outerRefId = readContext.lastPreservedRefId(); int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - // ref value or not-null value + // ref value or not-null value; the nested JDK object serializer binds through + // `readContext.reference`, so mirror this serializer's ref participation + readContext.preserveNotNullValueRefId(nextReadRefId, this); Object o = readObject(readContext); readContext.setReadRef(nextReadRefId, o); readContext.setReadRef(outerRefId, o); diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java index 9e76be18e4..569a38d994 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java @@ -153,6 +153,9 @@ public T read(ReadContext readContext, RefMode refMode) { T obj; int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + // a peer without ref tracking for this type sends NOT_NULL; reserve a placeholder so a + // read implementation that invokes `readContext.reference` stays balanced + readContext.preserveNotNullValueRefId(nextReadRefId, this); obj = read(readContext); readContext.setReadRef(nextReadRefId, obj); return obj; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java index aadd270719..5c5aae96eb 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java @@ -179,9 +179,11 @@ public Union read(ReadContext readContext) { TypeInfo readTypeInfo = resolver.readTypeInfo(readContext, declared); Serializer serializer = getCaseSerializer(caseId, readTypeInfo.getTypeId(), declared); GenericType genericType = getCaseGenericType(caseId, readTypeInfo.getTypeId()); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); caseValue = readCaseValue(readContext, serializer, genericType); } else { TypeInfo readTypeInfo = resolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, readTypeInfo.getSerializer()); caseValue = Serializers.read(readContext, readTypeInfo.getSerializer()); } readContext.setReadRef(nextReadRefId, caseValue); @@ -341,6 +343,7 @@ public static Object readCaseValue( readTypeInfo = resolver.readTypeInfo(readContext); } Serializer serializer = getCaseSerializer(fieldInfo, readTypeInfo.getTypeId(), readTypeInfo); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); Object caseValue = readCaseValue( readContext, serializer, getCaseGenericType(fieldInfo, readTypeInfo.getTypeId())); diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java index d029fe5e5d..1fb468fc95 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java @@ -52,6 +52,7 @@ public static UnknownCase readPayload(ReadContext readContext, int caseId) { // UnknownCase owns only the union payload envelope. It does not create a separate // depth frame; nested payload serializers and the root-context reset own depth state. TypeInfo typeInfo = readContext.getTypeResolver().readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object value = readContext.readNonRef(typeInfo); readContext.setReadRef(nextReadRefId, value); return UnknownCase.ofRuntime(caseId, typeInfo.getTypeId(), value); diff --git a/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java b/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java new file mode 100644 index 0000000000..e75a9caad1 --- /dev/null +++ b/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java @@ -0,0 +1,205 @@ +/* + * 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.serializer; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.fory.Fory; +import org.apache.fory.ForyTestBase; +import org.testng.annotations.Test; + +/** + * Reading {@code NOT_NULL_VALUE_FLAG} payloads with a reference-tracking reader. + * + *

A peer may disable reference tracking globally or per type and legitimately send {@code + * NOT_NULL} headers where a tracking reader preserved no read ref id. Reads that bind instances + * through {@code ReadContext#reference} must stay balanced on such input instead of popping an + * outer frame's id or underflowing the ref id stack. + */ +public class NotNullValueRefTrackingTest extends ForyTestBase { + + private static Fory fory(boolean trackingRef, boolean codegen) { + return builder().withRefTracking(trackingRef).withCodegen(codegen).build(); + } + + public static class Inner implements Serializable { + public int id; + public String name; + + public Inner() {} + + public Inner(int id, String name) { + this.id = id; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Inner)) { + return false; + } + Inner inner = (Inner) o; + return id == inner.id && Objects.equals(name, inner.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + } + + public static class Outer implements Serializable { + public String label; + public Inner inner; + public List inners; + public Map innerMap; + + @Override + public boolean equals(Object o) { + if (!(o instanceof Outer)) { + return false; + } + Outer outer = (Outer) o; + return Objects.equals(label, outer.label) + && Objects.equals(inner, outer.inner) + && Objects.equals(inners, outer.inners) + && Objects.equals(innerMap, outer.innerMap); + } + + @Override + public int hashCode() { + return Objects.hash(label, inner, inners, innerMap); + } + } + + public static class PolymorphicHolder implements Serializable { + public Object stringValue; + public Object numberValue; + public Object structValue; + + @Override + public boolean equals(Object o) { + if (!(o instanceof PolymorphicHolder)) { + return false; + } + PolymorphicHolder holder = (PolymorphicHolder) o; + return Objects.equals(stringValue, holder.stringValue) + && Objects.equals(numberValue, holder.numberValue) + && Objects.equals(structValue, holder.structValue); + } + + @Override + public int hashCode() { + return Objects.hash(stringValue, numberValue, structValue); + } + } + + public static class SelfReferencing implements Serializable { + public String name; + public SelfReferencing self; + public Inner shared1; + public Inner shared2; + } + + private static Outer newOuter() { + Outer outer = new Outer(); + outer.label = "outer"; + outer.inner = new Inner(1, "a"); + outer.inners = new ArrayList<>(Arrays.asList(new Inner(2, "b"), new Inner(3, "c"))); + outer.innerMap = new HashMap<>(); + outer.innerMap.put("k", new Inner(4, "d")); + return outer; + } + + @Test(dataProvider = "enableCodegen") + public void testStructFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + Outer outer = newOuter(); + assertEquals(reader.deserialize(writer.serialize(outer)), outer); + } + + @Test(dataProvider = "enableCodegen") + public void testTypedRootFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + Outer outer = newOuter(); + assertEquals(reader.deserialize(writer.serialize(outer), Outer.class), outer); + } + + @Test(dataProvider = "enableCodegen") + public void testContainerRootsFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + List list = new ArrayList<>(Arrays.asList(new Inner(1, "a"), new Inner(2, "b"))); + assertEquals(reader.deserialize(writer.serialize(list)), list); + Map map = new HashMap<>(); + map.put("k1", new Inner(3, "c")); + map.put("k2", new Inner(4, "d")); + assertEquals(reader.deserialize(writer.serialize(map)), map); + Object[] array = new Object[] {new Inner(5, "e"), "plain", 42}; + assertEquals(reader.deserialize(writer.serialize(array)), array); + } + + @Test(dataProvider = "enableCodegen") + public void testPolymorphicFieldsFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + PolymorphicHolder holder = new PolymorphicHolder(); + holder.stringValue = "value"; + holder.numberValue = 42L; + holder.structValue = new Inner(1, "a"); + assertEquals(reader.deserialize(writer.serialize(holder)), holder); + } + + @Test(dataProvider = "enableCodegen") + public void testSharedAndCircularRefsStillTracked(boolean codegen) { + Fory fory = fory(true, codegen); + SelfReferencing bean = new SelfReferencing(); + bean.name = "root"; + bean.self = bean; + Inner shared = new Inner(1, "shared"); + bean.shared1 = shared; + bean.shared2 = shared; + SelfReferencing read = (SelfReferencing) fory.deserialize(fory.serialize(bean)); + assertEquals(read.name, "root"); + assertSame(read.self, read); + assertSame(read.shared1, read.shared2); + assertEquals(read.shared1, shared); + } + + @Test + public void testXlangStructFromUntrackedPeer() { + Fory writer = Fory.builder().withXlang(true).withRefTracking(false).build(); + writer.register(Inner.class, 101); + Fory reader = Fory.builder().withXlang(true).withRefTracking(true).build(); + reader.register(Inner.class, 101); + Inner inner = new Inner(7, "xlang"); + assertEquals(reader.deserialize(writer.serialize(inner)), inner); + } +}