openframe-protocol-grpc 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openframe-protocol-grpc
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: OpenFrame Microservice Suite - gRPC server and client binding for openframe.core.inbound/remote.
5
5
  Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
6
6
  Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
@@ -35,6 +35,7 @@ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
35
35
  Requires-Dist: pytest>=8.0; extra == 'dev'
36
36
  Provides-Extra: server
37
37
  Requires-Dist: grpcio>=1.60; extra == 'server'
38
+ Requires-Dist: pydantic>=2.0; extra == 'server'
38
39
  Description-Content-Type: text/markdown
39
40
 
40
41
  # openframe-protocol-grpc
@@ -38,7 +38,7 @@ from contextlib import asynccontextmanager
38
38
  from typing import Any, AsyncIterator, Awaitable, Callable
39
39
 
40
40
  import grpc
41
- from pydantic import ValidationError
41
+ from pydantic import BaseModel, TypeAdapter, ValidationError
42
42
 
43
43
  from openframe.core.exceptions import AdapterConnectionError
44
44
  from openframe.core.inbound import RequestContext
@@ -103,11 +103,31 @@ def _get_or_create_servicer(server: grpc.aio.Server) -> _RouteRegisteringService
103
103
  return servicer
104
104
 
105
105
 
106
+ def _to_jsonable(value: Any) -> Any:
107
+ """
108
+ Recursively converts a handler's result into something `json.dumps`
109
+ can actually serialise. Needed because handlers built for `-http`
110
+ (where FastAPI's own response_model machinery already handles this)
111
+ routinely return `BaseModel` instances or plain containers of them —
112
+ e.g. `GetItem` returning an `Item`, or `ListItems` returning
113
+ `{"items": [Item, ...], "total": N}` — and `json.dumps` has no idea
114
+ what to do with a `BaseModel` on its own.
115
+ """
116
+ if isinstance(value, BaseModel):
117
+ return value.model_dump(mode="json")
118
+ if isinstance(value, dict):
119
+ return {k: _to_jsonable(v) for k, v in value.items()}
120
+ if isinstance(value, (list, tuple)):
121
+ return [_to_jsonable(v) for v in value]
122
+ return value
123
+
124
+
106
125
  def bind_service(
107
126
  server: grpc.aio.Server,
108
127
  route: str,
109
128
  *,
110
129
  handler_factory: Callable[[], Any],
130
+ input_model: Any = None,
111
131
  not_found_when: Callable[[Any], bool] | None = None,
112
132
  ) -> None:
113
133
  """
@@ -130,21 +150,43 @@ def bind_service(
130
150
  Called once per request, no caching, matching
131
151
  `handler_dep`'s own per-request lifecycle in
132
152
  `-http`'s `server.py`.
153
+ input_model: The gRPC counterpart of `-http`'s `body_model` —
154
+ a real need, not a speculative one: handlers
155
+ built for `-http` (e.g. `CreateItem.execute(self,
156
+ item: Item, ...)`) expect a validated domain
157
+ object, but the decoded `json_payload` is just a
158
+ plain dict/list with no casting of its own
159
+ (unlike FastAPI, which already validates a
160
+ `body_model` before the handler ever sees it).
161
+ Any type `pydantic.TypeAdapter` accepts — a
162
+ `BaseModel` subclass, `list[Item]`, etc. `None`
163
+ (the default) passes the decoded value through
164
+ unchanged, for handlers whose input is already a
165
+ plain dict/str/int/list of primitives.
133
166
  not_found_when: If the handler's result satisfies this, abort
134
167
  with `NOT_FOUND` instead of returning it.
135
168
  """
136
169
  servicer = _get_or_create_servicer(server)
170
+ adapter = TypeAdapter(input_model) if input_model is not None else None
137
171
 
138
172
  async def _handle(
139
173
  request: "generic_pb2.GenericRequest", context: grpc.aio.ServicerContext
140
174
  ) -> "generic_pb2.GenericResponse":
