Skip to content

Commit daf9a9d

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
feat(grpc): mount_grpc — thin gRPC integration over grpc.aio with observability + reflection + TLS passthrough
- app.mount_grpc(servicer, add_to_server=..., port=50051) wires a grpc.aio server into ASGI lifespan (startup/shutdown hooks installed on first call) - HawkAPIObservabilityInterceptor: context injection (hawkapi_app, hawkapi_request_id via _ContextProxy), structured logging to hawkapi.grpc logger, idempotent Prometheus counters/histograms - Reflection toggle: reflection=True + reflection_service_names required; ConfigurationError with clear message if grpcio-reflection missing - TLS passthrough: ssl_credentials -> add_secure_port - Port-merge: same port -> shared grpc.aio.Server; different port -> new server - Zero default runtime deps: grpcio imported lazily inside function bodies - 19 tests in tests/unit/test_grpc.py (18 pass, 1 skipped without prometheus_client); all guarded by pytest.importorskip("grpc") - docs/guide/grpc.md quickstart, full signature reference, roadmap - pyproject.toml: grpc optional-dep extra + dev deps - mkdocs.yml nav entry; CHANGELOG [Unreleased] bullet
1 parent aee882f commit daf9a9d

12 files changed

Lines changed: 1406 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `app.mount_grpc(servicer, add_to_server=..., port=50051)` — thin gRPC integration over `grpc.aio`: ASGI lifespan-tied server lifecycle, built-in `HawkAPIObservabilityInterceptor` (structured logging + Prometheus metrics), context injection (`context.hawkapi_app`, `context.hawkapi_request_id`), reflection toggle with `reflection_service_names`, TLS passthrough via `ssl_credentials`, port-merge for multi-servicer setups; zero runtime deps on default path (`grpcio` imported lazily) (Tier 2 gRPC thin mount)
1213
- `app.mount_graphql(path, executor=...)` — thin GraphQL-over-HTTP adapter: POST + GET wire protocol, GraphiQL UI served to browsers, context injection via `context_factory`, and two optional adapters (`from_graphql_core`, `from_strawberry`) behind lazy imports; zero new runtime deps (Tier 2 GraphQL thin mount)
1314
- Feature flags subsystem: `FlagProvider` Protocol, built-in Static/Env/File providers (File with mtime hot-reload, JSON/TOML/YAML), `Flags` facade, `Depends(get_flags)` DI helper with per-request `EvalContext`, `@requires_flag` decorator (404 on off), plugin hook `on_flag_evaluated`; zero external deps (Tier 2 feature flags)
1415
- `hawkapi gen-client` CLI: generates zero-dep TypeScript (`client.ts`) + Python (`client.py`) client SDKs from OpenAPI 3.1 spec; msgspec-backed for Python, native fetch for TS (Tier 3 OpenAPI codegen)

