This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new 3fe40485c feat(csharp): add generated grpc support for C# (#3761)
3fe40485c is described below
commit 3fe40485cf2315e501e7c7aadbd2f7eab3e01cb7
Author: Shawn Yang <[email protected]>
AuthorDate: Sat Jun 13 15:35:49 2026 +0800
feat(csharp): add generated grpc support for C# (#3761)
## Why?
C# users can generate Fory model types today, but schemas that define
services do not produce C# gRPC companions. This leaves C# out of the
existing `--grpc` workflow used by the other supported
service-generation targets.
## What does this PR do?
- Adds C# gRPC companion generation for Fory compiler services,
including service descriptors, server base classes, clients, bind
helpers, and Fory-backed request/response marshallers.
- Extends C# generation ownership so schema modules are named from the
source file stem and reused by service companions through the generated
`ThreadSafeFory` module.
- Adds C# preflight validation for generated output path and schema
module owner collisions before writing files.
- Updates compiler and service-codegen tests to cover C# gRPC output,
streaming shapes, imported service types, identifier escaping, collision
handling, and generated module naming.
- Wires C# output into the cross-language gRPC generation helper.
- Documents C# gRPC support, dependencies, generated API shape,
troubleshooting, and compiler guide updates.
## Related issues
#3266
## AI Contribution Checklist
- [ ] Substantial AI assistance was used in this PR: `yes` / `no`
- [ ] If `yes`, I included a completed [AI Contribution
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
in this PR description and the required `AI Usage Disclosure`.
- [ ] If `yes`, my PR description includes the required `ai_review`
summary and screenshot evidence of the final clean AI review results
from both fresh reviewers on the current PR diff or current HEAD after
the latest code changes.
## Does this PR introduce any user-facing change?
- [x] Does this PR introduce any public API change?
- Adds generated C# gRPC companion APIs when `foryc --csharp_out=...
--grpc` is used.
- [ ] Does this PR introduce any binary protocol compatibility change?
- The generated services use Fory-encoded gRPC message bodies, but this
PR does not change the Fory binary protocol.
## Benchmark
Not applicable.
---
.agents/languages/csharp.md | 2 +
compiler/fory_compiler/cli.py | 34 +-
compiler/fory_compiler/generators/csharp.py | 365 +++++---
.../fory_compiler/generators/services/csharp.py | 592 +++++++++++++
.../fory_compiler/tests/test_csharp_generator.py | 313 ++++++-
.../fory_compiler/tests/test_service_codegen.py | 954 ++++++++++++++++++++-
docs/compiler/compiler-guide.md | 17 +-
docs/compiler/flatbuffers-idl.md | 10 +-
docs/compiler/generated-code.md | 80 +-
docs/compiler/index.md | 15 +-
docs/compiler/protobuf-idl.md | 42 +-
docs/compiler/schema-idl.md | 6 +-
docs/guide/csharp/grpc-support.md | 311 +++++++
docs/guide/csharp/index.md | 1 +
docs/guide/csharp/troubleshooting.md | 22 +-
integration_tests/grpc_tests/generate_grpc.py | 2 +
.../idl_tests/csharp/IdlTests/RoundtripTests.cs | 2 +-
17 files changed, 2571 insertions(+), 197 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 7326470df..7c22adc8e 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,
@@ -321,6 +322,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 validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool:
"""Check package combinations that Scala source cannot compile."""
packages = {scala_package_for_schema(schema) for _, schema in graph}
@@ -766,15 +787,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
@@ -782,7 +805,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,
)
@@ -965,6 +988,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
if "scala" in lang_output_dirs:
if not validate_scala_generation(args.files, import_paths,
grpc=args.grpc):
return 1
diff --git a/compiler/fory_compiler/generators/csharp.py
b/compiler/fory_compiler/generators/csharp.py
index ee232e2e6..456385d3f 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,243 @@ from fory_compiler.ir.ast import (
from fory_compiler.ir.types import PrimitiveKind
-class CSharpGenerator(BaseGenerator):
+_CSHARP_KEYWORDS = {
+ "abstract",
+ "as",
+ "base",
+ "bool",
+ "break",
+ "byte",
+ "case",
+ "catch",
+ "char",
+ "checked",
+ "class",
+ "const",
+ "continue",
+ "decimal",
+ "default",
+ "delegate",
+ "do",
+ "double",
+ "else",
+ "enum",
+ "event",
+ "explicit",
+ "extern",
+ "false",
+ "finally",
+ "fixed",
+ "float",
+ "for",
+ "foreach",
+ "goto",
+ "if",
+ "implicit",
+ "in",
+ "int",
+ "interface",
+ "internal",
+ "is",
+ "lock",
+ "long",
+ "namespace",
+ "new",
+ "null",
+ "object",
+ "operator",
+ "out",
+ "override",
+ "params",
+ "private",
+ "protected",
+ "public",
+ "readonly",
+ "ref",
+ "return",
+ "sbyte",
+ "sealed",
+ "short",
+ "sizeof",
+ "stackalloc",
+ "static",
+ "string",
+ "struct",
+ "switch",
+ "this",
+ "throw",
+ "true",
+ "try",
+ "typeof",
+ "uint",
+ "ulong",
+ "unchecked",
+ "unsafe",
+ "ushort",
+ "using",
+ "virtual",
+ "void",
+ "volatile",
+ "while",
+}
+
+
+def csharp_safe_identifier(name: str) -> str:
+ if name in _CSHARP_KEYWORDS:
+ return f"@{name}"
+ return name
+
+
+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:
+ return f"{csharp_schema_owner_name(schema)}.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_schema_owner_name(schema: Schema) -> str:
+ stem = re.sub(r"[^0-9A-Za-z_]", "_", csharp_module_owner_stem(schema))
+ parts = [part for part in stem.split("_") if part]
+ owner_name = "".join(_pascal_identifier_part(part) for part in parts)
+ if not owner_name or not (owner_name[0].isalpha() or owner_name[0] == "_"):
+ owner_name = f"Schema{owner_name}"
+ return owner_name
+
+
+def _pascal_identifier_part(part: str) -> str:
+ if part.isupper():
+ return part.capitalize()
+ return f"{part[0].upper()}{part[1:]}"
+
+
+def csharp_module_class_name(schema: Schema) -> str:
+ return f"{csharp_schema_owner_name(schema)}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 csharp_top_level_symbols(
+ schema: Schema, include_services: bool = False
+) -> List[Tuple[str, str]]:
+ module_name = csharp_module_class_name(schema)
+ symbols = [
+ (
+ csharp_safe_identifier(module_name),
+ f"schema module {module_name}",
+ )
+ ]
+ for type_def in schema.enums + schema.unions + schema.messages:
+ symbols.append(
+ (
+ csharp_safe_identifier(type_def.name),
+ f"schema type {type_def.name}",
+ )
+ )
+ if include_services:
+ for service in schema.services:
+ symbols.append(
+ (
+ csharp_safe_identifier(service.name),
+ f"service {service.name}",
+ )
+ )
+ return symbols
+
+
+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]] = {}
+ symbol_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))
+ for symbol_name, owner in csharp_top_level_symbols(
+ schema, include_services=grpc
+ ):
+ symbol_owners.setdefault((namespace_name, symbol_name), []).append(
+ f"{path} {owner}"
+ )
+
+ 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}"
+ )
+ symbol_collisions = {
+ owner: paths for owner, paths in symbol_owners.items() if len(paths) >
1
+ }
+ if symbol_collisions:
+ details = ", ".join(
+ f"{namespace_name}.{symbol_name}: {', '.join(paths)}"
+ for (namespace_name, symbol_name), paths in sorted(
+ symbol_collisions.items()
+ )
+ )
+ raise ValueError(
+ "C# top-level symbol collision; rename schema files, schema types,
"
+ f"or services, or use distinct C# namespaces. Collisions:
{details}"
+ )
+ return True
+
+
+class CSharpGenerator(CSharpServiceMixin, BaseGenerator):
"""Generates C# models and registration helpers for Apache Fory."""
language_name = "csharp"
@@ -87,85 +325,7 @@ class CSharpGenerator(BaseGenerator):
PrimitiveKind.DECIMAL,
}
- CSHARP_KEYWORDS = {
- "abstract",
- "as",
- "base",
- "bool",
- "break",
- "byte",
- "case",
- "catch",
- "char",
- "checked",
- "class",
- "const",
- "continue",
- "decimal",
- "default",
- "delegate",
- "do",
- "double",
- "else",
- "enum",
- "event",
- "explicit",
- "extern",
- "false",
- "finally",
- "fixed",
- "float",
- "for",
- "foreach",
- "goto",
- "if",
- "implicit",
- "in",
- "int",
- "interface",
- "internal",
- "is",
- "lock",
- "long",
- "namespace",
- "new",
- "null",
- "object",
- "operator",
- "out",
- "override",
- "params",
- "private",
- "protected",
- "public",
- "readonly",
- "ref",
- "return",
- "sbyte",
- "sealed",
- "short",
- "sizeof",
- "stackalloc",
- "static",
- "string",
- "struct",
- "switch",
- "this",
- "throw",
- "true",
- "try",
- "typeof",
- "uint",
- "ulong",
- "unchecked",
- "unsafe",
- "ushort",
- "using",
- "virtual",
- "void",
- "volatile",
- "while",
- }
+ CSHARP_KEYWORDS = _CSHARP_KEYWORDS
def __init__(self, schema: Schema, options):
super().__init__(schema, options)
@@ -196,37 +356,22 @@ 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 ""
def safe_identifier(self, name: str) -> str:
- if name in self.CSHARP_KEYWORDS:
- return f"@{name}"
- return name
+ return csharp_safe_identifier(name)
def safe_type_identifier(self, name: str) -> str:
return self.safe_identifier(name)
@@ -313,12 +458,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 +484,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 +505,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..9c1704373
--- /dev/null
+++ b/compiler/fory_compiler/generators/services/csharp.py
@@ -0,0 +1,592 @@
+# 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)
+ 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("{")
+ if service.methods:
+ module_name =
self.safe_type_identifier(self.get_module_class_name())
+ 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))
+ if service.methods:
+ 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..af499937e 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):
@@ -49,12 +50,12 @@ def test_csharp_namespace_option_used():
"""
)
- assert file.path == "MyCorp/Payment/V1/payment.cs"
+ assert file.path == "MyCorp/Payment/V1/Payment.cs"
assert "namespace MyCorp.Payment.V1;" in file.content
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;
@@ -65,7 +66,7 @@ def test_csharp_namespace_fallback_to_package():
"""
)
- assert file.path == "com/example/models/com_example_models.cs"
+ assert file.path == "com/example/models/ComExampleModels.cs"
assert "namespace com.example.models;" in file.content
@@ -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,304 @@ def test_csharp_imported_registration_calls_generated():
assert "global::tree.TreeForyModule.Install(fory);" in file.content
+def test_csharp_model_file_uses_owner_name(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/OrderEvents.cs"
+ assert "public static class OrderEventsForyModule" in file.content
+ assert "public static class EventsForyModule" not in file.content
+
+
+def test_csharp_owner_name_prefixes_digits(tmp_path: Path):
+ schema_file = tmp_path / "123-schema.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/Schema123Schema.cs"
+ assert "public static class Schema123SchemaForyModule" 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# generated file path 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_service_type_collision(tmp_path: Path, capsys):
+ model = tmp_path / "model.fdl"
+ model.write_text(
+ """
+ package demo.same;
+
+ message Greeter {
+ string name = 1;
+ }
+ """
+ )
+ service = tmp_path / "service.fdl"
+ service.write_text(
+ """
+ package demo.same;
+
+ 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(model),
+ str(service),
+ ]
+ )
+
+ captured = capsys.readouterr()
+ assert result == 1
+ assert "C# top-level symbol collision" in captured.err
+ assert not out.exists()
+
+
+def test_csharp_service_module_collision(tmp_path: Path, capsys):
+ common = tmp_path / "common.fdl"
+ common.write_text(
+ """
+ package demo.same;
+
+ message Holder {
+ string name = 1;
+ }
+ """
+ )
+ service = tmp_path / "service.fdl"
+ service.write_text(
+ """
+ package demo.same;
+
+ message Req {}
+ message Res {}
+
+ service CommonForyModule {
+ rpc Call (Req) returns (Res);
+ }
+ """
+ )
+ out = tmp_path / "out"
+
+ result = foryc_main(
+ [
+ "--lang",
+ "csharp",
+ "--csharp_out",
+ str(out),
+ "--grpc",
+ str(common),
+ str(service),
+ ]
+ )
+
+ captured = capsys.readouterr()
+ assert result == 1
+ assert "C# top-level symbol 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 1d76c729a..2bcf7e08a 100644
--- a/compiler/fory_compiler/tests/test_service_codegen.py
+++ b/compiler/fory_compiler/tests/test_service_codegen.py
@@ -18,12 +18,19 @@
"""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,
validate_scala_generation
+from fory_compiler.cli import (
+ compile_file,
+ main as foryc_main,
+ resolve_imports,
+ validate_scala_generation,
+)
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
@@ -121,7 +128,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:
@@ -132,13 +139,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,
ScalaGenerator,
KotlinGenerator,
@@ -151,7 +159,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"}
@@ -166,7 +174,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"}
@@ -186,7 +194,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"}
@@ -222,6 +230,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_scala_grpc_marshaller():
schema = parse_fdl(_GREETER_WITH_SERVICE)
files = generate_service_files(schema, ScalaGenerator)
@@ -321,6 +369,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
+
scala = next(iter(generate_service_files(schema, ScalaGenerator).values()))
assert "io.grpc.MethodDescriptor.MethodType.UNARY" in scala
assert "io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING" in scala
@@ -367,7 +441,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(
"""
@@ -403,7 +477,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(
"""
@@ -427,7 +501,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(
@@ -489,6 +563,18 @@ 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
+ )
+
scala_files = generate_service_files(schema, ScalaGenerator)
assert set(scala_files) == {"api/ApiServiceGrpc.scala"}
scala = scala_files["api/ApiServiceGrpc.scala"]
@@ -497,7 +583,7 @@ def
test_grpc_services_use_imported_java_type_references(tmp_path: Path):
assert "def get(request: common.Shared): RpcFuture[Local]" in scala
-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(
@@ -554,13 +640,20 @@ 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
+
scala_files = generate_service_files(schema, ScalaGenerator)
scala = scala_files["api/ApiServiceGrpc.scala"]
assert "io.grpc.MethodDescriptor[common.Shared, Local]" in scala
assert "marshaller(classOf[common.Shared])" in scala
-def test_proto_grpc_absolute_rpc_type_uses_package_type_not_nested_shadow():
+def test_proto_grpc_absolute_type():
schema = parse_proto(
dedent(
"""
@@ -599,13 +692,18 @@ 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
+
scala_files = generate_service_files(schema, ScalaGenerator)
scala = scala_files["demo/ApiServiceGrpc.scala"]
assert "io.grpc.MethodDescriptor[Request, Response]" in scala
assert "io.grpc.MethodDescriptor[demo.Request, Response]" not in scala
-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(
@@ -660,13 +758,18 @@ 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
+
scala_files = generate_service_files(schema, ScalaGenerator)
scala = scala_files["alpha/ApiServiceGrpc.scala"]
assert "io.grpc.MethodDescriptor[alpha.beta.C, alpha.beta.C]" in scala
assert "io.grpc.MethodDescriptor[beta.C, beta.C]" not in scala
-def test_java_grpc_service_class_collision_fails():
+def test_java_grpc_class_collision():
schema = parse_fdl(
dedent(
"""
@@ -693,7 +796,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(
"""
@@ -713,6 +816,29 @@ def test_kotlin_grpc_service_class_collision_fails():
KotlinGenerator(schema, GeneratorOptions(output_dir=Path("/tmp"),
grpc=True))
+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_scala_grpc_class_collision():
schema = parse_fdl(
dedent(
@@ -812,7 +938,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(
@@ -923,6 +1049,17 @@ 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")
+
scala_generator = ScalaGenerator(
schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True)
)
@@ -962,7 +1099,7 @@ def test_scala_grpc_method_scopes():
assert "def completePromise(request: Req): RpcFuture[Res]" in content
-def test_java_python_grpc_method_keywords_are_safe_names():
+def test_grpc_method_keywords_safe():
schema = parse_fdl(
dedent(
"""
@@ -1000,7 +1137,7 @@ def
test_java_python_grpc_method_keywords_are_safe_names():
assert 'SERVICE_NAME,\n "Class"' in scala
-def test_python_grpc_service_registration_collisions_fail():
+def test_python_grpc_registration_collision():
schema = parse_fdl(
dedent(
"""
@@ -1023,7 +1160,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(
"""
@@ -1046,7 +1183,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(
"""
@@ -1124,7 +1261,7 @@ def test_proto_and_fbs_grpc_service_codegen():
)
-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)
@@ -1160,11 +1297,141 @@ def test_grpc_flag_compiles_services(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["scala"] / "demo" / "greeter" /
"GreeterGrpc.scala").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);
+ }
+
+ service Empty {}
+ """
+ )
+ )
+ 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"
+ empty_service = out / "Demo" / "Greeter" / "EmptyGrpc.cs"
+ main_model = out / "Demo" / "Greeter" / "Main.cs"
+ common_model = out / "Demo" / "Shared" / "Common.cs"
+ assert service.is_file()
+ assert empty_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
+ empty_service_code = empty_service.read_text()
+ assert "__ServiceName" not in empty_service_code
+ assert "__Fory" not in empty_service_code
+ assert "__Throw" not in empty_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())
@@ -1179,7 +1446,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 cd1701070..638c3ad26 100644
--- a/docs/compiler/compiler-guide.md
+++ b/docs/compiler/compiler-guide.md
@@ -141,22 +141,24 @@ 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
+ Scala + Kotlin models):**
+**Compile a simple schema containing service definitions (Java + Python + Rust
+ C# + Scala + Kotlin models):**
```bash
-foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--scala_out=./generated/scala --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 --scala_out=./generated/scala
--kotlin_out=./generated/kotlin
```
-**Generate Java, Python, Rust, Scala, and Kotlin gRPC service companions:**
+**Generate Java, Python, Rust, C#, Scala, and Kotlin gRPC service companions:**
```bash
-foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--scala_out=./generated/scala --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 --scala_out=./generated/scala
--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`. Scala output imports grpc-java APIs.
Kotlin output imports grpc-java and grpc-kotlin APIs and uses coroutine stubs.
+C# output imports `Grpc.Core.Api` types and can be hosted with normal .NET gRPC
+packages such as `Grpc.AspNetCore` or called through `Grpc.Net.Client`.
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.
@@ -367,15 +369,18 @@ generated/
generated/
└── csharp/
└── example/
- └── example.cs
+ └── Example.cs
```
-- Single `.cs` file per schema
+- Single `.cs` file per schema named from the normalized PascalCase source file
+ stem
- Namespace uses `csharp_namespace` (if set) or Fory IDL package
- Includes source-file-prefixed `XXXForyModule` installation helper and
`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 203a64c40..9a0d3e9a2 100644
--- a/docs/compiler/flatbuffers-idl.md
+++ b/docs/compiler/flatbuffers-idl.md
@@ -126,7 +126,7 @@ message Container {
FlatBuffers `rpc_service` definitions are translated to Fory services. With
`--grpc`, the compiler emits gRPC service companions for supported outputs such
-as Java, Python, Go, Rust, Scala, and Kotlin. These companions use Fory
+as Java, Python, Go, Rust, C#, Scala, and Kotlin. These companions use Fory
serialization for request and response payloads.
```fbs
@@ -137,13 +137,13 @@ rpc_service SearchService {
```
```bash
-foryc api.fbs --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --scala_out=./generated/scala
--kotlin_out=./generated/kotlin --grpc
+foryc api.fbs --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --csharp_out=./generated/csharp
--scala_out=./generated/scala --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, Scala grpc-java APIs, `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 dfdc403a0..a9035f9af 100644
--- a/docs/compiler/generated-code.md
+++ b/docs/compiler/generated-code.md
@@ -23,7 +23,11 @@ This document explains generated code for each target
language.
Fory IDL generated types are idiomatic in host languages and can be used
directly as domain objects. Generated types also include `to/from bytes`
helpers and schema modules or registration helpers, depending on the target
language.
-Generated schema modules are schema-file owners, not package or namespace
owners. In targets that expose the owner directly in a language package or
namespace, the owner name includes a source-file-derived prefix such as
`AddressbookForyModule` or `ComplexPbForyModule` so multiple IDL files can
target the same package or namespace without producing colliding `ForyModule`
types.
+Generated schema modules are named from the schema source file, not from the
+package or namespace. In targets that expose the module directly in a language
+package or namespace, names such as `AddressbookForyModule` or
+`ComplexPbForyModule` let multiple IDL files target the same package or
+namespace without producing colliding `ForyModule` types.
## Reference Schemas
@@ -868,7 +872,11 @@ packages do not add gRPC as a hard dependency.
C# output is one `.cs` file per schema, for example:
-- `<csharp_out>/addressbook/addressbook.cs`
+- `<csharp_out>/addressbook/Addressbook.cs`
+
+The C# model file name uses the normalized PascalCase source file stem. For
+example, `service.fdl` generates `Service.cs`, `order-events.fdl` generates
+`OrderEvents.cs`, and `123-schema.fdl` generates `Schema123Schema.cs`.
### Type Generation
@@ -916,7 +924,7 @@ public abstract partial record Animal
### Module Installation
-Each schema generates a module owner that installs imported modules first and
+Each schema generates a module class that installs imported modules first and
then registers the local schema types:
```csharp
@@ -931,12 +939,74 @@ public static class AddressbookForyModule
}
```
-The C# module owner keeps the schema-file prefix even when several schemas
share
-the same C# namespace.
+The C# model file basename and module class both use the normalized source file
+stem. They do not use `csharp_namespace` and they do not use gRPC service
names.
+For example, `service.fdl` generates `Service.cs` and `ServiceForyModule`,
+while `order-events.fdl` generates `OrderEvents.cs` and
+`OrderEventsForyModule`. A gRPC service named `Greeter` generates the service
+companion `GreeterGrpc.cs`; it does not change the schema module name. To get
+`GreeterForyModule`, name the schema file `greeter.fdl` or `Greeter.fdl`.
+
+This source-file rule lets several schemas target the same C# namespace without
+colliding. No namespace-derived or service-derived module alias is generated.
When explicit type IDs are not provided, generated installation uses computed
numeric IDs (same behavior as other targets).
+### 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 e635bcde0..99136fafb 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, Scala, 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#, Scala, 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, Scala, and Kotlin models plus gRPC service
companions with:
+Generate Java, Python, Rust, C#, Scala, and Kotlin models plus gRPC service
companions with:
```bash
-foryc animals.fdl --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --scala_out=./generated/scala
--kotlin_out=./generated/kotlin --grpc
+foryc animals.fdl --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --csharp_out=./generated/csharp
--scala_out=./generated/scala --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 acf7f7f5a..40ad72b85 100644
--- a/docs/compiler/protobuf-idl.md
+++ b/docs/compiler/protobuf-idl.md
@@ -41,19 +41,20 @@ 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/Scala/Kotlin service codegen |
-
-Fory can generate Java, Python, Go, Rust, Scala, 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#/Scala/Kotlin service codegen |
+
+Fory can generate Java, Python, Go, Rust, C#, Scala, 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,17 +315,18 @@ 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 --scala_out=./generated/scala
--kotlin_out=./generated/kotlin --grpc
+foryc api.proto --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --csharp_out=./generated/csharp
--scala_out=./generated/scala --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`,
-generated Scala service files compile against grpc-java, 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, generated Scala
service
+files compile against grpc-java, 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 4fcf7c52f..168c07ddc 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, Scala, and Kotlin.
+outputs such as Java, Python, Go, Rust, C#, Scala, 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..138b87b45
--- /dev/null
+++ b/docs/guide/csharp/grpc-support.md
@@ -0,0 +1,311 @@
+---
+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 System.Threading.Tasks;
+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(...)` |
+
+Server implementations can use the generated streaming method shapes directly:
+
+```csharp
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Demo.Greeter;
+using Grpc.Core;
+
+public sealed class GreeterService : Greeter.GreeterBase
+{
+ public override async Task LotsOfReplies(
+ HelloRequest request,
+ IServerStreamWriter<HelloReply> responseStream,
+ ServerCallContext context)
+ {
+ foreach (string reply in new[]
+ {
+ "Hello, " + request.Name,
+ "Welcome, " + request.Name,
+ })
+ {
+ await responseStream.WriteAsync(new HelloReply { Reply = reply });
+ }
+ }
+
+ public override async Task<HelloReply> LotsOfGreetings(
+ IAsyncStreamReader<HelloRequest> requestStream,
+ ServerCallContext context)
+ {
+ List<string> names = new();
+ while (await requestStream.MoveNext(context.CancellationToken))
+ {
+ names.Add(requestStream.Current.Name);
+ }
+
+ return new HelloReply { Reply = string.Join(", ", names) };
+ }
+
+ public override async Task Chat(
+ IAsyncStreamReader<HelloRequest> requestStream,
+ IServerStreamWriter<HelloReply> responseStream,
+ ServerCallContext context)
+ {
+ while (await requestStream.MoveNext(context.CancellationToken))
+ {
+ await responseStream.WriteAsync(new HelloReply
+ {
+ Reply = "Hello, " + requestStream.Current.Name,
+ });
+ }
+ }
+}
+```
+
+Generated clients return the standard gRPC streaming call objects:
+
+```csharp
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Demo.Greeter;
+using Grpc.Core;
+
+using AsyncServerStreamingCall<HelloReply> replies =
+ client.LotsOfReplies(new HelloRequest { Name = "Fory" });
+while (await replies.ResponseStream.MoveNext(CancellationToken.None))
+{
+ Console.WriteLine(replies.ResponseStream.Current.Reply);
+}
+
+using AsyncClientStreamingCall<HelloRequest, HelloReply> greetings =
+ client.LotsOfGreetings();
+await greetings.RequestStream.WriteAsync(new HelloRequest { Name = "Ada" });
+await greetings.RequestStream.WriteAsync(new HelloRequest { Name = "Grace" });
+await greetings.RequestStream.CompleteAsync();
+HelloReply summary = await greetings.ResponseAsync;
+Console.WriteLine(summary.Reply);
+
+using AsyncDuplexStreamingCall<HelloRequest, HelloReply> chat = client.Chat();
+Task readTask = Task.Run(async () =>
+{
+ while (await chat.ResponseStream.MoveNext(CancellationToken.None))
+ {
+ Console.WriteLine(chat.ResponseStream.Current.Reply);
+ }
+});
+await chat.RequestStream.WriteAsync(new HelloRequest { Name = "Fory" });
+await chat.RequestStream.CompleteAsync();
+await readTask;
+```
+
+The generated descriptors preserve the exact IDL service and method names for
+the gRPC path.
+
+## Generated Module Names
+
+C# schema module names come from the source file stem. They do not come from
+`csharp_namespace` and they do not come from gRPC service names.
+
+For example:
+
+| Schema input | Model file | Schema module |
+| ------------------ | ---------------- | ----------------------- |
+| `service.fdl` | `Service.cs` | `ServiceForyModule` |
+| `order-events.fdl` | `OrderEvents.cs` | `OrderEventsForyModule` |
+| `greeter.fdl` | `Greeter.cs` | `GreeterForyModule` |
+| `Greeter.fdl` | `Greeter.cs` | `GreeterForyModule` |
+
+A gRPC service named `Greeter` still generates the service companion
+`GreeterGrpc.cs`; it does not change the schema module name. This lets several
+schema files target the same C# namespace without colliding. No
+namespace-derived or service-derived module 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",
],
diff --git a/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
index 1f460d870..a61f6d067 100644
--- a/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
+++ b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
@@ -172,7 +172,7 @@ public sealed class RoundtripTests
public void AnyProtoRoundTrip(bool compatible)
{
ForyRuntime fory = BuildFory(compatible, false);
- any_example_pb.AnyExamplePbForyModule.Install(fory);
+ any_example_pb.AnyExampleForyModule.Install(fory);
any_example_pb.AnyHolder holder = BuildAnyProtoHolder();
any_example_pb.AnyHolder decoded =
fory.Deserialize<any_example_pb.AnyHolder>(fory.Serialize(holder));
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]