pydantic-rpc 0.9.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/PKG-INFO +130 -10
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/README.md +129 -9
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/pyproject.toml +1 -3
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/__init__.py +12 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/core.py +81 -12
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/decorators.py +72 -1
- pydantic_rpc-0.11.0/src/pydantic_rpc/tls.py +96 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/mcp/__init__.py +0 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/mcp/converter.py +0 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/mcp/exporter.py +0 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/options.py +0 -0
- {pydantic_rpc-0.9.0 → pydantic_rpc-0.11.0}/src/pydantic_rpc/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pydantic-rpc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: A Python library for building gRPC/ConnectRPC services with Pydantic models.
|
|
5
5
|
Author: Yasushi Itoh
|
|
6
6
|
Requires-Dist: annotated-types==0.7.0
|
|
@@ -62,9 +62,10 @@ class OlympicsLocationAgent:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
if __name__ == "__main__":
|
|
65
|
-
|
|
65
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
66
|
+
s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
|
|
66
67
|
loop = asyncio.get_event_loop()
|
|
67
|
-
loop.run_until_complete(s.run(
|
|
68
|
+
loop.run_until_complete(s.run())
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
|
|
@@ -106,8 +107,8 @@ class OlympicsLocationAgent:
|
|
|
106
107
|
result = await self._agent.run(req.prompt())
|
|
107
108
|
return result.data
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
app
|
|
110
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
111
|
+
app = ASGIApp(service=OlympicsLocationAgent())
|
|
111
112
|
|
|
112
113
|
```
|
|
113
114
|
|
|
@@ -129,6 +130,17 @@ app.mount(OlympicsLocationAgent())
|
|
|
129
130
|
- 🛠️ **Pre-generated Protobuf Files and Code:** Pre-generate proto files and corresponding code via the CLI. By setting the environment variable (PYDANTIC_RPC_SKIP_GENERATION), you can skip runtime generation.
|
|
130
131
|
- 🤖 **MCP (Model Context Protocol) Support:** Expose your services as tools for AI assistants using the official MCP SDK, supporting both stdio and HTTP/SSE transports.
|
|
131
132
|
|
|
133
|
+
## ⚠️ Important Notes for Connect-RPC
|
|
134
|
+
|
|
135
|
+
When using Connect-RPC with ASGIApp:
|
|
136
|
+
|
|
137
|
+
- **Endpoint Path Format**: Connect-RPC endpoints use CamelCase method names in the path: `/<package>.<service>/<Method>` (e.g., `/chat.v1.ChatService/SendMessage`)
|
|
138
|
+
- **Content-Type**: Set `Content-Type: application/json` or `application/connect+json` for requests
|
|
139
|
+
- **HTTP/2 Requirement**: Bidirectional streaming requires HTTP/2. Use Hypercorn instead of uvicorn for HTTP/2 support
|
|
140
|
+
- **Testing**: Use [buf curl](https://buf.build/docs/ecosystem/cli/curl) for testing Connect-RPC endpoints with proper streaming support
|
|
141
|
+
|
|
142
|
+
For detailed examples and testing instructions, see the [examples directory](examples/).
|
|
143
|
+
|
|
132
144
|
## 📦 Installation
|
|
133
145
|
|
|
134
146
|
Install PydanticRPC via pip:
|
|
@@ -137,6 +149,54 @@ Install PydanticRPC via pip:
|
|
|
137
149
|
pip install pydantic-rpc
|
|
138
150
|
```
|
|
139
151
|
|
|
152
|
+
For CLI support with built-in server runners:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 🆕 Enhanced Features (v0.10.0+)
|
|
159
|
+
|
|
160
|
+
**Note: All new features are fully backward compatible. Existing code continues to work without modification.**
|
|
161
|
+
|
|
162
|
+
### Enhanced Initialization API
|
|
163
|
+
All server classes now support optional initialization with services:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# Traditional API (still works)
|
|
167
|
+
server = AsyncIOServer()
|
|
168
|
+
server.set_port(50051)
|
|
169
|
+
await server.run(MyService())
|
|
170
|
+
|
|
171
|
+
# New enhanced API (optional)
|
|
172
|
+
server = AsyncIOServer(
|
|
173
|
+
service=MyService(),
|
|
174
|
+
port=50051,
|
|
175
|
+
package_name="my.package"
|
|
176
|
+
)
|
|
177
|
+
await server.run()
|
|
178
|
+
|
|
179
|
+
# Same for ASGI/WSGI apps
|
|
180
|
+
app = ASGIApp(service=MyService(), package_name="my.package")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Error Handling with Decorators
|
|
184
|
+
Automatically map exceptions to gRPC/Connect status codes:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from pydantic_rpc import error_handler
|
|
188
|
+
import grpc
|
|
189
|
+
|
|
190
|
+
class MyService:
|
|
191
|
+
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
|
|
192
|
+
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
|
|
193
|
+
async def get_user(self, request: GetUserRequest) -> User:
|
|
194
|
+
# Exceptions are automatically converted to proper status codes
|
|
195
|
+
if request.id not in users_db:
|
|
196
|
+
raise KeyError(f"User {request.id} not found")
|
|
197
|
+
return users_db[request.id]
|
|
198
|
+
```
|
|
199
|
+
|
|
140
200
|
## 🚀 Getting Started
|
|
141
201
|
|
|
142
202
|
PydanticRPC supports two main protocols:
|
|
@@ -901,8 +961,43 @@ class GoodMessage(Message):
|
|
|
901
961
|
- Test error cases thoroughly
|
|
902
962
|
- Be aware that errors fail silently
|
|
903
963
|
|
|
964
|
+
### 🔒 TLS/mTLS Support
|
|
965
|
+
|
|
966
|
+
PydanticRPC provides built-in support for TLS (Transport Layer Security) and mTLS (mutual TLS) for secure gRPC communication.
|
|
967
|
+
|
|
968
|
+
```python
|
|
969
|
+
from pydantic_rpc import AsyncIOServer, GrpcTLSConfig, extract_peer_identity
|
|
970
|
+
import grpc
|
|
971
|
+
|
|
972
|
+
# Basic TLS (server authentication only)
|
|
973
|
+
tls_config = GrpcTLSConfig(
|
|
974
|
+
cert_chain=server_cert_bytes,
|
|
975
|
+
private_key=server_key_bytes,
|
|
976
|
+
require_client_cert=False
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# mTLS (mutual authentication)
|
|
980
|
+
tls_config = GrpcTLSConfig(
|
|
981
|
+
cert_chain=server_cert_bytes,
|
|
982
|
+
private_key=server_key_bytes,
|
|
983
|
+
root_certs=ca_cert_bytes, # CA to verify client certificates
|
|
984
|
+
require_client_cert=True
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
# Create server with TLS
|
|
988
|
+
server = AsyncIOServer(tls=tls_config)
|
|
989
|
+
|
|
990
|
+
# Extract client identity in service methods
|
|
991
|
+
class SecureService:
|
|
992
|
+
async def secure_method(self, request, context: grpc.ServicerContext):
|
|
993
|
+
client_identity = extract_peer_identity(context)
|
|
994
|
+
if client_identity:
|
|
995
|
+
print(f"Authenticated client: {client_identity}")
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
For a complete example, see [examples/tls_server.py](examples/tls_server.py) and [examples/tls_client.py](examples/tls_client.py).
|
|
999
|
+
|
|
904
1000
|
### 🔗 Multiple Services with Custom Interceptors
|
|
905
|
-
>>>>>>> origin/main
|
|
906
1001
|
|
|
907
1002
|
PydanticRPC supports defining and running multiple gRPC services in a single server:
|
|
908
1003
|
|
|
@@ -1055,15 +1150,40 @@ MCP endpoints will be available at:
|
|
|
1055
1150
|
- SSE: `GET http://localhost:8000/mcp/sse`
|
|
1056
1151
|
- Messages: `POST http://localhost:8000/mcp/messages/`
|
|
1057
1152
|
|
|
1058
|
-
### 🗄️
|
|
1153
|
+
### 🗄️ CLI Tool (pydantic-rpc-cli)
|
|
1059
1154
|
|
|
1060
|
-
|
|
1155
|
+
The CLI tool provides powerful features for generating protobuf files and running servers. Install it separately:
|
|
1061
1156
|
|
|
1062
1157
|
```bash
|
|
1063
|
-
pydantic-rpc
|
|
1158
|
+
pip install pydantic-rpc-cli
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
#### Generate Protobuf Files
|
|
1162
|
+
|
|
1163
|
+
```bash
|
|
1164
|
+
# Generate .proto file from a service class
|
|
1165
|
+
pydantic-rpc generate myapp.services.UserService --output ./proto/
|
|
1166
|
+
|
|
1167
|
+
# Also compile to Python code
|
|
1168
|
+
pydantic-rpc generate myapp.services.UserService --compile
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
#### Run Servers Directly
|
|
1172
|
+
|
|
1173
|
+
The CLI can run any type of server:
|
|
1174
|
+
|
|
1175
|
+
```bash
|
|
1176
|
+
# Run as gRPC server (auto-detects async/sync)
|
|
1177
|
+
pydantic-rpc serve myapp.services.UserService --port 50051
|
|
1178
|
+
|
|
1179
|
+
# Run as Connect-RPC with ASGI (HTTP/2, uses Hypercorn)
|
|
1180
|
+
pydantic-rpc serve myapp.services.UserService --asgi --port 8000
|
|
1181
|
+
|
|
1182
|
+
# Run as Connect-RPC with WSGI (HTTP/1.1, uses Gunicorn)
|
|
1183
|
+
pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
|
|
1064
1184
|
```
|
|
1065
1185
|
|
|
1066
|
-
Using
|
|
1186
|
+
Using the generated proto files with tools like `protoc`, `buf` and `BSR`, you can generate code for any desired language.
|
|
1067
1187
|
|
|
1068
1188
|
|
|
1069
1189
|
## 📖 Data Type Mapping
|
|
@@ -45,9 +45,10 @@ class OlympicsLocationAgent:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
if __name__ == "__main__":
|
|
48
|
-
|
|
48
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
49
|
+
s = AsyncIOServer(service=OlympicsLocationAgent(), port=50051)
|
|
49
50
|
loop = asyncio.get_event_loop()
|
|
50
|
-
loop.run_until_complete(s.run(
|
|
51
|
+
loop.run_until_complete(s.run())
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
And here is an example of a simple Connect RPC service that exposes the same agent as an ASGI application:
|
|
@@ -89,8 +90,8 @@ class OlympicsLocationAgent:
|
|
|
89
90
|
result = await self._agent.run(req.prompt())
|
|
90
91
|
return result.data
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
app
|
|
93
|
+
# New enhanced initialization API (optional - backward compatible)
|
|
94
|
+
app = ASGIApp(service=OlympicsLocationAgent())
|
|
94
95
|
|
|
95
96
|
```
|
|
96
97
|
|
|
@@ -112,6 +113,17 @@ app.mount(OlympicsLocationAgent())
|
|
|
112
113
|
- 🛠️ **Pre-generated Protobuf Files and Code:** Pre-generate proto files and corresponding code via the CLI. By setting the environment variable (PYDANTIC_RPC_SKIP_GENERATION), you can skip runtime generation.
|
|
113
114
|
- 🤖 **MCP (Model Context Protocol) Support:** Expose your services as tools for AI assistants using the official MCP SDK, supporting both stdio and HTTP/SSE transports.
|
|
114
115
|
|
|
116
|
+
## ⚠️ Important Notes for Connect-RPC
|
|
117
|
+
|
|
118
|
+
When using Connect-RPC with ASGIApp:
|
|
119
|
+
|
|
120
|
+
- **Endpoint Path Format**: Connect-RPC endpoints use CamelCase method names in the path: `/<package>.<service>/<Method>` (e.g., `/chat.v1.ChatService/SendMessage`)
|
|
121
|
+
- **Content-Type**: Set `Content-Type: application/json` or `application/connect+json` for requests
|
|
122
|
+
- **HTTP/2 Requirement**: Bidirectional streaming requires HTTP/2. Use Hypercorn instead of uvicorn for HTTP/2 support
|
|
123
|
+
- **Testing**: Use [buf curl](https://buf.build/docs/ecosystem/cli/curl) for testing Connect-RPC endpoints with proper streaming support
|
|
124
|
+
|
|
125
|
+
For detailed examples and testing instructions, see the [examples directory](examples/).
|
|
126
|
+
|
|
115
127
|
## 📦 Installation
|
|
116
128
|
|
|
117
129
|
Install PydanticRPC via pip:
|
|
@@ -120,6 +132,54 @@ Install PydanticRPC via pip:
|
|
|
120
132
|
pip install pydantic-rpc
|
|
121
133
|
```
|
|
122
134
|
|
|
135
|
+
For CLI support with built-in server runners:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install pydantic-rpc-cli # Includes hypercorn and gunicorn
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## 🆕 Enhanced Features (v0.10.0+)
|
|
142
|
+
|
|
143
|
+
**Note: All new features are fully backward compatible. Existing code continues to work without modification.**
|
|
144
|
+
|
|
145
|
+
### Enhanced Initialization API
|
|
146
|
+
All server classes now support optional initialization with services:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# Traditional API (still works)
|
|
150
|
+
server = AsyncIOServer()
|
|
151
|
+
server.set_port(50051)
|
|
152
|
+
await server.run(MyService())
|
|
153
|
+
|
|
154
|
+
# New enhanced API (optional)
|
|
155
|
+
server = AsyncIOServer(
|
|
156
|
+
service=MyService(),
|
|
157
|
+
port=50051,
|
|
158
|
+
package_name="my.package"
|
|
159
|
+
)
|
|
160
|
+
await server.run()
|
|
161
|
+
|
|
162
|
+
# Same for ASGI/WSGI apps
|
|
163
|
+
app = ASGIApp(service=MyService(), package_name="my.package")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Error Handling with Decorators
|
|
167
|
+
Automatically map exceptions to gRPC/Connect status codes:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from pydantic_rpc import error_handler
|
|
171
|
+
import grpc
|
|
172
|
+
|
|
173
|
+
class MyService:
|
|
174
|
+
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
|
|
175
|
+
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
|
|
176
|
+
async def get_user(self, request: GetUserRequest) -> User:
|
|
177
|
+
# Exceptions are automatically converted to proper status codes
|
|
178
|
+
if request.id not in users_db:
|
|
179
|
+
raise KeyError(f"User {request.id} not found")
|
|
180
|
+
return users_db[request.id]
|
|
181
|
+
```
|
|
182
|
+
|
|
123
183
|
## 🚀 Getting Started
|
|
124
184
|
|
|
125
185
|
PydanticRPC supports two main protocols:
|
|
@@ -884,8 +944,43 @@ class GoodMessage(Message):
|
|
|
884
944
|
- Test error cases thoroughly
|
|
885
945
|
- Be aware that errors fail silently
|
|
886
946
|
|
|
947
|
+
### 🔒 TLS/mTLS Support
|
|
948
|
+
|
|
949
|
+
PydanticRPC provides built-in support for TLS (Transport Layer Security) and mTLS (mutual TLS) for secure gRPC communication.
|
|
950
|
+
|
|
951
|
+
```python
|
|
952
|
+
from pydantic_rpc import AsyncIOServer, GrpcTLSConfig, extract_peer_identity
|
|
953
|
+
import grpc
|
|
954
|
+
|
|
955
|
+
# Basic TLS (server authentication only)
|
|
956
|
+
tls_config = GrpcTLSConfig(
|
|
957
|
+
cert_chain=server_cert_bytes,
|
|
958
|
+
private_key=server_key_bytes,
|
|
959
|
+
require_client_cert=False
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# mTLS (mutual authentication)
|
|
963
|
+
tls_config = GrpcTLSConfig(
|
|
964
|
+
cert_chain=server_cert_bytes,
|
|
965
|
+
private_key=server_key_bytes,
|
|
966
|
+
root_certs=ca_cert_bytes, # CA to verify client certificates
|
|
967
|
+
require_client_cert=True
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
# Create server with TLS
|
|
971
|
+
server = AsyncIOServer(tls=tls_config)
|
|
972
|
+
|
|
973
|
+
# Extract client identity in service methods
|
|
974
|
+
class SecureService:
|
|
975
|
+
async def secure_method(self, request, context: grpc.ServicerContext):
|
|
976
|
+
client_identity = extract_peer_identity(context)
|
|
977
|
+
if client_identity:
|
|
978
|
+
print(f"Authenticated client: {client_identity}")
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
For a complete example, see [examples/tls_server.py](examples/tls_server.py) and [examples/tls_client.py](examples/tls_client.py).
|
|
982
|
+
|
|
887
983
|
### 🔗 Multiple Services with Custom Interceptors
|
|
888
|
-
>>>>>>> origin/main
|
|
889
984
|
|
|
890
985
|
PydanticRPC supports defining and running multiple gRPC services in a single server:
|
|
891
986
|
|
|
@@ -1038,15 +1133,40 @@ MCP endpoints will be available at:
|
|
|
1038
1133
|
- SSE: `GET http://localhost:8000/mcp/sse`
|
|
1039
1134
|
- Messages: `POST http://localhost:8000/mcp/messages/`
|
|
1040
1135
|
|
|
1041
|
-
### 🗄️
|
|
1136
|
+
### 🗄️ CLI Tool (pydantic-rpc-cli)
|
|
1042
1137
|
|
|
1043
|
-
|
|
1138
|
+
The CLI tool provides powerful features for generating protobuf files and running servers. Install it separately:
|
|
1044
1139
|
|
|
1045
1140
|
```bash
|
|
1046
|
-
pydantic-rpc
|
|
1141
|
+
pip install pydantic-rpc-cli
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
#### Generate Protobuf Files
|
|
1145
|
+
|
|
1146
|
+
```bash
|
|
1147
|
+
# Generate .proto file from a service class
|
|
1148
|
+
pydantic-rpc generate myapp.services.UserService --output ./proto/
|
|
1149
|
+
|
|
1150
|
+
# Also compile to Python code
|
|
1151
|
+
pydantic-rpc generate myapp.services.UserService --compile
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
#### Run Servers Directly
|
|
1155
|
+
|
|
1156
|
+
The CLI can run any type of server:
|
|
1157
|
+
|
|
1158
|
+
```bash
|
|
1159
|
+
# Run as gRPC server (auto-detects async/sync)
|
|
1160
|
+
pydantic-rpc serve myapp.services.UserService --port 50051
|
|
1161
|
+
|
|
1162
|
+
# Run as Connect-RPC with ASGI (HTTP/2, uses Hypercorn)
|
|
1163
|
+
pydantic-rpc serve myapp.services.UserService --asgi --port 8000
|
|
1164
|
+
|
|
1165
|
+
# Run as Connect-RPC with WSGI (HTTP/1.1, uses Gunicorn)
|
|
1166
|
+
pydantic-rpc serve myapp.services.UserService --wsgi --port 8000 --workers 4
|
|
1047
1167
|
```
|
|
1048
1168
|
|
|
1049
|
-
Using
|
|
1169
|
+
Using the generated proto files with tools like `protoc`, `buf` and `BSR`, you can generate code for any desired language.
|
|
1050
1170
|
|
|
1051
1171
|
|
|
1052
1172
|
## 📖 Data Type Mapping
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pydantic-rpc"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.0"
|
|
4
4
|
description = "A Python library for building gRPC/ConnectRPC services with Pydantic models."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Yasushi Itoh" }
|
|
@@ -19,8 +19,6 @@ dependencies = [
|
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
requires-python = ">= 3.11"
|
|
21
21
|
|
|
22
|
-
[project.scripts]
|
|
23
|
-
pydantic-rpc = "pydantic_rpc.core:main"
|
|
24
22
|
|
|
25
23
|
[build-system]
|
|
26
24
|
requires = ["uv_build>=0.7.21,<0.8.0"]
|
|
@@ -13,6 +13,13 @@ from .decorators import (
|
|
|
13
13
|
proto_option,
|
|
14
14
|
get_method_options,
|
|
15
15
|
has_http_option,
|
|
16
|
+
error_handler,
|
|
17
|
+
get_error_handlers,
|
|
18
|
+
)
|
|
19
|
+
from .tls import (
|
|
20
|
+
GrpcTLSConfig,
|
|
21
|
+
extract_peer_identity,
|
|
22
|
+
extract_peer_certificate_chain,
|
|
16
23
|
)
|
|
17
24
|
|
|
18
25
|
__all__ = [
|
|
@@ -26,6 +33,11 @@ __all__ = [
|
|
|
26
33
|
"proto_option",
|
|
27
34
|
"get_method_options",
|
|
28
35
|
"has_http_option",
|
|
36
|
+
"error_handler",
|
|
37
|
+
"get_error_handlers",
|
|
38
|
+
"GrpcTLSConfig",
|
|
39
|
+
"extract_peer_identity",
|
|
40
|
+
"extract_peer_certificate_chain",
|
|
29
41
|
]
|
|
30
42
|
|
|
31
43
|
# Optional MCP support
|
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from posixpath import basename
|
|
16
16
|
from typing import (
|
|
17
17
|
Any,
|
|
18
|
+
Optional,
|
|
18
19
|
TypeAlias,
|
|
19
20
|
get_args,
|
|
20
21
|
get_origin,
|
|
@@ -36,6 +37,7 @@ from grpc_reflection.v1alpha import reflection
|
|
|
36
37
|
from grpc_tools import protoc
|
|
37
38
|
from pydantic import BaseModel, ValidationError
|
|
38
39
|
from .decorators import get_method_options, has_http_option
|
|
40
|
+
from .tls import GrpcTLSConfig
|
|
39
41
|
|
|
40
42
|
###############################################################################
|
|
41
43
|
# 1. Message definitions & converter extensions
|
|
@@ -2602,13 +2604,23 @@ def generate_combined_descriptor_set(
|
|
|
2602
2604
|
class Server:
|
|
2603
2605
|
"""A simple gRPC server that uses ThreadPoolExecutor for concurrency."""
|
|
2604
2606
|
|
|
2605
|
-
def __init__(
|
|
2607
|
+
def __init__(
|
|
2608
|
+
self,
|
|
2609
|
+
service: Optional[object] = None,
|
|
2610
|
+
port: int = 50051,
|
|
2611
|
+
package_name: str = "",
|
|
2612
|
+
max_workers: int = 8,
|
|
2613
|
+
*interceptors: Any,
|
|
2614
|
+
tls: Optional["GrpcTLSConfig"] = None,
|
|
2615
|
+
) -> None:
|
|
2606
2616
|
self._server: grpc.Server = grpc.server(
|
|
2607
2617
|
futures.ThreadPoolExecutor(max_workers), interceptors=interceptors
|
|
2608
2618
|
)
|
|
2609
2619
|
self._service_names: list[str] = []
|
|
2610
|
-
self._package_name: str =
|
|
2611
|
-
self._port: int =
|
|
2620
|
+
self._package_name: str = package_name
|
|
2621
|
+
self._port: int = port
|
|
2622
|
+
self._tls_config = tls
|
|
2623
|
+
self._initial_service = service
|
|
2612
2624
|
|
|
2613
2625
|
def set_package_name(self, package_name: str):
|
|
2614
2626
|
"""Set the package name for .proto generation."""
|
|
@@ -2646,6 +2658,11 @@ class Server:
|
|
|
2646
2658
|
Mount multiple services and run the gRPC server with reflection and health check.
|
|
2647
2659
|
Press Ctrl+C or send SIGTERM to stop.
|
|
2648
2660
|
"""
|
|
2661
|
+
# Mount initial service if provided
|
|
2662
|
+
if self._initial_service:
|
|
2663
|
+
self.mount(self._initial_service, self._package_name)
|
|
2664
|
+
|
|
2665
|
+
# Mount additional services
|
|
2649
2666
|
for obj in objs:
|
|
2650
2667
|
self.mount(obj, self._package_name)
|
|
2651
2668
|
|
|
@@ -2658,7 +2675,16 @@ class Server:
|
|
|
2658
2675
|
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server)
|
|
2659
2676
|
reflection.enable_server_reflection(SERVICE_NAMES, self._server)
|
|
2660
2677
|
|
|
2661
|
-
self.
|
|
2678
|
+
if self._tls_config:
|
|
2679
|
+
# Use secure port with TLS
|
|
2680
|
+
credentials = self._tls_config.to_server_credentials()
|
|
2681
|
+
self._server.add_secure_port(f"[::]:{self._port}", credentials)
|
|
2682
|
+
print(f"gRPC server starting with TLS on port {self._port}...")
|
|
2683
|
+
else:
|
|
2684
|
+
# Use insecure port
|
|
2685
|
+
self._server.add_insecure_port(f"[::]:{self._port}")
|
|
2686
|
+
print(f"gRPC server starting on port {self._port}...")
|
|
2687
|
+
|
|
2662
2688
|
self._server.start()
|
|
2663
2689
|
|
|
2664
2690
|
def handle_signal(signum: signal.Signals, frame: Any):
|
|
@@ -2680,11 +2706,20 @@ class Server:
|
|
|
2680
2706
|
class AsyncIOServer:
|
|
2681
2707
|
"""An async gRPC server using asyncio."""
|
|
2682
2708
|
|
|
2683
|
-
def __init__(
|
|
2709
|
+
def __init__(
|
|
2710
|
+
self,
|
|
2711
|
+
service: Optional[object] = None,
|
|
2712
|
+
port: int = 50051,
|
|
2713
|
+
package_name: str = "",
|
|
2714
|
+
*interceptors: grpc.ServerInterceptor,
|
|
2715
|
+
tls: Optional["GrpcTLSConfig"] = None,
|
|
2716
|
+
) -> None:
|
|
2684
2717
|
self._server: grpc.aio.Server = grpc.aio.server(interceptors=interceptors)
|
|
2685
2718
|
self._service_names: list[str] = []
|
|
2686
|
-
self._package_name: str =
|
|
2687
|
-
self._port: int =
|
|
2719
|
+
self._package_name: str = package_name
|
|
2720
|
+
self._port: int = port
|
|
2721
|
+
self._tls_config = tls
|
|
2722
|
+
self._initial_service = service
|
|
2688
2723
|
|
|
2689
2724
|
def set_package_name(self, package_name: str):
|
|
2690
2725
|
"""Set the package name for .proto generation."""
|
|
@@ -2724,6 +2759,11 @@ class AsyncIOServer:
|
|
|
2724
2759
|
Mount multiple async services and run the gRPC server with reflection and health check.
|
|
2725
2760
|
Press Ctrl+C or send SIGTERM to stop.
|
|
2726
2761
|
"""
|
|
2762
|
+
# Mount initial service if provided
|
|
2763
|
+
if self._initial_service:
|
|
2764
|
+
self.mount(self._initial_service, self._package_name)
|
|
2765
|
+
|
|
2766
|
+
# Mount additional services
|
|
2727
2767
|
for obj in objs:
|
|
2728
2768
|
self.mount(obj, self._package_name)
|
|
2729
2769
|
|
|
@@ -2736,7 +2776,16 @@ class AsyncIOServer:
|
|
|
2736
2776
|
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server)
|
|
2737
2777
|
reflection.enable_server_reflection(SERVICE_NAMES, self._server)
|
|
2738
2778
|
|
|
2739
|
-
|
|
2779
|
+
if self._tls_config:
|
|
2780
|
+
# Use secure port with TLS
|
|
2781
|
+
credentials = self._tls_config.to_server_credentials()
|
|
2782
|
+
_ = self._server.add_secure_port(f"[::]:{self._port}", credentials)
|
|
2783
|
+
print(f"gRPC server starting with TLS on port {self._port}...")
|
|
2784
|
+
else:
|
|
2785
|
+
# Use insecure port
|
|
2786
|
+
_ = self._server.add_insecure_port(f"[::]:{self._port}")
|
|
2787
|
+
print(f"gRPC server starting on port {self._port}...")
|
|
2788
|
+
|
|
2740
2789
|
await self._server.start()
|
|
2741
2790
|
|
|
2742
2791
|
shutdown_event = asyncio.Event()
|
|
@@ -2771,10 +2820,11 @@ class ASGIApp:
|
|
|
2771
2820
|
An ASGI-compatible application that can serve Connect-RPC via Connecpy.
|
|
2772
2821
|
"""
|
|
2773
2822
|
|
|
2774
|
-
def __init__(self):
|
|
2823
|
+
def __init__(self, service: Optional[object] = None, package_name: str = ""):
|
|
2775
2824
|
self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
|
|
2776
2825
|
self._service_names: list[str] = []
|
|
2777
|
-
self._package_name: str =
|
|
2826
|
+
self._package_name: str = package_name
|
|
2827
|
+
self._initial_service = service
|
|
2778
2828
|
|
|
2779
2829
|
def mount(self, obj: object, package_name: str = ""):
|
|
2780
2830
|
"""Generate and compile proto files, then mount the async service implementation."""
|
|
@@ -2807,6 +2857,11 @@ class ASGIApp:
|
|
|
2807
2857
|
|
|
2808
2858
|
def mount_objs(self, *objs: object):
|
|
2809
2859
|
"""Mount multiple service objects into this ASGI app."""
|
|
2860
|
+
# Mount initial service if provided
|
|
2861
|
+
if self._initial_service:
|
|
2862
|
+
self.mount(self._initial_service, self._package_name)
|
|
2863
|
+
|
|
2864
|
+
# Mount additional services
|
|
2810
2865
|
for obj in objs:
|
|
2811
2866
|
self.mount(obj, self._package_name)
|
|
2812
2867
|
|
|
@@ -2817,6 +2872,10 @@ class ASGIApp:
|
|
|
2817
2872
|
send: Callable[[dict[str, Any]], Any],
|
|
2818
2873
|
):
|
|
2819
2874
|
"""ASGI entry point with routing for multiple services."""
|
|
2875
|
+
# Mount initial service on first call if not already mounted
|
|
2876
|
+
if self._initial_service and not self._services:
|
|
2877
|
+
self.mount(self._initial_service, self._package_name)
|
|
2878
|
+
|
|
2820
2879
|
if scope["type"] != "http":
|
|
2821
2880
|
await send({"type": "http.response.start", "status": 404})
|
|
2822
2881
|
await send({"type": "http.response.body", "body": b"Not Found"})
|
|
@@ -2843,10 +2902,11 @@ class WSGIApp:
|
|
|
2843
2902
|
A WSGI-compatible application that can serve Connect-RPC via Connecpy.
|
|
2844
2903
|
"""
|
|
2845
2904
|
|
|
2846
|
-
def __init__(self):
|
|
2905
|
+
def __init__(self, service: Optional[object] = None, package_name: str = ""):
|
|
2847
2906
|
self._services: list[tuple[Any, str]] = [] # List of (app, path) tuples
|
|
2848
2907
|
self._service_names: list[str] = []
|
|
2849
|
-
self._package_name: str =
|
|
2908
|
+
self._package_name: str = package_name
|
|
2909
|
+
self._initial_service = service
|
|
2850
2910
|
|
|
2851
2911
|
def mount(self, obj: object, package_name: str = ""):
|
|
2852
2912
|
"""Generate and compile proto files, then mount the sync service implementation."""
|
|
@@ -2879,6 +2939,11 @@ class WSGIApp:
|
|
|
2879
2939
|
|
|
2880
2940
|
def mount_objs(self, *objs: object):
|
|
2881
2941
|
"""Mount multiple service objects into this WSGI app."""
|
|
2942
|
+
# Mount initial service if provided
|
|
2943
|
+
if self._initial_service:
|
|
2944
|
+
self.mount(self._initial_service, self._package_name)
|
|
2945
|
+
|
|
2946
|
+
# Mount additional services
|
|
2882
2947
|
for obj in objs:
|
|
2883
2948
|
self.mount(obj, self._package_name)
|
|
2884
2949
|
|
|
@@ -2890,6 +2955,10 @@ class WSGIApp:
|
|
|
2890
2955
|
],
|
|
2891
2956
|
) -> Iterable[bytes]:
|
|
2892
2957
|
"""WSGI entry point with routing for multiple services."""
|
|
2958
|
+
# Mount initial service on first call if not already mounted
|
|
2959
|
+
if self._initial_service and not self._services:
|
|
2960
|
+
self.mount(self._initial_service, self._package_name)
|
|
2961
|
+
|
|
2893
2962
|
path = environ.get("PATH_INFO", "")
|
|
2894
2963
|
|
|
2895
2964
|
# Route to the appropriate service based on path
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Decorators for adding protobuf options to RPC methods."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Type
|
|
4
4
|
from functools import wraps
|
|
5
|
+
import grpc
|
|
6
|
+
from connecpy.code import Code as ConnectErrors
|
|
5
7
|
|
|
6
8
|
from .options import OptionMetadata, OPTION_METADATA_ATTR
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
ERROR_HANDLER_ATTR = "__pydantic_rpc_error_handlers__"
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
def http_option(
|
|
@@ -136,3 +139,71 @@ def has_proto_options(method: Callable) -> bool:
|
|
|
136
139
|
"""
|
|
137
140
|
metadata = get_method_options(method)
|
|
138
141
|
return metadata is not None and len(metadata.proto_options) > 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def error_handler(
|
|
145
|
+
exception_type: Type[Exception],
|
|
146
|
+
status_code: Optional[grpc.StatusCode] = None,
|
|
147
|
+
connect_code: Optional[ConnectErrors] = None,
|
|
148
|
+
handler: Optional[Callable[[Exception], tuple[str, Any]]] = None,
|
|
149
|
+
) -> Callable[[F], F]:
|
|
150
|
+
"""
|
|
151
|
+
Decorator to add automatic error handling to an RPC method.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
exception_type: The type of exception to handle
|
|
155
|
+
status_code: The gRPC status code to return (for gRPC services)
|
|
156
|
+
connect_code: The Connect error code to return (for Connect services)
|
|
157
|
+
handler: Optional custom handler function that returns (message, details)
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
@error_handler(ValidationError, status_code=grpc.StatusCode.INVALID_ARGUMENT)
|
|
161
|
+
@error_handler(KeyError, status_code=grpc.StatusCode.NOT_FOUND)
|
|
162
|
+
async def get_user(self, request: GetUserRequest) -> User:
|
|
163
|
+
...
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def decorator(func: F) -> F:
|
|
167
|
+
# Get or create error handlers list
|
|
168
|
+
if not hasattr(func, ERROR_HANDLER_ATTR):
|
|
169
|
+
setattr(func, ERROR_HANDLER_ATTR, [])
|
|
170
|
+
|
|
171
|
+
handlers = getattr(func, ERROR_HANDLER_ATTR)
|
|
172
|
+
|
|
173
|
+
# Add this handler to the list
|
|
174
|
+
handlers.append(
|
|
175
|
+
{
|
|
176
|
+
"exception_type": exception_type,
|
|
177
|
+
"status_code": status_code or grpc.StatusCode.INTERNAL,
|
|
178
|
+
"connect_code": connect_code or ConnectErrors.INTERNAL,
|
|
179
|
+
"handler": handler,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@wraps(func)
|
|
184
|
+
def wrapper(*args, **kwargs):
|
|
185
|
+
return func(*args, **kwargs)
|
|
186
|
+
|
|
187
|
+
# Preserve the error handlers on the wrapper
|
|
188
|
+
setattr(wrapper, ERROR_HANDLER_ATTR, handlers)
|
|
189
|
+
|
|
190
|
+
# Preserve any existing option metadata
|
|
191
|
+
if hasattr(func, OPTION_METADATA_ATTR):
|
|
192
|
+
setattr(wrapper, OPTION_METADATA_ATTR, getattr(func, OPTION_METADATA_ATTR))
|
|
193
|
+
|
|
194
|
+
return wrapper # type: ignore
|
|
195
|
+
|
|
196
|
+
return decorator
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_error_handlers(method: Callable) -> Optional[List[Dict[str, Any]]]:
|
|
200
|
+
"""
|
|
201
|
+
Get error handlers from a method.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
method: The method to get error handlers from
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
List of error handler configurations if present, None otherwise
|
|
208
|
+
"""
|
|
209
|
+
return getattr(method, ERROR_HANDLER_ATTR, None)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""TLS configuration for gRPC servers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
import grpc
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GrpcTLSConfig:
|
|
10
|
+
"""Configuration for gRPC server TLS.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
cert_chain: The PEM-encoded server certificate chain.
|
|
14
|
+
private_key: The PEM-encoded private key for the server certificate.
|
|
15
|
+
root_certs: Optional PEM-encoded root certificates for verifying client certificates.
|
|
16
|
+
If provided with require_client_cert=True, enables mTLS.
|
|
17
|
+
require_client_cert: If True, requires and verifies client certificates (mTLS).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
cert_chain: bytes
|
|
21
|
+
private_key: bytes
|
|
22
|
+
root_certs: Optional[bytes] = None
|
|
23
|
+
require_client_cert: bool = False
|
|
24
|
+
|
|
25
|
+
def to_server_credentials(self) -> grpc.ServerCredentials:
|
|
26
|
+
"""Convert to gRPC ServerCredentials."""
|
|
27
|
+
private_key_certificate_chain_pairs = [(self.private_key, self.cert_chain)]
|
|
28
|
+
|
|
29
|
+
if self.require_client_cert and self.root_certs:
|
|
30
|
+
# mTLS: require and verify client certificates
|
|
31
|
+
return grpc.ssl_server_credentials(
|
|
32
|
+
private_key_certificate_chain_pairs,
|
|
33
|
+
root_certificates=self.root_certs,
|
|
34
|
+
require_client_auth=True,
|
|
35
|
+
)
|
|
36
|
+
elif self.root_certs:
|
|
37
|
+
# Optional client certificates
|
|
38
|
+
return grpc.ssl_server_credentials(
|
|
39
|
+
private_key_certificate_chain_pairs,
|
|
40
|
+
root_certificates=self.root_certs,
|
|
41
|
+
require_client_auth=False,
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
# Server TLS only, no client certificate validation
|
|
45
|
+
return grpc.ssl_server_credentials(private_key_certificate_chain_pairs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_peer_identity(context: grpc.ServicerContext) -> Optional[str]:
|
|
49
|
+
"""Extract the peer identity from the ServicerContext.
|
|
50
|
+
|
|
51
|
+
For mTLS connections, this returns the client's certificate subject.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
context: The gRPC ServicerContext from a request handler.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The peer identity string if available, None otherwise.
|
|
58
|
+
"""
|
|
59
|
+
auth_context: Any = context.auth_context()
|
|
60
|
+
if auth_context:
|
|
61
|
+
# The peer identity is typically stored under the 'x509_common_name' key
|
|
62
|
+
# or 'x509_subject_alternative_name' for SANs
|
|
63
|
+
identities = auth_context.get("x509_common_name")
|
|
64
|
+
if identities and len(identities) > 0:
|
|
65
|
+
# Return the first identity (there's usually only one CN)
|
|
66
|
+
# gRPC returns bytes, so we decode to string
|
|
67
|
+
identity_bytes: bytes = identities[0]
|
|
68
|
+
return identity_bytes.decode("utf-8")
|
|
69
|
+
|
|
70
|
+
# Fallback to SAN if CN is not available
|
|
71
|
+
san_identities = auth_context.get("x509_subject_alternative_name")
|
|
72
|
+
if san_identities and len(san_identities) > 0:
|
|
73
|
+
# gRPC returns bytes, so we decode to string
|
|
74
|
+
san_bytes: bytes = san_identities[0]
|
|
75
|
+
return san_bytes.decode("utf-8")
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_peer_certificate_chain(context: grpc.ServicerContext) -> Optional[bytes]:
|
|
81
|
+
"""Extract the peer's certificate chain from the ServicerContext.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
context: The gRPC ServicerContext from a request handler.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The peer's certificate chain in PEM format if available, None otherwise.
|
|
88
|
+
"""
|
|
89
|
+
auth_context: Any = context.auth_context()
|
|
90
|
+
if auth_context:
|
|
91
|
+
cert_chain = auth_context.get("x509_peer_certificate")
|
|
92
|
+
if cert_chain and len(cert_chain) > 0:
|
|
93
|
+
cert_bytes: bytes = cert_chain[0]
|
|
94
|
+
return cert_bytes
|
|
95
|
+
|
|
96
|
+
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|