docs/guide/grpc.md

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# gRPC
2+
3+
HawkAPI ships a **thin gRPC mount** that wires a `grpc.aio` server into the
4+
ASGI lifespan — so your gRPC service starts and stops with your HTTP server,
5+
shares the same process, and gets built-in observability for free.
6+
7+
## Installation
8+
9+
```bash
10+
pip install hawkapi[grpc]
11+
# or with uv:
12+
uv add "hawkapi[grpc]"
13+
```
14+
15+
## Quickstart
16+
17+
### 1. Generate stubs
18+
19+
```bash
20+
python -m grpc_tools.protoc \
21+
-I proto \
22+
--python_out=. \
23+
--grpc_python_out=. \
24+
proto/greeter.proto
25+
```
26+
27+
This produces `greeter_pb2.py` and `greeter_pb2_grpc.py`.
28+
29+
### 2. Implement and mount the servicer
30+
31+
```python
32+
import hawkapi
33+
from greeter_pb2_grpc import GreeterServicer, add_GreeterServicer_to_server
34+
from greeter_pb2 import HelloReply
35+
36+
app = hawkapi.HawkAPI()
37+
38+
class MyGreeter(GreeterServicer):
39+
async def SayHello(self, request, context):
40+
return HelloReply(message=f"Hello, {request.name}!")
41+
42+
app.mount_grpc(
43+
MyGreeter(),
44+
add_to_server=add_GreeterServicer_to_server,
45+
port=50051,
46+
)
47+
```
48+
49+
That's it. When the ASGI server starts (e.g. `uvicorn`), the gRPC server
50+
starts on `:50051` automatically.
51+
52+
## ASGI lifespan integration
53+
54+
`mount_grpc` installs startup / shutdown hooks on the first call, so:
55+
56+
- **startup**`grpc.aio.server` is created, servicers are registered, port
57+
is bound, and `server.start()` is awaited.
58+
- **shutdown**`server.stop(grace=5.0)` is awaited, draining in-flight RPCs.
59+
60+
Use `autostart=False` if you need manual control:
61+
62+
```python
63+
mount = app.mount_grpc(
64+
MyGreeter(),
65+
add_to_server=add_GreeterServicer_to_server,
66+
port=50051,
67+
autostart=False,
68+
)
69+
70+
# Later:
71+
await mount.start()
72+
# ...
73+
await mount.stop(grace=10.0)
74+
```
75+
76+
## Accessing the HawkAPI app from a handler
77+
78+
The built-in observability interceptor attaches two attributes to the
79+
`ServicerContext` before delegating to your handler:
80+
81+
| Attribute | Value |
82+
|---|---|
83+
| `context.hawkapi_app` | The `HawkAPI` application instance |
84+
| `context.hawkapi_request_id` | `uuid.uuid4().hex` — 32-char hex string |
85+
86+
```python
87+
class MyServicer(EchoServicer):
88+
async def Echo(self, request, context):
89+
app = context.hawkapi_app # HawkAPI instance
90+
rid = context.hawkapi_request_id # e.g. "a3f2..."
91+
return EchoReply(message=request.message)
92+
```
93+
94+
## TLS passthrough
95+
96+
Pass a `grpc.ServerCredentials` object — HawkAPI calls
97+
`server.add_secure_port()` for you:
98+
99+
```python
100+
import grpc
101+
102+
credentials = grpc.ssl_server_credentials(
103+
[(open("server.key", "rb").read(), open("server.crt", "rb").read())]
104+
)
105+
106+
app.mount_grpc(
107+
MyGreeter(),
108+
add_to_server=add_GreeterServicer_to_server,
109+
port=50051,
110+
ssl_credentials=credentials,
111+
)
112+
```
113+
114+
## Reflection
115+
116+
Enable [gRPC server reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)
117+
so tools like `grpcurl` can discover your services at runtime.
118+
119+
Requires `pip install hawkapi[grpc]` (includes `grpcio-reflection`).
120+
121+
```python
122+
from grpc_reflection.v1alpha import reflection
123+
124+
app.mount_grpc(
125+
MyGreeter(),
126+
add_to_server=add_GreeterServicer_to_server,
127+
port=50051,
128+
reflection=True,
129+
reflection_service_names=[
130+
"greeter.Greeter", # your service name
131+
reflection.SERVICE_NAME, # the reflection service itself
132+
],
133+
)
134+
```
135+
136+
!!! note
137+
`reflection_service_names` is **required** when `reflection=True`.
138+
A `ConfigurationError` is raised with a clear message if it is omitted.
139+
140+
## Observability
141+
142+
### Structured logs
143+
144+
The built-in interceptor emits two `INFO` log records per RPC to
145+
`logging.getLogger("hawkapi.grpc")`:
146+
147+
```json
148+
{"event": "grpc.request", "method": "/greeter.Greeter/SayHello", "peer": "ipv6:[::1]:54321", "request_id": "a3f2..."}
149+
{"event": "grpc.response", "method": "/greeter.Greeter/SayHello", "code": "OK", "duration_ms": 1.234}
150+
```
151+
152+
### Prometheus metrics
153+
154+
When `prometheus_client` is installed, two metrics are registered globally:
155+
156+
| Metric | Type | Labels |
157+
|---|---|---|
158+
| `hawkapi_grpc_requests_total` | Counter | `method`, `code` |
159+
| `hawkapi_grpc_request_duration_seconds` | Histogram | `method` |
160+
161+
Metrics are created once (idempotent) — safe to import in tests multiple times.
162+
163+
### Disabling observability
164+
165+
```python
166+
app.mount_grpc(
167+
MyGreeter(),
168+
add_to_server=add_GreeterServicer_to_server,
169+
observability=False, # skip the built-in interceptor entirely
170+
)
171+
```
172+
173+
## Multiple services on one port
174+
175+
Call `mount_grpc` twice with the **same port** — servicers are merged onto one
176+
`grpc.aio.Server`:
177+
178+
```python
179+
mount_a = app.mount_grpc(GreeterServicer(), add_to_server=add_GreeterServicer_to_server, port=50051)
180+
mount_b = app.mount_grpc(EchoServicer(), add_to_server=add_EchoServicer_to_server, port=50051)
181+
assert mount_a is mount_b # same server
182+
```
183+
184+
## Custom interceptors
185+
186+
Pass additional `grpc.aio.ServerInterceptor` instances via `interceptors=`.
187+
The built-in observability interceptor always runs **first**:
188+
189+
```python
190+
from my_auth import AuthInterceptor
191+
192+
app.mount_grpc(
193+
MyGreeter(),
194+
add_to_server=add_GreeterServicer_to_server,
195+
interceptors=[AuthInterceptor()],
196+
)
197+
```
198+
199+
## Full signature reference
200+
201+
```python
202+
app.mount_grpc(
203+
servicer, # your servicer object
204+
add_to_server=add_Foo_to_server, # generated registration function
205+
port=50051, # TCP port (default 50051)
206+
host="[::]", # bind address (default all interfaces)
207+
interceptors=(), # extra ServerInterceptor instances
208+
observability=True, # built-in interceptor (default on)
209+
reflection=False, # gRPC server reflection
210+
reflection_service_names=None, # required when reflection=True
211+
ssl_credentials=None, # grpc.ServerCredentials for TLS
212+
autostart=True, # start on ASGI lifespan (default on)
213+
max_workers=None, # reserved, currently unused
214+
options=(), # grpc channel options
215+
)
216+
```
217+
218+
Returns a `GrpcMount` with:
219+
220+
- `.server` — the underlying `grpc.aio.Server` (available after start)
221+
- `.port` — bound port
222+
- `.start()` — async, idempotent
223+
- `.stop(grace=5.0)` — async, safe no-op if not started
224+
225+
## Roadmap
226+
227+
- Bi-directional streaming support (infrastructure is in place; tests cover unary + server-streaming)
228+
- Per-mount Prometheus registry (currently uses the default global registry)
229+
- Health checking protocol (`grpc.health.v1`)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ nav:
5353
- Client codegen: guide/client-codegen.md
5454
- Feature flags: guide/feature-flags.md
5555
- GraphQL: guide/graphql.md
56+
- gRPC: guide/grpc.md
5657
- Bulkhead: guide/bulkhead.md
5758
- Free-threaded Python (PEP 703): guide/free-threaded.md
5859
- Migration from FastAPI: guide/migration-from-fastapi.md

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ otel = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"]
3939
metrics = ["prometheus-client>=0.20"]
4040
logging = ["structlog>=24.0"]
4141
redis = ["redis>=5.0"]
42-
all = ["hawkapi[pydantic,granian,uvloop,uvicorn,otel,metrics,logging,redis]"]
42+
grpc = ["grpcio>=1.60", "grpcio-reflection>=1.60"]
43+
all = ["hawkapi[pydantic,granian,uvloop,uvicorn,otel,metrics,logging,redis,grpc]"]
4344
dev = [
4445
"pytest>=8.0",
4546
"pytest-asyncio>=0.24",
@@ -51,6 +52,9 @@ dev = [
5152
"pyright>=1.1",
5253
"libcst>=1.0",
5354
"fakeredis[lua]>=2.0",
55+
"grpcio>=1.60",
56+
"grpcio-reflection>=1.60",
57+
"protobuf>=4.0",
5458
]
5559
docs = [
5660
"mkdocs-material>=9.0",

src/hawkapi/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
requires_flag,
3030
)
3131
from hawkapi.graphql import GraphQLExecutor
32+
from hawkapi.grpc import GrpcMount, HawkAPIObservabilityInterceptor
3233
from hawkapi.middleware import Middleware
3334
from hawkapi.middleware.adaptive_concurrency import AdaptiveConcurrencyMiddleware
3435
from hawkapi.middleware.circuit_breaker import CircuitBreakerMiddleware
@@ -184,6 +185,9 @@
184185
"requires_flag": ("hawkapi.flags", "requires_flag"),
185186
# graphql
186187
"GraphQLExecutor": ("hawkapi.graphql", "GraphQLExecutor"),
188+
# grpc
189+
"GrpcMount": ("hawkapi.grpc", "GrpcMount"),
190+
"HawkAPIObservabilityInterceptor": ("hawkapi.grpc", "HawkAPIObservabilityInterceptor"),
187191
}
188192

