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 18b821c72 test(compiler): add IR validation and codegen tests for gRPC
service support (#3528)
18b821c72 is described below
commit 18b821c7290843dfcdc0f6b84835c6102715a812
Author: Darius <[email protected]>
AuthorDate: Mon Mar 30 15:13:18 2026 +0300
test(compiler): add IR validation and codegen tests for gRPC service
support (#3528)
## Why?
Issue #3277 requires unit tests for gRPC service support in the Fory
compiler: IR validation tests covering name, type, and streaming rules,
and golden codegen tests for service schemas. The parser tests for
FDL/proto/fbs service syntax already existed from closed sub-issues.
This PR fills the remaining gaps.
## What does this PR do?
**`compiler/fory_compiler/ir/validator.py`**
- Added `_check_services()` implementing three validation rules:
1. Duplicate service names → error
2. Duplicate method names within the same service → error
3. RPC request/response types must be `message` — `enum` and `union` are
rejected
- Wired `_check_services()` into `validate()` after
`_check_type_references()`
**`compiler/fory_compiler/tests/test_ir_service_validation.py`** (new)
- Tests for all three validation rules above, across FDL, proto, and fbs
frontends
- Tests that streaming RPCs (client, server, bidi) pass validation
without error
**`compiler/fory_compiler/tests/test_service_codegen.py`** (new)
- Asserts service definitions do not alter message codegen output across
all 7 language generators
- Documents that `generate_services()` returns `[]` as a baseline for
all generators
- Verifies `compile_file(..., grpc=True)` succeeds and produces output
for all languages using `examples/service.fdl`
- Asserts key signatures (`class HelloRequest`, `String name`, etc.) are
present in Java and Python generated output
## Related issues
- Closes #3277
- Related to #3266
---
compiler/fory_compiler/ir/validator.py | 32 +++
.../tests/test_ir_service_validation.py | 307 +++++++++++++++++++++
.../fory_compiler/tests/test_service_codegen.py | 147 ++++++++++
3 files changed, 486 insertions(+)
diff --git a/compiler/fory_compiler/ir/validator.py
b/compiler/fory_compiler/ir/validator.py
index 294657b2f..acd774177 100644
--- a/compiler/fory_compiler/ir/validator.py
+++ b/compiler/fory_compiler/ir/validator.py
@@ -66,6 +66,7 @@ class SchemaValidator:
self._check_duplicate_type_ids()
self._check_messages()
self._check_type_references()
+ self._check_services()
self._check_collection_nesting()
self._check_ref_rules()
self._check_weak_refs()
@@ -639,6 +640,37 @@ class SchemaValidator:
for f in union.fields:
check_field(f, None)
+ def _check_services(self) -> None:
+ seen_service_names: dict = {}
+ for service in self.schema.services:
+ if service.name in seen_service_names:
+ self._error(
+ f"Duplicate service name: {service.name}",
+ service.location,
+ )
+ seen_service_names.setdefault(service.name, service.location)
+
+ seen_method_names: dict = {}
+ for method in service.methods:
+ if method.name in seen_method_names:
+ self._error(
+ f"Duplicate method name in service {service.name}:
{method.name}",
+ method.location,
+ )
+ seen_method_names.setdefault(method.name, method.location)
+
+ for named_type in (method.request_type, method.response_type):
+ resolved = self.schema.get_type(named_type.name)
+ if resolved is None:
+ continue
+ if not isinstance(resolved, Message):
+ kind = "enum" if isinstance(resolved, Enum) else
"union"
+ self._error(
+ f"RPC type '{named_type.name}' in service
{service.name}"
+ f" must be a message, not a {kind}",
+ named_type.location,
+ )
+
def validate_schema(schema: Schema) -> List[str]:
"""Validate a schema and return a list of error messages."""
diff --git a/compiler/fory_compiler/tests/test_ir_service_validation.py
b/compiler/fory_compiler/tests/test_ir_service_validation.py
new file mode 100644
index 000000000..7abf44f90
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_ir_service_validation.py
@@ -0,0 +1,307 @@
+# 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.
+
+"""Tests for IR-level service validation rules."""
+
+from fory_compiler.frontend.fdl.parser import Parser
+from fory_compiler.frontend.proto.lexer import Lexer as ProtoLexer
+from fory_compiler.frontend.proto.parser import Parser as ProtoParser
+from fory_compiler.frontend.proto.translator import ProtoTranslator
+from fory_compiler.frontend.fbs.lexer import Lexer as FbsLexer
+from fory_compiler.frontend.fbs.parser import Parser as FbsParser
+from fory_compiler.frontend.fbs.translator import FbsTranslator
+from fory_compiler.ir.validator import SchemaValidator
+
+
+def parse_fdl(source: str):
+ return Parser.from_source(source).parse()
+
+
+def parse_proto(source: str):
+ lexer = ProtoLexer(source)
+ parser = ProtoParser(lexer.tokenize())
+ return ProtoTranslator(parser.parse()).translate()
+
+
+def parse_fbs(source: str):
+ lexer = FbsLexer(source)
+ parser = FbsParser(lexer.tokenize())
+ return FbsTranslator(parser.parse()).translate()
+
+
+def validate(schema):
+ validator = SchemaValidator(schema)
+ validator.validate()
+ return validator
+
+
+def test_duplicate_service_names_fails_validation():
+ source = """
+ package test;
+
+ service Alpha {}
+ service Alpha {}
+ """
+ v = validate(parse_fdl(source))
+ assert any("Duplicate service name: Alpha" in e.message for e in v.errors)
+
+
+def test_unique_service_names_passes_validation():
+ source = """
+ package test;
+
+ service Alpha {}
+ service Beta {}
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_duplicate_method_names_fails_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Greeter {
+ rpc SayHello (Req) returns (Res);
+ rpc SayHello (Req) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert any(
+ "Duplicate method name in service Greeter: SayHello" in e.message
+ for e in v.errors
+ )
+
+
+def test_same_method_name_in_different_services_passes_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Alpha {
+ rpc SayHello (Req) returns (Res);
+ }
+
+ service Beta {
+ rpc SayHello (Req) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_unique_method_names_passes_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Greeter {
+ rpc SayHello (Req) returns (Res);
+ rpc SayGoodbye (Req) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_rpc_request_type_enum_fails_validation():
+ source = """
+ package test;
+
+ enum Status { OK = 0; }
+ message Res {}
+
+ service Svc {
+ rpc Call (Status) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert any(
+ "RPC type 'Status'" in e.message and "not a enum" in e.message for e
in v.errors
+ )
+
+
+def test_rpc_response_type_enum_fails_validation():
+ source = """
+ package test;
+
+ message Req {}
+ enum Status { OK = 0; }
+
+ service Svc {
+ rpc Call (Req) returns (Status);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert any(
+ "RPC type 'Status'" in e.message and "not a enum" in e.message for e
in v.errors
+ )
+
+
+def test_rpc_request_type_union_fails_validation():
+ source = """
+ package test;
+
+ union Payload { string text = 1; }
+ message Res {}
+
+ service Svc {
+ rpc Call (Payload) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert any(
+ "RPC type 'Payload'" in e.message and "not a union" in e.message
+ for e in v.errors
+ )
+
+
+def test_rpc_response_type_union_fails_validation():
+ source = """
+ package test;
+
+ message Req {}
+ union Payload { string text = 1; }
+
+ service Svc {
+ rpc Call (Req) returns (Payload);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert any(
+ "RPC type 'Payload'" in e.message and "not a union" in e.message
+ for e in v.errors
+ )
+
+
+def test_rpc_message_types_pass_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Svc {
+ rpc Call (Req) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_proto_rpc_enum_type_fails_validation():
+ source = """
+ syntax = "proto3";
+ package test;
+
+ enum Status { OK = 0; }
+ message Res { string result = 1; }
+
+ service Svc {
+ rpc Call (Status) returns (Res);
+ }
+ """
+ v = validate(parse_proto(source))
+ assert any("RPC type 'Status'" in e.message for e in v.errors)
+
+
+def test_fbs_rpc_duplicate_method_fails_validation():
+ source = """
+ namespace test;
+
+ table Req { id: int; }
+ table Res { result: string; }
+
+ rpc_service Svc {
+ Call(Req):Res;
+ Call(Req):Res;
+ }
+ """
+ v = validate(parse_fbs(source))
+ assert any(
+ "Duplicate method name in service Svc: Call" in e.message for e in
v.errors
+ )
+
+
+def test_client_streaming_rpc_passes_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Svc {
+ rpc Call (stream Req) returns (Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_server_streaming_rpc_passes_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Svc {
+ rpc Call (Req) returns (stream Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_bidi_streaming_rpc_passes_validation():
+ source = """
+ package test;
+
+ message Req {}
+ message Res {}
+
+ service Svc {
+ rpc Call (stream Req) returns (stream Res);
+ }
+ """
+ v = validate(parse_fdl(source))
+ assert v.errors == []
+
+
+def test_proto_streaming_rpc_passes_validation():
+ source = """
+ syntax = "proto3";
+ package test;
+
+ message Req { string id = 1; }
+ message Res { string result = 1; }
+
+ service Svc {
+ rpc ClientStream (stream Req) returns (Res);
+ rpc ServerStream (Req) returns (stream Res);
+ rpc BidiStream (stream Req) returns (stream Res);
+ }
+ """
+ v = validate(parse_proto(source))
+ assert v.errors == []
diff --git a/compiler/fory_compiler/tests/test_service_codegen.py
b/compiler/fory_compiler/tests/test_service_codegen.py
new file mode 100644
index 000000000..e27425d1f
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_service_codegen.py
@@ -0,0 +1,147 @@
+# 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.
+
+"""Codegen smoke tests for schemas that contain service definitions."""
+
+from pathlib import Path
+from textwrap import dedent
+from typing import Dict, Tuple, Type
+
+from fory_compiler.cli import compile_file
+from fory_compiler.frontend.fdl.lexer import Lexer
+from fory_compiler.frontend.fdl.parser import Parser
+from fory_compiler.generators.base import BaseGenerator, GeneratorOptions
+from fory_compiler.generators.cpp import CppGenerator
+from fory_compiler.generators.csharp import CSharpGenerator
+from fory_compiler.generators.go import GoGenerator
+from fory_compiler.generators.java import JavaGenerator
+from fory_compiler.generators.python import PythonGenerator
+from fory_compiler.generators.rust import RustGenerator
+from fory_compiler.generators.swift import SwiftGenerator
+from fory_compiler.ir.ast import Schema
+
+
+GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = (
+ JavaGenerator,
+ PythonGenerator,
+ CppGenerator,
+ RustGenerator,
+ GoGenerator,
+ CSharpGenerator,
+ SwiftGenerator,
+)
+
+_GREETER_WITH_SERVICE = dedent(
+ """
+ package demo.greeter;
+
+ message HelloRequest {
+ string name = 1;
+ }
+
+ message HelloReply {
+ string reply = 1;
+ }
+
+ service Greeter {
+ rpc SayHello (HelloRequest) returns (HelloReply);
+ }
+ """
+)
+
+_GREETER_WITHOUT_SERVICE = dedent(
+ """
+ package demo.greeter;
+
+ message HelloRequest {
+ string name = 1;
+ }
+
+ message HelloReply {
+ string reply = 1;
+ }
+ """
+)
+
+
+def parse_fdl(source: str) -> Schema:
+ return Parser(Lexer(source).tokenize()).parse()
+
+
+def generate_files(
+ schema: Schema, generator_cls: Type[BaseGenerator]
+) -> Dict[str, str]:
+ options = GeneratorOptions(output_dir=Path("/tmp"))
+ generator = generator_cls(schema, options)
+ return {item.path: item.content for item in generator.generate()}
+
+
+def test_service_definition_does_not_affect_message_codegen():
+ schema_with = parse_fdl(_GREETER_WITH_SERVICE)
+ schema_without = parse_fdl(_GREETER_WITHOUT_SERVICE)
+ for generator_cls in GENERATOR_CLASSES:
+ files_with = generate_files(schema_with, generator_cls)
+ files_without = generate_files(schema_without, generator_cls)
+ assert files_with == files_without, (
+ f"{generator_cls.language_name}: service definition changed
message output"
+ )
+
+
+def test_generate_services_returns_empty_list_for_all_generators():
+ schema = parse_fdl(_GREETER_WITH_SERVICE)
+ for generator_cls in GENERATOR_CLASSES:
+ options = GeneratorOptions(output_dir=Path("/tmp"))
+ generator = generator_cls(schema, options)
+ assert generator.generate_services() == [], (
+ f"{generator_cls.language_name}: generate_services() should return
[] until gRPC is implemented"
+ )
+
+
+def test_service_schema_produces_one_file_per_message_per_language():
+ schema = parse_fdl(_GREETER_WITH_SERVICE)
+ for generator_cls in GENERATOR_CLASSES:
+ files = generate_files(schema, generator_cls)
+ assert len(files) >= 1, (
+ f"{generator_cls.language_name}: expected at least one generated
file"
+ )
+
+
+def test_compile_service_schema_with_grpc_flag(tmp_path: Path):
+ example_path = Path(__file__).resolve().parents[2] / "examples" /
"service.fdl"
+ lang_dirs = {}
+ for lang in ("java", "python", "rust", "go", "cpp", "csharp", "swift"):
+ lang_dirs[lang] = tmp_path / lang
+ ok = compile_file(example_path, lang_dirs, grpc=True)
+ assert ok is True
+ for lang, lang_dir in lang_dirs.items():
+ files = [p for p in lang_dir.rglob("*") if p.is_file()]
+ assert len(files) >= 1, f"{lang}: expected at least one file with
grpc=True"
+
+
+def test_generated_message_contains_key_signatures():
+ schema = parse_fdl(_GREETER_WITH_SERVICE)
+ java_files = generate_files(schema, JavaGenerator)
+ all_java = "\n".join(java_files.values())
+ assert "class HelloRequest" in all_java
+ assert "class HelloReply" in all_java
+ assert "String name" in all_java
+ assert "String reply" in all_java
+
+ python_files = generate_files(schema, PythonGenerator)
+ all_python = "\n".join(python_files.values())
+ assert "HelloRequest" in all_python
+ assert "HelloReply" in all_python
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]