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 0d8f412c2 feat(scala): add generated grpc service support for scala
(#3762)
0d8f412c2 is described below
commit 0d8f412c2eb3b0496986c569708c55bf7fdd742c
Author: Shawn Yang <[email protected]>
AuthorDate: Sat Jun 13 15:02:10 2026 +0800
feat(scala): add generated grpc service support for scala (#3762)
## Why?
Scala schema generation already emits Scala 3 model types and modules,
but schemas with service definitions could not generate Scala gRPC
companions. This left Scala users without generated client/server
bindings for Fory-encoded gRPC services even though Java, Python, Go,
Rust, and Kotlin already support `--grpc`.
## What does this PR do?
- Adds Scala gRPC service companion generation for `foryc
--scala_out=... --grpc`, including service descriptors, server base
classes, generated clients, Fory-backed grpc-java marshallers, unary
calls, and streaming method shapes.
- Adds Scala compiler preflight validation for package/import
combinations and generated file path collisions, including service
companion paths.
- Adds transport-free Scala RPC handle traits, `RpcFuture` and
`RpcIterator`, for generated unary and server-streaming client
convenience APIs.
- Extends service codegen tests to cover Scala marshalling output,
streaming shapes, imported type references, collision handling, keyword
escaping, protobuf/FlatBuffers service inputs, and end-to-end `--grpc`
output creation.
- Documents Scala gRPC generation across the compiler docs and Scala
guide, including dependencies, client/server examples, streaming
behavior, and cross-IDL support.
## 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?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
User-facing changes:
- Adds Scala `--grpc` service companion generation and new Scala RPC
handle traits used by generated clients.
- Does not change the Fory binary protocol.
## Benchmark
---
compiler/fory_compiler/cli.py | 69 ++
compiler/fory_compiler/generators/scala.py | 126 +++-
.../fory_compiler/generators/services/scala.py | 810 +++++++++++++++++++++
.../fory_compiler/tests/test_service_codegen.py | 207 +++++-
docs/compiler/compiler-guide.md | 23 +-
docs/compiler/flatbuffers-idl.md | 6 +-
docs/compiler/generated-code.md | 86 +++
docs/compiler/index.md | 6 +-
docs/compiler/protobuf-idl.md | 27 +-
docs/compiler/schema-idl.md | 2 +-
docs/guide/java/grpc-support.md | 5 +-
docs/guide/scala/grpc-support.md | 397 ++++++++++
docs/guide/scala/index.md | 1 +
docs/guide/scala/schema-idl.md | 4 +
.../org/apache/fory/scala/rpc/RpcFuture.scala | 40 +
.../org/apache/fory/scala/rpc/RpcIterator.scala | 36 +
.../org/apache/fory/scala/rpc/RpcHandleTest.scala | 71 ++
17 files changed, 1859 insertions(+), 57 deletions(-)
diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py
index a654f6a4e..7326470df 100644
--- a/compiler/fory_compiler/cli.py
+++ b/compiler/fory_compiler/cli.py
@@ -35,6 +35,10 @@ from fory_compiler.generators.kotlin import (
kotlin_output_paths,
kotlin_package_for_schema,
)
+from fory_compiler.generators.scala import (
+ scala_output_paths,
+ scala_package_for_schema,
+)
class ImportError(Exception):
@@ -317,6 +321,68 @@ def validate_kotlin_generation(
return validate_kotlin_output_paths(graph, grpc=grpc)
+def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool:
+ """Check package combinations that Scala source cannot compile."""
+ packages = {scala_package_for_schema(schema) for _, schema in graph}
+ if None not in packages or len(packages) <= 1:
+ return True
+ details = ", ".join(
+ f"{package or '<default>'}: {path}"
+ for path, schema in sorted(graph, key=lambda item: str(item[0]))
+ for package in [scala_package_for_schema(schema)]
+ )
+ print(
+ "Error: Scala imports cannot mix default-package schemas with named "
+ f"Scala packages; found {details}",
+ file=sys.stderr,
+ )
+ return False
+
+
+def validate_scala_output_paths(
+ graph: List[Tuple[Path, Schema]],
+ grpc: bool = False,
+) -> bool:
+ """Check Scala output paths for the current generation run."""
+ outputs: Dict[str, List[str]] = {}
+ for path, schema in graph:
+ for output_path, owner in scala_output_paths(schema,
include_services=grpc):
+ outputs.setdefault(output_path, []).append(f"{path} {owner}")
+ collisions = {
+ output_path: paths for output_path, paths in outputs.items() if
len(paths) > 1
+ }
+ if not collisions:
+ return True
+ details = ", ".join(
+ f"{output_path}: {', '.join(paths)}"
+ for output_path, paths in sorted(collisions.items())
+ )
+ print(
+ "Error: Scala generated file path collision; rename schema files, "
+ f"schema types, or services, or use distinct Scala packages.
Collisions: {details}",
+ file=sys.stderr,
+ )
+ return False
+
+
+def validate_scala_generation(
+ files: List[Path],
+ import_paths: List[Path],
+ grpc: bool = False,
+) -> bool:
+ """Preflight Scala package and helper paths before writing output."""
+ cache: Dict[Path, Schema] = {}
+ graph: List[Tuple[Path, Schema]] = []
+ 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)
+ if not validate_scala_import_packages(graph):
+ return False
+ return validate_scala_output_paths(graph, grpc=grpc)
+
+
def _find_go_module_root(base_go_out: Path) -> Optional[Path]:
base_go_out = base_go_out.resolve()
for candidate in (base_go_out, *base_go_out.parents):
@@ -899,6 +965,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 "scala" in lang_output_dirs:
+ if not validate_scala_generation(args.files, import_paths,
grpc=args.grpc):
+ return 1
# Create output directories
for out_dir in lang_output_dirs.values():
diff --git a/compiler/fory_compiler/generators/scala.py
b/compiler/fory_compiler/generators/scala.py
index bba6a1a6c..c978c54f1 100644
--- a/compiler/fory_compiler/generators/scala.py
+++ b/compiler/fory_compiler/generators/scala.py
@@ -23,6 +23,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from fory_compiler.generators.base import BaseGenerator, GeneratedFile
+from fory_compiler.generators.services.scala import ScalaServiceGeneratorMixin
from fory_compiler.ir.ast import (
ArrayType,
Enum,
@@ -40,7 +41,103 @@ from fory_compiler.ir.construction import analyze_shapes
from fory_compiler.ir.types import PrimitiveKind
-class ScalaGenerator(BaseGenerator):
+def _to_pascal_case(name: str) -> str:
+ if not name:
+ return name
+ if "_" in name:
+ return "".join(word.capitalize() for word in name.lower().split("_"))
+ if name.isupper():
+ return name.capitalize()
+ return name[0].upper() + name[1:]
+
+
+def scala_package_for_schema(schema: Schema) -> Optional[str]:
+ """Return the Scala source package for a schema."""
+ return schema.package
+
+
+def scala_package_path(schema: Schema) -> str:
+ """Return the generated Scala package directory."""
+ package = scala_package_for_schema(schema)
+ return package.replace(".", "/") if package else ""
+
+
+def scala_module_name(schema: Schema) -> str:
+ """Return the generated Scala schema module name."""
+ package = scala_package_for_schema(schema)
+ if package:
+ return _to_pascal_case(package.split(".")[-1]) + "ForyModule"
+ if schema.source_file and not schema.source_file.startswith("<"):
+ cleaned = "".join(
+ char if char.isascii() and (char.isalnum() or char == "_") else "_"
+ for char in Path(schema.source_file).stem
+ )
+ prefix = _to_pascal_case(cleaned) if cleaned else "Schema"
+ if not prefix or not (prefix[0].isalpha() or prefix[0] == "_"):
+ prefix = f"Schema{prefix}"
+ return prefix + "ForyModule"
+ return "ForyModule"
+
+
+def scala_source_path(schema: Schema, type_name: str) -> str:
+ """Return a generated Scala source path for a top-level owner."""
+ path = scala_package_path(schema)
+ file_name = f"{type_name}.scala"
+ return f"{path}/{file_name}" if path else file_name
+
+
+def scala_module_file_path(schema: Schema) -> str:
+ """Return the generated Scala schema module path."""
+ return scala_source_path(schema, scala_module_name(schema))
+
+
+def _is_schema_local(type_def: object, schema: Schema) -> bool:
+ if not schema.source_file or schema.source_file.startswith("<"):
+ return True
+ location = getattr(type_def, "location", None)
+ file_path = getattr(location, "file", None) if location else None
+ if not file_path:
+ return True
+ try:
+ return Path(file_path).resolve() == Path(schema.source_file).resolve()
+ except Exception:
+ return file_path == schema.source_file
+
+
+def scala_output_paths(
+ schema: Schema,
+ local_only: bool = False,
+ include_services: bool = False,
+) -> List[Tuple[str, str]]:
+ """Return generated Scala output paths and their owning schema elements."""
+ outputs: List[Tuple[str, str]] = []
+
+ def add_type(type_def: object, kind: str) -> None:
+ if local_only and not _is_schema_local(type_def, schema):
+ return
+ outputs.append((scala_source_path(schema, type_def.name), kind))
+
+ for enum in schema.enums:
+ add_type(enum, f"enum {enum.name}")
+ for union in schema.unions:
+ add_type(union, f"union {union.name}")
+ for message in schema.messages:
+ add_type(message, f"message {message.name}")
+ outputs.append((scala_module_file_path(schema), "schema module"))
+ if include_services:
+ for service in schema.services:
+ if local_only and not _is_schema_local(service, schema):
+ continue
+ outputs.append(
+ (
+ scala_source_path(schema, f"{service.name}Grpc"),
+ f"service {service.name}",
+ )
+ )
+ return outputs
+
+
+class ScalaGenerator(ScalaServiceGeneratorMixin, BaseGenerator):
"""Generates Scala 3 models with Fory macro-derived serializers."""
language_name = "scala"
@@ -136,11 +233,10 @@ class ScalaGenerator(BaseGenerator):
self._construction_shapes = analyze_shapes(schema)
def get_scala_package(self) -> Optional[str]:
- return self.schema.package
+ return scala_package_for_schema(self.schema)
def get_scala_package_path(self) -> str:
- package = self.get_scala_package()
- return package.replace(".", "/") if package else ""
+ return scala_package_path(self.schema)
def get_module_name(self) -> str:
return self._module_name(self.schema)
@@ -167,7 +263,7 @@ class ScalaGenerator(BaseGenerator):
return path_str
def _scala_package_for_schema(self, schema: Schema) -> Optional[str]:
- return schema.package
+ return scala_package_for_schema(schema)
def _validate_import_packages(self) -> None:
schemas = self._schema_graph()
@@ -184,25 +280,7 @@ class ScalaGenerator(BaseGenerator):
)
def _module_name(self, schema: Schema) -> str:
- package = self._scala_package_for_schema(schema)
- if package:
- return self.to_pascal_case(package.split(".")[-1]) + "ForyModule"
- prefix = self._module_prefix_from_source(schema)
- if prefix:
- return prefix + "ForyModule"
- return "ForyModule"
-
- def _module_prefix_from_source(self, schema: Schema) -> str:
- if not schema.source_file or schema.source_file.startswith("<"):
- return ""
- cleaned = "".join(
- char if char.isascii() and (char.isalnum() or char == "_") else "_"
- for char in Path(schema.source_file).stem
- )
- prefix = self.to_pascal_case(cleaned) if cleaned else "Schema"
- if not prefix or not (prefix[0].isalpha() or prefix[0] == "_"):
- prefix = f"Schema{prefix}"
- return prefix
+ return scala_module_name(schema)
def _scala_package_for_type(self, type_def: object) -> Optional[str]:
location = getattr(type_def, "location", None)
diff --git a/compiler/fory_compiler/generators/services/scala.py
b/compiler/fory_compiler/generators/services/scala.py
new file mode 100644
index 000000000..630b6aa9d
--- /dev/null
+++ b/compiler/fory_compiler/generators/services/scala.py
@@ -0,0 +1,810 @@
+# 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.
+
+"""Scala gRPC service generator helpers."""
+
+from __future__ import annotations
+
+from typing import Dict, List
+
+from fory_compiler.generators.base import GeneratedFile
+from fory_compiler.generators.services.base import StreamingMode,
streaming_mode
+from fory_compiler.ir.ast import NamedType, RpcMethod, Service
+
+
+class ScalaServiceGeneratorMixin:
+ """Generates Scala gRPC service companions."""
+
+ def generate_services(self) -> List[GeneratedFile]:
+ """Generate Scala gRPC service companions for local service
definitions."""
+ local_services = [
+ service
+ for service in self.schema.services
+ if not self.is_imported_type(service)
+ ]
+ if not local_services:
+ return []
+ self.check_scala_grpc_service_collisions(local_services)
+ self.check_scala_grpc_method_collisions(local_services)
+ return [
+ self.generate_scala_grpc_service_file(service) for service in
local_services
+ ]
+
+ def scala_generated_output_paths(
+ self, include_services: bool = False
+ ) -> List[tuple[str, str]]:
+ """Return generated Scala paths for the current schema graph."""
+ from fory_compiler.generators.scala import scala_output_paths
+
+ outputs: List[tuple[str, str]] = []
+ for index, (_source, schema) in enumerate(self._schema_graph()):
+ outputs.extend(
+ scala_output_paths(
+ schema,
+ local_only=index == 0,
+ include_services=include_services,
+ )
+ )
+ return outputs
+
+ def check_scala_grpc_service_collisions(self, services: List[Service]) ->
None:
+ generated_paths: Dict[str, str] = {}
+ for path, owner in
self.scala_generated_output_paths(include_services=True):
+ prior = generated_paths.get(path)
+ if prior is not None:
+ raise ValueError(
+ "Scala generated file path collision; rename schema files,
"
+ "schema types, or services, or use distinct Scala
packages. "
+ f"Collisions: {path}: {prior}, {owner}"
+ )
+ generated_paths[path] = owner
+
+ seen_service_objects: Dict[str, str] = {}
+ for service in services:
+ owner = self.scala_grpc_owner_name(service)
+ prior = seen_service_objects.get(owner)
+ if prior is not None:
+ raise ValueError(
+ f"Scala gRPC service object collision: {prior} and "
+ f"{service.name} both generate {owner}"
+ )
+ seen_service_objects[owner] = service.name
+
+ def check_scala_grpc_method_collisions(self, services: List[Service]) ->
None:
+ top_level_reserved = {
+ "SERVICE_NAME",
+ "FORY",
+ "completePromise",
+ "internalError",
+ "interrupted",
+ "marshaller",
+ "newClient",
+ "readBytes",
+ "readUnknownLengthBytes",
+ "serviceDescriptor",
+ "serviceDescriptorValue",
+ "unimplemented",
+ }
+ helper_names = {
+ "ForyMarshaller",
+ "KnownLengthByteArrayInputStream",
+ "RpcFutureAdapter",
+ "RpcIteratorAdapter",
+ }
+ for service in services:
+ seen_top_level: Dict[str, str] = {}
+ seen_service_methods: Dict[str, str] = {}
+ seen_client_methods: Dict[str, str] = {}
+ for method in service.methods:
+ base_name = self.scala_grpc_method_name(method)
+ top_level_names = [
+ self.scala_grpc_method_descriptor(method),
+ self.scala_grpc_method_backing_field(method),
+ ]
+ service_method_names = [base_name]
+ client_method_names = [base_name]
+ mode = streaming_mode(method)
+ if mode in (StreamingMode.UNARY,
StreamingMode.SERVER_STREAMING):
+ client_method_names.append(
+ self.scala_grpc_blocking_method_name(method)
+ )
+ if mode is StreamingMode.UNARY:
+ client_method_names.append(
+ self.scala_grpc_future_method_name(method)
+ )
+ for name in top_level_names:
+ if name in top_level_reserved or name in helper_names:
+ raise ValueError(
+ f"Scala gRPC method name collision in service "
+ f"{service.name}: {method.name} generates reserved
"
+ f"member {name}"
+ )
+ prior = seen_top_level.get(name)
+ if prior is not None:
+ raise ValueError(
+ f"Scala gRPC method name collision in service "
+ f"{service.name}: {prior} and {method.name} both "
+ f"generate top-level member {name}"
+ )
+ seen_top_level[name] = method.name
+ for name in service_method_names:
+ prior = seen_service_methods.get(name)
+ if prior is not None:
+ raise ValueError(
+ f"Scala gRPC method name collision in service "
+ f"{service.name}: {prior} and {method.name} both "
+ f"generate service method {name}"
+ )
+ seen_service_methods[name] = method.name
+ for name in client_method_names:
+ prior = seen_client_methods.get(name)
+ if prior is not None:
+ raise ValueError(
+ f"Scala gRPC method name collision in service "
+ f"{service.name}: {prior} and {method.name} both "
+ f"generate client method {name}"
+ )
+ seen_client_methods[name] = method.name
+
+ def generate_scala_grpc_service_file(self, service: Service) ->
GeneratedFile:
+ imports = {"org.apache.fory.scala.rpc.{RpcFuture, RpcIterator}"}
+ lines = self.source_header(imports)
+ owner = self.scala_grpc_owner_name(service)
+ lines.append(f"object {owner} {{")
+ lines.append(
+ f' final val SERVICE_NAME: String =
"{self.scala_string_literal(self.get_grpc_service_name(service))}"'
+ )
+ lines.append("")
+ lines.append(
+ " private val FORY: org.apache.fory.ThreadSafeFory = "
+ f"{self.get_module_name()}.getFory"
+ )
+ lines.append("")
+ lines.extend(self.generate_scala_grpc_service_descriptor(service))
+ for method in service.methods:
+ lines.extend(self.generate_scala_grpc_method_descriptor(method))
+ lines.extend(self.generate_scala_grpc_factories(service))
+ lines.extend(self.generate_scala_grpc_service_base(service))
+ lines.extend(self.generate_scala_grpc_client(service))
+ lines.extend(self.generate_scala_grpc_marshaller())
+ lines.append("}")
+ return self.source_file(owner, lines)
+
+ def generate_scala_grpc_service_descriptor(self, service: Service) ->
List[str]:
+ lines = [
+ " private lazy val serviceDescriptorValue:
io.grpc.ServiceDescriptor =",
+ " io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)",
+ ]
+ for method in service.methods:
+ lines.append(
+ f"
.addMethod({self.scala_grpc_method_descriptor(method)})"
+ )
+ lines.extend(
+ [
+ " .build()",
+ "",
+ " def serviceDescriptor: io.grpc.ServiceDescriptor =",
+ " serviceDescriptorValue",
+ "",
+ ]
+ )
+ return lines
+
+ def generate_scala_grpc_method_descriptor(self, method: RpcMethod) ->
List[str]:
+ request_type = self.scala_grpc_type(method.request_type)
+ response_type = self.scala_grpc_type(method.response_type)
+ descriptor = self.scala_grpc_method_descriptor(method)
+ backing_field = self.scala_grpc_method_backing_field(method)
+ method_type = self.scala_grpc_method_type(method)
+ return [
+ f" private lazy val {backing_field}:
io.grpc.MethodDescriptor[{request_type}, {response_type}] =",
+ " io.grpc.MethodDescriptor",
+ f" .newBuilder[{request_type}, {response_type}]()",
+ f"
.setType(io.grpc.MethodDescriptor.MethodType.{method_type})",
+ " .setFullMethodName(",
+ " io.grpc.MethodDescriptor.generateFullMethodName(",
+ " SERVICE_NAME,",
+ f'
"{self.scala_string_literal(method.name)}"))',
+ " .setSampledToLocalTracing(true)",
+ f"
.setRequestMarshaller(marshaller(classOf[{request_type}]))",
+ f"
.setResponseMarshaller(marshaller(classOf[{response_type}]))",
+ " .build()",
+ "",
+ f" def {descriptor}: io.grpc.MethodDescriptor[{request_type},
{response_type}] =",
+ f" {backing_field}",
+ "",
+ ]
+
+ def generate_scala_grpc_factories(self, service: Service) -> List[str]:
+ client = self.scala_grpc_client_name(service)
+ return [
+ f" def newClient(channel: io.grpc.Channel): {client} =",
+ f" new {client}(channel, io.grpc.CallOptions.DEFAULT)",
+ "",
+ " def newClient(",
+ " channel: io.grpc.Channel,",
+ f" callOptions: io.grpc.CallOptions): {client} =",
+ f" new {client}(channel, callOptions)",
+ "",
+ ]
+
+ def generate_scala_grpc_service_base(self, service: Service) -> List[str]:
+ base_name = self.scala_grpc_service_base_name(service)
+ lines = [f" abstract class {base_name} extends
io.grpc.BindableService {{"]
+ for method in service.methods:
+ lines.extend(self.generate_scala_grpc_server_method(method))
+ lines.extend(self.generate_scala_grpc_bind_service(service))
+ lines.append(" }")
+ lines.append("")
+ return lines
+
+ def generate_scala_grpc_server_method(self, method: RpcMethod) ->
List[str]:
+ method_name = self.safe_identifier(self.scala_grpc_method_name(method))
+ request_type = self.scala_grpc_type(method.request_type)
+ response_type = self.scala_grpc_type(method.response_type)
+ descriptor = self.scala_grpc_method_descriptor(method)
+ mode = streaming_mode(method)
+ lines: List[str] = [""]
+ if mode is StreamingMode.UNARY:
+ lines.extend(
+ [
+ f" def {method_name}(request: {request_type}):
{response_type} =",
+ f' throw
unimplemented("{self.scala_string_literal(method.name)}")',
+ "",
+ " def "
+ f"{method_name}(request: {request_type}, "
+ f"responseObserver:
io.grpc.stub.StreamObserver[{response_type}]): Unit = {{",
+ " try {",
+ f" val response = {method_name}(request)",
+ " responseObserver.onNext(response)",
+ " responseObserver.onCompleted()",
+ " } catch {",
+ " case e: io.grpc.StatusException =>",
+ " responseObserver.onError(e)",
+ " case e: io.grpc.StatusRuntimeException
=>",
+ " responseObserver.onError(e)",
+ " case scala.util.control.NonFatal(e) =>",
+ " responseObserver.onError(",
+ f' internalError("Scala gRPC method
{self.scala_string_literal(method.name)} failed", e))',
+ " }",
+ " }",
+ ]
+ )
+ elif mode is StreamingMode.SERVER_STREAMING:
+ lines.extend(
+ [
+ " def "
+ f"{method_name}(request: {request_type}, "
+ f"responseObserver:
io.grpc.stub.StreamObserver[{response_type}]): Unit =",
+ "
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(",
+ f" {descriptor}, responseObserver)",
+ ]
+ )
+ else:
+ lines.extend(
+ [
+ f" def {method_name}(",
+ f" responseObserver:
io.grpc.stub.StreamObserver[{response_type}]",
+ f" ): io.grpc.stub.StreamObserver[{request_type}]
=",
+ "
io.grpc.stub.ServerCalls.asyncUnimplementedStreamingCall(",
+ f" {descriptor}, responseObserver)",
+ ]
+ )
+ return lines
+
+ def generate_scala_grpc_bind_service(self, service: Service) -> List[str]:
+ lines = [
+ "",
+ " final override def bindService():
io.grpc.ServerServiceDefinition = {",
+ " val builder =
io.grpc.ServerServiceDefinition.builder(serviceDescriptor)",
+ ]
+ for method in service.methods:
+ lines.extend(self.generate_scala_grpc_bind_method(service, method))
+ lines.extend([" builder.build()", " }"])
+ return lines
+
+ def generate_scala_grpc_bind_method(
+ self, service: Service, method: RpcMethod
+ ) -> List[str]:
+ base_name = self.scala_grpc_service_base_name(service)
+ method_name = self.safe_identifier(self.scala_grpc_method_name(method))
+ request_type = self.scala_grpc_type(method.request_type)
+ response_type = self.scala_grpc_type(method.response_type)
+ descriptor = self.scala_grpc_method_descriptor(method)
+ mode = streaming_mode(method)
+ if mode is StreamingMode.UNARY:
+ call = "asyncUnaryCall"
+ trait_name = "UnaryMethod"
+ invoke = [
+ f"
{base_name}.this.{method_name}(request, responseObserver)",
+ ]
+ elif mode is StreamingMode.SERVER_STREAMING:
+ call = "asyncServerStreamingCall"
+ trait_name = "ServerStreamingMethod"
+ invoke = [
+ f"
{base_name}.this.{method_name}(request, responseObserver)",
+ ]
+ elif mode is StreamingMode.CLIENT_STREAMING:
+ call = "asyncClientStreamingCall"
+ trait_name = "ClientStreamingMethod"
+ invoke = [
+ f"
{base_name}.this.{method_name}(responseObserver)",
+ ]
+ else:
+ call = "asyncBidiStreamingCall"
+ trait_name = "BidiStreamingMethod"
+ invoke = [
+ f"
{base_name}.this.{method_name}(responseObserver)",
+ ]
+ if mode in (StreamingMode.UNARY, StreamingMode.SERVER_STREAMING):
+ return [
+ " builder.addMethod(",
+ f" {descriptor},",
+ f" io.grpc.stub.ServerCalls.{call}(",
+ f" new
io.grpc.stub.ServerCalls.{trait_name}[{request_type}, {response_type}] {{",
+ " override def invoke(",
+ f" request: {request_type},",
+ f" responseObserver:
io.grpc.stub.StreamObserver[{response_type}]): Unit =",
+ *invoke,
+ " }))",
+ "",
+ ]
+ return [
+ " builder.addMethod(",
+ f" {descriptor},",
+ f" io.grpc.stub.ServerCalls.{call}(",
+ f" new
io.grpc.stub.ServerCalls.{trait_name}[{request_type}, {response_type}] {{",
+ " override def invoke(",
+ f" responseObserver:
io.grpc.stub.StreamObserver[{response_type}]",
+ f" ):
io.grpc.stub.StreamObserver[{request_type}] =",
+ *invoke,
+ " }))",
+ "",
+ ]
+
+ def generate_scala_grpc_client(self, service: Service) -> List[str]:
+ client = self.scala_grpc_client_name(service)
+ owner = self.scala_grpc_owner_name(service)
+ lines = [
+ f" final class {client} private[{owner}] (",
+ " channel: io.grpc.Channel,",
+ " callOptions: io.grpc.CallOptions)",
+ f" extends io.grpc.stub.AbstractStub[{client}](channel,
callOptions) {{",
+ " protected override def build(",
+ " channel: io.grpc.Channel,",
+ f" callOptions: io.grpc.CallOptions): {client} =",
+ f" new {client}(channel, callOptions)",
+ ]
+ for method in service.methods:
+ lines.extend(self.generate_scala_grpc_client_method(method))
+ lines.append(" }")
+ lines.append("")
+ return lines
+
+ def generate_scala_grpc_client_method(self, method: RpcMethod) ->
List[str]:
+ method_name = self.safe_identifier(self.scala_grpc_method_name(method))
+ request_type = self.scala_grpc_type(method.request_type)
+ response_type = self.scala_grpc_type(method.response_type)
+ descriptor = self.scala_grpc_method_descriptor(method)
+ mode = streaming_mode(method)
+ lines: List[str] = [""]
+ if mode is StreamingMode.UNARY:
+ future_method = self.scala_grpc_future_method_name(method)
+ blocking_method = self.scala_grpc_blocking_method_name(method)
+ lines.extend(
+ [
+ f" def {method_name}(request: {request_type}):
RpcFuture[{response_type}] =",
+ " new RpcFutureAdapter(",
+ "
io.grpc.stub.ClientCalls.futureUnaryCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " request))",
+ "",
+ " def "
+ f"{method_name}(request: {request_type}, "
+ f"responseObserver:
io.grpc.stub.StreamObserver[{response_type}]): Unit =",
+ " io.grpc.stub.ClientCalls.asyncUnaryCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " request,",
+ " responseObserver)",
+ "",
+ f" def {blocking_method}(request: {request_type}):
{response_type} =",
+ " io.grpc.stub.ClientCalls.blockingUnaryCall(",
+ f" getChannel(), {descriptor},
getCallOptions(), request)",
+ "",
+ f" def {future_method}(",
+ f" request: {request_type}):
com.google.common.util.concurrent.ListenableFuture[{response_type}] =",
+ " io.grpc.stub.ClientCalls.futureUnaryCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " request)",
+ ]
+ )
+ elif mode is StreamingMode.SERVER_STREAMING:
+ blocking_method = self.scala_grpc_blocking_method_name(method)
+ lines.extend(
+ [
+ f" def {method_name}(request: {request_type}):
RpcIterator[{response_type}] =",
+ " new RpcIteratorAdapter(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " request)",
+ "",
+ " def "
+ f"{method_name}(request: {request_type}, "
+ f"responseObserver:
io.grpc.stub.StreamObserver[{response_type}]): Unit =",
+ "
io.grpc.stub.ClientCalls.asyncServerStreamingCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " request,",
+ " responseObserver)",
+ "",
+ f" def {blocking_method}(request: {request_type}):
java.util.Iterator[{response_type}] =",
+ "
io.grpc.stub.ClientCalls.blockingServerStreamingCall(",
+ f" getChannel(), {descriptor},
getCallOptions(), request)",
+ ]
+ )
+ elif mode is StreamingMode.CLIENT_STREAMING:
+ lines.extend(
+ [
+ f" def {method_name}(",
+ f" responseObserver:
io.grpc.stub.StreamObserver[{response_type}]",
+ f" ): io.grpc.stub.StreamObserver[{request_type}]
=",
+ "
io.grpc.stub.ClientCalls.asyncClientStreamingCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " responseObserver)",
+ ]
+ )
+ else:
+ lines.extend(
+ [
+ f" def {method_name}(",
+ f" responseObserver:
io.grpc.stub.StreamObserver[{response_type}]",
+ f" ): io.grpc.stub.StreamObserver[{request_type}]
=",
+ "
io.grpc.stub.ClientCalls.asyncBidiStreamingCall(",
+ f" getChannel().newCall({descriptor},
getCallOptions()),",
+ " responseObserver)",
+ ]
+ )
+ return lines
+
+ def generate_scala_grpc_marshaller(self) -> List[str]:
+ return [
+ " private final class RpcFutureAdapter[A](",
+ " delegate:
com.google.common.util.concurrent.ListenableFuture[A])",
+ " extends RpcFuture[A] {",
+ " @volatile private var futureView:
scala.concurrent.Future[A] = null",
+ "",
+ " override def asFuture: scala.concurrent.Future[A] = {",
+ " var local = futureView",
+ " if (local eq null) {",
+ " this.synchronized {",
+ " local = futureView",
+ " if (local eq null) {",
+ " val promise =
scala.concurrent.Promise[A]()",
+ " delegate.addListener(",
+ " new Runnable {",
+ " override def run(): Unit =",
+ " completePromise(delegate,
promise)",
+ " },",
+ "
com.google.common.util.concurrent.MoreExecutors.directExecutor())",
+ " local = promise.future",
+ " futureView = local",
+ " }",
+ " }",
+ " }",
+ " local",
+ " }",
+ "",
+ " override def cancel(): Boolean = delegate.cancel(true)",
+ "",
+ " override def isCancelled: Boolean = delegate.isCancelled",
+ "",
+ " override def isDone: Boolean = delegate.isDone",
+ " }",
+ "",
+ " private final class RpcIteratorAdapter[ReqT <: AnyRef, RespT
<: AnyRef](",
+ " call: io.grpc.ClientCall[ReqT, RespT],",
+ " request: ReqT)",
+ " extends RpcIterator[RespT] {",
+ " private[this] val lock = new Object",
+ " private[this] var nextValue: RespT =
null.asInstanceOf[RespT]",
+ " private[this] var hasValue = false",
+ " private[this] var completed = false",
+ " private[this] var closed = false",
+ " private[this] var cancelled = false",
+ " private[this] var failure: Throwable = null",
+ "",
+ " start(request)",
+ "",
+ " override def hasNext: Boolean = {",
+ " var cancelCall = false",
+ " var thrown: Throwable = null",
+ " val result = lock.synchronized {",
+ " cancelCall = awaitValueLocked()",
+ " if (hasValue) true",
+ " else if (failure ne null) {",
+ " thrown = failure",
+ " false",
+ " } else false",
+ " }",
+ " if (cancelCall) {",
+ ' call.cancel("RpcIterator interrupted",
thrown)',
+ " }",
+ " if (thrown ne null) {",
+ " throw thrown",
+ " }",
+ " result",
+ " }",
+ "",
+ " override def next(): RespT = {",
+ " var requestMore = false",
+ " var cancelCall = false",
+ " var thrown: Throwable = null",
+ " var empty = false",
+ " val result = lock.synchronized {",
+ " cancelCall = awaitValueLocked()",
+ " if (!hasValue) {",
+ " if (failure ne null) {",
+ " thrown = failure",
+ " } else {",
+ " empty = true",
+ " }",
+ " null.asInstanceOf[RespT]",
+ " } else {",
+ " val value = nextValue",
+ " nextValue = null.asInstanceOf[RespT]",
+ " hasValue = false",
+ " requestMore = !closed && (failure eq null)",
+ " value",
+ " }",
+ " }",
+ " if (cancelCall) {",
+ ' call.cancel("RpcIterator interrupted", thrown)',
+ " }",
+ " if (thrown ne null) {",
+ " throw thrown",
+ " }",
+ " if (empty) {",
+ ' throw new NoSuchElementException("next on empty
RpcIterator")',
+ " }",
+ " if (requestMore) {",
+ " call.request(1)",
+ " }",
+ " result",
+ " }",
+ "",
+ " override def cancel(): Unit = {",
+ " var shouldCancel = false",
+ " lock.synchronized {",
+ " if (!cancelled && !completed) {",
+ " cancelled = true",
+ " shouldCancel = true",
+ " }",
+ " if (!closed) {",
+ " closed = true",
+ " hasValue = false",
+ " nextValue = null.asInstanceOf[RespT]",
+ " lock.notifyAll()",
+ " }",
+ " }",
+ " if (shouldCancel) {",
+ ' call.cancel("RpcIterator cancelled", null)',
+ " }",
+ " }",
+ "",
+ " override def isClosed: Boolean =",
+ " lock.synchronized { closed }",
+ "",
+ " private def start(request: ReqT): Unit = {",
+ " call.start(",
+ " new io.grpc.ClientCall.Listener[RespT] {",
+ " override def onMessage(message: RespT): Unit
=",
+ " lock.synchronized {",
+ " if (!closed) {",
+ " if (hasValue) {",
+ " failure =
io.grpc.Status.INTERNAL",
+ '
.withDescription("Received response without iterator demand")',
+ " .asRuntimeException()",
+ " closed = true",
+ " } else {",
+ " nextValue = message",
+ " hasValue = true",
+ " }",
+ " lock.notifyAll()",
+ " }",
+ " }",
+ "",
+ " override def onClose(",
+ " status: io.grpc.Status,",
+ " trailers: io.grpc.Metadata): Unit =",
+ " lock.synchronized {",
+ " if (!status.isOk && !cancelled) {",
+ " failure =
status.asRuntimeException(trailers)",
+ " } else {",
+ " completed = true",
+ " }",
+ " closed = true",
+ " lock.notifyAll()",
+ " }",
+ " },",
+ " new io.grpc.Metadata())",
+ " call.request(1)",
+ " call.sendMessage(request)",
+ " call.halfClose()",
+ " }",
+ "",
+ " private def awaitValueLocked(): Boolean = {",
+ " var cancelCall = false",
+ " while (!hasValue && !completed && (failure eq null)
&& !closed) {",
+ " try {",
+ " lock.wait()",
+ " } catch {",
+ " case e: InterruptedException =>",
+ " failure = interrupted(e)",
+ " closed = true",
+ " cancelled = true",
+ " cancelCall = true",
+ " }",
+ " }",
+ " cancelCall",
+ " }",
+ " }",
+ "",
+ " private def completePromise[A](",
+ " delegate:
com.google.common.util.concurrent.ListenableFuture[A],",
+ " promise: scala.concurrent.Promise[A]): Unit =",
+ " try {",
+ " promise.success(delegate.get())",
+ " } catch {",
+ " case e: java.util.concurrent.CancellationException
=>",
+ " promise.failure(e)",
+ " case e: java.util.concurrent.ExecutionException if
e.getCause ne null =>",
+ " promise.failure(e.getCause)",
+ " case e: InterruptedException =>",
+ " Thread.currentThread().interrupt()",
+ " promise.failure(e)",
+ " case scala.util.control.NonFatal(e) =>",
+ " promise.failure(e)",
+ " }",
+ "",
+ " private def marshaller[T <: AnyRef](",
+ " typ: Class[T]): io.grpc.MethodDescriptor.Marshaller[T] =",
+ " new ForyMarshaller(FORY, typ)",
+ "",
+ " private final class ForyMarshaller[T <: AnyRef](",
+ " fory: org.apache.fory.ThreadSafeFory,",
+ " typ: Class[T])",
+ " extends io.grpc.MethodDescriptor.Marshaller[T] {",
+ " override def stream(value: T): java.io.InputStream =",
+ " try {",
+ " new
KnownLengthByteArrayInputStream(fory.serialize(value))",
+ " } catch {",
+ " case scala.util.control.NonFatal(e) =>",
+ ' throw internalError("Fory serialization
failed", e)',
+ " }",
+ "",
+ " override def parse(stream: java.io.InputStream): T =",
+ " try {",
+ " fory.deserialize(readBytes(stream), typ)",
+ " } catch {",
+ " case e: java.io.IOException =>",
+ ' throw internalError("Fory deserialization
failed", e)',
+ " case scala.util.control.NonFatal(e) =>",
+ ' throw internalError("Fory deserialization
failed", e)',
+ " }",
+ " }",
+ "",
+ " private final class KnownLengthByteArrayInputStream(bytes:
Array[Byte])",
+ " extends java.io.ByteArrayInputStream(bytes)",
+ " with io.grpc.KnownLength",
+ "",
+ " private def readBytes(stream: java.io.InputStream):
Array[Byte] = {",
+ " if (stream.isInstanceOf[io.grpc.KnownLength]) {",
+ " val size = stream.available()",
+ " val bytes = new Array[Byte](size)",
+ " var offset = 0",
+ " while (offset < size) {",
+ " val read = stream.read(bytes, offset, size -
offset)",
+ " if (read == -1) {",
+ " throw new java.io.EOFException(",
+ ' "Expected " + size + " bytes, got " +
offset)',
+ " }",
+ " offset += read",
+ " }",
+ " bytes",
+ " } else {",
+ " readUnknownLengthBytes(stream)",
+ " }",
+ " }",
+ "",
+ " private def readUnknownLengthBytes(stream:
java.io.InputStream): Array[Byte] = {",
+ " val out = new java.io.ByteArrayOutputStream()",
+ " val buffer = new Array[Byte](8192)",
+ " var read = stream.read(buffer)",
+ " while (read != -1) {",
+ " out.write(buffer, 0, read)",
+ " read = stream.read(buffer)",
+ " }",
+ " out.toByteArray()",
+ " }",
+ "",
+ " private def internalError(",
+ " description: String,",
+ " cause: Throwable): io.grpc.StatusRuntimeException =",
+ " io.grpc.Status.INTERNAL",
+ " .withDescription(description)",
+ " .withCause(cause)",
+ " .asRuntimeException()",
+ "",
+ " private def interrupted(e: InterruptedException):
io.grpc.StatusRuntimeException = {",
+ " Thread.currentThread().interrupt()",
+ " io.grpc.Status.CANCELLED",
+ ' .withDescription("Interrupted while waiting for RPC
response")',
+ " .withCause(e)",
+ " .asRuntimeException()",
+ " }",
+ "",
+ " private def unimplemented(methodName: String):
io.grpc.StatusRuntimeException =",
+ " io.grpc.Status.UNIMPLEMENTED",
+ ' .withDescription("Method " + SERVICE_NAME + "/" +
methodName + " is unimplemented")',
+ " .asRuntimeException()",
+ "",
+ ]
+
+ def scala_grpc_type(self, named_type: NamedType) -> str:
+ type_name = self.schema.resolve_type_name(named_type.name)
+ return self.resolve_scala_type_name(type_name, None)
+
+ def scala_grpc_owner_name(self, service: Service) -> str:
+ return f"{service.name}Grpc"
+
+ def scala_grpc_service_base_name(self, service: Service) -> str:
+ return f"{service.name}ImplBase"
+
+ def scala_grpc_client_name(self, service: Service) -> str:
+ return f"{service.name}Client"
+
+ def scala_grpc_method_name(self, method: RpcMethod) -> str:
+ return self.to_camel_case(method.name)
+
+ def scala_grpc_method_descriptor(self, method: RpcMethod) -> str:
+ return f"{self.scala_grpc_method_name(method)}Method"
+
+ def scala_grpc_method_backing_field(self, method: RpcMethod) -> str:
+ return f"{self.scala_grpc_method_name(method)}MethodValue"
+
+ def scala_grpc_blocking_method_name(self, method: RpcMethod) -> str:
+ return f"{self.scala_grpc_method_name(method)}Blocking"
+
+ def scala_grpc_future_method_name(self, method: RpcMethod) -> str:
+ return f"{self.scala_grpc_method_name(method)}Future"
+
+ def scala_grpc_method_type(self, method: RpcMethod) -> str:
+ mode = streaming_mode(method)
+ if mode is StreamingMode.BIDIRECTIONAL:
+ return "BIDI_STREAMING"
+ if mode is StreamingMode.CLIENT_STREAMING:
+ return "CLIENT_STREAMING"
+ if mode is StreamingMode.SERVER_STREAMING:
+ return "SERVER_STREAMING"
+ return "UNARY"
+
+ def scala_string_literal(self, value: str) -> str:
+ return (
+ value.replace("\\", "\\\\")
+ .replace('"', '\\"')
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ )
diff --git a/compiler/fory_compiler/tests/test_service_codegen.py
b/compiler/fory_compiler/tests/test_service_codegen.py
index f2e794ba7..1d76c729a 100644
--- a/compiler/fory_compiler/tests/test_service_codegen.py
+++ b/compiler/fory_compiler/tests/test_service_codegen.py
@@ -23,7 +23,7 @@ from typing import Dict, Tuple, Type
import pytest
-from fory_compiler.cli import compile_file, resolve_imports
+from fory_compiler.cli import compile_file, 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
@@ -40,6 +40,7 @@ from fory_compiler.generators.java import JavaGenerator
from fory_compiler.generators.kotlin import KotlinGenerator
from fory_compiler.generators.python import PythonGenerator
from fory_compiler.generators.rust import RustGenerator
+from fory_compiler.generators.scala import ScalaGenerator
from fory_compiler.generators.swift import SwiftGenerator
from fory_compiler.ir.ast import Schema
from fory_compiler.ir.validator import SchemaValidator
@@ -53,6 +54,7 @@ GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = (
GoGenerator,
CSharpGenerator,
SwiftGenerator,
+ ScalaGenerator,
KotlinGenerator,
)
@@ -138,6 +140,7 @@ def
test_generate_services_returns_empty_list_for_unsupported_generators():
PythonGenerator,
GoGenerator,
RustGenerator,
+ ScalaGenerator,
KotlinGenerator,
):
continue
@@ -219,6 +222,37 @@ def
test_kotlin_grpc_service_codegen_contains_fory_marshaller():
assert "MessageLite" not in content
+def test_scala_grpc_marshaller():
+ schema = parse_fdl(_GREETER_WITH_SERVICE)
+ files = generate_service_files(schema, ScalaGenerator)
+ assert set(files) == {"demo/greeter/GreeterGrpc.scala"}
+ content = files["demo/greeter/GreeterGrpc.scala"]
+ assert "object GreeterGrpc" in content
+ assert 'SERVICE_NAME: String = "demo.greeter.Greeter"' in content
+ assert "private val FORY: org.apache.fory.ThreadSafeFory" in content
+ assert "GreeterForyModule.getFory" in content
+ assert "import org.apache.fory.scala.rpc.{RpcFuture, RpcIterator}" in
content
+ assert (
+ "private lazy val sayHelloMethodValue:
io.grpc.MethodDescriptor[HelloRequest, HelloReply]"
+ in content
+ )
+ assert "def sayHello(request: HelloRequest): RpcFuture[HelloReply]" in
content
+ assert "def sayHelloBlocking(request: HelloRequest): HelloReply" in content
+ assert (
+ "def sayHelloFuture(\n request: HelloRequest):
com.google.common.util.concurrent.ListenableFuture[HelloReply]"
+ in content
+ )
+ assert "protected override def build(" in content
+ assert "private final class ForyMarshaller[T <: AnyRef]" in content
+ assert "fory.serialize(value)" in content
+ assert "fory.deserialize(readBytes(stream), typ)" in content
+ assert "with io.grpc.KnownLength" in content
+ assert "ProtoUtils" not in content
+ assert "MessageLite" not in content
+ assert "ScalaPB" not in content
+ assert "org.apache.fory.scala.grpc" not in content
+
+
def test_grpc_streaming_method_shapes():
schema = parse_fdl(
dedent(
@@ -287,6 +321,29 @@ def test_grpc_streaming_method_shapes():
assert "io.grpc.kotlin.ServerCalls.clientStreamingServerMethodDefinition"
in kotlin
assert "io.grpc.kotlin.ServerCalls.bidiStreamingServerMethodDefinition" in
kotlin
+ 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
+ assert "io.grpc.MethodDescriptor.MethodType.CLIENT_STREAMING" in scala
+ assert "io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING" in scala
+ assert "def unary(request: Req): RpcFuture[Res]" in scala
+ assert "def server(request: Req): RpcIterator[Res]" in scala
+ assert (
+ "def client(\n responseObserver:
io.grpc.stub.StreamObserver[Res]\n ): io.grpc.stub.StreamObserver[Req]"
+ in scala
+ )
+ assert (
+ "def bidi(\n responseObserver:
io.grpc.stub.StreamObserver[Payload]\n ):
io.grpc.stub.StreamObserver[Payload]"
+ in scala
+ )
+ assert "io.grpc.stub.ClientCalls.asyncServerStreamingCall" in scala
+ assert "io.grpc.stub.ClientCalls.asyncClientStreamingCall" in scala
+ assert "io.grpc.stub.ClientCalls.asyncBidiStreamingCall" in scala
+ assert "new RpcIteratorAdapter(" in scala
+ assert "call.request(1)" in scala
+ assert "call.sendMessage(request)" in scala
+ assert "call.halfClose()" in scala
+
def test_go_grpc_service_codegen():
schema = parse_fdl(_GREETER_WITH_SERVICE)
@@ -432,6 +489,13 @@ 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
+ scala_files = generate_service_files(schema, ScalaGenerator)
+ assert set(scala_files) == {"api/ApiServiceGrpc.scala"}
+ scala = scala_files["api/ApiServiceGrpc.scala"]
+ assert "io.grpc.MethodDescriptor[common.Shared, Local]" in scala
+ assert "marshaller(classOf[common.Shared])" in scala
+ assert "def get(request: common.Shared): RpcFuture[Local]" in scala
+
def test_proto_grpc_services_use_imported_qualified_type_references(tmp_path:
Path):
common = tmp_path / "common.proto"
@@ -490,6 +554,11 @@ 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
+ 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():
schema = parse_proto(
@@ -530,6 +599,11 @@ 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
+ 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):
common = tmp_path / "common.proto"
@@ -586,6 +660,11 @@ 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
+ 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():
schema = parse_fdl(
@@ -634,6 +713,64 @@ def test_kotlin_grpc_service_class_collision_fails():
KotlinGenerator(schema, GeneratorOptions(output_dir=Path("/tmp"),
grpc=True))
+def test_scala_grpc_class_collision():
+ schema = parse_fdl(
+ dedent(
+ """
+ package demo.collision;
+
+ message GreeterGrpc {}
+ message Req {}
+ message Res {}
+
+ service Greeter {
+ rpc Call (Req) returns (Res);
+ }
+ """
+ )
+ )
+ generator = ScalaGenerator(
+ schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True)
+ )
+ with pytest.raises(ValueError, match="Scala generated file path
collision"):
+ generator.generate_services()
+
+
+def test_scala_grpc_preflight_collision(tmp_path: Path, capsys):
+ common = tmp_path / "common.fdl"
+ common.write_text(
+ dedent(
+ """
+ package demo.collision;
+
+ message GreeterGrpc {}
+ """
+ )
+ )
+ main = tmp_path / "main.fdl"
+ main.write_text(
+ dedent(
+ """
+ package demo.collision;
+
+ import "common.fdl";
+
+ message Req {}
+ message Res {}
+
+ service Greeter {
+ rpc Call (Req) returns (Res);
+ }
+ """
+ )
+ )
+
+ assert validate_scala_generation([main], [tmp_path], grpc=True) is False
+ err = capsys.readouterr().err
+ assert "Scala generated file path collision" in err
+ assert "GreeterGrpc.scala" in err
+
+
def test_java_grpc_service_class_collision_with_imported_type_fails(tmp_path:
Path):
common = tmp_path / "common.fdl"
common.write_text(
@@ -786,6 +923,44 @@ def test_grpc_method_name_collisions_fail():
else:
raise AssertionError("Expected Kotlin gRPC method name collision")
+ scala_generator = ScalaGenerator(
+ schema, GeneratorOptions(output_dir=Path("/tmp"), grpc=True)
+ )
+ try:
+ scala_generator.generate_services()
+ except ValueError as e:
+ assert "Scala gRPC method name collision" in str(e)
+ assert "Foo and foo" in str(e)
+ else:
+ raise AssertionError("Expected Scala gRPC method name collision")
+
+
+def test_scala_grpc_method_scopes():
+ schema = parse_fdl(
+ dedent(
+ """
+ package demo.scope;
+
+ message Req {}
+ message Res {}
+
+ service Greeter {
+ rpc Cancel (Req) returns (Res);
+ rpc Close (Req) returns (stream Res);
+ rpc ReadBytes (Req) returns (Res);
+ rpc CompletePromise (Req) returns (Res);
+ }
+ """
+ )
+ )
+
+ files = generate_service_files(schema, ScalaGenerator)
+ content = files["demo/scope/GreeterGrpc.scala"]
+ assert "def cancel(request: Req): RpcFuture[Res]" in content
+ assert "def close(request: Req): RpcIterator[Res]" in content
+ assert "def readBytes(request: Req): RpcFuture[Res]" in content
+ assert "def completePromise(request: Req): RpcFuture[Res]" in content
+
def test_java_python_grpc_method_keywords_are_safe_names():
schema = parse_fdl(
@@ -819,6 +994,11 @@ def test_java_python_grpc_method_keywords_are_safe_names():
assert "public suspend fun `class`(" in kotlin
assert "implementation = ::`class`" in kotlin
+ scala = next(iter(generate_service_files(schema, ScalaGenerator).values()))
+ assert "def `class`(request: Req): RpcFuture[Res]" in scala
+ assert "def `class`(request: Req, responseObserver:" in scala
+ assert 'SERVICE_NAME,\n "Class"' in scala
+
def test_python_grpc_service_registration_collisions_fail():
schema = parse_fdl(
@@ -907,10 +1087,14 @@ def test_proto_and_fbs_grpc_service_codegen():
)
proto_java = generate_service_files(proto_schema, JavaGenerator)
proto_python = generate_service_files(proto_schema, PythonGenerator)
+ proto_scala = generate_service_files(proto_schema, ScalaGenerator)
assert "demo/proto/ProtoSvcGrpc.java" in proto_java
assert "demo_proto_grpc.py" in proto_python
+ assert "demo/proto/ProtoSvcGrpc.scala" in proto_scala
assert "MethodType.SERVER_STREAMING" in
proto_java["demo/proto/ProtoSvcGrpc.java"]
assert "channel.unary_stream(" in proto_python["demo_proto_grpc.py"]
+ assert "MethodType.SERVER_STREAMING" in
proto_scala["demo/proto/ProtoSvcGrpc.scala"]
+ assert "RpcIterator[Res]" in proto_scala["demo/proto/ProtoSvcGrpc.scala"]
fbs_schema = parse_fbs(
dedent(
@@ -928,10 +1112,16 @@ def test_proto_and_fbs_grpc_service_codegen():
)
fbs_java = generate_service_files(fbs_schema, JavaGenerator)
fbs_python = generate_service_files(fbs_schema, PythonGenerator)
+ fbs_scala = generate_service_files(fbs_schema, ScalaGenerator)
assert "demo/fbs/FbsSvcGrpc.java" in fbs_java
assert "demo_fbs_grpc.py" in fbs_python
+ assert "demo/fbs/FbsSvcGrpc.scala" in fbs_scala
assert 'SERVICE_NAME = "demo.fbs.FbsSvc"' in
fbs_java["demo/fbs/FbsSvcGrpc.java"]
assert '"/demo.fbs.FbsSvc/Call"' in fbs_python["demo_fbs_grpc.py"]
+ assert (
+ 'SERVICE_NAME: String = "demo.fbs.FbsSvc"'
+ in fbs_scala["demo/fbs/FbsSvcGrpc.scala"]
+ )
def test_service_schema_produces_one_file_per_message_per_language():
@@ -943,10 +1133,20 @@ def
test_service_schema_produces_one_file_per_message_per_language():
)
-def test_compile_service_schema_with_grpc_flag(tmp_path: Path, capsys):
+def test_grpc_flag_compiles_services(tmp_path: Path, capsys):
example_path = Path(__file__).resolve().parents[2] / "examples" /
"service.fdl"
lang_dirs = {}
- for lang in ("java", "python", "rust", "go", "cpp", "csharp", "swift",
"kotlin"):
+ for lang in (
+ "java",
+ "python",
+ "rust",
+ "go",
+ "cpp",
+ "csharp",
+ "swift",
+ "scala",
+ "kotlin",
+ ):
lang_dirs[lang] = tmp_path / lang
ok = compile_file(example_path, lang_dirs, grpc=True, generated_outputs={})
output = capsys.readouterr().out
@@ -960,6 +1160,7 @@ def test_compile_service_schema_with_grpc_flag(tmp_path:
Path, capsys):
assert output.count("demo_greeter_grpc.go") == 1
assert (lang_dirs["rust"] / "demo_greeter_service.rs").exists()
assert (lang_dirs["rust"] / "demo_greeter_service_grpc.rs").exists()
+ assert (lang_dirs["scala"] / "demo" / "greeter" /
"GreeterGrpc.scala").exists()
assert (lang_dirs["kotlin"] / "demo" / "greeter" /
"GreeterGrpcKt.kt").exists()
diff --git a/docs/compiler/compiler-guide.md b/docs/compiler/compiler-guide.md
index 683bd3417..cd1701070 100644
--- a/docs/compiler/compiler-guide.md
+++ b/docs/compiler/compiler-guide.md
@@ -141,24 +141,25 @@ foryc schema.fdl --output ./src/generated
foryc user.fdl order.fdl product.fdl --output ./generated
```
-**Compile a simple schema containing service definitions (Java + Python + Rust
+ Kotlin models):**
+**Compile a simple schema containing service definitions (Java + Python + Rust
+ Scala + Kotlin models):**
```bash
-foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--kotlin_out=./generated/kotlin
+foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--scala_out=./generated/scala --kotlin_out=./generated/kotlin
```
-**Generate Java, Python, Rust, and Kotlin gRPC service companions:**
+**Generate Java, Python, Rust, Scala, and Kotlin gRPC service companions:**
```bash
-foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--kotlin_out=./generated/kotlin --grpc
+foryc compiler/examples/service.fdl --java_out=./generated/java
--python_out=./generated/python --rust_out=./generated/rust
--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`. Kotlin output imports grpc-java and
-grpc-kotlin APIs and uses coroutine stubs. Applications that compile or run
-those generated service files must provide their own gRPC dependencies. Fory
-packages do not add a hard gRPC dependency for this feature.
+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.
+Applications that compile or run those generated service files must provide
+their own gRPC dependencies. Fory packages do not add a hard gRPC dependency
for
+this feature.
**Use import search paths:**
@@ -194,6 +195,9 @@ foryc schema.fdl --java_out=./gen/java -I proto/ -I common/
# Generate Scala 3 code to a specific directory
foryc schema.fdl --scala_out=./src/main/scala
+# Generate Scala 3 models and gRPC service companions
+foryc service.fdl --scala_out=./src/main/scala --grpc
+
# Generate Kotlin code to a specific directory
foryc schema.fdl --kotlin_out=./src/main/kotlin
```
@@ -415,6 +419,7 @@ generated/
├── User.scala
├── Status.scala
├── Animal.scala
+ ├── ExampleServiceGrpc.scala
└── ExampleForyModule.scala
```
@@ -425,6 +430,8 @@ generated/
- Enums use Scala 3 `enum`
- Unions use Scala 3 ADT `enum` with `@ForyUnion`, `@ForyCase`, and an
`Unknown`
- Schema module object included
+- With `--grpc`, one `<ServiceName>Grpc.scala` service companion is generated
+ per local service definition
### Kotlin
diff --git a/docs/compiler/flatbuffers-idl.md b/docs/compiler/flatbuffers-idl.md
index fa29946ac..203a64c40 100644
--- a/docs/compiler/flatbuffers-idl.md
+++ b/docs/compiler/flatbuffers-idl.md
@@ -126,8 +126,8 @@ message Container {
FlatBuffers `rpc_service` definitions are translated to Fory services. With
`--grpc`, the compiler emits gRPC service companions for supported outputs such
-as Java, Python, Go, Rust, and Kotlin. These companions use Fory serialization
for
-request and response payloads.
+as Java, Python, Go, Rust, Scala, and Kotlin. These companions use Fory
+serialization for request and response payloads.
```fbs
rpc_service SearchService {
@@ -137,7 +137,7 @@ rpc_service SearchService {
```
```bash
-foryc api.fbs --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc
+foryc api.fbs --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --scala_out=./generated/scala
--kotlin_out=./generated/kotlin --grpc
```
Generated service code imports grpc APIs, so applications must provide
grpc-java,
diff --git a/docs/compiler/generated-code.md b/docs/compiler/generated-code.md
index d197fb86f..dfdc403a0 100644
--- a/docs/compiler/generated-code.md
+++ b/docs/compiler/generated-code.md
@@ -1589,6 +1589,92 @@ object AddressbookForyModule extends
org.apache.fory.ForyModule {
}
```
+### gRPC Service Companions
+
+When a schema contains services and the compiler is run with `--grpc`, Scala
+generation emits one `<ServiceName>Grpc.scala` companion per local service
+definition. The companion lives in the same Scala package as the generated
+models and schema module.
+
+For a service such as:
+
+```protobuf
+service AddressBookService {
+ rpc Lookup (Person) returns (AddressBook);
+ rpc Watch (Person) returns (stream AddressBook);
+ rpc Upload (stream Person) returns (AddressBook);
+ rpc Chat (stream Person) returns (stream AddressBook);
+}
+```
+
+the generated companion contains:
+
+- `SERVICE_NAME` and grpc-java method descriptors
+- `AddressBookServiceImplBase` for server implementations
+- `AddressBookServiceClient` for client calls
+- Fory-backed grpc-java marshallers for request and response payloads
+
+The generated Scala client keeps grpc-java available per method while adding
+Scala-friendly convenience methods for the shapes where a direct Scala handle
+can preserve the needed lifecycle controls:
+
+| RPC shape | Scala convenience
method | grpc-java-style method |
+| ------------------------------------------------------- |
----------------------------------- |
------------------------------------------------ |
+| `rpc Lookup (Person) returns (AddressBook)` | `lookup(request):
RpcFuture[Resp]` | async observer, blocking, and `ListenableFuture` |
+| `rpc Watch (Person) returns (stream AddressBook)` | `watch(request):
RpcIterator[Resp]` | async observer and blocking iterator |
+| `rpc Upload (stream Person) returns (AddressBook)` | None
| request `StreamObserver` |
+| `rpc Chat (stream Person) returns (stream AddressBook)` | None
| request and response `StreamObserver` |
+
+Unary client convenience methods return `org.apache.fory.scala.rpc.RpcFuture`:
+
+```scala
+val client = AddressBookServiceGrpc.newClient(channel)
+val call = client.lookup(person)
+call.asFuture.foreach(handleAddressBook)(scala.concurrent.ExecutionContext.global)
+```
+
+Server-streaming client convenience methods return
+`org.apache.fory.scala.rpc.RpcIterator`:
+
+```scala
+val stream = client.watch(person)
+try {
+ while (stream.hasNext) {
+ handleAddressBook(stream.next())
+ }
+} finally {
+ stream.close()
+}
+```
+
+Close or cancel the `RpcIterator` when the client stops before consuming the
+whole stream. The generated adapter cancels the underlying gRPC call so the
+server is not left writing a response stream the client no longer reads.
+
+Client-streaming and bidirectional methods use grpc-java `StreamObserver` APIs:
+
+```scala
+val requestStream = client.upload(
+ new io.grpc.stub.StreamObserver[AddressBook] {
+ override def onNext(value: AddressBook): Unit = handleAddressBook(value)
+ override def onError(t: Throwable): Unit = handleError(t)
+ override def onCompleted(): Unit = ()
+ }
+)
+requestStream.onNext(person)
+requestStream.onCompleted()
+```
+
+Server implementations mirror grpc-java. Unary methods can override the direct
+request-to-response method generated by Scala, but streaming methods override
+observer-based methods and must call `onNext`, `onError`, and `onCompleted`
+according to grpc-java lifecycle rules.
+
+Applications compiling generated Scala gRPC companions must provide grpc-java
+dependencies such as `grpc-api`, `grpc-stub`, and a transport like
+`grpc-netty-shaded`. The `fory-scala` artifact does not add grpc-java as a hard
+dependency.
+
## Cross-Language Notes
### Type ID Behavior
diff --git a/docs/compiler/index.md b/docs/compiler/index.md
index f9fea15b4..e635bcde0 100644
--- a/docs/compiler/index.md
+++ b/docs/compiler/index.md
@@ -23,7 +23,7 @@ Fory IDL is a schema definition language for Apache Fory that
enables type-safe
cross-language serialization. Define your data structures once and generate
native data structure code for Java, Python, C++, Go, Rust,
JavaScript/TypeScript, C#, Swift, Dart, Scala, and Kotlin. Fory IDL can also
-describe RPC services; for Java, Python, Go, Rust, and Kotlin, the compiler can
+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.
@@ -88,10 +88,10 @@ service AnimalService {
}
```
-Generate Java, Python, Rust, and Kotlin models plus gRPC service companions
with:
+Generate Java, Python, Rust, 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 --kotlin_out=./generated/kotlin --grpc
+foryc animals.fdl --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --scala_out=./generated/scala
--kotlin_out=./generated/kotlin --grpc
```
The generated service code uses normal gRPC APIs, but request and response
diff --git a/docs/compiler/protobuf-idl.md b/docs/compiler/protobuf-idl.md
index 4ce825e37..acf7f7f5a 100644
--- a/docs/compiler/protobuf-idl.md
+++ b/docs/compiler/protobuf-idl.md
@@ -41,17 +41,17 @@ how protobuf concepts map to Fory, and how to use
protobuf-only Fory extension o
## Protobuf vs Fory at a Glance
-| Aspect | Protocol Buffers | Fory
|
-| ------------------ | ----------------------------- |
------------------------------------------ |
-| Primary purpose | RPC/message contracts | High-performance object
serialization |
-| Encoding model | Tag-length-value | Fory binary protocol
|
-| Reference tracking | Not built-in | First-class (`ref`)
|
-| Circular refs | Not supported | Supported
|
-| Unknown fields | Preserved | Not preserved
|
-| Generated types | Protobuf-specific model types | Native language
constructs |
-| gRPC ecosystem | Native |
Java/Python/Go/Rust/Kotlin service codegen |
-
-Fory can generate Java, Python, Go, Rust, and Kotlin gRPC service companions
with
+| 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
tooling, schema reflection, and protobuf-native interceptors, protobuf remains
@@ -314,12 +314,13 @@ languages.
For supported service outputs, add `--grpc` to emit gRPC companion code:
```bash
-foryc api.proto --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --kotlin_out=./generated/kotlin --grpc
+foryc api.proto --java_out=./generated/java --python_out=./generated/python
--rust_out=./generated/rust --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`,
-and generated Kotlin service files compile against grpc-java and grpc-kotlin.
+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
diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md
index 40f082108..4fcf7c52f 100644
--- a/docs/compiler/schema-idl.md
+++ b/docs/compiler/schema-idl.md
@@ -908,7 +908,7 @@ union_field := ['repeated'] field_type IDENTIFIER '='
INTEGER [field_options] ';
Services define RPC method contracts in Fory IDL. They are optional: schemas
with services still generate the normal data model types, and gRPC service code
is generated only when the compiler is run with `--grpc` for supported language
-outputs such as Java, Python, Go, Rust, and Kotlin.
+outputs such as Java, Python, Go, Rust, Scala, and Kotlin.
```protobuf
message GetPetRequest [id=200] {
diff --git a/docs/guide/java/grpc-support.md b/docs/guide/java/grpc-support.md
index 4a233852d..7bebb010f 100644
--- a/docs/guide/java/grpc-support.md
+++ b/docs/guide/java/grpc-support.md
@@ -30,8 +30,9 @@ Fory payload encoding. Use standard protobuf gRPC code
generation when your API
must be consumed by generic protobuf clients, reflection tools, or components
that expect protobuf message bytes.
-For Kotlin coroutine stubs and service bases, see
-[Kotlin gRPC Support](../kotlin/grpc-support.md).
+For Scala generated grpc-java companions, see
+[Scala gRPC Support](../scala/grpc-support.md). For Kotlin coroutine stubs and
+service bases, see [Kotlin gRPC Support](../kotlin/grpc-support.md).
## Add Dependencies
diff --git a/docs/guide/scala/grpc-support.md b/docs/guide/scala/grpc-support.md
new file mode 100644
index 000000000..0b97929db
--- /dev/null
+++ b/docs/guide/scala/grpc-support.md
@@ -0,0 +1,397 @@
+---
+title: gRPC Support
+sidebar_position: 6
+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 Scala 3 gRPC service companions for schemas that define
+services. The generated service code uses normal grpc-java channels, servers,
+deadlines, status codes, interceptors, and transport security, while request
+and response objects are serialized with Fory instead of protobuf.
+
+Use this mode when both sides of the RPC are generated from the same Fory IDL,
+protobuf IDL, or FlatBuffers IDL and you want gRPC transport semantics with
+Fory payload encoding. Use standard protobuf gRPC code generation when your API
+must be consumed by generic protobuf clients, reflection tools, or components
+that expect protobuf message bytes.
+
+## Add Dependencies
+
+Generated Scala service files compile against grpc-java. The `fory-scala`
+artifact does not add gRPC as a hard dependency, so add grpc-java dependencies
+in your application build and align the version with the rest of your service
+stack.
+
+```sbt
+libraryDependencies ++= Seq(
+ "org.apache.fory" %% "fory-scala" % "<fory-version>",
+ "io.grpc" % "grpc-api" % "<grpc-version>",
+ "io.grpc" % "grpc-stub" % "<grpc-version>",
+ "io.grpc" % "grpc-netty-shaded" % "<grpc-version>"
+)
+```
+
+Generated Scala models and gRPC service companions are Scala 3 source. The
+`fory-scala` artifact remains cross-built for Scala 2.13 and Scala 3, and the
+dependency-free `org.apache.fory.scala.rpc` handle traits are available from
+the shared artifact.
+
+## Define a Service
+
+Service definitions can come from Fory IDL, protobuf IDL, or FlatBuffers
+`rpc_service` definitions. A Fory IDL service looks like this:
+
+```protobuf
+package demo.greeter;
+
+message HelloRequest {
+ string name = 1;
+}
+
+message HelloReply {
+ string reply = 1;
+}
+
+service Greeter {
+ rpc SayHello (HelloRequest) returns (HelloReply);
+}
+```
+
+Generate Scala model and gRPC companion code with `--grpc`:
+
+```bash
+foryc service.fdl --scala_out=./generated/scala --grpc
+```
+
+For this schema, the Scala generator emits:
+
+| File | Purpose |
+| ------------------------- | -------------------------------------------- |
+| `HelloRequest.scala` | Fory model type for the request |
+| `HelloReply.scala` | Fory model type for the response |
+| `GreeterForyModule.scala` | Fory registration module for generated types |
+| `GreeterGrpc.scala` | grpc-java service base, client, and codecs |
+
+## Implement a Server
+
+Extend the generated `GreeterGrpc.GreeterImplBase` class and register it with a
+standard grpc-java `Server`. Unary RPCs can be implemented with a direct
+request-to-response method:
+
+```scala
+package demo.greeter
+
+import io.grpc.ServerBuilder
+
+final class GreeterService extends GreeterGrpc.GreeterImplBase {
+ override def sayHello(request: HelloRequest): HelloReply =
+ HelloReply(s"Hello, ${request.name}")
+}
+
+@main def runServer(): Unit = {
+ val server = ServerBuilder
+ .forPort(50051)
+ .addService(new GreeterService)
+ .build()
+ .start()
+ server.awaitTermination()
+}
+```
+
+Generated request and response types are registered by the generated code, so
+service implementations do not perform manual serializer registration.
+
+## Create a Client
+
+Use the generated client with an ordinary grpc-java channel:
+
+```scala
+package demo.greeter
+
+import io.grpc.ManagedChannelBuilder
+import scala.concurrent.Await
+import scala.concurrent.duration.DurationInt
+
+@main def runClient(): Unit = {
+ val channel = ManagedChannelBuilder
+ .forAddress("localhost", 50051)
+ .usePlaintext()
+ .build()
+ try {
+ val client = GreeterGrpc.newClient(channel)
+ val call = client.sayHello(HelloRequest("Fory"))
+ val reply = Await.result(call.asFuture, 30.seconds)
+ println(reply.reply)
+ } finally {
+ channel.shutdownNow()
+ }
+}
+```
+
+Unary Scala-friendly methods return `RpcFuture[A]`. Use `asFuture` for Scala
+composition, and call `cancel()` when the RPC should be cancelled before it
+completes. The same generated client also exposes grpc-java-style per-method
+variants such as observer-based async calls, blocking calls, and
+`ListenableFuture` unary calls.
+
+## Streaming RPCs
+
+Fory service definitions can use the same gRPC streaming shapes as grpc-java:
+
+```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 Scala methods use these shapes:
+
+| IDL shape | Scala client convenience | grpc-java-style methods
|
+| ----------------------- | ------------------------ |
------------------------------------------------ |
+| Unary | `RpcFuture[Resp]` | Async observer,
blocking, and `ListenableFuture` |
+| Server streaming | `RpcIterator[Resp]` | Async observer and
blocking iterator |
+| Client streaming | None | `StreamObserver`
request stream |
+| Bidirectional streaming | None | `StreamObserver`
request and response streams |
+
+The Scala-friendly convenience layer covers the cases where a direct Scala
+handle can keep the important lifecycle controls. Unary calls use
+`RpcFuture[A]` so callers can compose with Scala `Future` without losing
+cancellation. Server-streaming calls use `RpcIterator[A]` so callers can
consume
+responses with the normal Scala `Iterator` contract while still being able to
+close the underlying RPC. Client-streaming and bidirectional streaming stay on
+grpc-java `StreamObserver` APIs because the request stream lifecycle,
+completion, cancellation, and flow-control rules are the grpc-java rules.
+
+### Server-Streaming Clients
+
+Use the Scala-friendly method when the client wants pull-style consumption:
+
+```scala
+val stream = client.lotsOfReplies(HelloRequest("Fory"))
+try {
+ while (stream.hasNext) {
+ val reply = stream.next()
+ println(reply.reply)
+ }
+} finally {
+ stream.close()
+}
+```
+
+`RpcIterator[A]` extends Scala `Iterator[A]` and `AutoCloseable`. A fully
+consumed stream closes when the server completes it. If the caller stops early,
+call `close()` or `cancel()` to release the gRPC call and notify the server
that
+the response stream is no longer needed.
+
+Use the observer overload when the client wants grpc-java async callbacks:
+
+```scala
+import io.grpc.stub.StreamObserver
+
+client.lotsOfReplies(
+ HelloRequest("Fory"),
+ new StreamObserver[HelloReply] {
+ override def onNext(value: HelloReply): Unit =
+ println(value.reply)
+
+ override def onError(t: Throwable): Unit =
+ t.printStackTrace()
+
+ override def onCompleted(): Unit =
+ println("done")
+ }
+)
+```
+
+The generated client also exposes a blocking grpc-java-style iterator through
+`lotsOfRepliesBlocking(request)`. Prefer the Scala-friendly `RpcIterator` when
+you need early cancellation; use the blocking iterator only when matching an
+existing grpc-java workflow.
+
+### Client-Streaming Clients
+
+For client-streaming RPCs, the generated method accepts a response observer and
+returns the request observer. Send every request with `onNext`, then call
+`onCompleted` exactly once when the client has finished sending:
+
+```scala
+import io.grpc.stub.StreamObserver
+
+val requests = client.lotsOfGreetings(
+ new StreamObserver[HelloReply] {
+ override def onNext(value: HelloReply): Unit =
+ println(value.reply)
+
+ override def onError(t: Throwable): Unit =
+ t.printStackTrace()
+
+ override def onCompleted(): Unit =
+ println("server completed")
+ }
+)
+
+requests.onNext(HelloRequest("Ada"))
+requests.onNext(HelloRequest("Grace"))
+requests.onCompleted()
+```
+
+If the client cannot finish sending requests, signal the failure with
+`requests.onError(error)`. Deadlines, cancellation, and call options are the
+standard grpc-java stub features, so they are configured on the generated
client
+stub before starting the call.
+
+### Bidirectional Clients
+
+Bidirectional streaming uses the same grpc-java request observer pattern, but
+responses can arrive while the client is still sending requests:
+
+```scala
+import io.grpc.stub.StreamObserver
+
+val requests = client.chat(
+ new StreamObserver[HelloReply] {
+ override def onNext(value: HelloReply): Unit =
+ println(value.reply)
+
+ override def onError(t: Throwable): Unit =
+ t.printStackTrace()
+
+ override def onCompleted(): Unit =
+ println("chat closed")
+ }
+)
+
+requests.onNext(HelloRequest("first"))
+requests.onNext(HelloRequest("second"))
+requests.onCompleted()
+```
+
+Use grpc-java observer subtypes such as `ClientResponseObserver`,
+`ClientCallStreamObserver`, or `ServerCallStreamObserver` when an application
+needs manual inbound flow control, readiness callbacks, cancellation handlers,
+or direct transport-level cancellation. The generated Scala methods accept the
+standard grpc-java observer types, so those advanced grpc-java patterns remain
+available without a separate Fory API.
+
+### Streaming Servers
+
+Unary server methods can use the direct Scala-friendly override shown earlier.
+Streaming server methods use grpc-java observers. A server-streaming
+implementation receives one request and writes zero or more responses:
+
+```scala
+import io.grpc.stub.StreamObserver
+import scala.util.control.NonFatal
+
+final class GreeterService extends GreeterGrpc.GreeterImplBase {
+ override def lotsOfReplies(
+ request: HelloRequest,
+ responseObserver: StreamObserver[HelloReply]
+ ): Unit =
+ try {
+ responseObserver.onNext(HelloReply(s"Hello, ${request.name}"))
+ responseObserver.onNext(HelloReply(s"Welcome, ${request.name}"))
+ responseObserver.onCompleted()
+ } catch {
+ case NonFatal(e) =>
+ responseObserver.onError(e)
+ }
+}
+```
+
+Client-streaming servers return an observer for incoming requests and write the
+single response when the request stream completes:
+
+```scala
+import io.grpc.stub.StreamObserver
+import scala.collection.mutable.ArrayBuffer
+
+final class GreeterService extends GreeterGrpc.GreeterImplBase {
+ override def lotsOfGreetings(
+ responseObserver: StreamObserver[HelloReply]
+ ): StreamObserver[HelloRequest] =
+ new StreamObserver[HelloRequest] {
+ private val names = ArrayBuffer.empty[String]
+
+ override def onNext(value: HelloRequest): Unit =
+ names += value.name
+
+ override def onError(t: Throwable): Unit =
+ names.clear()
+
+ override def onCompleted(): Unit = {
+ responseObserver.onNext(HelloReply(names.mkString("Hello ", ", ", "")))
+ responseObserver.onCompleted()
+ }
+ }
+}
+```
+
+Bidirectional servers also return an observer for incoming requests, but may
+emit responses from each `onNext` call:
+
+```scala
+import io.grpc.stub.StreamObserver
+
+final class GreeterService extends GreeterGrpc.GreeterImplBase {
+ override def chat(
+ responseObserver: StreamObserver[HelloReply]
+ ): StreamObserver[HelloRequest] =
+ new StreamObserver[HelloRequest] {
+ override def onNext(value: HelloRequest): Unit =
+ responseObserver.onNext(HelloReply(s"Echo: ${value.name}"))
+
+ override def onError(t: Throwable): Unit = ()
+
+ override def onCompleted(): Unit =
+ responseObserver.onCompleted()
+ }
+}
+```
+
+Server-streaming, client-streaming, and bidirectional server methods use
+grpc-java `StreamObserver` APIs because streaming completion, request flow
+control, cancellation, and backpressure follow grpc-java behavior.
+
+## Operations
+
+The generated service code only replaces request and response serialization.
+All normal gRPC operational features still belong to grpc-java:
+
+- 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 `io.grpc` or Guava Classes
+
+Add the grpc-java dependencies shown above. Generated Fory service files import
+grpc-java APIs, but `fory-scala` intentionally does not depend on gRPC.
+
+### Generic Protobuf Clients Cannot Read Payloads
+
+Fory-generated gRPC services use Fory bytes inside gRPC message frames, not
+protobuf message bytes. Use a Fory-generated client for Fory-generated
services,
+or provide a separate protobuf service endpoint for generic protobuf clients.
diff --git a/docs/guide/scala/index.md b/docs/guide/scala/index.md
index 80cc25744..80cea5a40 100644
--- a/docs/guide/scala/index.md
+++ b/docs/guide/scala/index.md
@@ -119,3 +119,4 @@ Fory Scala is built on top of Fory Java. Most configuration
options, features, a
- [Schema Metadata](schema-metadata.md) - Scala annotations, references, enum
IDs, and union metadata
- [Default Values](default-values.md) - Scala class default values support
- [Schema IDL And Xlang](schema-idl.md) - Scala 3 generated models and
macro-derived xlang serializers
+- [gRPC Support](grpc-support.md) - Scala 3 generated gRPC service companions
diff --git a/docs/guide/scala/schema-idl.md b/docs/guide/scala/schema-idl.md
index e9ae8c55d..d61d04294 100644
--- a/docs/guide/scala/schema-idl.md
+++ b/docs/guide/scala/schema-idl.md
@@ -44,6 +44,10 @@ Generated schema modules are also Fory modules. Use
`.withModule(...)` when
creating a custom Fory instance, or use the generated no-argument `toBytes` and
`fromBytes` helpers when the generated default Fory instance is sufficient.
+Schemas with service definitions can also generate Scala 3 gRPC service
+companions with `foryc --scala_out=... --grpc`. See
+[gRPC Support](grpc-support.md) for dependencies and client/server examples.
+
Generated helpers register message type identities before installing message
serializers. This two-phase order lets mutually recursive message graphs build
descriptor metadata through the normal `TypeResolver` path without temporary
diff --git a/scala/src/main/scala/org/apache/fory/scala/rpc/RpcFuture.scala
b/scala/src/main/scala/org/apache/fory/scala/rpc/RpcFuture.scala
new file mode 100644
index 000000000..973ad3722
--- /dev/null
+++ b/scala/src/main/scala/org/apache/fory/scala/rpc/RpcFuture.scala
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.scala.rpc
+
+/** A cancellable unary RPC handle returned by generated Scala gRPC clients.
+ *
+ * `RpcFuture` is transport-free: it has no grpc-java types in its public
+ * signature, and it does not define how a generated client stores transport
+ * state. Generated gRPC service companions own the grpc-java integration.
+ */
+trait RpcFuture[+A] {
+ /** Return a Scala `Future` view of this RPC result. */
+ def asFuture: scala.concurrent.Future[A]
+
+ /** Cancel the underlying RPC if it has not completed. */
+ def cancel(): Boolean
+
+ /** Return true when the underlying RPC was cancelled. */
+ def isCancelled: Boolean
+
+ /** Return true when the underlying RPC has completed, failed, or cancelled.
*/
+ def isDone: Boolean
+}
diff --git a/scala/src/main/scala/org/apache/fory/scala/rpc/RpcIterator.scala
b/scala/src/main/scala/org/apache/fory/scala/rpc/RpcIterator.scala
new file mode 100644
index 000000000..9031984d4
--- /dev/null
+++ b/scala/src/main/scala/org/apache/fory/scala/rpc/RpcIterator.scala
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.scala.rpc
+
+/** A closeable server-streaming RPC iterator returned by generated Scala gRPC
clients.
+ *
+ * Generated gRPC service companions own the underlying transport call.
Calling
+ * `close()` or `cancel()` releases that call early when the caller stops
+ * reading before the stream completes.
+ */
+trait RpcIterator[+A] extends Iterator[A] with AutoCloseable {
+ /** Cancel or close the underlying RPC stream. */
+ def cancel(): Unit
+
+ /** Return true when the underlying RPC stream is closed. */
+ def isClosed: Boolean
+
+ final override def close(): Unit = cancel()
+}
diff --git a/scala/src/test/scala/org/apache/fory/scala/rpc/RpcHandleTest.scala
b/scala/src/test/scala/org/apache/fory/scala/rpc/RpcHandleTest.scala
new file mode 100644
index 000000000..06075d795
--- /dev/null
+++ b/scala/src/test/scala/org/apache/fory/scala/rpc/RpcHandleTest.scala
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.scala.rpc
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import scala.concurrent.Future
+
+class RpcHandleTest extends AnyFunSuite {
+ test("rpc future keeps cancellation separate from future view") {
+ final class TestFuture extends RpcFuture[Int] {
+ private var cancelled = false
+
+ override def asFuture: Future[Int] = Future.successful(1)
+
+ override def cancel(): Boolean = {
+ cancelled = true
+ true
+ }
+
+ override def isCancelled: Boolean = cancelled
+
+ override def isDone: Boolean = cancelled
+ }
+
+ val handle = new TestFuture
+ assert(handle.asFuture.isCompleted)
+ assert(!handle.isCancelled)
+ assert(handle.cancel())
+ assert(handle.isCancelled)
+ assert(handle.isDone)
+ }
+
+ test("rpc iterator close delegates to cancel") {
+ final class TestIterator extends RpcIterator[Int] {
+ private var closed = false
+
+ override def hasNext: Boolean = false
+
+ override def next(): Int = throw new NoSuchElementException
+
+ override def cancel(): Unit = {
+ closed = true
+ }
+
+ override def isClosed: Boolean = closed
+ }
+
+ val iterator = new TestIterator
+ assert(!iterator.isClosed)
+ iterator.close()
+ assert(iterator.isClosed)
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]