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 dc9f3e7a0 feat(gpRc): update FDL lexer/parser to support service and
rpc definitions (#3308)
dc9f3e7a0 is described below
commit dc9f3e7a033e34262d4507ac358de26b1f67797b
Author: Ayush Kumar <[email protected]>
AuthorDate: Mon Feb 9 00:00:22 2026 +0530
feat(gpRc): update FDL lexer/parser to support service and rpc definitions
(#3308)
## Why?
Using fory with gRPCs forced using the protobuffs for the services and
methods of a class. Users needed to define separate files for generating
stubs. Which turns their focus on using protobuff completely, ignoring
the fory's performance.
## What does this PR do?
Adds the parser for rpc methods and services to allow users use FDL with
gRPC.
### Added Keywords
`service`
`rpc`
`returns`
`stream`
### Changes Made
#### 1. compiler/fory_compiler/ir/ast.py
Added `RpcMethod` and `Service` Data classes.
```python
@dataclass
class Service:
"""A service definition."""
name: str
methods: List[RpcMethod] = field(default_factory=list)
options: dict = field(default_factory=dict)
line: int = 0
column: int = 0
location: Optional[SourceLocation] = None
def __repr__(self) -> str:
opts_str = f", options={len(self.options)}" if self.options else ""
return f"Service({self.name},
methods={len(self.methods)}{opts_str})"
```
### 2. compiler/fory_compiler/frontend/fdl/lexer.py
- Added the `SERVICE` `RPC` `RETURNS` `STREAM` to the `TokenType` enum
- Mapped Keywords in `Lexer.Keywords`
```python
SERVICE = auto()
RPC = auto()
RETURNS = auto()
STREAM = auto()
```
```python
"service": TokenType.SERVICE,
"rpc": TokenType.RPC,
"returns": TokenType.RETURNS,
"stream": TokenType.STREAM,
```
### 3. compiler/fory_compiler/frontend/fdl/parser.py
- Added the `parse_service` and `parse_rpc_method` method.
```python
def parse_service(self) -> Service:
"""Parse a service definition: service Greeter { rpc ... }"""
start = self.current()
self.consume(TokenType.SERVICE)
name = self.consume(TokenType.IDENT, "Expected service name").value
self.consume(TokenType.LBRACE, "Expected '{' after service name")
.....
```
```python
def parse_rpc_method(self) -> RpcMethod:
"""Parse an RPC method: rpc Name (stream? Req) returns (stream?
Res) { option ... };"""
start = self.current()
self.consume(TokenType.RPC)
name = self.consume(TokenType.IDENT, "Expected method name").value
# Parse request type
self.consume(TokenType.LPAREN, "Expected '(' before request type")
client_streaming = False
if self.check(TokenType.STREAM):
self.advance()
client_streaming = True
```
### 4. Done the same updates just a slight different based on
implementations for the protobuffs and flatbuffers.
## Related issues
- Closes #3268
## Does this PR introduce any user-facing change?
- [x] **Does this PR introduce any public API change?**
This PR extends the FDL language with support for service definitions
using `service` `rpc` `returns` and `stream` keywords. This allows users
to define gRPC services directly in their FDL schemas.
- [ ] **Does this PR introduce any binary protocol compatibility
change?**
## Benchmark
N/A
---
---
compiler/fory_compiler/frontend/fbs/ast.py | 24 +++
compiler/fory_compiler/frontend/fbs/lexer.py | 9 ++
compiler/fory_compiler/frontend/fbs/parser.py | 75 ++++++++-
compiler/fory_compiler/frontend/fbs/translator.py | 51 +++++++
compiler/fory_compiler/frontend/fdl/lexer.py | 8 +
compiler/fory_compiler/frontend/fdl/parser.py | 121 +++++++++++++++
compiler/fory_compiler/frontend/proto/ast.py | 26 ++++
compiler/fory_compiler/frontend/proto/lexer.py | 2 +
compiler/fory_compiler/frontend/proto/parser.py | 96 ++++++++++--
.../fory_compiler/frontend/proto/translator.py | 40 +++++
compiler/fory_compiler/ir/ast.py | 45 +++++-
compiler/fory_compiler/tests/test_fbs_service.py | 109 +++++++++++++
compiler/fory_compiler/tests/test_fdl_service.py | 170 +++++++++++++++++++++
compiler/fory_compiler/tests/test_proto_service.py | 122 +++++++++++++++
14 files changed, 885 insertions(+), 13 deletions(-)
diff --git a/compiler/fory_compiler/frontend/fbs/ast.py
b/compiler/fory_compiler/frontend/fbs/ast.py
index 17e0f4c1a..ad3bde947 100644
--- a/compiler/fory_compiler/frontend/fbs/ast.py
+++ b/compiler/fory_compiler/frontend/fbs/ast.py
@@ -109,6 +109,29 @@ class FbsStruct:
column: int = 0
+@dataclass
+class FbsRpcMethod:
+ """An RPC method declaration."""
+
+ name: str
+ request_type: str
+ response_type: str
+ attributes: Dict[str, object] = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+
+
+@dataclass
+class FbsService:
+ """A FlatBuffers service declaration."""
+
+ name: str
+ methods: List[FbsRpcMethod] = field(default_factory=list)
+ attributes: Dict[str, object] = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+
+
@dataclass
class FbsSchema:
"""The root node representing a FlatBuffers schema."""
@@ -120,5 +143,6 @@ class FbsSchema:
unions: List[FbsUnion] = field(default_factory=list)
tables: List[FbsTable] = field(default_factory=list)
structs: List[FbsStruct] = field(default_factory=list)
+ services: List[FbsService] = field(default_factory=list)
root_type: Optional[str] = None
source_file: Optional[str] = None
diff --git a/compiler/fory_compiler/frontend/fbs/lexer.py
b/compiler/fory_compiler/frontend/fbs/lexer.py
index f70b5a95a..a392ab983 100644
--- a/compiler/fory_compiler/frontend/fbs/lexer.py
+++ b/compiler/fory_compiler/frontend/fbs/lexer.py
@@ -38,6 +38,10 @@ class TokenType(Enum):
FILE_EXTENSION = auto()
TRUE = auto()
FALSE = auto()
+ SERVICE = auto()
+ RPC = auto()
+ RETURNS = auto()
+ STREAM = auto()
# Literals
IDENT = auto()
@@ -97,6 +101,11 @@ class Lexer:
"file_extension": TokenType.FILE_EXTENSION,
"true": TokenType.TRUE,
"false": TokenType.FALSE,
+ "service": TokenType.SERVICE,
+ "rpc_service": TokenType.SERVICE,
+ "rpc": TokenType.RPC,
+ "returns": TokenType.RETURNS,
+ "stream": TokenType.STREAM,
}
PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/fbs/parser.py
b/compiler/fory_compiler/frontend/fbs/parser.py
index 5c84e008e..67013c26e 100644
--- a/compiler/fory_compiler/frontend/fbs/parser.py
+++ b/compiler/fory_compiler/frontend/fbs/parser.py
@@ -30,6 +30,8 @@ from fory_compiler.frontend.fbs.ast import (
FbsTypeName,
FbsTypeRef,
FbsVectorType,
+ FbsService,
+ FbsRpcMethod,
)
from fory_compiler.frontend.fbs.lexer import Token, TokenType
@@ -96,7 +98,9 @@ class Parser:
enums: List[FbsEnum] = []
unions: List[FbsUnion] = []
tables: List[FbsTable] = []
+ tables: List[FbsTable] = []
structs: List[FbsStruct] = []
+ services: List[FbsService] = []
root_type: Optional[str] = None
while not self.at_end():
@@ -122,6 +126,8 @@ class Parser:
self.parse_file_extension()
elif self.check(TokenType.UNION):
unions.append(self.parse_union())
+ elif self.check(TokenType.SERVICE) or self.check(TokenType.RPC):
+ services.append(self.parse_service())
elif self.check(TokenType.SEMI):
self.advance()
else:
@@ -135,6 +141,7 @@ class Parser:
unions=unions,
tables=tables,
structs=structs,
+ services=services,
root_type=root_type,
source_file=self.filename,
)
@@ -369,7 +376,11 @@ class Parser:
if self.match(TokenType.FALSE):
return False
if self.check(TokenType.INT):
- return int(self.advance().value, 0)
+ value = self.advance().value
+ try:
+ return int(value, 0)
+ except ValueError:
+ return int(value)
if self.check(TokenType.FLOAT):
return float(self.advance().value)
if self.check(TokenType.STRING):
@@ -377,3 +388,65 @@ class Parser:
if self.check(TokenType.IDENT):
return self.advance().value
raise self.error("Expected value")
+
+ def parse_service(self) -> FbsService:
+ start = self.current()
+ # Support both 'service' and 'rpc_service' keywords for defining
services.
+ if self.check(TokenType.SERVICE):
+ self.advance()
+ elif self.check(TokenType.RPC):
+ # 'rpc_service' is mapped to TokenType.SERVICE in the lexer,
+ # but we also check for separate 'rpc' token just in case.
+ self.advance()
+ if self.check(TokenType.SERVICE):
+ self.advance()
+ else:
+ raise self.error("Expected 'service' or 'rpc_service'")
+
+ name = self.consume(TokenType.IDENT, "Expected service name").value
+ attributes = self.parse_metadata()
+ self.consume(TokenType.LBRACE, "Expected '{' after service name")
+
+ methods: List[FbsRpcMethod] = []
+ while not self.check(TokenType.RBRACE):
+ if self.check(TokenType.SEMI):
+ self.advance()
+ continue
+ methods.append(self.parse_rpc_method())
+
+ self.consume(TokenType.RBRACE, "Expected '}' after service body")
+ if self.check(TokenType.SEMI):
+ self.advance()
+
+ return FbsService(
+ name=name,
+ methods=methods,
+ attributes=attributes,
+ line=start.line,
+ column=start.column,
+ )
+
+ def parse_rpc_method(self) -> FbsRpcMethod:
+ # Parse method signature: name(RequestType):ResponseType;
+ start = self.current()
+ name = self.consume(TokenType.IDENT, "Expected method name").value
+
+ self.consume(TokenType.LPAREN, "Expected '(' after method name")
+ # Parsing request type. FBS allows type name here.
+ req_type = self.parse_qualified_ident()
+ self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+ self.consume(TokenType.COLON, "Expected ':' before response type")
+ res_type = self.parse_qualified_ident()
+
+ attributes = self.parse_metadata()
+ self.consume(TokenType.SEMI, "Expected ';' after method declaration")
+
+ return FbsRpcMethod(
+ name=name,
+ request_type=req_type,
+ response_type=res_type,
+ attributes=attributes,
+ line=start.line,
+ column=start.column,
+ )
diff --git a/compiler/fory_compiler/frontend/fbs/translator.py
b/compiler/fory_compiler/frontend/fbs/translator.py
index c42ce0bfc..677cf1420 100644
--- a/compiler/fory_compiler/frontend/fbs/translator.py
+++ b/compiler/fory_compiler/frontend/fbs/translator.py
@@ -28,6 +28,8 @@ from fory_compiler.frontend.fbs.ast import (
FbsTypeRef,
FbsVectorType,
FbsUnion,
+ FbsService,
+ FbsRpcMethod,
)
from fory_compiler.ir.ast import (
Enum,
@@ -39,6 +41,8 @@ from fory_compiler.ir.ast import (
NamedType,
PrimitiveType,
Schema,
+ Service,
+ RpcMethod,
SourceLocation,
Union,
)
@@ -82,6 +86,7 @@ class FbsTranslator:
enums=[self._translate_enum(e) for e in self.schema.enums],
unions=[self._translate_union(u) for u in self.schema.unions],
messages=self._translate_messages(),
+ services=[self._translate_service(s) for s in
self.schema.services],
options={},
source_file=self.schema.source_file,
source_format="fbs",
@@ -278,3 +283,49 @@ class FbsTranslator:
fbs_type.name, location=self._location(fbs_type.line,
fbs_type.column)
)
raise ValueError("Unknown FlatBuffers type")
+
+ def _translate_service(self, fbs_service: FbsService) -> Service:
+ return Service(
+ name=fbs_service.name,
+ methods=[self._translate_rpc_method(m) for m in
fbs_service.methods],
+ options=dict(fbs_service.attributes),
+ line=fbs_service.line,
+ column=fbs_service.column,
+ location=self._location(fbs_service.line, fbs_service.column),
+ )
+
+ def _translate_rpc_method(self, fbs_method: FbsRpcMethod) -> RpcMethod:
+ # Map FBS 'streaming' attribute to Fory's
client_streaming/server_streaming flags.
+ # Expected 'streaming' values: "client", "server", "bidi".
+ # Default is unary (no streaming).
+
+ attributes = dict(fbs_method.attributes)
+ client_streaming = False
+ server_streaming = False
+
+ # Check for streaming attributes if any (convention)
+ if attributes.get("streaming") == "client":
+ client_streaming = True
+ elif attributes.get("streaming") == "server":
+ server_streaming = True
+ elif attributes.get("streaming") == "bidi":
+ client_streaming = True
+ server_streaming = True
+
+ return RpcMethod(
+ name=fbs_method.name,
+ request_type=NamedType(
+ name=fbs_method.request_type,
+ location=self._location(fbs_method.line, fbs_method.column),
+ ),
+ response_type=NamedType(
+ name=fbs_method.response_type,
+ location=self._location(fbs_method.line, fbs_method.column),
+ ),
+ client_streaming=client_streaming,
+ server_streaming=server_streaming,
+ options=attributes,
+ line=fbs_method.line,
+ column=fbs_method.column,
+ location=self._location(fbs_method.line, fbs_method.column),
+ )
diff --git a/compiler/fory_compiler/frontend/fdl/lexer.py
b/compiler/fory_compiler/frontend/fdl/lexer.py
index 8bd160849..6043de496 100644
--- a/compiler/fory_compiler/frontend/fdl/lexer.py
+++ b/compiler/fory_compiler/frontend/fdl/lexer.py
@@ -44,6 +44,10 @@ class TokenType(Enum):
RESERVED = auto()
TO = auto()
MAX = auto()
+ SERVICE = auto()
+ RPC = auto()
+ RETURNS = auto()
+ STREAM = auto()
# Literals
IDENT = auto()
@@ -112,6 +116,10 @@ class Lexer:
"reserved": TokenType.RESERVED,
"to": TokenType.TO,
"max": TokenType.MAX,
+ "service": TokenType.SERVICE,
+ "rpc": TokenType.RPC,
+ "returns": TokenType.RETURNS,
+ "stream": TokenType.STREAM,
}
PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/fdl/parser.py
b/compiler/fory_compiler/frontend/fdl/parser.py
index f2caf1be4..79c568f0a 100644
--- a/compiler/fory_compiler/frontend/fdl/parser.py
+++ b/compiler/fory_compiler/frontend/fdl/parser.py
@@ -25,6 +25,8 @@ from fory_compiler.ir.ast import (
Message,
Enum,
Union,
+ Service,
+ RpcMethod,
Field,
EnumValue,
Import,
@@ -107,6 +109,14 @@ KNOWN_UNION_OPTIONS: Set[str] = {
"deprecated",
}
+KNOWN_SERVICE_OPTIONS: Set[str] = {
+ "deprecated",
+}
+
+KNOWN_METHOD_OPTIONS: Set[str] = {
+ "deprecated",
+}
+
class ParseError(Exception):
"""Error during parsing."""
@@ -196,6 +206,7 @@ class Parser:
enums = []
messages = []
unions = []
+ services = []
options = {}
while not self.at_end():
@@ -215,6 +226,8 @@ class Parser:
unions.append(self.parse_union())
elif self.check(TokenType.MESSAGE):
messages.append(self.parse_message())
+ elif self.check(TokenType.SERVICE):
+ services.append(self.parse_service())
else:
raise self.error(f"Unexpected token: {self.current().value}")
@@ -225,6 +238,7 @@ class Parser:
enums=enums,
messages=messages,
unions=unions,
+ services=services,
options=options,
source_file=self.filename,
source_format=self.source_format,
@@ -822,6 +836,113 @@ class Parser:
if thread_safe is not None:
target["thread_safe_pointer"] = thread_safe
+ def parse_service(self) -> Service:
+ """Parse a service definition: service Greeter { rpc ... }"""
+ start = self.current()
+ self.consume(TokenType.SERVICE)
+ name = self.consume(TokenType.IDENT, "Expected service name").value
+ self.consume(TokenType.LBRACE, "Expected '{' after service name")
+
+ methods = []
+ options = {}
+
+ while not self.check(TokenType.RBRACE):
+ if self.check(TokenType.OPTION):
+ # Service-level option
+ name_token = self.current()
+ opt_name, opt_value = (
+ self.parse_file_option()
+ ) # Reusing generic option parser
+ options[opt_name] = opt_value
+ if opt_name not in KNOWN_SERVICE_OPTIONS:
+ warnings.warn(
+ f"Line {name_token.line}: ignoring unknown service
option '{opt_name}'",
+ stacklevel=2,
+ )
+ elif self.check(TokenType.RPC):
+ methods.append(self.parse_rpc_method())
+ else:
+ raise self.error("Expected 'rpc' or 'option' inside service
block")
+
+ self.consume(TokenType.RBRACE, "Expected '}' after service body")
+
+ return Service(
+ name=name,
+ methods=methods,
+ options=options,
+ line=start.line,
+ column=start.column,
+ location=self.make_location(start),
+ )
+
+ def parse_rpc_method(self) -> RpcMethod:
+ """Parse an RPC method: rpc Name (stream? Req) returns (stream? Res) {
option ... };"""
+ start = self.current()
+ self.consume(TokenType.RPC)
+ name = self.consume(TokenType.IDENT, "Expected method name").value
+
+ # Parse request type
+ self.consume(TokenType.LPAREN, "Expected '(' before request type")
+ client_streaming = False
+ if self.check(TokenType.STREAM):
+ self.advance()
+ client_streaming = True
+
+ req_type_token = self.consume(TokenType.IDENT, "Expected request
message type")
+ request_type = NamedType(
+ name=req_type_token.value,
location=self.make_location(req_type_token)
+ )
+ self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+ # Parse return type
+ self.consume(TokenType.RETURNS, "Expected 'returns' keyword")
+ self.consume(TokenType.LPAREN, "Expected '(' before response type")
+ server_streaming = False
+ if self.check(TokenType.STREAM):
+ self.advance()
+ server_streaming = True
+
+ res_type_token = self.consume(TokenType.IDENT, "Expected response
message type")
+ response_type = NamedType(
+ name=res_type_token.value,
location=self.make_location(res_type_token)
+ )
+ self.consume(TokenType.RPAREN, "Expected ')' after response type")
+
+ # Parse optional method options block
+ options = {}
+ if self.check(TokenType.LBRACE):
+ self.consume(TokenType.LBRACE)
+ while not self.check(TokenType.RBRACE):
+ if self.check(TokenType.OPTION):
+ name_token = self.current()
+ opt_name, opt_value = self.parse_file_option()
+ options[opt_name] = opt_value
+ if opt_name not in KNOWN_METHOD_OPTIONS:
+ warnings.warn(
+ f"Line {name_token.line}: ignoring unknown method
option '{opt_name}'",
+ stacklevel=2,
+ )
+ elif self.check(TokenType.SEMI):
+ # Allow empty ; inside block
+ self.advance()
+ else:
+ raise self.error("Expected 'option' inside method block")
+ self.consume(TokenType.RBRACE, "Expected '}' after method options")
+ else:
+ self.consume(TokenType.SEMI, "Expected ';' after method
definition")
+
+ return RpcMethod(
+ name=name,
+ request_type=request_type,
+ response_type=response_type,
+ client_streaming=client_streaming,
+ server_streaming=server_streaming,
+ options=options,
+ line=start.line,
+ column=start.column,
+ location=self.make_location(start),
+ )
+
def parse_type_options(
self, type_name: str, known_options: Set[str], allow_zero_id: bool =
False
) -> dict:
diff --git a/compiler/fory_compiler/frontend/proto/ast.py
b/compiler/fory_compiler/frontend/proto/ast.py
index b881f467f..8b5ece33f 100644
--- a/compiler/fory_compiler/frontend/proto/ast.py
+++ b/compiler/fory_compiler/frontend/proto/ast.py
@@ -91,6 +91,31 @@ class ProtoMessage:
column: int = 0
+@dataclass
+class ProtoRpcMethod:
+ """An RPC method declaration."""
+
+ name: str
+ request_type: str
+ response_type: str
+ client_streaming: bool = False
+ server_streaming: bool = False
+ options: Dict[str, object] = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+
+
+@dataclass
+class ProtoService:
+ """A ProtoBuffers service declaration."""
+
+ name: str
+ methods: List["ProtoRpcMethod"] = field(default_factory=list)
+ options: Dict[str, object] = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+
+
@dataclass
class ProtoSchema:
"""Represents a proto file."""
@@ -99,6 +124,7 @@ class ProtoSchema:
package: Optional[str]
imports: List[str] = field(default_factory=list)
enums: List[ProtoEnum] = field(default_factory=list)
+ services: List[ProtoService] = field(default_factory=list)
messages: List[ProtoMessage] = field(default_factory=list)
options: Dict[str, object] = field(default_factory=dict)
source_file: Optional[str] = None
diff --git a/compiler/fory_compiler/frontend/proto/lexer.py
b/compiler/fory_compiler/frontend/proto/lexer.py
index c8afe4e7c..67a4c71fb 100644
--- a/compiler/fory_compiler/frontend/proto/lexer.py
+++ b/compiler/fory_compiler/frontend/proto/lexer.py
@@ -48,6 +48,7 @@ class TokenType(Enum):
FALSE = auto()
TO = auto()
MAX = auto()
+ STREAM = auto()
# Literals
IDENT = auto()
@@ -120,6 +121,7 @@ class Lexer:
"false": TokenType.FALSE,
"to": TokenType.TO,
"max": TokenType.MAX,
+ "stream": TokenType.STREAM,
}
PUNCTUATION = {
diff --git a/compiler/fory_compiler/frontend/proto/parser.py
b/compiler/fory_compiler/frontend/proto/parser.py
index 127a5cad0..a5c69b407 100644
--- a/compiler/fory_compiler/frontend/proto/parser.py
+++ b/compiler/fory_compiler/frontend/proto/parser.py
@@ -27,6 +27,8 @@ from fory_compiler.frontend.proto.ast import (
ProtoField,
ProtoOneof,
ProtoType,
+ ProtoService,
+ ProtoRpcMethod,
)
from fory_compiler.frontend.proto.lexer import Token, TokenType
@@ -92,6 +94,7 @@ class Parser:
imports: List[str] = []
enums: List[ProtoEnum] = []
messages: List[ProtoMessage] = []
+ services: List[ProtoService] = []
options = {}
while not self.at_end():
@@ -116,7 +119,7 @@ class Parser:
elif self.check(TokenType.ENUM):
enums.append(self.parse_enum())
elif self.check(TokenType.SERVICE):
- self.parse_service()
+ services.append(self.parse_service())
elif self.check(TokenType.SEMI):
self.advance()
else:
@@ -133,6 +136,7 @@ class Parser:
imports=imports,
enums=enums,
messages=messages,
+ services=services,
options=options,
source_file=self.filename,
)
@@ -392,20 +396,90 @@ class Parser:
self.advance()
self.consume(TokenType.SEMI, "Expected ';' after extensions")
- def parse_service(self) -> None:
+ def parse_service(self) -> ProtoService:
+ start = self.current()
self.consume(TokenType.SERVICE, "Expected 'service'")
- self.consume(TokenType.IDENT, "Expected service name")
+ name = self.consume(TokenType.IDENT, "Expected service name").value
self.consume(TokenType.LBRACE, "Expected '{' after service name")
- depth = 1
- while depth > 0:
- if self.at_end():
- raise self.error("Unterminated service block")
- if self.match(TokenType.LBRACE):
- depth += 1
- elif self.match(TokenType.RBRACE):
- depth -= 1
+
+ methods: List[ProtoRpcMethod] = []
+ options = {}
+
+ while not self.check(TokenType.RBRACE):
+ if self.check(TokenType.OPTION):
+ opt_name, opt_value = self.parse_option_statement()
+ options[opt_name] = opt_value
+ elif self.check(TokenType.RPC):
+ methods.append(self.parse_rpc_method())
+ elif self.check(TokenType.SEMI):
+ self.advance()
else:
+ raise self.error("Expected 'rpc' or 'option' inside service")
+
+ self.consume(TokenType.RBRACE, "Expected '}' after service")
+ if self.check(TokenType.SEMI):
+ self.advance()
+
+ return ProtoService(
+ name=name,
+ methods=methods,
+ options=options,
+ line=start.line,
+ column=start.column,
+ )
+
+ def parse_rpc_method(self) -> ProtoRpcMethod:
+ start = self.current()
+ self.consume(TokenType.RPC, "Expected 'rpc'")
+ name = self.consume(TokenType.IDENT, "Expected method name").value
+
+ # Request
+ self.consume(TokenType.LPAREN, "Expected '(' before request type")
+ client_streaming = False
+ if self.match(TokenType.STREAM):
+ client_streaming = True
+
+ req_type = self.parse_full_ident()
+ self.consume(TokenType.RPAREN, "Expected ')' after request type")
+
+ self.consume(TokenType.RETURNS, "Expected 'returns'")
+
+ # Response
+ self.consume(TokenType.LPAREN, "Expected '(' before response type")
+ server_streaming = False
+ if self.match(TokenType.STREAM):
+ server_streaming = True
+
+ res_type = self.parse_full_ident()
+ self.consume(TokenType.RPAREN, "Expected ')' after response type")
+
+ options = {}
+ if self.check(TokenType.LBRACE):
+ self.consume(TokenType.LBRACE, "Expected '{'")
+ while not self.check(TokenType.RBRACE):
+ if self.check(TokenType.OPTION):
+ opt_name, opt_value = self.parse_option_statement()
+ options[opt_name] = opt_value
+ elif self.check(TokenType.SEMI):
+ self.advance()
+ else:
+ raise self.error("Expected 'option' in method body")
+ self.consume(TokenType.RBRACE, "Expected '}'")
+ if self.check(TokenType.SEMI):
self.advance()
+ else:
+ self.consume(TokenType.SEMI, "Expected ';' or '{' after method
signature")
+
+ return ProtoRpcMethod(
+ name=name,
+ request_type=req_type,
+ response_type=res_type,
+ client_streaming=client_streaming,
+ server_streaming=server_streaming,
+ options=options,
+ line=start.line,
+ column=start.column,
+ )
def parse_option_name(self) -> str:
if self.match(TokenType.LPAREN):
diff --git a/compiler/fory_compiler/frontend/proto/translator.py
b/compiler/fory_compiler/frontend/proto/translator.py
index 2828dc0e8..00c598433 100644
--- a/compiler/fory_compiler/frontend/proto/translator.py
+++ b/compiler/fory_compiler/frontend/proto/translator.py
@@ -26,9 +26,13 @@ from fory_compiler.frontend.proto.ast import (
ProtoField,
ProtoType,
ProtoOneof,
+ ProtoService,
+ ProtoRpcMethod,
)
from fory_compiler.ir.ast import (
Schema,
+ Service,
+ RpcMethod,
Message,
Enum,
Union,
@@ -101,6 +105,7 @@ class ProtoTranslator:
imports=self._translate_imports(),
enums=[self._translate_enum(e) for e in self.proto_schema.enums],
messages=[self._translate_message(m) for m in
self.proto_schema.messages],
+ services=[self._translate_service(s) for s in
self.proto_schema.services],
options=self._translate_file_options(self.proto_schema.options),
source_file=self.proto_schema.source_file,
source_format="proto",
@@ -318,6 +323,8 @@ class ProtoTranslator:
type_id = value
elif name.startswith("fory."):
translated[name.removeprefix("fory.")] = value
+ else:
+ translated[name] = value
return type_id, translated
def _translate_field_options(
@@ -363,3 +370,36 @@ class ProtoTranslator:
if isinstance(field_type, PrimitiveType):
return PrimitiveType(override, location=self._location(line,
column))
raise ValueError("fory.type overrides are only supported for primitive
fields")
+
+ def _translate_service(self, proto_service: ProtoService) -> Service:
+ # Translate ProtoService to Service
+ _, options = self._translate_type_options(proto_service.options)
+ return Service(
+ name=proto_service.name,
+ methods=[self._translate_rpc_method(m) for m in
proto_service.methods],
+ options=options,
+ line=proto_service.line,
+ column=proto_service.column,
+ location=self._location(proto_service.line, proto_service.column),
+ )
+
+ def _translate_rpc_method(self, proto_method: ProtoRpcMethod) -> RpcMethod:
+ # Translate ProtoRpcMethod to RpcMethod
+ _, options = self._translate_type_options(proto_method.options)
+ return RpcMethod(
+ name=proto_method.name,
+ request_type=NamedType(
+ name=proto_method.request_type,
+ location=self._location(proto_method.line,
proto_method.column),
+ ),
+ response_type=NamedType(
+ name=proto_method.response_type,
+ location=self._location(proto_method.line,
proto_method.column),
+ ),
+ client_streaming=proto_method.client_streaming,
+ server_streaming=proto_method.server_streaming,
+ options=options,
+ line=proto_method.line,
+ column=proto_method.column,
+ location=self._location(proto_method.line, proto_method.column),
+ )
diff --git a/compiler/fory_compiler/ir/ast.py b/compiler/fory_compiler/ir/ast.py
index 4a1f2296b..0c1d75009 100644
--- a/compiler/fory_compiler/ir/ast.py
+++ b/compiler/fory_compiler/ir/ast.py
@@ -245,6 +245,47 @@ class Union:
return f"Union({self.name}{id_str}, fields={self.fields}{opts_str})"
+@dataclass
+class RpcMethod:
+ """An RPC method inside a service."""
+
+ name: str
+ request_type: NamedType
+ response_type: NamedType
+ client_streaming: bool = False
+ server_streaming: bool = False
+ options: dict = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+ location: Optional[SourceLocation] = None
+
+ def __repr__(self) -> str:
+ opts_str = f" [{self.options}]" if self.options else ""
+ req_stream = "stream " if self.client_streaming else ""
+ res_stream = "stream " if self.server_streaming else ""
+ return (
+ f"RpcMethod({self.name} "
+ f"({req_stream}{self.request_type}) returns
({res_stream}{self.response_type})"
+ f"{opts_str})"
+ )
+
+
+@dataclass
+class Service:
+ """A service definition."""
+
+ name: str
+ methods: List[RpcMethod] = field(default_factory=list)
+ options: dict = field(default_factory=dict)
+ line: int = 0
+ column: int = 0
+ location: Optional[SourceLocation] = None
+
+ def __repr__(self) -> str:
+ opts_str = f", options={len(self.options)}" if self.options else ""
+ return f"Service({self.name}, methods={len(self.methods)}{opts_str})"
+
+
@dataclass
class Schema:
"""The root AST node representing a complete FDL file."""
@@ -255,6 +296,7 @@ class Schema:
enums: List[Enum] = field(default_factory=list)
messages: List[Message] = field(default_factory=list)
unions: List[Union] = field(default_factory=list)
+ services: List[Service] = field(default_factory=list)
options: dict = field(
default_factory=dict
) # File-level options (java_package, go_package, etc.)
@@ -266,7 +308,8 @@ class Schema:
alias = f", package_alias={self.package_alias}" if self.package_alias
else ""
return (
f"Schema(package={self.package}{alias},
imports={len(self.imports)}, "
- f"enums={len(self.enums)}, messages={len(self.messages)},
unions={len(self.unions)}{opts})"
+ f"enums={len(self.enums)}, messages={len(self.messages)},
unions={len(self.unions)}, "
+ f"services={len(self.services)}{opts})"
)
def get_option(self, name: str, default: Optional[str] = None) ->
Optional[str]:
diff --git a/compiler/fory_compiler/tests/test_fbs_service.py
b/compiler/fory_compiler/tests/test_fbs_service.py
new file mode 100644
index 000000000..27b86c784
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_fbs_service.py
@@ -0,0 +1,109 @@
+# 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 FBS service parsing."""
+
+from fory_compiler.frontend.fbs.lexer import Lexer
+from fory_compiler.frontend.fbs.parser import Parser
+from fory_compiler.frontend.fbs.translator import FbsTranslator
+
+
+def parse_and_translate(source):
+ lexer = Lexer(source)
+ parser = Parser(lexer.tokenize())
+ schema = parser.parse()
+ translator = FbsTranslator(schema)
+ return translator.translate()
+
+
+def test_rpc_service_parsing():
+ source = """
+ namespace demo;
+
+ table Request {
+ id: int;
+ }
+
+ table Response {
+ result: string;
+ }
+
+ rpc_service Greeter {
+ SayHello(Request):Response;
+ SayGoodbye(Request):Response (deprecated);
+ }
+ """
+ schema = parse_and_translate(source)
+ assert len(schema.services) == 1
+ service = schema.services[0]
+ assert service.name == "Greeter"
+ assert len(service.methods) == 2
+
+ assert service.methods[0].name == "SayHello"
+ assert service.methods[0].request_type.name == "Request"
+ assert service.methods[0].response_type.name == "Response"
+ assert not service.methods[0].client_streaming
+ assert not service.methods[0].server_streaming
+
+ assert service.methods[1].name == "SayGoodbye"
+ assert service.methods[1].options["deprecated"] is True
+
+
+def test_service_keyword_parsing():
+ """Test using 'service' keyword instead of 'rpc_service'."""
+ source = """
+ namespace demo;
+
+ service Greeter {
+ SayHello(Request):Response;
+ }
+ """
+ schema = parse_and_translate(source)
+ assert len(schema.services) == 1
+ assert schema.services[0].name == "Greeter"
+
+
+def test_streaming_attributes():
+ source = """
+ namespace demo;
+
+ rpc_service Streamer {
+ ClientStream(Request):Response (streaming: "client");
+ ServerStream(Request):Response (streaming: "server");
+ BidiStream(Request):Response (streaming: "bidi");
+ }
+ """
+ schema = parse_and_translate(source)
+ service = schema.services[0]
+
+ # Client streaming
+ m1 = service.methods[0]
+ assert m1.name == "ClientStream"
+ assert m1.client_streaming is True
+ assert m1.server_streaming is False
+
+ # Server streaming
+ m2 = service.methods[1]
+ assert m2.name == "ServerStream"
+ assert m2.client_streaming is False
+ assert m2.server_streaming is True
+
+ # Bidi streaming
+ m3 = service.methods[2]
+ assert m3.name == "BidiStream"
+ assert m3.client_streaming is True
+ assert m3.server_streaming is True
diff --git a/compiler/fory_compiler/tests/test_fdl_service.py
b/compiler/fory_compiler/tests/test_fdl_service.py
new file mode 100644
index 000000000..a884c9820
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_fdl_service.py
@@ -0,0 +1,170 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+from fory_compiler.frontend.fdl.parser import Parser, ParseError
+
+
+def parse(source: str):
+ parser = Parser.from_source(source)
+ schema = parser.parse()
+ return schema
+
+
+def test_empty_service():
+ source = """
+ package test;
+ service Greeter {}
+ """
+ schema = parse(source)
+ assert len(schema.services) == 1
+ service = schema.services[0]
+ assert service.name == "Greeter"
+ assert len(service.methods) == 0
+
+
+def test_unary_rpc():
+ source = """
+ package test;
+
+ message HelloRequest {}
+ message HelloReply {}
+
+ service Greeter {
+ rpc SayHello (HelloRequest) returns (HelloReply);
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ assert len(service.methods) == 1
+ method = service.methods[0]
+ assert method.name == "SayHello"
+ assert method.request_type.name == "HelloRequest"
+ assert method.response_type.name == "HelloReply"
+ assert not method.client_streaming
+ assert not method.server_streaming
+
+
+def test_client_streaming_rpc():
+ source = """
+ package test;
+
+ message HelloRequest {}
+ message HelloReply {}
+
+ service Greeter {
+ rpc LotsOfGreetings (stream HelloRequest) returns (HelloReply);
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ method = service.methods[0]
+ assert method.name == "LotsOfGreetings"
+ assert method.client_streaming
+ assert not method.server_streaming
+
+
+def test_server_streaming_rpc():
+ source = """
+ package test;
+
+ message HelloRequest {}
+ message HelloReply {}
+
+ service Greeter {
+ rpc LotsOfReplies (HelloRequest) returns (stream HelloReply);
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ method = service.methods[0]
+ assert method.name == "LotsOfReplies"
+ assert not method.client_streaming
+ assert method.server_streaming
+
+
+def test_bidi_streaming_rpc():
+ source = """
+ package test;
+
+ message HelloRequest {}
+ message HelloReply {}
+
+ service Greeter {
+ rpc BidiHello (stream HelloRequest) returns (stream HelloReply);
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ method = service.methods[0]
+ assert method.name == "BidiHello"
+ assert method.client_streaming
+ assert method.server_streaming
+
+
+def test_service_options():
+ source = """
+ package test;
+
+ service Greeter {
+ option deprecated = true;
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ assert service.options["deprecated"] is True
+
+
+def test_method_options():
+ source = """
+ package test;
+
+ message HelloRequest {}
+ message HelloReply {}
+
+ service Greeter {
+ rpc SayHello (HelloRequest) returns (HelloReply) {
+ option deprecated = true;
+ }
+ }
+ """
+ schema = parse(source)
+ service = schema.services[0]
+ method = service.methods[0]
+ assert method.options["deprecated"] is True
+
+
+def test_invalid_syntax_missing_returns():
+ source = """
+ package test;
+ service Greeter {
+ rpc SayHello (HelloRequest);
+ }
+ """
+ with pytest.raises(ParseError):
+ parse(source)
+
+
+def test_invalid_syntax_missing_parens():
+ source = """
+ package test;
+ service Greeter {
+ rpc SayHello HelloRequest returns HelloReply;
+ }
+ """
+ with pytest.raises(ParseError):
+ parse(source)
diff --git a/compiler/fory_compiler/tests/test_proto_service.py
b/compiler/fory_compiler/tests/test_proto_service.py
new file mode 100644
index 000000000..c7878cbc3
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_proto_service.py
@@ -0,0 +1,122 @@
+# 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 Proto service parsing."""
+
+from fory_compiler.frontend.proto.lexer import Lexer
+from fory_compiler.frontend.proto.parser import Parser
+from fory_compiler.frontend.proto.translator import ProtoTranslator
+
+
+def parse_and_translate(source):
+ lexer = Lexer(source)
+ parser = Parser(lexer.tokenize())
+ schema = parser.parse()
+ translator = ProtoTranslator(schema)
+ return translator.translate()
+
+
+def test_service_parsing():
+ source = """
+ syntax = "proto3";
+ package demo;
+
+ message Request {
+ int32 id = 1;
+ }
+
+ message Response {
+ string result = 1;
+ }
+
+ service Greeter {
+ rpc SayHello (Request) returns (Response);
+ rpc SayGoodbye (Request) returns (Response) {
+ option deprecated = true;
+ }
+ }
+ """
+ schema = parse_and_translate(source)
+ assert len(schema.services) == 1
+ service = schema.services[0]
+ assert service.name == "Greeter"
+ assert len(service.methods) == 2
+
+ m1 = service.methods[0]
+ assert m1.name == "SayHello"
+ assert m1.request_type.name == "Request"
+ assert m1.response_type.name == "Response"
+ assert not m1.client_streaming
+ assert not m1.server_streaming
+
+ m2 = service.methods[1]
+ assert m2.name == "SayGoodbye"
+ assert m2.options["deprecated"] is True
+
+
+def test_streaming_rpc():
+ source = """
+ syntax = "proto3";
+ package demo;
+
+ message Request {}
+ message Response {}
+
+ service Streamer {
+ rpc ClientStream (stream Request) returns (Response);
+ rpc ServerStream (Request) returns (stream Response);
+ rpc BidiStream (stream Request) returns (stream Response);
+ }
+ """
+ schema = parse_and_translate(source)
+ service = schema.services[0]
+
+ # Client streaming
+ m1 = service.methods[0]
+ assert m1.name == "ClientStream"
+ assert m1.client_streaming is True
+ assert m1.server_streaming is False
+
+ # Server streaming
+ m2 = service.methods[1]
+ assert m2.name == "ServerStream"
+ assert m2.client_streaming is False
+ assert m2.server_streaming is True
+
+ # Bidi streaming
+ m3 = service.methods[2]
+ assert m3.name == "BidiStream"
+ assert m3.client_streaming is True
+ assert m3.server_streaming is True
+
+
+def test_service_options():
+ source = """
+ syntax = "proto3";
+ package demo;
+
+ service OptionsService {
+ option deprecated = true;
+ rpc Method (Req) returns (Res);
+ }
+
+ message Req {}
+ message Res {}
+ """
+ schema = parse_and_translate(source)
+ service = schema.services[0]
+ assert service.options["deprecated"] is True
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]