141
175
  handler = handler_factory()
142
- handler_input = json.loads(request.json_payload) if request.json_payload else None
143
176
  metadata = dict(context.invocation_metadata())
144
177
  request_context: RequestContext = build_request_context(metadata)
145
178
  context.set_trailing_metadata((("x-correlation-id", request_context.correlation_id),))
146
179
 
147
180
  try:
181
+ raw_input = json.loads(request.json_payload) if request.json_payload else None
182
+ # Validation errors from an invalid input_model cast go through
183
+ # the same categorize()/build_envelope() path as a handler's
184
+ # own exception — matching how FastAPI's RequestValidationError
185
+ # (from an invalid body_model) categorizes as INVALID on -http,
186
+ # rather than crashing uncaught before context.abort() runs.
187
+ handler_input = (
188
+ adapter.validate_python(raw_input) if adapter is not None and raw_input is not None else raw_input
189
+ )
148
190
  result = await handler.execute(handler_input, request_context)
149
191
  except BaseException as exc:
150
192
  await _abort_for_exception(context, exc, request_context.correlation_id)
@@ -154,7 +196,7 @@ def bind_service(
154
196
  await context.abort(grpc.StatusCode.NOT_FOUND, "not found")
155
197
  return # unreachable
156
198
 
157
- return generic_pb2.GenericResponse(json_payload=json.dumps(result))
199
+ return generic_pb2.GenericResponse(json_payload=json.dumps(_to_jsonable(result)))
158
200
 
159
201
  servicer.register(route, _handle)
160
202
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openframe-protocol-grpc"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "OpenFrame Microservice Suite - gRPC server and client binding for openframe.core.inbound/remote."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -30,6 +30,12 @@ dependencies = [
30
30
  [project.optional-dependencies]
31
31
  server = [
32
32
  "grpcio>=1.60",
33
+ # bind_service's input_model casting / output serialisation
34
+ # (TypeAdapter, BaseModel.model_dump) — a real runtime dependency now,
35
+ # not just a test-only one, since a real handler built for -http
36
+ # (expecting a validated domain object, not a raw decoded dict) needs
37
+ # this to work over gRPC at all.
38
+ "pydantic>=2.0",
33
39
  ]
34
40
  client = [
35
41
  # Unlike HTTP, gRPC's client and server share the same core
@@ -0,0 +1,134 @@
1
+ """
2
+ Proves bind_service(input_model=...) and the matching output
3
+ serialisation — added when wiring a real HTTP-built domain handler
4
+ (CreateItem.execute(self, item: Item, ...)) over gRPC for the first time
5
+ revealed two real gaps: the decoded json_payload is a plain dict/list
6
+ with no casting (unlike FastAPI's body_model, which already validates
7
+ before a handler ever sees it), and json.dumps can't serialise a
8
+ BaseModel a handler returns on its own.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import grpc
13
+ import pytest
14
+ from pydantic import BaseModel
15
+
16
+ from openframe.protocol.grpc import ROUTE_METADATA_KEY, bind_service, generic_pb2, generic_pb2_grpc
17
+
18
+
19
+ class _Item(BaseModel):
20
+ id: str
21
+ name: str
22
+
23
+
24
+ class _EchoHandler:
25
+ """Returns exactly what it received — proves the cast happened and the type survived."""
26
+
27
+ async def execute(self, value, context=None):
28
+ return value
29
+
30
+
31
+ class _CreateItemLikeHandler:
32
+ """Mirrors a real -http-built handler: expects a validated _Item, returns one."""
33
+
34
+ async def execute(self, item: _Item, context=None):
35
+ assert isinstance(item, _Item) # would be a plain dict without input_model casting
36
+ return item
37
+
38
+
39
+ @pytest.fixture
40
+ async def grpc_server():
41
+ server = grpc.aio.server()
42
+ bind_service(server, "echo_raw", handler_factory=lambda: _EchoHandler())
43
+ bind_service(
44
+ server, "create_item", handler_factory=lambda: _CreateItemLikeHandler(), input_model=_Item
45
+ )
46
+ bind_service(
47
+ server, "list_items", handler_factory=lambda: _EchoHandler(), input_model=list[_Item]
48
+ )
49
+ port = server.add_insecure_port("127.0.0.1:0")
50
+ await server.start()
51
+ yield server, port
52
+ await server.stop(None)
53
+
54
+
55
+ async def _call(port: int, route: str, payload) -> str:
56
+ import json
57
+
58
+ channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}")
59
+ stub = generic_pb2_grpc.GenericServiceStub(channel)
60
+ response = await stub.Call(
61
+ generic_pb2.GenericRequest(json_payload=json.dumps(payload)),
62
+ metadata=((ROUTE_METADATA_KEY, route),),
63
+ )
64
+ await channel.close()
65
+ return response.json_payload
66
+
67
+
68
+ async def test_without_input_model_raw_dict_passes_through(grpc_server) -> None:
69
+ _server, port = grpc_server
70
+ result = await _call(port, "echo_raw", {"id": "abc", "name": "Widget"})
71
+ assert result == '{"id": "abc", "name": "Widget"}'
72
+
73
+
74
+ async def test_input_model_casts_dict_into_basemodel_before_execute(grpc_server) -> None:
75
+ _server, port = grpc_server
76
+ result = await _call(port, "create_item", {"id": "abc", "name": "Widget"})
77
+ # The handler asserted isinstance(item, _Item) internally — reaching
78
+ # here with the right JSON back proves the cast AND the serialisation
79
+ # of the returned BaseModel both worked.
80
+ assert result == '{"id": "abc", "name": "Widget"}'
81
+
82
+
83
+ async def test_input_model_casts_list_of_basemodels() -> None:
84
+ server = grpc.aio.server()
85
+ bind_service(server, "list_items", handler_factory=lambda: _EchoHandler(), input_model=list[_Item])
86
+ port = server.add_insecure_port("127.0.0.1:0")
87
+ await server.start()
88
+ try:
89
+ result = await _call(port, "list_items", [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}])
90
+ assert result == '[{"id": "a", "name": "A"}, {"id": "b", "name": "B"}]'
91
+ finally:
92
+ await server.stop(None)
93
+
94
+
95
+ async def test_invalid_input_categorises_as_invalid_argument() -> None:
96
+ server = grpc.aio.server()
97
+ bind_service(server, "create_item", handler_factory=lambda: _CreateItemLikeHandler(), input_model=_Item)
98
+ port = server.add_insecure_port("127.0.0.1:0")
99
+ await server.start()
100
+ try:
101
+ channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}")
102
+ stub = generic_pb2_grpc.GenericServiceStub(channel)
103
+ import json
104
+
105
+ with pytest.raises(grpc.aio.AioRpcError) as exc_info:
106
+ await stub.Call(
107
+ generic_pb2.GenericRequest(json_payload=json.dumps({"id": "abc"})), # missing "name"
108
+ metadata=((ROUTE_METADATA_KEY, "create_item"),),
109
+ )
110
+ assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
111
+ await channel.close()
112
+ finally:
113
+ await server.stop(None)
114
+
115
+
116
+ async def test_dict_result_containing_basemodels_is_serialised() -> None:
117
+ class _ListLikeHandler:
118
+ async def execute(self, value, context=None):
119
+ return {"items": [_Item(id="a", name="A"), _Item(id="b", name="B")], "total": 2}
120
+
121
+ server = grpc.aio.server()
122
+ bind_service(server, "list_items", handler_factory=lambda: _ListLikeHandler())
123
+ port = server.add_insecure_port("127.0.0.1:0")
124
+ await server.start()
125
+ try:
126
+ result = await _call(port, "list_items", None)
127
+ import json
128
+
129
+ assert json.loads(result) == {
130
+ "items": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
131
+ "total": 2,
132
+ }
133
+ finally:
134
+ await server.stop(None)