This is an automated email from the ASF dual-hosted git repository. chaokunyang pushed a commit to branch add_csharp_grpc_support in repository https://gitbox.apache.org/repos/asf/fory.git
commit 809d618139a20a7d1981e35f518e5995d4063b18 Author: 慕白 <[email protected]> AuthorDate: Sat Jun 13 13:00:28 2026 +0800 feat(csharp): add generated grpc support --- .agents/languages/csharp.md | 2 + compiler/fory_compiler/cli.py | 34 +- compiler/fory_compiler/generators/csharp.py | 139 ++- .../fory_compiler/generators/services/csharp.py | 590 +++++++++++++ .../fory_compiler/tests/test_csharp_generator.py | 199 ++++- .../fory_compiler/tests/test_service_codegen.py | 945 ++++++++++++++++++++- docs/compiler/compiler-guide.md | 18 +- docs/compiler/flatbuffers-idl.md | 12 +- docs/compiler/generated-code.md | 61 ++ docs/compiler/index.md | 15 +- docs/compiler/protobuf-idl.md | 39 +- docs/compiler/schema-idl.md | 6 +- docs/guide/csharp/grpc-support.md | 220 +++++ docs/guide/csharp/index.md | 1 + docs/guide/csharp/troubleshooting.md | 22 +- integration_tests/grpc_tests/generate_grpc.py | 2 + 16 files changed, 2195 insertions(+), 110 deletions(-) diff --git a/.agents/languages/csharp.md b/.agents/languages/csharp.md index 891339a8f..098f9a50f 100644 --- a/.agents/languages/csharp.md +++ b/.agents/languages/csharp.md @@ -9,6 +9,8 @@ Load this file when changing `csharp/` or C# xlang behavior. - C# code must build without compiler or analyzer warnings. Treat warnings as blockers in project, test, and generated code. - Fory C# requires .NET SDK `8.0+` and C# `12+`. - Use `dotnet format` to keep C# code style consistent. +- Generated C# gRPC service companions are compiler-owned files that depend on application-provided gRPC packages, not `csharp/src/Fory`. Keep gRPC package references out of the Fory runtime package. +- C# generated schema modules are source-file owners. Service companions must use that module's `ThreadSafeFory` and must not introduce namespace-owned aliases or duplicate serializer registration paths. - Compatible scalar, list-array, and binary/uint8-array adaptations are immediate-field-only. Recursive matched-field comparison for collection elements, array elements, map keys, and map values must require exact nullability, ref tracking, generic arity, and type shape except documented user-type family normalization. - When extending C# tests from Java references, prioritize xlang spec behavior and the public C# contract before adding complex Java-specific parity cases. diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py index a654f6a4e..13dfbef24 100644 --- a/compiler/fory_compiler/cli.py +++ b/compiler/fory_compiler/cli.py @@ -31,6 +31,7 @@ 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.csharp import validate_csharp_generation from fory_compiler.generators.kotlin import ( kotlin_output_paths, kotlin_package_for_schema, @@ -317,6 +318,26 @@ def validate_kotlin_generation( return validate_kotlin_output_paths(graph, grpc=grpc) +def validate_csharp_files( + 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]] = [] + 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_csharp_generation(graph, grpc=grpc) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return False + + def _find_go_module_root(base_go_out: Path) -> Optional[Path]: base_go_out = base_go_out.resolve() for candidate in (base_go_out, *base_go_out.parents): @@ -700,15 +721,17 @@ def compile_file( print(f"Error: {e}", file=sys.stderr) return False - if lang == "rust": - # Special error handling for Rust + if lang in {"rust", "csharp"}: + # Special error handling for languages with run-wide generated path + # validation. + display_lang = "C#" if lang == "csharp" else "Rust" output_targets: List[Path] = [] for f in files: target = (lang_output / f.path).resolve() # Reject overwriting existing non-generated files if target.exists() and not is_generated_file(target): print( - f"Error: Rust output path collision: {target} already exists", + f"Error: {display_lang} output path collision: {target} already exists", file=sys.stderr, ) return False @@ -716,7 +739,7 @@ def compile_file( previous_source = generated_outputs.get(target) if previous_source is not None and previous_source != file_path: print( - "Error: Rust output path collision: " + f"Error: {display_lang} output path collision: " f"{previous_source} and {file_path} both generate {target}", file=sys.stderr, ) @@ -899,6 +922,9 @@ def cmd_compile(args: argparse.Namespace) -> int: 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 # Create output directories for out_dir in lang_output_dirs.values(): diff --git a/compiler/fory_compiler/generators/csharp.py b/compiler/fory_compiler/generators/csharp.py index ee232e2e6..67395fddf 100644 --- a/compiler/fory_compiler/generators/csharp.py +++ b/compiler/fory_compiler/generators/csharp.py @@ -18,10 +18,12 @@ """C# code generator.""" from pathlib import Path +import re from typing import Dict, List, Optional, Set, Tuple, Union as TypingUnion from fory_compiler.frontend.utils import parse_idl_file from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.generators.services.csharp import CSharpServiceMixin from fory_compiler.ir.ast import ( ArrayType, Enum, @@ -38,7 +40,101 @@ from fory_compiler.ir.ast import ( from fory_compiler.ir.types import PrimitiveKind -class CSharpGenerator(BaseGenerator): +def csharp_namespace_for_schema(schema: Schema) -> str: + value = schema.get_option("csharp_namespace") + if value: + return str(value) + if schema.package: + return schema.package + return "generated" + + +def csharp_module_file_name(schema: Schema) -> str: + if schema.source_file and not schema.source_file.startswith("<"): + return f"{Path(schema.source_file).stem}.cs" + if schema.package: + return f"{schema.package.replace('.', '_')}.cs" + return "generated.cs" + + +def csharp_module_owner_stem(schema: Schema) -> str: + if schema.source_file and not schema.source_file.startswith("<"): + return Path(schema.source_file).stem + if schema.package: + return schema.package.replace(".", "_") + return "generated" + + +def csharp_module_class_name(schema: Schema) -> str: + stem = re.sub(r"[^0-9A-Za-z_]", "_", csharp_module_owner_stem(schema)) + parts = [part for part in stem.lower().split("_") if part] + class_name = "".join(part.capitalize() for part in parts) + if not class_name or not (class_name[0].isalpha() or class_name[0] == "_"): + class_name = f"Schema{class_name}" + return f"{class_name}ForyModule" + + +def csharp_output_paths( + schema: Schema, include_services: bool = False +) -> List[Tuple[str, str]]: + namespace_name = csharp_namespace_for_schema(schema) + namespace_path = namespace_name.replace(".", "/") if namespace_name else "" + model_file = csharp_module_file_name(schema) + model_path = f"{namespace_path}/{model_file}" if namespace_path else model_file + outputs = [(model_path, f"schema module {csharp_module_class_name(schema)}")] + if include_services: + for service in schema.services: + file_name = f"{service.name}Grpc.cs" + path = f"{namespace_path}/{file_name}" if namespace_path else file_name + outputs.append((path, f"service {service.name}")) + return outputs + + +def validate_csharp_generation( + graph: List[Tuple[Path, Schema]], grpc: bool = False +) -> bool: + output_owners: Dict[str, List[str]] = {} + module_owners: Dict[Tuple[str, str], List[str]] = {} + for path, schema in graph: + for output_path, owner in csharp_output_paths(schema, include_services=grpc): + output_owners.setdefault(output_path, []).append(f"{path} {owner}") + namespace_name = csharp_namespace_for_schema(schema) + module_name = csharp_module_class_name(schema) + module_owners.setdefault((namespace_name, module_name), []).append(str(path)) + + output_collisions = { + output_path: owners + for output_path, owners in output_owners.items() + if len(owners) > 1 + } + if output_collisions: + details = ", ".join( + f"{output_path}: {', '.join(owners)}" + for output_path, owners in sorted(output_collisions.items()) + ) + raise ValueError( + "C# generated file path collision; rename schema files or services, " + f"or use distinct C# namespaces. Collisions: {details}" + ) + + module_collisions = { + owner: paths for owner, paths in module_owners.items() if len(paths) > 1 + } + if module_collisions: + details = ", ".join( + f"{namespace_name}.{module_name}: {', '.join(paths)}" + for (namespace_name, module_name), paths in sorted( + module_collisions.items() + ) + ) + raise ValueError( + "C# schema module owner collision; rename schema files or use " + f"distinct C# namespaces. Collisions: {details}" + ) + return True + + +class CSharpGenerator(CSharpServiceMixin, BaseGenerator): """Generates C# models and registration helpers for Apache Fory.""" language_name = "csharp" @@ -196,29 +292,16 @@ class CSharpGenerator(BaseGenerator): visit_message(message, []) def get_csharp_namespace(self) -> str: - csharp_ns = self.schema.get_option("csharp_namespace") - if csharp_ns: - return str(csharp_ns) - if self.schema.package: - return self.schema.package - return "generated" + return csharp_namespace_for_schema(self.schema) def get_module_class_name(self) -> str: - return self._module_class_name_for_namespace(self.get_csharp_namespace()) + return csharp_module_class_name(self.schema) - def _module_class_name_for_namespace(self, namespace_name: str) -> str: - if namespace_name: - leaf = namespace_name.split(".")[-1] - else: - leaf = "generated" - return f"{self.to_pascal_case(leaf)}ForyModule" + def _module_class_name_for_schema(self, schema: Schema) -> str: + return csharp_module_class_name(schema) def _module_file_name(self) -> str: - if self.schema.source_file and not self.schema.source_file.startswith("<"): - return f"{Path(self.schema.source_file).stem}.cs" - if self.schema.package: - return f"{self.schema.package.replace('.', '_')}.cs" - return "generated.cs" + return csharp_module_file_name(self.schema) def _namespace_path(self, namespace_name: str) -> str: return namespace_name.replace(".", "/") if namespace_name else "" @@ -313,12 +396,7 @@ class CSharpGenerator(BaseGenerator): return schema def _csharp_namespace_for_schema(self, schema: Schema) -> str: - value = schema.get_option("csharp_namespace") - if value: - return str(value) - if schema.package: - return schema.package - return "generated" + return csharp_namespace_for_schema(schema) def _csharp_namespace_for_type(self, type_def: object) -> str: location = getattr(type_def, "location", None) @@ -344,7 +422,7 @@ class CSharpGenerator(BaseGenerator): if imported_schema is None: continue namespace_name = self._csharp_namespace_for_schema(imported_schema) - module_name = self._module_class_name_for_namespace(namespace_name) + module_name = self._module_class_name_for_schema(imported_schema) file_info[normalized] = (namespace_name, module_name) ordered: List[Tuple[str, str]] = [] @@ -365,14 +443,7 @@ class CSharpGenerator(BaseGenerator): continue ordered.append(file_info[key]) - deduped: List[Tuple[str, str]] = [] - seen: Set[Tuple[str, str]] = set() - for item in ordered: - if item in seen: - continue - seen.add(item) - deduped.append(item) - return deduped + return ordered def generate(self) -> List[GeneratedFile]: return [self.generate_file()] diff --git a/compiler/fory_compiler/generators/services/csharp.py b/compiler/fory_compiler/generators/services/csharp.py new file mode 100644 index 000000000..1fa5fe11d --- /dev/null +++ b/compiler/fory_compiler/generators/services/csharp.py @@ -0,0 +1,590 @@ +# 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. + +"""C# gRPC service companion generator.""" + +import re +from typing import Dict, List, Set, Tuple + +from fory_compiler.generators.base import GeneratedFile +from fory_compiler.generators.services.base import StreamingMode, streaming_mode +from fory_compiler.ir.ast import Service + + +class CSharpServiceMixin: + """Generates C# gRPC service companions.""" + + 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._validate_csharp_services(services) + return [self._generate_csharp_service(service) for service in services] + + def _generate_csharp_service(self, service: Service) -> GeneratedFile: + namespace_name = self.get_csharp_namespace() + service_name = self.safe_type_identifier(service.name) + module_name = self.safe_type_identifier(self.get_module_class_name()) + marshallers = self._csharp_marshallers(service) + + lines: List[str] = [] + lines.append(self.get_license_header("//")) + lines.append("#pragma warning disable 8981") + lines.append("") + lines.append("using grpc = global::Grpc.Core;") + lines.append("") + lines.append(f"namespace {namespace_name};") + lines.append("") + lines.append(f"public static partial class {service_name}") + lines.append("{") + lines.append( + f'{self.indent_str}static readonly string __ServiceName = "{self.get_grpc_service_name(service)}";' + ) + lines.append( + f"{self.indent_str}private static readonly global::Apache.Fory.ThreadSafeFory __Fory =" + ) + lines.append(f"{self.indent_str * 2}{module_name}.GetFory();") + lines.append("") + lines.extend(self._generate_marshallers(marshallers)) + lines.append("") + lines.extend(self._generate_methods(service, marshallers)) + lines.append("") + lines.extend(self._generate_server_base(service)) + lines.append("") + lines.extend(self._generate_client(service)) + lines.append("") + lines.extend(self._generate_bind_service(service)) + lines.append("") + lines.extend(self._generate_cold_helpers()) + lines.append("}") + + file_name = f"{service.name}Grpc.cs" + ns_path = self._namespace_path(namespace_name) + path = f"{ns_path}/{file_name}" if ns_path else file_name + return GeneratedFile(path=path, content="\n".join(lines)) + + def _csharp_marshallers( + self, service: Service + ) -> Dict[str, Tuple[str, str, str, str]]: + marshallers: Dict[str, Tuple[str, str, str, str]] = {} + used_names: Set[str] = set() + for method in service.methods: + for named_type in (method.request_type, method.response_type): + type_ref = self._named_type_reference(named_type) + if type_ref in marshallers: + continue + suffix = self._private_identifier(type_ref) + if suffix in used_names: + index = 2 + candidate = f"{suffix}_{index}" + while candidate in used_names: + index += 1 + candidate = f"{suffix}_{index}" + suffix = candidate + used_names.add(suffix) + marshaller = f"__Marshaller_{suffix}" + serialize = f"__Serialize_{suffix}" + deserialize = f"__Deserialize_{suffix}" + marshallers[type_ref] = (marshaller, serialize, deserialize, type_ref) + return marshallers + + def _generate_marshallers( + self, marshallers: Dict[str, Tuple[str, str, str, str]] + ) -> List[str]: + lines: List[str] = [] + for _, (marshaller, serialize, deserialize, type_ref) in marshallers.items(): + lines.append( + f"{self.indent_str}static readonly grpc::Marshaller<{type_ref}> {marshaller} =" + ) + lines.append( + f"{self.indent_str * 2}grpc::Marshallers.Create({serialize}, {deserialize});" + ) + lines.append("") + lines.append( + f"{self.indent_str}private static void {serialize}({type_ref} value, grpc::SerializationContext context)" + ) + lines.append(f"{self.indent_str}{{") + lines.append(f"{self.indent_str * 2}try") + lines.append(f"{self.indent_str * 2}{{") + lines.append( + f"{self.indent_str * 3}context.Complete(__Fory.Serialize<{type_ref}>(in value));" + ) + lines.append(f"{self.indent_str * 2}}}") + lines.append(f"{self.indent_str * 2}catch (global::System.Exception error)") + lines.append(f"{self.indent_str * 2}{{") + lines.append(f"{self.indent_str * 3}__ThrowSerializeError(error);") + lines.append(f"{self.indent_str * 2}}}") + lines.append(f"{self.indent_str}}}") + lines.append("") + lines.append( + f"{self.indent_str}private static {type_ref} {deserialize}(grpc::DeserializationContext context)" + ) + lines.append(f"{self.indent_str}{{") + lines.append(f"{self.indent_str * 2}try") + lines.append(f"{self.indent_str * 2}{{") + lines.append( + f"{self.indent_str * 3}global::System.Buffers.ReadOnlySequence<byte> body = context.PayloadAsReadOnlySequence();" + ) + lines.append( + f"{self.indent_str * 3}{type_ref} value = __Fory.Deserialize<{type_ref}>(ref body);" + ) + lines.append(f"{self.indent_str * 3}if (!body.IsEmpty)") + lines.append(f"{self.indent_str * 3}{{") + lines.append( + f"{self.indent_str * 4}return __ThrowTrailingBody<{type_ref}>();" + ) + lines.append(f"{self.indent_str * 3}}}") + lines.append(f"{self.indent_str * 3}return value;") + lines.append(f"{self.indent_str * 2}}}") + lines.append( + f"{self.indent_str * 2}catch (global::System.Exception error) when (error is not grpc::RpcException)" + ) + lines.append(f"{self.indent_str * 2}{{") + lines.append( + f"{self.indent_str * 3}return __ThrowDeserializeError<{type_ref}>(error);" + ) + lines.append(f"{self.indent_str * 2}}}") + lines.append(f"{self.indent_str}}}") + lines.append("") + if lines: + lines.pop() + return lines + + def _generate_methods( + self, + service: Service, + marshallers: Dict[str, Tuple[str, str, str, str]], + ) -> List[str]: + lines: List[str] = [] + for method in service.methods: + request_type = self._named_type_reference(method.request_type) + response_type = self._named_type_reference(method.response_type) + request_marshaller = marshallers[request_type][0] + response_marshaller = marshallers[response_type][0] + descriptor = self._method_field_name(method.name) + grpc_type = self._method_type(method) + lines.append( + f"{self.indent_str}static readonly grpc::Method<{request_type}, {response_type}> {descriptor} =" + ) + lines.append( + f"{self.indent_str * 2}new grpc::Method<{request_type}, {response_type}>(" + ) + lines.append(f"{self.indent_str * 3}{grpc_type},") + lines.append(f"{self.indent_str * 3}__ServiceName,") + lines.append(f'{self.indent_str * 3}"{method.name}",') + lines.append(f"{self.indent_str * 3}{request_marshaller},") + lines.append(f"{self.indent_str * 3}{response_marshaller});") + lines.append("") + if lines: + lines.pop() + return lines + + def _generate_server_base(self, service: Service) -> List[str]: + service_name = self.safe_type_identifier(service.name) + base_name = self._base_type_name(service) + lines = [ + f'{self.indent_str}[grpc::BindServiceMethod(typeof({service_name}), "BindService")]', + f"{self.indent_str}public abstract partial class {base_name}", + f"{self.indent_str}{{", + ] + for method in service.methods: + lines.append("") + lines.extend(self._server_base_method(method)) + lines.append(f"{self.indent_str}}}") + return lines + + def _server_base_method(self, method) -> List[str]: + request_type = self._named_type_reference(method.request_type) + response_type = self._named_type_reference(method.response_type) + method_name = self._public_method_name(method.name) + mode = streaming_mode(method) + lines: List[str] = [] + if mode is StreamingMode.UNARY: + lines.append( + f"{self.indent_str * 2}public virtual global::System.Threading.Tasks.Task<{response_type}> {method_name}(" + ) + lines.append(f"{self.indent_str * 3}{request_type} request,") + lines.append(f"{self.indent_str * 3}grpc::ServerCallContext context)") + lines.append(f"{self.indent_str * 2}{{") + lines.append( + f"{self.indent_str * 3}return __ThrowUnimplemented<{response_type}>();" + ) + elif mode is StreamingMode.SERVER_STREAMING: + lines.append( + f"{self.indent_str * 2}public virtual global::System.Threading.Tasks.Task {method_name}(" + ) + lines.append(f"{self.indent_str * 3}{request_type} request,") + lines.append( + f"{self.indent_str * 3}grpc::IServerStreamWriter<{response_type}> responseStream," + ) + lines.append(f"{self.indent_str * 3}grpc::ServerCallContext context)") + lines.append(f"{self.indent_str * 2}{{") + lines.append(f"{self.indent_str * 3}return __ThrowUnimplemented();") + elif mode is StreamingMode.CLIENT_STREAMING: + lines.append( + f"{self.indent_str * 2}public virtual global::System.Threading.Tasks.Task<{response_type}> {method_name}(" + ) + lines.append( + f"{self.indent_str * 3}grpc::IAsyncStreamReader<{request_type}> requestStream," + ) + lines.append(f"{self.indent_str * 3}grpc::ServerCallContext context)") + lines.append(f"{self.indent_str * 2}{{") + lines.append( + f"{self.indent_str * 3}return __ThrowUnimplemented<{response_type}>();" + ) + else: + lines.append( + f"{self.indent_str * 2}public virtual global::System.Threading.Tasks.Task {method_name}(" + ) + lines.append( + f"{self.indent_str * 3}grpc::IAsyncStreamReader<{request_type}> requestStream," + ) + lines.append( + f"{self.indent_str * 3}grpc::IServerStreamWriter<{response_type}> responseStream," + ) + lines.append(f"{self.indent_str * 3}grpc::ServerCallContext context)") + lines.append(f"{self.indent_str * 2}{{") + lines.append(f"{self.indent_str * 3}return __ThrowUnimplemented();") + lines.append(f"{self.indent_str * 2}}}") + return lines + + def _generate_client(self, service: Service) -> List[str]: + client_name = self._client_type_name(service) + lines = [ + f"{self.indent_str}public partial class {client_name} : grpc::ClientBase<{client_name}>", + f"{self.indent_str}{{", + f"{self.indent_str * 2}public {client_name}(grpc::ChannelBase channel) : base(channel)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public {client_name}(grpc::CallInvoker callInvoker) : base(callInvoker)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}protected {client_name}() : base()", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}protected {client_name}(ClientBaseConfiguration configuration) : base(configuration)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 2}}}", + ] + for method in service.methods: + lines.append("") + lines.extend(self._client_methods(method)) + lines.append("") + lines.append( + f"{self.indent_str * 2}protected override {client_name} NewInstance(ClientBaseConfiguration configuration)" + ) + lines.append(f"{self.indent_str * 2}{{") + lines.append(f"{self.indent_str * 3}return new {client_name}(configuration);") + lines.append(f"{self.indent_str * 2}}}") + lines.append(f"{self.indent_str}}}") + return lines + + def _client_methods(self, method) -> List[str]: + request_type = self._named_type_reference(method.request_type) + response_type = self._named_type_reference(method.response_type) + method_name = self._public_method_name(method.name) + descriptor = self._method_field_name(method.name) + mode = streaming_mode(method) + lines: List[str] = [] + if mode is StreamingMode.UNARY: + lines.extend( + self._unary_client_methods( + method_name, descriptor, request_type, response_type + ) + ) + elif mode is StreamingMode.SERVER_STREAMING: + lines.extend( + self._server_stream_client_methods( + method_name, descriptor, request_type, response_type + ) + ) + elif mode is StreamingMode.CLIENT_STREAMING: + lines.extend( + self._client_stream_client_methods( + method_name, descriptor, request_type, response_type + ) + ) + else: + lines.extend( + self._duplex_client_methods( + method_name, descriptor, request_type, response_type + ) + ) + return lines + + def _unary_client_methods( + self, method_name: str, descriptor: str, request_type: str, response_type: str + ) -> List[str]: + async_name = self._append_identifier(method_name, "Async") + return [ + f"{self.indent_str * 2}public virtual {response_type} {method_name}({request_type} request, grpc::Metadata? headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return {method_name}(request, new grpc::CallOptions(headers, deadline, cancellationToken));", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual {response_type} {method_name}({request_type} request, grpc::CallOptions options)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return CallInvoker.BlockingUnaryCall({descriptor}, null, options, request);", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual grpc::AsyncUnaryCall<{response_type}> {async_name}({request_type} request, grpc::Metadata? headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return {async_name}(request, new grpc::CallOptions(headers, deadline, cancellationToken));", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual grpc::AsyncUnaryCall<{response_type}> {async_name}({request_type} request, grpc::CallOptions options)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return CallInvoker.AsyncUnaryCall({descriptor}, null, options, request);", + f"{self.indent_str * 2}}}", + ] + + def _server_stream_client_methods( + self, method_name: str, descriptor: str, request_type: str, response_type: str + ) -> List[str]: + return [ + f"{self.indent_str * 2}public virtual grpc::AsyncServerStreamingCall<{response_type}> {method_name}({request_type} request, grpc::Metadata? headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return {method_name}(request, new grpc::CallOptions(headers, deadline, cancellationToken));", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual grpc::AsyncServerStreamingCall<{response_type}> {method_name}({request_type} request, grpc::CallOptions options)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return CallInvoker.AsyncServerStreamingCall({descriptor}, null, options, request);", + f"{self.indent_str * 2}}}", + ] + + def _client_stream_client_methods( + self, method_name: str, descriptor: str, request_type: str, response_type: str + ) -> List[str]: + return [ + f"{self.indent_str * 2}public virtual grpc::AsyncClientStreamingCall<{request_type}, {response_type}> {method_name}(grpc::Metadata? headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return {method_name}(new grpc::CallOptions(headers, deadline, cancellationToken));", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual grpc::AsyncClientStreamingCall<{request_type}, {response_type}> {method_name}(grpc::CallOptions options)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return CallInvoker.AsyncClientStreamingCall({descriptor}, null, options);", + f"{self.indent_str * 2}}}", + ] + + def _duplex_client_methods( + self, method_name: str, descriptor: str, request_type: str, response_type: str + ) -> List[str]: + return [ + f"{self.indent_str * 2}public virtual grpc::AsyncDuplexStreamingCall<{request_type}, {response_type}> {method_name}(grpc::Metadata? headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return {method_name}(new grpc::CallOptions(headers, deadline, cancellationToken));", + f"{self.indent_str * 2}}}", + "", + f"{self.indent_str * 2}public virtual grpc::AsyncDuplexStreamingCall<{request_type}, {response_type}> {method_name}(grpc::CallOptions options)", + f"{self.indent_str * 2}{{", + f"{self.indent_str * 3}return CallInvoker.AsyncDuplexStreamingCall({descriptor}, null, options);", + f"{self.indent_str * 2}}}", + ] + + def _generate_bind_service(self, service: Service) -> List[str]: + base_name = self._base_type_name(service) + builder_lines = [ + f"{self.indent_str}public static grpc::ServerServiceDefinition BindService({base_name} serviceImpl)", + f"{self.indent_str}{{", + f"{self.indent_str * 2}global::System.ArgumentNullException.ThrowIfNull(serviceImpl);", + f"{self.indent_str * 2}return grpc::ServerServiceDefinition.CreateBuilder()", + ] + for index, method in enumerate(service.methods): + suffix = ".Build();" if index == len(service.methods) - 1 else "" + builder_lines.append( + f"{self.indent_str * 3}.AddMethod({self._method_field_name(method.name)}, serviceImpl.{self._public_method_name(method.name)}){suffix}" + ) + if not service.methods: + builder_lines[-1] = ( + f"{self.indent_str * 2}return grpc::ServerServiceDefinition.CreateBuilder().Build();" + ) + builder_lines.append(f"{self.indent_str}}}") + builder_lines.append("") + builder_lines.append( + f"{self.indent_str}public static void BindService(grpc::ServiceBinderBase serviceBinder, {base_name}? serviceImpl)" + ) + builder_lines.append(f"{self.indent_str}{{") + builder_lines.append( + f"{self.indent_str * 2}global::System.ArgumentNullException.ThrowIfNull(serviceBinder);" + ) + for method in service.methods: + builder_lines.extend(self._binder_method(method)) + builder_lines.append(f"{self.indent_str}}}") + return builder_lines + + def _binder_method(self, method) -> List[str]: + request_type = self._named_type_reference(method.request_type) + response_type = self._named_type_reference(method.response_type) + delegate_type = { + StreamingMode.UNARY: "UnaryServerMethod", + StreamingMode.SERVER_STREAMING: "ServerStreamingServerMethod", + StreamingMode.CLIENT_STREAMING: "ClientStreamingServerMethod", + StreamingMode.BIDIRECTIONAL: "DuplexStreamingServerMethod", + }[streaming_mode(method)] + return [ + f"{self.indent_str * 2}serviceBinder.AddMethod(", + f"{self.indent_str * 3}{self._method_field_name(method.name)},", + f"{self.indent_str * 3}serviceImpl == null", + f"{self.indent_str * 4}? null", + f"{self.indent_str * 4}: new grpc::{delegate_type}<{request_type}, {response_type}>(serviceImpl.{self._public_method_name(method.name)}));", + ] + + def _generate_cold_helpers(self) -> List[str]: + no_inline = ( + "global::System.Runtime.CompilerServices.MethodImpl(" + "global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining)" + ) + return [ + f"{self.indent_str}[{no_inline}]", + f"{self.indent_str}private static void __ThrowSerializeError(global::System.Exception error)", + f"{self.indent_str}{{", + f'{self.indent_str * 2}throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Internal, $"Fory gRPC serialization failed: {{error.Message}}"));', + f"{self.indent_str}}}", + "", + f"{self.indent_str}[{no_inline}]", + f"{self.indent_str}private static T __ThrowDeserializeError<T>(global::System.Exception error)", + f"{self.indent_str}{{", + f'{self.indent_str * 2}throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Internal, $"Fory gRPC deserialization failed: {{error.Message}}"));', + f"{self.indent_str}}}", + "", + f"{self.indent_str}[{no_inline}]", + f"{self.indent_str}private static T __ThrowTrailingBody<T>()", + f"{self.indent_str}{{", + f'{self.indent_str * 2}throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Internal, "Fory gRPC message contained trailing bytes"));', + f"{self.indent_str}}}", + "", + f"{self.indent_str}[{no_inline}]", + f"{self.indent_str}private static global::System.Threading.Tasks.Task<T> __ThrowUnimplemented<T>()", + f"{self.indent_str}{{", + f'{self.indent_str * 2}throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));', + f"{self.indent_str}}}", + "", + f"{self.indent_str}[{no_inline}]", + f"{self.indent_str}private static global::System.Threading.Tasks.Task __ThrowUnimplemented()", + f"{self.indent_str}{{", + f'{self.indent_str * 2}throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));', + f"{self.indent_str}}}", + ] + + def _validate_csharp_services(self, services: List[Service]) -> None: + namespace_name = self.get_csharp_namespace() + top_level_types = self._top_level_type_names(namespace_name) + module_names = self._module_names(namespace_name) + service_names: Dict[str, str] = {} + for service in services: + service_name = self.safe_type_identifier(service.name) + prior = service_names.get(service_name) + if prior is not None: + raise ValueError( + f"C# gRPC service class collision: {prior} and {service.name} both generate {service_name}" + ) + service_names[service_name] = service.name + if service_name in top_level_types: + raise ValueError( + f"C# gRPC service class {service_name} conflicts with generated type in namespace {namespace_name}" + ) + if service_name in module_names: + raise ValueError( + f"C# gRPC service class {service_name} conflicts with generated module in namespace {namespace_name}" + ) + if service_name in {"BindService", "NewInstance"}: + raise ValueError( + f"C# gRPC service class {service.name} conflicts with generated member {service_name}" + ) + self._validate_method_names(service) + + def _validate_method_names(self, service: Service) -> None: + client_name = self._client_type_name(service).lstrip("@") + base_name = self._base_type_name(service).lstrip("@") + fixed = {"BindService", "NewInstance", client_name, base_name} + client_members: Dict[str, str] = {} + base_members: Dict[str, str] = {} + for method in service.methods: + method_name = self._public_method_name(method.name) + raw_name = method_name.lstrip("@") + if raw_name in fixed: + raise ValueError( + f"C# gRPC method {service.name}.{method.name} conflicts with generated member {raw_name}" + ) + self._reserve_member(base_members, raw_name, method.name, service.name) + self._reserve_member(client_members, raw_name, method.name, service.name) + if streaming_mode(method) is StreamingMode.UNARY: + async_name = self._append_identifier(method_name, "Async").lstrip("@") + self._reserve_member( + client_members, async_name, f"{method.name} async", service.name + ) + + def _reserve_member( + self, members: Dict[str, str], name: str, source: str, service_name: str + ) -> None: + prior = members.get(name) + if prior is not None: + raise ValueError( + f"C# gRPC method name collision in service {service_name}: {prior} and {source} both generate {name}" + ) + members[name] = source + + def _top_level_type_names(self, namespace_name: str) -> Set[str]: + names: Set[str] = set() + for type_def in self.schema.enums + self.schema.unions + self.schema.messages: + if self._type_namespace(type_def) == namespace_name: + names.add(self.safe_type_identifier(type_def.name)) + return names + + def _module_names(self, namespace_name: str) -> Set[str]: + names = {self.safe_type_identifier(self.get_module_class_name())} + for module_namespace, module_name in self._collect_imported_modules(): + if module_namespace == namespace_name: + names.add(self.safe_type_identifier(module_name)) + return names + + def _method_type(self, method) -> str: + return { + StreamingMode.UNARY: "grpc::MethodType.Unary", + StreamingMode.SERVER_STREAMING: "grpc::MethodType.ServerStreaming", + StreamingMode.CLIENT_STREAMING: "grpc::MethodType.ClientStreaming", + StreamingMode.BIDIRECTIONAL: "grpc::MethodType.DuplexStreaming", + }[streaming_mode(method)] + + def _public_method_name(self, name: str) -> str: + return self.safe_member_name(name) + + def _method_field_name(self, name: str) -> str: + return f"__Method_{self._private_identifier(name)}" + + def _client_type_name(self, service: Service) -> str: + return self.safe_type_identifier(f"{service.name}Client") + + def _base_type_name(self, service: Service) -> str: + return self.safe_type_identifier(f"{service.name}Base") + + def _private_identifier(self, value: str) -> str: + identifier = re.sub(r"[^0-9A-Za-z_]", "_", value.replace("global::", "")) + identifier = re.sub(r"_+", "_", identifier).strip("_") + if not identifier or identifier[0].isdigit(): + identifier = f"_{identifier}" + return identifier + + def _append_identifier(self, identifier: str, suffix: str) -> str: + if identifier.startswith("@"): + return f"@{identifier[1:]}{suffix}" + return f"{identifier}{suffix}" diff --git a/compiler/fory_compiler/tests/test_csharp_generator.py b/compiler/fory_compiler/tests/test_csharp_generator.py index 190dc7cac..307eb88fa 100644 --- a/compiler/fory_compiler/tests/test_csharp_generator.py +++ b/compiler/fory_compiler/tests/test_csharp_generator.py @@ -20,11 +20,12 @@ import warnings from pathlib import Path -from fory_compiler.cli import resolve_imports +from fory_compiler.cli import main as foryc_main, resolve_imports from fory_compiler.frontend.fdl.lexer import Lexer from fory_compiler.frontend.fdl.parser import Parser from fory_compiler.generators.base import GeneratorOptions from fory_compiler.generators.csharp import CSharpGenerator +from fory_compiler.ir.validator import SchemaValidator def parse_schema(source: str): @@ -54,7 +55,7 @@ def test_csharp_namespace_option_used(): assert "public sealed partial class Payment" in file.content -def test_csharp_namespace_fallback_to_package(): +def test_csharp_namespace_uses_package(): file = generate( """ package com.example.models; @@ -123,7 +124,7 @@ def test_csharp_semantic_model_attributes(): assert "[ForyObject]" not in file.content -def test_csharp_registration_uses_fdl_package_for_name_registration(): +def test_csharp_registers_fdl_name(): file = generate( """ package myapp.models; @@ -161,7 +162,7 @@ def test_csharp_field_encoding_attributes(): assert "public int Plain { get; set; }" in file.content -def test_csharp_nested_schema_type_attributes(): +def test_csharp_nested_type_attrs(): file = generate( """ package example; @@ -207,7 +208,7 @@ def test_csharp_reduced_precision_carriers(): assert "public List<BFloat16> Bf16Values { get; set; } = new();" in file.content -def test_csharp_imported_registration_calls_generated(): +def test_csharp_imported_modules(): repo_root = Path(__file__).resolve().parents[3] idl_dir = repo_root / "integration_tests" / "idl_tests" / "idl" schema = resolve_imports(idl_dir / "root.idl", [idl_dir]) @@ -219,6 +220,194 @@ def test_csharp_imported_registration_calls_generated(): assert "global::tree.TreeForyModule.Install(fory);" in file.content +def test_csharp_module_uses_source_file(tmp_path: Path): + schema_file = tmp_path / "order-events.fdl" + schema_file.write_text( + """ + package app.events; + option csharp_namespace = "App.Events"; + + message Event { + string name = 1; + } + """ + ) + + schema = resolve_imports(schema_file) + validator = SchemaValidator(schema) + assert validator.validate(), validator.errors + file = CSharpGenerator(schema, GeneratorOptions(output_dir=tmp_path)).generate()[0] + + assert file.path == "App/Events/order-events.cs" + assert "public static class OrderEventsForyModule" in file.content + assert "public static class EventsForyModule" not in file.content + + +def test_csharp_import_modules_distinct(tmp_path: Path): + first = tmp_path / "first.fdl" + first.write_text( + """ + package shared; + option csharp_namespace = "Demo.Shared"; + + message First { + string name = 1; + } + """ + ) + second = tmp_path / "second.fdl" + second.write_text( + """ + package shared; + option csharp_namespace = "Demo.Shared"; + + message Second { + string name = 1; + } + """ + ) + main = tmp_path / "main.fdl" + main.write_text( + """ + package app; + option csharp_namespace = "Demo.App"; + + import "first.fdl"; + import "second.fdl"; + + message Holder { + First first = 1; + Second second = 2; + } + """ + ) + + schema = resolve_imports(main, [tmp_path]) + validator = SchemaValidator(schema) + assert validator.validate(), validator.errors + file = CSharpGenerator(schema, GeneratorOptions(output_dir=tmp_path)).generate()[0] + + assert "global::Demo.Shared.FirstForyModule.Install(fory);" in file.content + assert "global::Demo.Shared.SecondForyModule.Install(fory);" in file.content + + +def test_csharp_grpc_path_collision(tmp_path: Path, capsys): + schema_file = tmp_path / "GreeterGrpc.fdl" + schema_file.write_text( + """ + package demo.collision; + + message Req {} + message Res {} + + service Greeter { + rpc Call (Req) returns (Res); + } + """ + ) + out = tmp_path / "out" + + result = foryc_main( + [ + "--lang", + "csharp", + "--csharp_out", + str(out), + "--grpc", + str(schema_file), + ] + ) + + captured = capsys.readouterr() + assert result == 1 + assert "C# generated file path collision" in captured.err + assert not out.exists() + + +def test_csharp_module_collision(tmp_path: Path, capsys): + first = tmp_path / "foo-bar.fdl" + first.write_text( + """ + package demo.same; + + message First { + string name = 1; + } + """ + ) + second = tmp_path / "foo_bar.fdl" + second.write_text( + """ + package demo.same; + + message Second { + string name = 1; + } + """ + ) + out = tmp_path / "out" + + result = foryc_main( + [ + "--lang", + "csharp", + "--csharp_out", + str(out), + str(first), + str(second), + ] + ) + + captured = capsys.readouterr() + assert result == 1 + assert "C# schema module owner collision" in captured.err + assert not out.exists() + + +def test_csharp_output_collision(tmp_path: Path, capsys): + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + first_dir.mkdir() + second_dir.mkdir() + first = first_dir / "common.fdl" + first.write_text( + """ + package demo.same; + + message First { + string name = 1; + } + """ + ) + second = second_dir / "common.fdl" + second.write_text( + """ + package demo.same; + + message Second { + string name = 1; + } + """ + ) + out = tmp_path / "out" + + result = foryc_main( + [ + "--lang", + "csharp", + "--csharp_out", + str(out), + str(first), + str(second), + ] + ) + + captured = capsys.readouterr() + assert result == 1 + assert "C# generated file path collision" in captured.err + assert not out.exists() + + def test_csharp_namespace_option_is_known(): source = """ package myapp; diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index f2e794ba7..2376d1264 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -18,12 +18,14 @@ """Codegen smoke tests for schemas that contain service definitions.""" from pathlib import Path +import shutil +import subprocess from textwrap import dedent from typing import Dict, Tuple, Type import pytest -from fory_compiler.cli import compile_file, resolve_imports +from fory_compiler.cli import compile_file, main as foryc_main, resolve_imports 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 @@ -119,7 +121,7 @@ def generate_service_files( return {item.path: item.content for item in generator.generate_services()} -def test_service_definition_does_not_affect_message_codegen(): +def test_services_do_not_change_models(): schema_with = parse_fdl(_GREETER_WITH_SERVICE) schema_without = parse_fdl(_GREETER_WITHOUT_SERVICE) for generator_cls in GENERATOR_CLASSES: @@ -130,13 +132,14 @@ def test_service_definition_does_not_affect_message_codegen(): ) -def test_generate_services_returns_empty_list_for_unsupported_generators(): +def test_unsupported_generators_no_services(): schema = parse_fdl(_GREETER_WITH_SERVICE) for generator_cls in GENERATOR_CLASSES: if generator_cls in ( JavaGenerator, PythonGenerator, GoGenerator, + CSharpGenerator, RustGenerator, KotlinGenerator, ): @@ -148,7 +151,7 @@ def test_generate_services_returns_empty_list_for_unsupported_generators(): ) -def test_java_grpc_service_codegen_contains_fory_marshaller(): +def test_java_grpc_fory_marshaller(): schema = parse_fdl(_GREETER_WITH_SERVICE) files = generate_service_files(schema, JavaGenerator) assert set(files) == {"demo/greeter/GreeterGrpc.java"} @@ -163,7 +166,7 @@ def test_java_grpc_service_codegen_contains_fory_marshaller(): assert "ProtoUtils" not in content -def test_python_grpc_service_codegen_uses_byte_callbacks(): +def test_python_grpc_byte_callbacks(): schema = parse_fdl(_GREETER_WITH_SERVICE) files = generate_service_files(schema, PythonGenerator) assert set(files) == {"demo_greeter_grpc.py"} @@ -183,7 +186,7 @@ def test_python_grpc_service_codegen_uses_byte_callbacks(): assert "FromString" not in content -def test_kotlin_grpc_service_codegen_contains_fory_marshaller(): +def test_kotlin_grpc_fory_marshaller(): schema = parse_fdl(_GREETER_WITH_SERVICE) files = generate_service_files(schema, KotlinGenerator) assert set(files) == {"demo/greeter/GreeterGrpcKt.kt"} @@ -219,6 +222,46 @@ def test_kotlin_grpc_service_codegen_contains_fory_marshaller(): assert "MessageLite" not in content +def test_csharp_grpc_fory_marshaller(): + schema = parse_fdl(_GREETER_WITH_SERVICE) + files = generate_service_files(schema, CSharpGenerator) + assert set(files) == {"demo/greeter/GreeterGrpc.cs"} + content = files["demo/greeter/GreeterGrpc.cs"] + assert "public static partial class Greeter" in content + assert 'static readonly string __ServiceName = "demo.greeter.Greeter"' in content + assert ( + "private static readonly global::Apache.Fory.ThreadSafeFory __Fory" in content + ) + assert "demoGreeterForyModule" not in content + assert "grpc::Marshallers.Create(__Serialize_" in content + assert "context.Complete(__Fory.Serialize<" in content + assert "PayloadAsReadOnlySequence()" in content + assert "PayloadAsNewBuffer" not in content + assert "new grpc::Method<global::demo.greeter.HelloRequest" in content + assert "grpc::MethodType.Unary" in content + assert '[grpc::BindServiceMethod(typeof(Greeter), "BindService")]' in content + assert ( + "public partial class GreeterClient : grpc::ClientBase<GreeterClient>" + in content + ) + assert "protected override GreeterClient NewInstance" in content + assert "public static grpc::ServerServiceDefinition BindService" in content + assert ( + "public static void BindService(grpc::ServiceBinderBase serviceBinder" + in content + ) + assert "serviceImpl == null" in content + assert "new grpc::UnaryServerMethod" in content + assert "MethodImplOptions.NoInlining" in content + assert "ProtoUtils" not in content + hot_path = content.split("__ThrowSerializeError", 1)[0] + assert '$"' not in hot_path + assert "System.Reflection" not in content + assert "ValueTuple" not in content + assert "Tuple<" not in content + assert "Activator" not in content + + def test_grpc_streaming_method_shapes(): schema = parse_fdl( dedent( @@ -287,6 +330,32 @@ def test_grpc_streaming_method_shapes(): assert "io.grpc.kotlin.ServerCalls.clientStreamingServerMethodDefinition" in kotlin assert "io.grpc.kotlin.ServerCalls.bidiStreamingServerMethodDefinition" in kotlin + csharp = next(iter(generate_service_files(schema, CSharpGenerator).values())) + assert "grpc::MethodType.Unary" in csharp + assert "grpc::MethodType.ServerStreaming" in csharp + assert "grpc::MethodType.ClientStreaming" in csharp + assert "grpc::MethodType.DuplexStreaming" in csharp + assert "public virtual global::demo.streams.Res Unary(" in csharp + assert ( + "public virtual grpc::AsyncUnaryCall<global::demo.streams.Res> UnaryAsync" + in csharp + ) + assert ( + "public virtual grpc::AsyncServerStreamingCall<global::demo.streams.Res> Server" + in csharp + ) + assert ( + "public virtual grpc::AsyncClientStreamingCall<global::demo.streams.Req, global::demo.streams.Res> Client" + in csharp + ) + assert ( + "public virtual grpc::AsyncDuplexStreamingCall<global::demo.streams.Payload, global::demo.streams.Payload> Bidi" + in csharp + ) + assert "new grpc::ServerStreamingServerMethod" in csharp + assert "new grpc::ClientStreamingServerMethod" in csharp + assert "new grpc::DuplexStreamingServerMethod" in csharp + def test_go_grpc_service_codegen(): schema = parse_fdl(_GREETER_WITH_SERVICE) @@ -310,7 +379,7 @@ def test_go_grpc_service_codegen(): assert "mustEmbedUnimplementedGreeterServer()" in content -def test_go_grpc_service_desc_uses_idl_method_names(): +def test_go_grpc_uses_idl_names(): schema = parse_fdl( dedent( """ @@ -346,7 +415,7 @@ def test_go_grpc_service_desc_uses_idl_method_names(): assert 'StreamName:\t"StreamReplies"' not in content -def test_java_outer_classname_service_references_nested_model_types(): +def test_java_outer_service_types(): schema = parse_fdl( dedent( """ @@ -370,7 +439,7 @@ def test_java_outer_classname_service_references_nested_model_types(): assert "marshaller(OuterModels.Res.class)" in content -def test_grpc_services_use_imported_java_type_references(tmp_path: Path): +def test_grpc_imported_java_types(tmp_path: Path): common = tmp_path / "common.fdl" common.write_text( dedent( @@ -432,8 +501,20 @@ def test_grpc_services_use_imported_java_type_references(tmp_path: Path): assert "marshaller(common.Shared::class.java)" in kotlin assert "public open suspend fun get(request: common.Shared): Local" in kotlin + csharp_files = generate_service_files(schema, CSharpGenerator) + assert set(csharp_files) == {"api/ApiServiceGrpc.cs"} + csharp = csharp_files["api/ApiServiceGrpc.cs"] + assert ( + "grpc::Method<global::common.Shared, global::api.Local> __Method_Get" in csharp + ) + assert "grpc::Marshaller<global::common.Shared>" in csharp + assert ( + "public virtual global::System.Threading.Tasks.Task<global::api.Local> Get(" + in csharp + ) -def test_proto_grpc_services_use_imported_qualified_type_references(tmp_path: Path): + +def test_proto_grpc_imported_types(tmp_path: Path): common = tmp_path / "common.proto" common.write_text( dedent( @@ -490,8 +571,15 @@ def test_proto_grpc_services_use_imported_qualified_type_references(tmp_path: Pa assert "io.grpc.MethodDescriptor<common.Shared, Local>" in kotlin assert "marshaller(common.Shared::class.java)" in kotlin + csharp_files = generate_service_files(schema, CSharpGenerator) + csharp = csharp_files["api/ApiServiceGrpc.cs"] + assert ( + "grpc::Method<global::common.Shared, global::api.Local> __Method_Get" in csharp + ) + assert "grpc::Marshaller<global::common.Shared>" in csharp + -def test_proto_grpc_absolute_rpc_type_uses_package_type_not_nested_shadow(): +def test_proto_grpc_absolute_type(): schema = parse_proto( dedent( """ @@ -530,8 +618,13 @@ def test_proto_grpc_absolute_rpc_type_uses_package_type_not_nested_shadow(): assert "io.grpc.MethodDescriptor<Request, Response>" in kotlin assert "io.grpc.MethodDescriptor<demo.Request, Response>" not in kotlin + csharp_files = generate_service_files(schema, CSharpGenerator) + csharp = csharp_files["demo/ApiServiceGrpc.cs"] + assert "grpc::Method<global::demo.Request, global::demo.Response>" in csharp + assert "global::demo.demo.Request" not in csharp -def test_proto_grpc_absolute_rpc_type_prefers_longest_package_prefix(tmp_path: Path): + +def test_proto_grpc_longest_package(tmp_path: Path): common = tmp_path / "common.proto" common.write_text( dedent( @@ -586,8 +679,13 @@ def test_proto_grpc_absolute_rpc_type_prefers_longest_package_prefix(tmp_path: P assert "io.grpc.MethodDescriptor<alpha.beta.C, alpha.beta.C>" in kotlin assert "io.grpc.MethodDescriptor<beta.C, beta.C>" not in kotlin + csharp_files = generate_service_files(schema, CSharpGenerator) + csharp = csharp_files["alpha/ApiServiceGrpc.cs"] + assert "grpc::Method<global::alpha.beta.C, global::alpha.beta.C>" in csharp + assert "global::alpha.beta.beta.C" not in csharp + -def test_java_grpc_service_class_collision_fails(): +def test_java_grpc_class_collision(): schema = parse_fdl( dedent( """ @@ -614,7 +712,7 @@ def test_java_grpc_service_class_collision_fails(): raise AssertionError("Expected Java gRPC service class collision") -def test_kotlin_grpc_service_class_collision_fails(): +def test_kotlin_grpc_class_collision(): schema = parse_fdl( dedent( """ @@ -634,7 +732,30 @@ def test_kotlin_grpc_service_class_collision_fails(): KotlinGenerator(schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True)) -def test_java_grpc_service_class_collision_with_imported_type_fails(tmp_path: Path): +def test_csharp_grpc_class_collision(): + schema = parse_fdl( + dedent( + """ + package demo.collision; + + message Greeter {} + message Req {} + message Res {} + + service Greeter { + rpc Call (Req) returns (Res); + } + """ + ) + ) + generator = CSharpGenerator( + schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True) + ) + with pytest.raises(ValueError, match="C# gRPC service class Greeter conflicts"): + generator.generate_services() + + +def test_java_grpc_import_type_collision(tmp_path: Path): common = tmp_path / "common.fdl" common.write_text( dedent( @@ -675,7 +796,7 @@ def test_java_grpc_service_class_collision_with_imported_type_fails(tmp_path: Pa raise AssertionError("Expected imported Java gRPC service class collision") -def test_java_grpc_service_class_collision_with_imported_outer_fails(tmp_path: Path): +def test_java_grpc_import_outer_collision(tmp_path: Path): common = tmp_path / "common.fdl" common.write_text( dedent( @@ -786,8 +907,19 @@ def test_grpc_method_name_collisions_fail(): else: raise AssertionError("Expected Kotlin gRPC method name collision") + csharp_generator = CSharpGenerator( + schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True) + ) + try: + csharp_generator.generate_services() + except ValueError as e: + assert "C# gRPC method name collision" in str(e) + assert "Foo and foo" in str(e) + else: + raise AssertionError("Expected C# gRPC method name collision") -def test_java_python_grpc_method_keywords_are_safe_names(): + +def test_grpc_method_keywords_safe(): schema = parse_fdl( dedent( """ @@ -820,7 +952,7 @@ def test_java_python_grpc_method_keywords_are_safe_names(): assert "implementation = ::`class`" in kotlin -def test_python_grpc_service_registration_collisions_fail(): +def test_python_grpc_registration_collision(): schema = parse_fdl( dedent( """ @@ -843,7 +975,7 @@ def test_python_grpc_service_registration_collisions_fail(): raise AssertionError("Expected Python gRPC service registration collision") -def test_rust_grpc_service_module_collisions_fail(): +def test_rust_grpc_module_collision(): schema = parse_fdl( dedent( """ @@ -866,7 +998,7 @@ def test_rust_grpc_service_module_collisions_fail(): raise AssertionError("Expected Rust gRPC service module collision") -def test_default_package_java_grpc_output_path_and_service_name(): +def test_java_grpc_default_package(): schema = parse_fdl( dedent( """ @@ -934,7 +1066,7 @@ def test_proto_and_fbs_grpc_service_codegen(): assert '"/demo.fbs.FbsSvc/Call"' in fbs_python["demo_fbs_grpc.py"] -def test_service_schema_produces_one_file_per_message_per_language(): +def test_service_schema_model_files(): schema = parse_fdl(_GREETER_WITH_SERVICE) for generator_cls in GENERATOR_CLASSES: files = generate_files(schema, generator_cls) @@ -943,7 +1075,7 @@ def test_service_schema_produces_one_file_per_message_per_language(): ) -def test_compile_service_schema_with_grpc_flag(tmp_path: Path, capsys): +def test_compile_service_with_grpc(tmp_path: Path, capsys): example_path = Path(__file__).resolve().parents[2] / "examples" / "service.fdl" lang_dirs = {} for lang in ("java", "python", "rust", "go", "cpp", "csharp", "swift", "kotlin"): @@ -960,10 +1092,132 @@ def test_compile_service_schema_with_grpc_flag(tmp_path: Path, capsys): assert output.count("demo_greeter_grpc.go") == 1 assert (lang_dirs["rust"] / "demo_greeter_service.rs").exists() assert (lang_dirs["rust"] / "demo_greeter_service_grpc.rs").exists() + assert (lang_dirs["csharp"] / "demo" / "greeter" / "service.cs").exists() + assert (lang_dirs["csharp"] / "demo" / "greeter" / "GreeterGrpc.cs").exists() assert (lang_dirs["kotlin"] / "demo" / "greeter" / "GreeterGrpcKt.kt").exists() -def test_generated_message_contains_key_signatures(): [email protected](shutil.which("dotnet") is None, reason="dotnet not installed") +def test_csharp_grpc_dotnet_fixture(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[3] + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + option csharp_namespace = "Demo.Shared"; + + message SharedRequest { + string name = 1; + } + + message SharedReply { + string text = 1; + } + + union SharedChoice { + SharedRequest request = 1; + SharedReply reply = 2; + } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.greeter; + option csharp_namespace = "Demo.Greeter"; + + import "common.fdl"; + + message LocalRequest { + string name = 1; + } + + message LocalReply { + string text = 1; + } + + union LocalChoice { + LocalRequest request = 1; + LocalReply reply = 2; + } + + service Greeter { + rpc Unary (LocalRequest) returns (LocalReply); + rpc Server (LocalRequest) returns (stream LocalReply); + rpc Client (stream SharedRequest) returns (SharedReply); + rpc Bidi (stream LocalChoice) returns (stream SharedChoice); + } + """ + ) + ) + out = tmp_path / "out" + assert ( + foryc_main( + [ + "--lang", + "csharp", + "--csharp_out", + str(out), + "--grpc", + str(common), + str(main), + ] + ) + == 0 + ) + + service = out / "Demo" / "Greeter" / "GreeterGrpc.cs" + main_model = out / "Demo" / "Greeter" / "main.cs" + common_model = out / "Demo" / "Shared" / "common.cs" + assert service.is_file() + assert main_model.is_file() + assert common_model.is_file() + service_code = service.read_text() + assert "MainForyModule.GetFory()" in service_code + assert "global::Demo.Shared.SharedChoice" in service_code + assert "PayloadAsNewBuffer" not in service_code + + project = out / "GrpcValidation.csproj" + project.write_text( + dedent( + f""" + <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <LangVersion>12.0</LangVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="{repo_root / "csharp/src/Fory/Fory.csproj"}" /> + <ProjectReference Include="{repo_root / "csharp/src/Fory.Generator/Fory.Generator.csproj"}" + OutputItemType="Analyzer" + ReferenceOutputAssembly="false" /> + <PackageReference Include="Grpc.Core.Api" Version="2.71.0" /> + </ItemGroup> + </Project> + """ + ).strip() + ) + (out / "Program.cs").write_text(CSHARP_GRPC_VALIDATION_PROGRAM) + + result = subprocess.run( + ["dotnet", "run", "--project", str(project), "-v:quiet"], + cwd=repo_root, + text=True, + capture_output=True, + timeout=180, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) all_java = "\n".join(java_files.values()) @@ -978,7 +1232,650 @@ def test_generated_message_contains_key_signatures(): assert "HelloReply" in all_python -def test_rust_grpc_rejects_non_thread_safe_refs(): +CSHARP_GRPC_VALIDATION_PROGRAM = dedent( + r""" + using System; + using System.Buffers; + using System.Collections.Generic; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + using Apache.Fory; + using Demo.Greeter; + using Demo.Shared; + using Grpc.Core; + + static class Program + { + static async Task Main() + { + TestMarshallers(); + TestClientDispatch(); + await TestServerBinding(); + TestGeneratedSerializers(); + TestAllocationBaseline(); + } + + static void TestMarshallers() + { + Method<LocalRequest, LocalReply> unary = + GetMethod<LocalRequest, LocalReply>("__Method_Unary"); + Method<SharedRequest, SharedReply> client = + GetMethod<SharedRequest, SharedReply>("__Method_Client"); + Method<LocalChoice, SharedChoice> bidi = + GetMethod<LocalChoice, SharedChoice>("__Method_Bidi"); + + LocalRequest localRequest = new() { Name = "local" }; + LocalRequest decodedLocal = RoundTrip(unary.RequestMarshaller, localRequest); + Require(decodedLocal.Name == "local", "local request marshaller"); + + LocalReply localReply = new() { Text = "reply" }; + LocalReply decodedReply = RoundTrip(unary.ResponseMarshaller, localReply); + Require(decodedReply.Text == "reply", "local response marshaller"); + + SharedRequest sharedRequest = new() { Name = "shared" }; + SharedRequest decodedShared = + RoundTrip(client.RequestMarshaller, sharedRequest); + Require(decodedShared.Name == "shared", "imported request marshaller"); + + SharedReply sharedReply = new() { Text = "shared-reply" }; + SharedReply decodedSharedReply = + RoundTrip(client.ResponseMarshaller, sharedReply); + Require( + decodedSharedReply.Text == "shared-reply", + "imported response marshaller"); + + LocalChoice localUnion = + new LocalChoice.Request(new LocalRequest { Name = "union-local" }); + LocalChoice decodedLocalUnion = + RoundTrip(bidi.RequestMarshaller, localUnion); + Require( + decodedLocalUnion is LocalChoice.Request { Value.Name: "union-local" }, + "local union marshaller"); + + SharedChoice sharedUnion = + new SharedChoice.Reply(new SharedReply { Text = "union-shared" }); + SharedChoice decodedSharedUnion = + RoundTrip(bidi.ResponseMarshaller, sharedUnion); + Require( + decodedSharedUnion is SharedChoice.Reply { Value.Text: "union-shared" }, + "imported union marshaller"); + + byte[] bytes = Serialize(unary.RequestMarshaller, localRequest); + byte[] trailing = new byte[bytes.Length + 1]; + Array.Copy(bytes, trailing, bytes.Length); + RpcException error = RequireThrows<RpcException>(() => + unary.RequestMarshaller.ContextualDeserializer( + new BytesDeserializationContext(trailing))); + Require(error.StatusCode == StatusCode.Internal, "trailing bytes rejected"); + } + + static void TestClientDispatch() + { + CapturingInvoker invoker = new(); + Greeter.GreeterClient client = new(invoker); + LocalRequest request = new() { Name = "client" }; + + client.Unary(request); + client.UnaryAsync(request); + client.Server(request); + client.Client(); + client.Bidi(); + + Require(invoker.Calls.Count == 5, "client dispatch count"); + Require(invoker.Calls[0] == "Unary:Unary", "blocking unary dispatch"); + Require(invoker.Calls[1] == "AsyncUnary:Unary", "async unary dispatch"); + Require( + invoker.Calls[2] == "ServerStreaming:Server", + "server streaming dispatch"); + Require( + invoker.Calls[3] == "ClientStreaming:Client", + "client streaming dispatch"); + Require( + invoker.Calls[4] == "DuplexStreaming:Bidi", + "duplex streaming dispatch"); + } + + static async Task TestServerBinding() + { + CapturingBinder metadataBinder = new(); + Greeter.BindService(metadataBinder, null); + Require(metadataBinder.Handlers.Count == 4, "metadata binding count"); + Require( + metadataBinder.Handlers.TrueForAll(static handler => handler is null), + "metadata binding uses null handlers"); + + CapturingBinder binder = new(); + Greeter.BindService(binder, new ServiceImpl()); + Require(binder.Handlers.Count == 4, "implementation binding count"); + Require(binder.Methods[0].Name == "Unary", "unary method name"); + Require( + binder.Methods[1].Type == MethodType.ServerStreaming, + "server stream method type"); + Require( + binder.Methods[2].Type == MethodType.ClientStreaming, + "client stream method type"); + Require( + binder.Methods[3].Type == MethodType.DuplexStreaming, + "duplex method type"); + + var unary = + (UnaryServerMethod<LocalRequest, LocalReply>)binder.Handlers[0]!; + LocalReply reply = await unary( + new LocalRequest { Name = "bound" }, + TestServerCallContext.Instance); + Require(reply.Text == "bound", "unary delegate dispatch"); + + var server = + (ServerStreamingServerMethod<LocalRequest, LocalReply>) + binder.Handlers[1]!; + CapturingServerWriter<LocalReply> serverWriter = new(); + await server( + new LocalRequest { Name = "stream" }, + serverWriter, + TestServerCallContext.Instance); + Require( + serverWriter.Messages.Count == 1 + && serverWriter.Messages[0].Text == "stream", + "server stream dispatch"); + + var client = + (ClientStreamingServerMethod<SharedRequest, SharedReply>) + binder.Handlers[2]!; + SharedReply clientReply = await client( + new ArrayStreamReader<SharedRequest>( + new SharedRequest { Name = "a" }, + new SharedRequest { Name = "b" }), + TestServerCallContext.Instance); + Require(clientReply.Text == "a,b", "client stream dispatch"); + + var duplex = + (DuplexStreamingServerMethod<LocalChoice, SharedChoice>) + binder.Handlers[3]!; + CapturingServerWriter<SharedChoice> duplexWriter = new(); + await duplex( + new ArrayStreamReader<LocalChoice>( + new LocalChoice.Request(new LocalRequest { Name = "duplex" })), + duplexWriter, + TestServerCallContext.Instance); + Require(duplexWriter.Messages.Count == 1, "duplex stream dispatch"); + Require( + duplexWriter.Messages[0] + is SharedChoice.Reply { Value.Text: "duplex" }, + "duplex union response"); + } + + static void TestGeneratedSerializers() + { + Fory fory = Fory.Builder().TrackRef(true).Build(); + MainForyModule.Install(fory); + TypeResolver resolver = Resolver(fory); + Require(IsGeneratedSerializer<LocalRequest>(resolver), "local message serializer"); + Require(IsGeneratedSerializer<LocalChoice>(resolver), "local union serializer"); + Require(IsGeneratedSerializer<SharedRequest>(resolver), "imported message serializer"); + Require(IsGeneratedSerializer<SharedChoice>(resolver), "imported union serializer"); + } + + static void TestAllocationBaseline() + { + Method<LocalRequest, LocalReply> method = + GetMethod<LocalRequest, LocalReply>("__Method_Unary"); + LocalRequest request = new() { Name = "allocation" }; + BytesSerializationContext serialization = new(); + BytesDeserializationContext deserialization = new(Array.Empty<byte>()); + Apache.Fory.ThreadSafeFory fory = MainForyModule.GetFory(); + + for (int i = 0; i < 256; i++) + { + DirectRoundTrip(fory, request); + GeneratedRoundTrip(method.RequestMarshaller, request, serialization, deserialization); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + long direct = Measure(2048, () => DirectRoundTrip(fory, request)); + long generated = Measure( + 2048, + () => GeneratedRoundTrip( + method.RequestMarshaller, + request, + serialization, + deserialization)); + Require( + generated <= direct + 32768, + $"generated marshaller allocated {generated} bytes vs direct {direct}"); + } + + static bool IsGeneratedSerializer<T>(TypeResolver resolver) + { + string name = resolver.GetSerializer<T>().GetType().Name; + return name.Contains("__ForySerializer_", StringComparison.Ordinal); + } + + static TypeResolver Resolver(Fory fory) + { + FieldInfo field = typeof(Fory).GetField( + "_typeResolver", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Fory resolver field not found"); + return (TypeResolver)field.GetValue(fory)!; + } + + static void DirectRoundTrip( + Apache.Fory.ThreadSafeFory fory, + LocalRequest request) + { + byte[] bytes = fory.Serialize<LocalRequest>(in request); + ReadOnlySequence<byte> sequence = new(bytes); + LocalRequest decoded = fory.Deserialize<LocalRequest>(ref sequence); + Require(decoded.Name == request.Name, "direct roundtrip"); + } + + static void GeneratedRoundTrip( + Marshaller<LocalRequest> marshaller, + LocalRequest request, + BytesSerializationContext serialization, + BytesDeserializationContext deserialization) + { + serialization.Reset(); + marshaller.ContextualSerializer(request, serialization); + deserialization.Reset(serialization.Payload); + LocalRequest decoded = marshaller.ContextualDeserializer(deserialization); + Require(decoded.Name == request.Name, "generated roundtrip"); + Require( + deserialization.ReadOnlySequenceCalls == 1 + && deserialization.NewBufferCalls == 0, + "generated deserializer used read-only sequence"); + } + + static long Measure(int iterations, Action action) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + action(); + } + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + static Method<TRequest, TResponse> GetMethod<TRequest, TResponse>(string name) + where TRequest : class + where TResponse : class + { + FieldInfo field = typeof(Greeter).GetField( + name, + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException( + $"Missing generated method field {name}"); + return (Method<TRequest, TResponse>)field.GetValue(null)!; + } + + static T RoundTrip<T>(Marshaller<T> marshaller, T value) + { + BytesDeserializationContext deserialization = + new(Serialize(marshaller, value)); + T decoded = marshaller.ContextualDeserializer(deserialization); + Require( + deserialization.ReadOnlySequenceCalls == 1 + && deserialization.NewBufferCalls == 0, + "deserializer used read-only sequence"); + return decoded; + } + + static byte[] Serialize<T>(Marshaller<T> marshaller, T value) + { + BytesSerializationContext context = new(); + marshaller.ContextualSerializer(value, context); + return context.Payload; + } + + static TException RequireThrows<TException>(Action action) + where TException : Exception + { + try + { + action(); + } + catch (TException error) + { + return error; + } + throw new InvalidOperationException($"Expected {typeof(TException).Name}"); + } + + static void Require(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + } + + sealed class BytesSerializationContext : SerializationContext + { + readonly ArrayBufferWriter<byte> _writer = new(); + + public byte[] Payload { get; private set; } = Array.Empty<byte>(); + + public void Reset() + { + Payload = Array.Empty<byte>(); + } + + public override void Complete(byte[] payload) + { + Payload = payload; + } + + public override IBufferWriter<byte> GetBufferWriter() + { + return _writer; + } + + public override void SetPayloadLength(int payloadLength) + { + } + + public override void Complete() + { + Payload = _writer.WrittenMemory.ToArray(); + } + } + + sealed class BytesDeserializationContext(byte[] payload) : DeserializationContext + { + byte[] _payload = payload; + + public int NewBufferCalls { get; private set; } + public int ReadOnlySequenceCalls { get; private set; } + public override int PayloadLength => _payload.Length; + + public void Reset(byte[] payload) + { + _payload = payload; + NewBufferCalls = 0; + ReadOnlySequenceCalls = 0; + } + + public override byte[] PayloadAsNewBuffer() + { + NewBufferCalls++; + return (byte[])_payload.Clone(); + } + + public override ReadOnlySequence<byte> PayloadAsReadOnlySequence() + { + ReadOnlySequenceCalls++; + return new ReadOnlySequence<byte>(_payload); + } + } + + sealed class CapturingInvoker : CallInvoker + { + public List<string> Calls { get; } = []; + + public override TResponse BlockingUnaryCall<TRequest, TResponse>( + Method<TRequest, TResponse> method, + string? host, + CallOptions options, + TRequest request) + { + Calls.Add($"Unary:{method.Name}"); + return Create<TResponse>(); + } + + public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( + Method<TRequest, TResponse> method, + string? host, + CallOptions options, + TRequest request) + { + Calls.Add($"AsyncUnary:{method.Name}"); + return new AsyncUnaryCall<TResponse>( + Task.FromResult(Create<TResponse>()), + Task.FromResult(new Metadata()), + static () => Status.DefaultSuccess, + static () => new Metadata(), + static () => { }); + } + + public override AsyncServerStreamingCall<TResponse> + AsyncServerStreamingCall<TRequest, TResponse>( + Method<TRequest, TResponse> method, + string? host, + CallOptions options, + TRequest request) + { + Calls.Add($"ServerStreaming:{method.Name}"); + return new AsyncServerStreamingCall<TResponse>( + new ArrayStreamReader<TResponse>(), + Task.FromResult(new Metadata()), + static () => Status.DefaultSuccess, + static () => new Metadata(), + static () => { }); + } + + public override AsyncClientStreamingCall<TRequest, TResponse> + AsyncClientStreamingCall<TRequest, TResponse>( + Method<TRequest, TResponse> method, + string? host, + CallOptions options) + { + Calls.Add($"ClientStreaming:{method.Name}"); + return new AsyncClientStreamingCall<TRequest, TResponse>( + new ClientStreamWriter<TRequest>(), + Task.FromResult(Create<TResponse>()), + Task.FromResult(new Metadata()), + static () => Status.DefaultSuccess, + static () => new Metadata(), + static () => { }); + } + + public override AsyncDuplexStreamingCall<TRequest, TResponse> + AsyncDuplexStreamingCall<TRequest, TResponse>( + Method<TRequest, TResponse> method, + string? host, + CallOptions options) + { + Calls.Add($"DuplexStreaming:{method.Name}"); + return new AsyncDuplexStreamingCall<TRequest, TResponse>( + new ClientStreamWriter<TRequest>(), + new ArrayStreamReader<TResponse>(), + Task.FromResult(new Metadata()), + static () => Status.DefaultSuccess, + static () => new Metadata(), + static () => { }); + } + + static T Create<T>() + { + return Activator.CreateInstance<T>(); + } + } + + sealed class CapturingBinder : ServiceBinderBase + { + public List<IMethod> Methods { get; } = []; + public List<Delegate?> Handlers { get; } = []; + + public override void AddMethod<TRequest, TResponse>( + Method<TRequest, TResponse> method, + UnaryServerMethod<TRequest, TResponse>? handler) + { + Methods.Add(method); + Handlers.Add(handler); + } + + public override void AddMethod<TRequest, TResponse>( + Method<TRequest, TResponse> method, + ClientStreamingServerMethod<TRequest, TResponse>? handler) + { + Methods.Add(method); + Handlers.Add(handler); + } + + public override void AddMethod<TRequest, TResponse>( + Method<TRequest, TResponse> method, + ServerStreamingServerMethod<TRequest, TResponse>? handler) + { + Methods.Add(method); + Handlers.Add(handler); + } + + public override void AddMethod<TRequest, TResponse>( + Method<TRequest, TResponse> method, + DuplexStreamingServerMethod<TRequest, TResponse>? handler) + { + Methods.Add(method); + Handlers.Add(handler); + } + } + + sealed class ServiceImpl : Greeter.GreeterBase + { + public override Task<LocalReply> Unary( + LocalRequest request, + ServerCallContext context) + { + return Task.FromResult(new LocalReply { Text = request.Name }); + } + + public override async Task Server( + LocalRequest request, + IServerStreamWriter<LocalReply> responseStream, + ServerCallContext context) + { + await responseStream.WriteAsync(new LocalReply { Text = request.Name }); + } + + public override async Task<SharedReply> Client( + IAsyncStreamReader<SharedRequest> requestStream, + ServerCallContext context) + { + List<string> names = []; + while (await requestStream.MoveNext(CancellationToken.None)) + { + names.Add(requestStream.Current.Name); + } + return new SharedReply { Text = string.Join(",", names) }; + } + + public override async Task Bidi( + IAsyncStreamReader<LocalChoice> requestStream, + IServerStreamWriter<SharedChoice> responseStream, + ServerCallContext context) + { + while (await requestStream.MoveNext(CancellationToken.None)) + { + string name = requestStream.Current switch + { + LocalChoice.Request request => request.Value.Name, + LocalChoice.Reply reply => reply.Value.Text, + _ => "unknown", + }; + await responseStream.WriteAsync( + new SharedChoice.Reply(new SharedReply { Text = name })); + } + } + } + + sealed class ArrayStreamReader<T>(params T[] items) : IAsyncStreamReader<T> + { + int _index = -1; + + public T Current { get; private set; } = default!; + + public Task<bool> MoveNext(CancellationToken cancellationToken) + { + _index++; + if (_index >= items.Length) + { + return Task.FromResult(false); + } + Current = items[_index]; + return Task.FromResult(true); + } + } + + sealed class CapturingServerWriter<T> : IServerStreamWriter<T> + { + public List<T> Messages { get; } = []; + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) + { + Messages.Add(message); + return Task.CompletedTask; + } + + public Task WriteAsync(T message, CancellationToken cancellationToken) + { + Messages.Add(message); + return Task.CompletedTask; + } + } + + sealed class ClientStreamWriter<T> : IClientStreamWriter<T> + { + public WriteOptions? WriteOptions { get; set; } + + public Task CompleteAsync() + { + return Task.CompletedTask; + } + + public Task WriteAsync(T message) + { + return Task.CompletedTask; + } + + public Task WriteAsync(T message, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + sealed class TestServerCallContext : ServerCallContext + { + public static readonly TestServerCallContext Instance = new(); + readonly Dictionary<object, object> _userState = []; + Metadata _trailers = []; + Status _status = Status.DefaultSuccess; + + protected override string MethodCore => "test"; + protected override string HostCore => "localhost"; + protected override string PeerCore => "peer"; + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + protected override Metadata RequestHeadersCore => []; + protected override CancellationToken CancellationTokenCore => + CancellationToken.None; + protected override Metadata ResponseTrailersCore => _trailers; + protected override Status StatusCore { get => _status; set => _status = value; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore => + new("none", new Dictionary<string, List<AuthProperty>>()); + + protected override ContextPropagationToken CreatePropagationTokenCore( + ContextPropagationOptions? options) + { + throw new NotSupportedException(); + } + + protected override Task WriteResponseHeadersAsyncCore( + Metadata responseHeaders) + { + return Task.CompletedTask; + } + + protected override IDictionary<object, object> UserStateCore => _userState; + } + """ +).lstrip() + + +def test_rust_grpc_rejects_unsafe_refs(): cases = [ ( "Rust gRPC payload type Request.node uses non-thread-safe ref", diff --git a/docs/compiler/compiler-guide.md b/docs/compiler/compiler-guide.md index 683bd3417..d513ac577 100644 --- a/docs/compiler/compiler-guide.md +++ b/docs/compiler/compiler-guide.md @@ -141,24 +141,26 @@ foryc schema.fdl --output ./src/generated foryc user.fdl order.fdl product.fdl --output ./generated ``` -**Compile a simple schema containing service definitions (Java + Python + Rust + Kotlin models):** +**Compile a simple schema containing service definitions (Java + Python + Rust + C# + Kotlin models):** ```bash -foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --kotlin_out=./generated/kotlin +foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --csharp_out=./generated/csharp --kotlin_out=./generated/kotlin ``` -**Generate Java, Python, Rust, and Kotlin gRPC service companions:** +**Generate Java, Python, Rust, C#, and Kotlin gRPC service companions:** ```bash -foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc +foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --csharp_out=./generated/csharp --kotlin_out=./generated/kotlin --grpc ``` The generated gRPC service code uses Fory to serialize request and response payloads. Java output imports grpc-java APIs, Python output imports `grpc`, and Rust output imports `tonic` and `bytes`. Kotlin output imports grpc-java and -grpc-kotlin APIs and uses coroutine stubs. 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. +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`. 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. **Use import search paths:** @@ -372,6 +374,8 @@ generated/ `ToBytes`/`FromBytes` methods - Imported schemas are installed transitively (for example `root.idl` importing `addressbook.fdl` and `tree.fdl`) +- With `--grpc`, one `<ServiceName>Grpc.cs` companion per service next to the + schema file output ### Swift diff --git a/docs/compiler/flatbuffers-idl.md b/docs/compiler/flatbuffers-idl.md index fa29946ac..9764dc930 100644 --- a/docs/compiler/flatbuffers-idl.md +++ b/docs/compiler/flatbuffers-idl.md @@ -126,8 +126,8 @@ message Container { FlatBuffers `rpc_service` definitions are translated to Fory services. With `--grpc`, the compiler emits gRPC service companions for supported outputs such -as Java, Python, Go, Rust, and Kotlin. These companions use Fory serialization for -request and response payloads. +as Java, Python, Go, Rust, C#, and Kotlin. These companions use Fory +serialization for request and response payloads. ```fbs rpc_service SearchService { @@ -137,13 +137,13 @@ rpc_service SearchService { ``` ```bash -foryc api.fbs --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc +foryc api.fbs --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --csharp_out=./generated/csharp --kotlin_out=./generated/kotlin --grpc ``` Generated service code imports grpc APIs, so applications must provide grpc-java, -grpc-kotlin, `grpcio`, grpc-go, or Rust `tonic` and `bytes` dependencies when -they compile or run those files. Fory packages do not add gRPC as a hard -dependency. +grpc-kotlin, `grpcio`, grpc-go, Rust `tonic` and `bytes`, or C# +`Grpc.Core.Api` plus server/client dependencies when they compile or run those +files. Fory packages do not add gRPC as a hard dependency. ### Defaults and Metadata diff --git a/docs/compiler/generated-code.md b/docs/compiler/generated-code.md index d197fb86f..782c941d4 100644 --- a/docs/compiler/generated-code.md +++ b/docs/compiler/generated-code.md @@ -937,6 +937,67 @@ the same C# namespace. When explicit type IDs are not provided, generated installation uses computed numeric IDs (same behavior as other targets). +For existing generated C# call sites, this is a breaking generated-code name +rule when the namespace leaf and source file stem differ. For example, +`service.fdl` with `option csharp_namespace = "Demo.Greeter";` now generates +`ServiceForyModule`, not `GreeterForyModule`. Regenerate the files and update +manual calls such as `GreeterForyModule.Install(fory)` to +`ServiceForyModule.Install(fory)`. No legacy alias is emitted. + +### gRPC Service Companions + +When a schema contains services and the compiler is run with `--grpc`, C# +generation emits one `<ServiceName>Grpc.cs` file per service next to the schema +model file. + +```csharp +public static partial class AddressBookService +{ + public abstract partial class AddressBookServiceBase + { + public virtual Task<AddressBook> Lookup( + Person request, + grpc::ServerCallContext context) { ... } + } + + public partial class AddressBookServiceClient + : grpc::ClientBase<AddressBookServiceClient> + { + public virtual AddressBook Lookup(Person request, grpc::CallOptions options) { ... } + public virtual grpc::AsyncUnaryCall<AddressBook> LookupAsync( + Person request, + grpc::CallOptions options) { ... } + } + + public static grpc::ServerServiceDefinition BindService( + AddressBookServiceBase serviceImpl) { ... } + + public static void BindService( + grpc::ServiceBinderBase serviceBinder, + AddressBookServiceBase? serviceImpl) { ... } +} +``` + +Each generated method descriptor uses a static Fory-backed +`Grpc.Core.Marshaller<T>` that reuses the schema module's `ThreadSafeFory`. +Deserialization reads the gRPC body through `PayloadAsReadOnlySequence()` and +rejects trailing bytes after the single Fory frame. Generated service companions +do not use protobuf parsers and do not create Fory instances per RPC call. + +Streaming RPCs map to standard gRPC C# APIs: + +| IDL shape | Server method | Client method | +| ----------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------- | +| `rpc A (Req) returns (Res)` | `Task<Res> A(Req request, ServerCallContext context)` | `A(...)` and `AAsync(...)` | +| `rpc A (Req) returns (stream Res)` | `Task A(Req request, IServerStreamWriter<Res> responseStream, ...)` | `AsyncServerStreamingCall<Res> A(...)` | +| `rpc A (stream Req) returns (Res)` | `Task<Res> A(IAsyncStreamReader<Req> requestStream, ...)` | `AsyncClientStreamingCall<Req, Res> A(...)` | +| `rpc A (stream Req) returns (stream Res)` | `Task A(IAsyncStreamReader<Req> requestStream, IServerStreamWriter<Res> ...)` | `AsyncDuplexStreamingCall<Req, Res> A(...)` | + +Applications compiling generated C# service files must provide `Grpc.Core.Api` +and their chosen .NET gRPC hosting or client package, such as `Grpc.AspNetCore` +or `Grpc.Net.Client`. The `Apache.Fory` package does not add gRPC dependencies +as hard dependencies. + ## JavaScript/TypeScript ### Output Layout diff --git a/docs/compiler/index.md b/docs/compiler/index.md index f9fea15b4..4fda9db63 100644 --- a/docs/compiler/index.md +++ b/docs/compiler/index.md @@ -23,9 +23,9 @@ Fory IDL is a schema definition language for Apache Fory that enables type-safe cross-language serialization. Define your data structures once and generate native data structure code for Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, and Kotlin. Fory IDL can also -describe RPC services; for Java, Python, Go, Rust, and Kotlin, the compiler can -generate gRPC service companions that use Fory serialization for request and -response payloads. +describe RPC services; for Java, Python, Go, Rust, C#, and Kotlin, the compiler +can generate gRPC service companions that use Fory serialization for request +and response payloads. ## Example Schema @@ -88,16 +88,17 @@ service AnimalService { } ``` -Generate Java, Python, Rust, and Kotlin models plus gRPC service companions with: +Generate Java, Python, Rust, C#, and Kotlin models plus gRPC service companions with: ```bash -foryc animals.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc +foryc animals.fdl --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --csharp_out=./generated/csharp --kotlin_out=./generated/kotlin --grpc ``` The generated service code uses normal gRPC APIs, but request and response objects are serialized with Fory. Applications provide their own grpc-java, -grpc-kotlin, `grpcio`, grpc-go, or Rust `tonic` and `bytes` dependencies; Fory -packages do not add gRPC as a hard dependency. +grpc-kotlin, `grpcio`, grpc-go, Rust `tonic` and `bytes`, or C# +`Grpc.Core.Api` and hosting/client dependencies; Fory packages do not add gRPC +as a hard dependency. ## Why Fory IDL? diff --git a/docs/compiler/protobuf-idl.md b/docs/compiler/protobuf-idl.md index 4ce825e37..92549ff75 100644 --- a/docs/compiler/protobuf-idl.md +++ b/docs/compiler/protobuf-idl.md @@ -41,19 +41,19 @@ how protobuf concepts map to Fory, and how to use protobuf-only Fory extension o ## Protobuf vs Fory at a Glance -| Aspect | Protocol Buffers | Fory | -| ------------------ | ----------------------------- | ------------------------------------------ | -| Primary purpose | RPC/message contracts | High-performance object serialization | -| Encoding model | Tag-length-value | Fory binary protocol | -| Reference tracking | Not built-in | First-class (`ref`) | -| Circular refs | Not supported | Supported | -| Unknown fields | Preserved | Not preserved | -| Generated types | Protobuf-specific model types | Native language constructs | -| gRPC ecosystem | Native | Java/Python/Go/Rust/Kotlin service codegen | - -Fory can generate Java, Python, Go, Rust, and Kotlin gRPC service companions with -`--grpc`. Those services use normal gRPC transports but serialize request and -response payloads with Fory rather than protobuf. For broad gRPC ecosystem +| Aspect | Protocol Buffers | Fory | +| ------------------ | ----------------------------- | --------------------------------------------- | +| Primary purpose | RPC/message contracts | High-performance object serialization | +| Encoding model | Tag-length-value | Fory binary protocol | +| Reference tracking | Not built-in | First-class (`ref`) | +| Circular refs | Not supported | Supported | +| Unknown fields | Preserved | Not preserved | +| Generated types | Protobuf-specific model types | Native language constructs | +| gRPC ecosystem | Native | Java/Python/Go/Rust/C#/Kotlin service codegen | + +Fory can generate Java, Python, Go, Rust, C#, and Kotlin gRPC service companions +with `--grpc`. Those services use normal gRPC transports but serialize request +and response payloads with Fory rather than protobuf. For broad gRPC ecosystem tooling, schema reflection, and protobuf-native interceptors, protobuf remains the mature/default choice. @@ -314,16 +314,17 @@ languages. For supported service outputs, add `--grpc` to emit gRPC companion code: ```bash -foryc api.proto --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc +foryc api.proto --java_out=./generated/java --python_out=./generated/python --rust_out=./generated/rust --csharp_out=./generated/csharp --kotlin_out=./generated/kotlin --grpc ``` Generated Java service files compile against grpc-java, generated Python service modules import `grpc`, generated Rust service files import `tonic` and `bytes`, -and generated Kotlin service files compile against grpc-java and grpc-kotlin. -Add those dependencies in your application build; Fory packages do not add gRPC -as a hard dependency. Protobuf `oneof` fields are translated to Fory union -fields inside request and response messages. Direct union RPC request or -response types are not part of normal protobuf RPC syntax. +generated C# service files import `Grpc.Core.Api` types, and generated Kotlin +service files compile against grpc-java and grpc-kotlin. Add those dependencies +in your application build; Fory packages do not add gRPC as a hard dependency. +Protobuf `oneof` fields are translated to Fory union fields inside request and +response messages. Direct union RPC request or response types are not part of +normal protobuf RPC syntax. ### Step 5: Run Compatibility Checks diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md index 40f082108..56399b8bd 100644 --- a/docs/compiler/schema-idl.md +++ b/docs/compiler/schema-idl.md @@ -908,7 +908,7 @@ union_field := ['repeated'] field_type IDENTIFIER '=' INTEGER [field_options] '; Services define RPC method contracts in Fory IDL. They are optional: schemas with services still generate the normal data model types, and gRPC service code is generated only when the compiler is run with `--grpc` for supported language -outputs such as Java, Python, Go, Rust, and Kotlin. +outputs such as Java, Python, Go, Rust, C#, and Kotlin. ```protobuf message GetPetRequest [id=200] { @@ -950,8 +950,8 @@ service PetDirectory { of a service contract. - The generated gRPC companions use Fory serialization for each RPC payload. Applications that compile or run those companions provide their own gRPC - dependency, such as grpc-java, grpc-kotlin, `grpcio`, grpc-go, or Rust - `tonic` and `bytes`. + dependency, such as grpc-java, grpc-kotlin, `grpcio`, grpc-go, Rust `tonic` + and `bytes`, or C# `Grpc.Core.Api` plus a server or client package. **Grammar:** diff --git a/docs/guide/csharp/grpc-support.md b/docs/guide/csharp/grpc-support.md new file mode 100644 index 000000000..cb246b7d7 --- /dev/null +++ b/docs/guide/csharp/grpc-support.md @@ -0,0 +1,220 @@ +--- +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 C# gRPC service companions for schemas that define services. +The generated code uses normal gRPC clients, service bases, method descriptors, +metadata, deadlines, cancellations, and status codes, 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 +message bytes. + +## Add Dependencies + +The `Apache.Fory` package does not add gRPC dependencies. Add the gRPC packages +in the application that compiles or runs generated service companions. + +Server project: + +```xml +<ItemGroup> + <PackageReference Include="Apache.Fory" Version="1.2.0-dev" /> + <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" /> +</ItemGroup> +``` + +Client project: + +```xml +<ItemGroup> + <PackageReference Include="Apache.Fory" Version="1.2.0-dev" /> + <PackageReference Include="Grpc.Core.Api" Version="2.71.0" /> + <PackageReference Include="Grpc.Net.Client" Version="2.71.0" /> +</ItemGroup> +``` + +`Grpc.Core.Api` is the API surface used by generated companions. Server and +client applications can choose their normal gRPC hosting or transport packages. + +## 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; +option csharp_namespace = "Demo.Greeter"; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string reply = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} +``` + +Generate C# model and gRPC companion code with `--grpc`: + +```bash +foryc service.fdl --csharp_out=./Generated --grpc +``` + +For this schema, the C# generator emits: + +| File | Purpose | +| ------------------------------------------- | -------------------------------------------- | +| `Demo/Greeter/service.cs` | Fory model types and schema module | +| `Demo/Greeter/GreeterGrpc.cs` | gRPC service base, client, and descriptors | +| `ServiceForyModule` in `service.cs` | Fory registration module for generated types | +| `Greeter.GreeterBase` in `GreeterGrpc.cs` | Base class for server implementations | +| `Greeter.GreeterClient` in `GreeterGrpc.cs` | Client stub for gRPC calls | + +## Implement a Server + +Extend the generated `Greeter.GreeterBase` class and map it through normal +ASP.NET Core gRPC hosting: + +```csharp +using Demo.Greeter; +using Grpc.Core; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddGrpc(); + +var app = builder.Build(); +app.MapGrpcService<GreeterService>(); +app.Run(); + +public sealed class GreeterService : Greeter.GreeterBase +{ + public override Task<HelloReply> SayHello( + HelloRequest request, + ServerCallContext context) + { + return Task.FromResult(new HelloReply + { + Reply = "Hello, " + request.Name, + }); + } +} +``` + +Generated request and response types are registered by the generated schema +module used by the service companion, so service implementations do not perform +manual serializer registration. + +## Create a Client + +Use the generated client with a `Grpc.Net.Client` call invoker: + +```csharp +using Demo.Greeter; +using Grpc.Net.Client; + +using GrpcChannel channel = GrpcChannel.ForAddress("https://localhost:5001"); +var client = new Greeter.GreeterClient(channel.CreateCallInvoker()); + +HelloReply reply = await client.SayHelloAsync( + new HelloRequest { Name = "Fory" }); +Console.WriteLine(reply.Reply); +``` + +The generated client also exposes synchronous unary methods and the normal +gRPC streaming call shapes. + +## Streaming RPCs + +Fory service definitions can use the same gRPC streaming shapes: + +```protobuf +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); + rpc LotsOfReplies (HelloRequest) returns (stream HelloReply); + rpc LotsOfGreetings (stream HelloRequest) returns (HelloReply); + rpc Chat (stream HelloRequest) returns (stream HelloReply); +} +``` + +Generated C# service methods follow gRPC C# conventions: + +| IDL shape | Server method | Client method | +| ----------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------- | +| `rpc A (Req) returns (Res)` | `Task<Res> A(Req request, ServerCallContext context)` | `Res A(...)` and `AsyncUnaryCall<Res> AAsync(...)` | +| `rpc A (Req) returns (stream Res)` | `Task A(Req request, IServerStreamWriter<Res> responseStream, ...)` | `AsyncServerStreamingCall<Res> A(...)` | +| `rpc A (stream Req) returns (Res)` | `Task<Res> A(IAsyncStreamReader<Req> requestStream, ...)` | `AsyncClientStreamingCall<Req, Res> A(...)` | +| `rpc A (stream Req) returns (stream Res)` | `Task A(IAsyncStreamReader<Req> requestStream, IServerStreamWriter<Res> ...)` | `AsyncDuplexStreamingCall<Req, Res> A(...)` | + +The generated descriptors preserve the exact IDL service and method names for +the gRPC path. + +## Generated Module Names + +C# schema modules are named from the source file stem, not from the namespace. +This lets several schema files target the same C# namespace without colliding. +For example, `service.fdl` with `option csharp_namespace = "Demo.Greeter";` +generates `ServiceForyModule`. + +If you previously called a namespace-derived module name, update that call after +regenerating: + +```csharp +// Before +GreeterForyModule.Install(fory); + +// After +ServiceForyModule.Install(fory); +``` + +No legacy alias is generated. + +## Operations + +The generated service code only replaces request and response serialization. +All normal gRPC operational features still belong to your gRPC stack: + +- Deadlines and cancellations +- TLS and authentication +- Name resolution and load balancing +- Client and server interceptors +- Status codes and metadata +- Channel pooling and lifecycle management + +## Troubleshooting + +### Missing `Grpc.Core` Types + +Add `Grpc.Core.Api` or a server/client package that brings it transitively. +Generated Fory service files import gRPC APIs, but `Apache.Fory` intentionally +does not depend on gRPC. + +### Protobuf Clients Cannot Decode the Service + +Fory gRPC companions do not use protobuf wire encoding for messages. Use a +Fory-generated client for Fory-generated services, or expose a separate protobuf +service endpoint for generic protobuf clients. diff --git a/docs/guide/csharp/index.md b/docs/guide/csharp/index.md index 24b33e281..fee0e7fa9 100644 --- a/docs/guide/csharp/index.md +++ b/docs/guide/csharp/index.md @@ -96,6 +96,7 @@ User decoded = fory.Deserialize<User>(payload); | [Schema Evolution](schema-evolution.md) | Compatible mode behavior | | [Supported Types](supported-types.md) | Built-in and generated type support | | [Thread Safety](thread-safety.md) | `Fory` vs `ThreadSafeFory` usage | +| [gRPC Support](grpc-support.md) | Generated Fory-backed gRPC service companions | | [Troubleshooting](troubleshooting.md) | Common errors and debugging steps | ## Related Resources diff --git a/docs/guide/csharp/troubleshooting.md b/docs/guide/csharp/troubleshooting.md index 8ac2a414e..e5d6bb6bd 100644 --- a/docs/guide/csharp/troubleshooting.md +++ b/docs/guide/csharp/troubleshooting.md @@ -1,6 +1,6 @@ --- title: Troubleshooting -sidebar_position: 12 +sidebar_position: 13 id: troubleshooting license: | Licensed to the Apache Software Foundation (ASF) under one or more @@ -84,6 +84,25 @@ Fory fory = Fory.Builder().TrackRef(true).Build(); **Fix**: Use `BuildThreadSafe()`. +## Generated gRPC Compile Errors + +**Symptom**: Generated `*Grpc.cs` files cannot find `Grpc.Core` types. + +**Cause**: gRPC packages are application dependencies. The `Apache.Fory` +package does not add gRPC as a hard dependency. + +**Fix**: Add `Grpc.Core.Api` and your chosen gRPC server or client package, such +as `Grpc.AspNetCore` for server hosting or `Grpc.Net.Client` for clients. See +[gRPC Support](grpc-support.md). + +## Protobuf Client Cannot Decode a Fory gRPC Service + +**Cause**: Fory gRPC companions use gRPC transports with Fory-encoded message +bodies. They do not send protobuf message bytes. + +**Fix**: Use a Fory-generated client and server for the Fory endpoint, or expose +a separate protobuf endpoint for generic protobuf clients. + ## Validation Commands Run C# tests from repo root: @@ -96,5 +115,6 @@ dotnet test Fory.sln -c Release ## Related Topics - [Configuration](configuration.md) +- [gRPC Support](grpc-support.md) - [Schema Evolution](schema-evolution.md) - [Thread Safety](thread-safety.md) diff --git a/integration_tests/grpc_tests/generate_grpc.py b/integration_tests/grpc_tests/generate_grpc.py index 9f9c64332..9c45e5925 100644 --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -35,6 +35,7 @@ OUTPUTS = { "python": TEST_DIR / "python/grpc_tests/generated", "go": TEST_DIR / "go/generated", "rust": TEST_DIR / "rust/generated/src", + "csharp": TEST_DIR / "csharp/generated", "kotlin": TEST_DIR / "kotlin/src/main/kotlin/generated", } @@ -77,6 +78,7 @@ def main() -> int: f"--python_out={OUTPUTS['python']}", f"--go_out={go_pkg_out}", f"--rust_out={OUTPUTS['rust']}", + f"--csharp_out={OUTPUTS['csharp']}", f"--kotlin_out={OUTPUTS['kotlin']}", "--grpc", ], --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
