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.
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/PKG-INFO +2 -1
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/server.py +45 -3
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/pyproject.toml +7 -1
- openframe_protocol_grpc-0.3.0/tests/test_bind_service_input_model.py +134 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/.gitignore +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/README.md +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/__init__.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/client.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/generic.proto +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/generic_pb2.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/generic_pb2_grpc.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/tests/conftest.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/tests/test_client.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/tests/test_conformance.py +0 -0
- {openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/tests/test_create_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openframe-protocol-grpc
|
|
3
|
-
Version: 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
|
{openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/server.py
RENAMED
|
@@ -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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
{openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/__init__.py
RENAMED
|
File without changes
|
{openframe_protocol_grpc-0.2.0 → openframe_protocol_grpc-0.3.0}/openframe/protocol/grpc/client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|