189193

@@ -283,4 +287,6 @@ def __getattr__(name: str) -> Any:
283287
"Flags",
284288
"StaticFlagProvider",
285289
"GraphQLExecutor",
290+
"GrpcMount",
291+
"HawkAPIObservabilityInterceptor",
286292
]

src/hawkapi/app.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,92 @@ async def _endpoint(request: Request) -> Any:
254254
name=f"graphql:{path}",
255255
)
256256

257+
def mount_grpc(
258+
self,
259+
servicer: object,
260+
*,
261+
add_to_server: Callable[[object, Any], None],
262+
port: int = 50051,
263+
host: str = "[::]",
264+
interceptors: Any = (),
265+
observability: bool = True,
266+
reflection: bool = False,
267+
reflection_service_names: Any = None,
268+
ssl_credentials: Any = None,
269+
autostart: bool = True,
270+
max_workers: int | None = None,
271+
options: Any = (),
272+
) -> Any:
273+
"""Mount a gRPC servicer and tie its lifecycle to ASGI lifespan.
274+
275+
Args:
276+
servicer: The servicer object (from ``*_pb2_grpc.py``).
277+
add_to_server: The generated ``add_XxxServicer_to_server`` function.
278+
port: TCP port to listen on (default 50051).
279+
host: Bind address (default ``"[::]"`` = all interfaces).
280+
interceptors: Additional ``grpc.aio.ServerInterceptor`` instances.
281+
observability: Install the built-in ``HawkAPIObservabilityInterceptor``
282+
first (default True).
283+
reflection: Enable gRPC server reflection (requires ``grpcio-reflection``
284+
and ``reflection_service_names``).
285+
reflection_service_names: Fully-qualified service names for reflection.
286+
ssl_credentials: ``grpc.ServerCredentials`` for TLS; ``None`` = insecure.
287+
autostart: Start automatically on ASGI lifespan startup (default True).
288+
max_workers: Reserved for future thread-pool use; currently unused.
289+
options: Extra ``(key, value)`` channel options for ``grpc.aio.server()``.
290+
291+
Returns:
292+
A ``GrpcMount`` with ``.server``, ``.port``, ``.start()``, ``.stop(grace)``.
293+
"""
294+
from hawkapi.grpc._interceptor import HawkAPIObservabilityInterceptor # noqa: PLC0415
295+
from hawkapi.grpc._mount import GrpcMount # noqa: PLC0415
296+
297+
# Initialise mount registry on first call and install lifespan hooks once
298+
if not hasattr(self, "_grpc_mounts"):
299+
self._grpc_mounts: list[Any] = []
300+
self._grpc_ports: dict[int, Any] = {}
301+
self._hooks.on_startup(self._start_grpc_mounts)
302+
self._hooks.on_shutdown(self._stop_grpc_mounts)
303+
304+
# Build interceptor list — observability interceptor goes first
305+
all_interceptors: list[Any] = []
306+
if observability:
307+
all_interceptors.append(HawkAPIObservabilityInterceptor(self))
308+
all_interceptors.extend(interceptors)
309+
310+
# Same port → reuse existing mount; different port → new mount
311+
if port in self._grpc_ports:
312+
mount: Any = self._grpc_ports[port]
313+
mount._add_servicer(servicer, add_to_server)
314+
else:
315+
mount = GrpcMount(
316+
port=port,
317+
host=host,
318+
interceptors=all_interceptors,
319+
ssl_credentials=ssl_credentials,
320+
reflection=reflection,
321+
reflection_service_names=reflection_service_names,
322+
options=options,
323+
max_workers=max_workers,
324+
)
325+
mount._autostart = autostart
326+
mount._add_servicer(servicer, add_to_server)
327+
self._grpc_mounts.append(mount)
328+
self._grpc_ports[port] = mount
329+
330+
return mount
331+
332+
async def _start_grpc_mounts(self) -> None:
333+
"""ASGI startup hook: start all autostart gRPC mounts."""
334+
for mount in getattr(self, "_grpc_mounts", []):
335+
if getattr(mount, "_autostart", True):
336+
await mount._start()
337+
338+
async def _stop_grpc_mounts(self) -> None:
339+
"""ASGI shutdown hook: stop all gRPC mounts."""
340+
for mount in getattr(self, "_grpc_mounts", []):
341+
await mount._stop()
342+
257343
def readiness_check(self, name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
258344
"""Register a readiness check (decorator).
259345

src/hawkapi/grpc/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""gRPC thin-mount subsystem for HawkAPI."""
2+
3+
from __future__ import annotations
4+
5+
from hawkapi.grpc._interceptor import HawkAPIObservabilityInterceptor
6+
from hawkapi.grpc._mount import GrpcMount
7+
from hawkapi.grpc._reflection import ConfigurationError
8+
9+
__all__ = [
10+
"ConfigurationError",
11+
"GrpcMount",
12+
"HawkAPIObservabilityInterceptor",
13+
]

0 commit comments

Comments
 (0)