openframe-protocol-http 0.5.0__tar.gz → 0.8.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_http-0.5.0 → openframe_protocol_http-0.8.0}/PKG-INFO +2 -1
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/__init__.py +30 -0
- openframe_protocol_http-0.8.0/openframe/protocol/http/sse.py +140 -0
- openframe_protocol_http-0.8.0/openframe/protocol/http/webhook.py +152 -0
- openframe_protocol_http-0.8.0/openframe/protocol/http/websocket.py +176 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/pyproject.toml +2 -1
- openframe_protocol_http-0.8.0/tests/test_bind_session.py +184 -0
- openframe_protocol_http-0.8.0/tests/test_bind_webhook.py +177 -0
- openframe_protocol_http-0.8.0/tests/test_bind_websocket.py +136 -0
- openframe_protocol_http-0.8.0/tests/test_session_conformance.py +191 -0
- openframe_protocol_http-0.8.0/tests/test_websocket_session_conformance.py +233 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/.gitignore +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/README.md +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/client.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/server.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/conftest.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_bind_command_query_model.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_client.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_conformance.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_create_app.py +0 -0
- {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_not_found_detail.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openframe-protocol-http
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: OpenFrame Microservice Suite - HTTP/ASGI server binding for openframe.core.inbound handlers.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
|
|
6
6
|
Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
|
|
@@ -32,6 +32,7 @@ Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
|
32
32
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
33
|
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
34
34
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: uvicorn>=0.30; extra == 'dev'
|
|
35
36
|
Provides-Extra: server
|
|
36
37
|
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
37
38
|
Description-Content-Type: text/markdown
|
{openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/__init__.py
RENAMED
|
@@ -30,6 +30,9 @@ from typing import TYPE_CHECKING
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
31
|
from openframe.protocol.http.server import bind_command, bind_query, create_app, install_error_handlers
|
|
32
32
|
from openframe.protocol.http.client import HttpRemoteCommandClient, HttpRemoteQueryClient, RequestSpec
|
|
33
|
+
from openframe.protocol.http.sse import SseSession, bind_session
|
|
34
|
+
from openframe.protocol.http.websocket import WebSocketSession, bind_websocket
|
|
35
|
+
from openframe.protocol.http.webhook import SignatureVerificationError, bind_webhook, verify_hmac_sha256
|
|
33
36
|
|
|
34
37
|
__all__ = [
|
|
35
38
|
"bind_query",
|
|
@@ -39,10 +42,20 @@ __all__ = [
|
|
|
39
42
|
"HttpRemoteQueryClient",
|
|
40
43
|
"HttpRemoteCommandClient",
|
|
41
44
|
"RequestSpec",
|
|
45
|
+
"bind_session",
|
|
46
|
+
"SseSession",
|
|
47
|
+
"bind_websocket",
|
|
48
|
+
"WebSocketSession",
|
|
49
|
+
"bind_webhook",
|
|
50
|
+
"verify_hmac_sha256",
|
|
51
|
+
"SignatureVerificationError",
|
|
42
52
|
]
|
|
43
53
|
|
|
44
54
|
_FROM_SERVER = {"bind_query", "bind_command", "install_error_handlers", "create_app"}
|
|
45
55
|
_FROM_CLIENT = {"HttpRemoteQueryClient", "HttpRemoteCommandClient", "RequestSpec"}
|
|
56
|
+
_FROM_SSE = {"bind_session", "SseSession"}
|
|
57
|
+
_FROM_WEBSOCKET = {"bind_websocket", "WebSocketSession"}
|
|
58
|
+
_FROM_WEBHOOK = {"bind_webhook", "verify_hmac_sha256", "SignatureVerificationError"}
|
|
46
59
|
|
|
47
60
|
|
|
48
61
|
def __getattr__(name: str):
|
|
@@ -54,4 +67,21 @@ def __getattr__(name: str):
|
|
|
54
67
|
from openframe.protocol.http import client
|
|
55
68
|
|
|
56
69
|
return getattr(client, name)
|
|
70
|
+
if name in _FROM_SSE:
|
|
71
|
+
# Needs the [server] extra (FastAPI) — same as _FROM_SERVER, not
|
|
72
|
+
# its own extra, since SSE's dependency footprint is identical.
|
|
73
|
+
from openframe.protocol.http import sse
|
|
74
|
+
|
|
75
|
+
return getattr(sse, name)
|
|
76
|
+
if name in _FROM_WEBSOCKET:
|
|
77
|
+
# Same [server] extra as SSE — Starlette's own WebSocket support
|
|
78
|
+
# needs no package beyond FastAPI itself to build or test against.
|
|
79
|
+
from openframe.protocol.http import websocket
|
|
80
|
+
|
|
81
|
+
return getattr(websocket, name)
|
|
82
|
+
if name in _FROM_WEBHOOK:
|
|
83
|
+
# Same [server] extra — hmac/hashlib are stdlib, no new dependency.
|
|
84
|
+
from openframe.protocol.http import webhook
|
|
85
|
+
|
|
86
|
+
return getattr(webhook, name)
|
|
57
87
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/http/sse.py
|
|
3
|
+
=================================
|
|
4
|
+
`bind_session` — register a Server-Sent Events route backed by a
|
|
5
|
+
`SessionHandler` (`openframe.core.inbound`, ADR-008), the first real
|
|
6
|
+
transport to implement it. A module of `-http`, not a new package —
|
|
7
|
+
per the design spec's own placement decision: SSE has no handshake
|
|
8
|
+
beyond a normal HTTP GET, so it belongs with HTTP, not as its own
|
|
9
|
+
transport family.
|
|
10
|
+
|
|
11
|
+
**Does not use `openframe.protocol.base.session.run_opened_session`** —
|
|
12
|
+
found while spiking this, not assumed. `run_opened_session` dispatches
|
|
13
|
+
inbound events to `on_event`, mapping any exception it raises into an
|
|
14
|
+
in-band error. SSE has **no inbound events at all** — `on_event` is
|
|
15
|
+
never called (see `SessionHandler.on_event`'s own docstring) — so there
|
|
16
|
+
is nothing for `run_opened_session` to dispatch. An SSE handler instead
|
|
17
|
+
drives its own background production after `on_open` returns (e.g.
|
|
18
|
+
`asyncio.create_task` inside `on_open`, pushing via `session.send()`
|
|
19
|
+
until it calls `session.close()`), and is responsible for catching its
|
|
20
|
+
own background task's failures and reporting them via the now-public
|
|
21
|
+
`openframe.protocol.base.session.send_error_event` directly — the same
|
|
22
|
+
mapping `run_opened_session` would have used, just invoked by the
|
|
23
|
+
handler's own code instead of a generic dispatch loop with nothing to
|
|
24
|
+
dispatch. `open_session` is still used, and still matters: it's the
|
|
25
|
+
accept/reject half, and SSE needs that exactly as much as any other
|
|
26
|
+
session transport (see that function's own docstring for why it must
|
|
27
|
+
run before `StreamingResponse` is constructed, not inside it).
|
|
28
|
+
|
|
29
|
+
Dependency order:
|
|
30
|
+
protocol/http/sse → protocol/base/context, protocol/base/session,
|
|
31
|
+
openframe.core.inbound
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
from typing import Any, Callable
|
|
39
|
+
|
|
40
|
+
from fastapi import APIRouter, Request
|
|
41
|
+
from fastapi.responses import StreamingResponse
|
|
42
|
+
|
|
43
|
+
from openframe.core.inbound import CloseReason, RequestContext, SessionHandler
|
|
44
|
+
from openframe.protocol.base.context import build_request_context
|
|
45
|
+
from openframe.protocol.base.session import open_session
|
|
46
|
+
|
|
47
|
+
__all__ = ["SseSession", "bind_session"]
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_SENTINEL = object()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SseSession:
|
|
55
|
+
"""
|
|
56
|
+
`Session[TOut]` backed by an `asyncio.Queue` — `send()` enqueues,
|
|
57
|
+
the SSE body generator (`_drain`) dequeues and formats each item as
|
|
58
|
+
a `data: {json}\\n\\n` line. `close()` is idempotent and records the
|
|
59
|
+
*first* reason it was called with, in :attr:`close_reason` — the
|
|
60
|
+
transport reads this after the stream ends to call `on_close` with
|
|
61
|
+
the reason the session actually intended, not a generically-guessed
|
|
62
|
+
one.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *, context: RequestContext) -> None:
|
|
66
|
+
self.context = context
|
|
67
|
+
self._queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
68
|
+
self._closed = False
|
|
69
|
+
self.close_reason: CloseReason | None = None
|
|
70
|
+
|
|
71
|
+
async def send(self, event: Any) -> None:
|
|
72
|
+
if self._closed:
|
|
73
|
+
raise RuntimeError("cannot send on a closed session")
|
|
74
|
+
await self._queue.put(event)
|
|
75
|
+
|
|
76
|
+
async def close(self, reason: CloseReason = CloseReason.COMPLETED) -> None:
|
|
77
|
+
if self._closed:
|
|
78
|
+
return
|
|
79
|
+
self._closed = True
|
|
80
|
+
self.close_reason = reason
|
|
81
|
+
await self._queue.put(_SENTINEL)
|
|
82
|
+
|
|
83
|
+
async def _drain(self):
|
|
84
|
+
"""Yields formatted SSE lines until `close()` enqueues the sentinel."""
|
|
85
|
+
while True:
|
|
86
|
+
item = await self._queue.get()
|
|
87
|
+
if item is _SENTINEL:
|
|
88
|
+
break
|
|
89
|
+
yield f"data: {json.dumps(item, default=str)}\n\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bind_session(
|
|
93
|
+
router: APIRouter,
|
|
94
|
+
path: str,
|
|
95
|
+
*,
|
|
96
|
+
handler_factory: Callable[[], SessionHandler],
|
|
97
|
+
) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Register a GET route at *path* opening an SSE stream backed by a
|
|
100
|
+
fresh `SessionHandler` (built by `handler_factory`, called once per
|
|
101
|
+
request — a plain callable, not a FastAPI `Depends`, since there is
|
|
102
|
+
no per-request dependency graph need beyond "build me a handler").
|
|
103
|
+
|
|
104
|
+
`on_open` runs — and can reject — *before* the `StreamingResponse` is
|
|
105
|
+
constructed, so a reject becomes a normal mapped error response
|
|
106
|
+
(through `install_error_handlers`, wired by `create_app`), not a
|
|
107
|
+
stream that already sent a 200 before failing. See
|
|
108
|
+
`openframe.protocol.base.session.open_session`'s own docstring for
|
|
109
|
+
the full reasoning.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
async def endpoint(request: Request) -> StreamingResponse:
|
|
113
|
+
context = build_request_context(dict(request.headers))
|
|
114
|
+
session = SseSession(context=context)
|
|
115
|
+
handler = handler_factory()
|
|
116
|
+
|
|
117
|
+
# Accept/reject before anything is committed to the wire.
|
|
118
|
+
await open_session(handler, session)
|
|
119
|
+
|
|
120
|
+
async def event_stream():
|
|
121
|
+
try:
|
|
122
|
+
async for line in session._drain():
|
|
123
|
+
yield line
|
|
124
|
+
except asyncio.CancelledError:
|
|
125
|
+
# The ASGI framework cancels this generator's task on a
|
|
126
|
+
# real client disconnect — report it, then re-raise
|
|
127
|
+
# (standard cancellation hygiene: never swallow it).
|
|
128
|
+
await session.close(CloseReason.CLIENT_DISCONNECT)
|
|
129
|
+
raise
|
|
130
|
+
finally:
|
|
131
|
+
await handler.on_close(session, session.close_reason or CloseReason.COMPLETED)
|
|
132
|
+
|
|
133
|
+
response = StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
134
|
+
# Same convention as bind_query/bind_command — one correlation id
|
|
135
|
+
# for the whole response, set once, not per-event: a session has
|
|
136
|
+
# exactly one RequestContext for its entire lifetime.
|
|
137
|
+
response.headers["X-Correlation-ID"] = context.correlation_id
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
router.add_api_route(path, endpoint, methods=["GET"])
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/http/webhook.py
|
|
3
|
+
=====================================
|
|
4
|
+
`bind_webhook` — register a webhook receiver backed by a
|
|
5
|
+
`CommandHandler`/`UseCase`, verifying an HMAC signature against the
|
|
6
|
+
**raw** request body before the handler ever runs.
|
|
7
|
+
|
|
8
|
+
Deliberately NOT a `SessionHandler` (ADR-008) — a webhook is a one-shot
|
|
9
|
+
exchange, one HTTP POST in, one ack out, exactly the shape
|
|
10
|
+
`CommandHandler`/`UseCase` already fit (confirmed by the design spec's
|
|
11
|
+
own text, §5.5: "`CommandHandler`/`QueryHandler` fit one-shot exchanges
|
|
12
|
+
— HTTP, gRPC, webhooks..."). The one thing a webhook receiver needs that
|
|
13
|
+
`bind_command` doesn't provide: proving the payload actually came from
|
|
14
|
+
the claimed sender, not just that it's well-formed JSON — which needs
|
|
15
|
+
the **raw** bytes, read once, verified, then parsed from those same
|
|
16
|
+
bytes (not re-serialized from an already-parsed body, which wouldn't
|
|
17
|
+
byte-match what was actually signed).
|
|
18
|
+
|
|
19
|
+
A module of `-http`, not its own package — per the design spec's own
|
|
20
|
+
placement decision ("SSE and webhook receivers as `server.py` modules"),
|
|
21
|
+
alongside `sse.py`/`websocket.py`.
|
|
22
|
+
|
|
23
|
+
**Signature verification is deliberately not universal.** Providers
|
|
24
|
+
differ: GitHub/Shopify sign the raw body directly with HMAC-SHA256,
|
|
25
|
+
hex-encoded, often prefixed (`"sha256=..."`); Stripe signs a
|
|
26
|
+
timestamp+payload concatenation instead. `verify_hmac_sha256` here
|
|
27
|
+
covers the common GitHub/Shopify-style case; a provider with a different
|
|
28
|
+
scheme supplies its own `verify` callable to `bind_webhook` rather than
|
|
29
|
+
this module trying to special-case every provider's own format.
|
|
30
|
+
|
|
31
|
+
A signature failure maps straight to a 401 here, not through
|
|
32
|
+
`categorize()`/`build_envelope()` — this is an authentication concern
|
|
33
|
+
specific to this one binder, not a generic error shape a
|
|
34
|
+
`CommandHandler`/`UseCase` would also produce, so it doesn't need a new
|
|
35
|
+
`ErrorCategory` member (the existing five don't draw an
|
|
36
|
+
authenticated/unauthenticated distinction, and adding one for a single
|
|
37
|
+
binder's own pre-handler check would be the kind of addition `-base`'s
|
|
38
|
+
own rule-of-three discipline warns against). Any error the *handler*
|
|
39
|
+
itself raises after signature verification passes still flows through
|
|
40
|
+
`install_error_handlers`'s normal path unchanged.
|
|
41
|
+
|
|
42
|
+
Dependency order:
|
|
43
|
+
protocol/http/webhook → protocol/base/context, openframe.core.inbound
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import hashlib
|
|
48
|
+
import hmac
|
|
49
|
+
import logging
|
|
50
|
+
from typing import Any, Callable
|
|
51
|
+
|
|
52
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
53
|
+
from pydantic import BaseModel
|
|
54
|
+
|
|
55
|
+
from openframe.core.inbound import RequestContext
|
|
56
|
+
from openframe.protocol.base.context import build_request_context
|
|
57
|
+
|
|
58
|
+
__all__ = ["bind_webhook", "verify_hmac_sha256", "SignatureVerificationError"]
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SignatureVerificationError(Exception):
|
|
64
|
+
"""Raised by a `verify` callable when a webhook's signature does not match."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def verify_hmac_sha256(*, secret: str, raw_body: bytes, signature: str, prefix: str = "sha256=") -> None:
|
|
68
|
+
"""
|
|
69
|
+
Default verifier — the common GitHub/Shopify-style scheme: HMAC-SHA256
|
|
70
|
+
over the raw body, hex-encoded, optionally prefixed.
|
|
71
|
+
|
|
72
|
+
Uses `hmac.compare_digest` — a plain `==` on digests is a timing-attack
|
|
73
|
+
surface; this is the standard library's own constant-time comparison.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
secret: The shared webhook secret.
|
|
77
|
+
raw_body: The exact bytes the sender signed — not a re-serialized
|
|
78
|
+
parse of them.
|
|
79
|
+
signature: The header value to check, e.g. `"sha256=abcd1234..."`.
|
|
80
|
+
prefix: Stripped from `signature` before comparing, if present.
|
|
81
|
+
Pass `""` for a provider that sends the bare hex digest
|
|
82
|
+
with no prefix.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
SignatureVerificationError: If the signature does not match.
|
|
86
|
+
"""
|
|
87
|
+
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
|
88
|
+
candidate = signature[len(prefix):] if prefix and signature.startswith(prefix) else signature
|
|
89
|
+
if not hmac.compare_digest(expected, candidate):
|
|
90
|
+
raise SignatureVerificationError("webhook signature mismatch")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bind_webhook(
|
|
94
|
+
router: APIRouter,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
handler_dep: Callable[..., Any],
|
|
98
|
+
body_model: type[BaseModel],
|
|
99
|
+
signature_header: str,
|
|
100
|
+
secret: str,
|
|
101
|
+
verify: Callable[..., None] = verify_hmac_sha256,
|
|
102
|
+
status_code: int = 200,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Register a POST route at *path* backed by a `CommandHandler`/`UseCase`,
|
|
106
|
+
verifying *signature_header*'s value against the raw request body
|
|
107
|
+
with *verify* before the handler ever runs.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
router: The `APIRouter` to register onto.
|
|
111
|
+
path: Route path, e.g. `"/webhooks/github"`.
|
|
112
|
+
handler_dep: A FastAPI dependency resolving to the handler
|
|
113
|
+
instance for this request — same convention as
|
|
114
|
+
`bind_command`'s own `handler_dep`.
|
|
115
|
+
body_model: The payload's Pydantic model. Parsed directly
|
|
116
|
+
from the same raw bytes the signature was
|
|
117
|
+
checked against (`model_validate_json`), not
|
|
118
|
+
FastAPI's own `Body(...)` extraction — there is
|
|
119
|
+
only one read of the body, used for both.
|
|
120
|
+
signature_header: The request header carrying the signature, e.g.
|
|
121
|
+
`"X-Hub-Signature-256"`.
|
|
122
|
+
secret: The shared webhook secret passed to *verify*.
|
|
123
|
+
verify: `(secret, raw_body, signature) -> None`, raising
|
|
124
|
+
`SignatureVerificationError` on mismatch.
|
|
125
|
+
Defaults to `verify_hmac_sha256`; a provider
|
|
126
|
+
with a different scheme (e.g. Stripe's
|
|
127
|
+
timestamp+payload concatenation) supplies its
|
|
128
|
+
own.
|
|
129
|
+
status_code: Defaults to 200 — most webhook providers just
|
|
130
|
+
need an ack, not a particular response shape.
|
|
131
|
+
|
|
132
|
+
A missing or invalid signature returns 401 **before** `body_model`
|
|
133
|
+
validation or the handler ever runs — a sender that can't prove who
|
|
134
|
+
it is doesn't get to cause a validation error or a handler side
|
|
135
|
+
effect either.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
async def endpoint(request: Request, __handler__: Any = Depends(handler_dep)) -> Any:
|
|
139
|
+
raw_body = await request.body()
|
|
140
|
+
signature = request.headers.get(signature_header)
|
|
141
|
+
if signature is None:
|
|
142
|
+
raise HTTPException(status_code=401, detail="missing webhook signature")
|
|
143
|
+
try:
|
|
144
|
+
verify(secret=secret, raw_body=raw_body, signature=signature)
|
|
145
|
+
except SignatureVerificationError:
|
|
146
|
+
raise HTTPException(status_code=401, detail="invalid webhook signature") from None
|
|
147
|
+
|
|
148
|
+
body = body_model.model_validate_json(raw_body)
|
|
149
|
+
context: RequestContext = build_request_context(dict(request.headers))
|
|
150
|
+
return await __handler__.execute(body, context)
|
|
151
|
+
|
|
152
|
+
router.add_api_route(path, endpoint, methods=["POST"], status_code=status_code)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/http/websocket.py
|
|
3
|
+
=======================================
|
|
4
|
+
`bind_websocket` — register a WebSocket route backed by a `SessionHandler`
|
|
5
|
+
(`openframe.core.inbound`, ADR-008), the second real transport to
|
|
6
|
+
implement it (after SSE) and the first to exercise `on_event` for real —
|
|
7
|
+
WebSocket is genuinely bidirectional, unlike SSE. A module of `-http`,
|
|
8
|
+
not its own package: FastAPI/Starlette's WebSocket support needs no extra
|
|
9
|
+
dependency beyond `[server]` (same as `bind_session`) — serving real WS
|
|
10
|
+
traffic over uvicorn needs `websockets` or `wsproto` installed, but
|
|
11
|
+
that's an operational concern for whoever runs the service, not this
|
|
12
|
+
package's own dependency surface (the same relationship uvicorn itself
|
|
13
|
+
already has to this package).
|
|
14
|
+
|
|
15
|
+
**Accept/reject timing, confirmed with a real spike (`TestClient.
|
|
16
|
+
websocket_connect`), not assumed** — a WebSocket connection has no
|
|
17
|
+
response body/status the way an SSE GET does, so `open_session`'s own
|
|
18
|
+
"run `on_open` before committing to the wire" advice maps onto the
|
|
19
|
+
`accept()` call, not a `StreamingResponse`. Calling `websocket.close()`
|
|
20
|
+
*before* `accept()` sends a real rejection the client sees as a failed
|
|
21
|
+
handshake — `websocket_connect` raises on `__enter__` in that case,
|
|
22
|
+
confirmed directly. `on_open` therefore always runs before `accept()`; a
|
|
23
|
+
handler that wants to push something immediately on accept does so from
|
|
24
|
+
a background task it starts inside `on_open` itself (same pattern as
|
|
25
|
+
SSE's own handlers), since `WebSocketSession.send()` accepts the
|
|
26
|
+
connection on first call if it hasn't happened yet — a handler can
|
|
27
|
+
decide to accept without sending anything at all (a listen-only session
|
|
28
|
+
waiting on `on_event`).
|
|
29
|
+
|
|
30
|
+
**`WebSocketDisconnect` is translated to `asyncio.CancelledError`**,
|
|
31
|
+
confirmed necessary, not assumed: `run_opened_session`
|
|
32
|
+
(`openframe-protocol-base`) only special-cases `asyncio.CancelledError`
|
|
33
|
+
as `CloseReason.CLIENT_DISCONNECT` — it has no Starlette dependency of
|
|
34
|
+
its own and can't know about `WebSocketDisconnect`. A real client
|
|
35
|
+
disconnect during `websocket.receive_text()` raises `WebSocketDisconnect`
|
|
36
|
+
to Starlette; `inbound_events()` here re-raises it as `CancelledError`
|
|
37
|
+
instead, so `run_opened_session`'s existing disconnect handling applies
|
|
38
|
+
unchanged rather than this module needing to duplicate it.
|
|
39
|
+
|
|
40
|
+
Dependency order:
|
|
41
|
+
protocol/http/websocket → protocol/base/context, protocol/base/session,
|
|
42
|
+
openframe.core.inbound
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
from typing import Any, Callable
|
|
50
|
+
|
|
51
|
+
from fastapi import APIRouter
|
|
52
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
|
|
53
|
+
|
|
54
|
+
from openframe.core.inbound import CloseReason, RequestContext, SessionHandler
|
|
55
|
+
from openframe.protocol.base.context import build_request_context
|
|
56
|
+
from openframe.protocol.base.session import open_session, run_opened_session
|
|
57
|
+
|
|
58
|
+
__all__ = ["WebSocketSession", "bind_websocket"]
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
_CLOSE_CODES: dict[CloseReason, int] = {
|
|
63
|
+
CloseReason.COMPLETED: 1000,
|
|
64
|
+
CloseReason.SERVER_SHUTDOWN: 1001,
|
|
65
|
+
CloseReason.ERROR: 1011,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WebSocketSession:
|
|
70
|
+
"""
|
|
71
|
+
`Session[TOut]` backed directly by a Starlette `WebSocket` — no
|
|
72
|
+
intermediate queue needed (unlike `SseSession`), since `send()` can
|
|
73
|
+
just call the real socket's own `send_text()` directly.
|
|
74
|
+
|
|
75
|
+
`send()` accepts the connection on first call if it hasn't happened
|
|
76
|
+
yet, so a handler that decides to accept in `on_open` without
|
|
77
|
+
immediately sending anything (a listen-only session) still works —
|
|
78
|
+
the connection is accepted explicitly via :meth:`accept`, called by
|
|
79
|
+
`bind_websocket` right after `on_open` succeeds, not lazily deferred
|
|
80
|
+
to the first `send()`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, *, websocket: WebSocket, context: RequestContext) -> None:
|
|
84
|
+
self.context = context
|
|
85
|
+
self._websocket = websocket
|
|
86
|
+
self._closed = False
|
|
87
|
+
self.close_reason: CloseReason | None = None
|
|
88
|
+
|
|
89
|
+
async def accept(self) -> None:
|
|
90
|
+
"""Accept the connection if not already accepted. Idempotent."""
|
|
91
|
+
if self._websocket.client_state == WebSocketState.CONNECTING:
|
|
92
|
+
await self._websocket.accept()
|
|
93
|
+
|
|
94
|
+
async def send(self, event: Any) -> None:
|
|
95
|
+
if self._closed:
|
|
96
|
+
raise RuntimeError("cannot send on a closed session")
|
|
97
|
+
await self.accept()
|
|
98
|
+
await self._websocket.send_text(json.dumps(event, default=str))
|
|
99
|
+
|
|
100
|
+
async def close(self, reason: CloseReason = CloseReason.COMPLETED) -> None:
|
|
101
|
+
if self._closed:
|
|
102
|
+
return
|
|
103
|
+
self._closed = True
|
|
104
|
+
self.close_reason = reason
|
|
105
|
+
if self._websocket.client_state == WebSocketState.DISCONNECTED:
|
|
106
|
+
return # the client is already gone — nothing to send
|
|
107
|
+
try:
|
|
108
|
+
await self._websocket.close(code=_CLOSE_CODES.get(reason, 1000))
|
|
109
|
+
except RuntimeError:
|
|
110
|
+
# Starlette raises if close() races a concurrent disconnect —
|
|
111
|
+
# the connection is ending either way; nothing more to do.
|
|
112
|
+
_logger.debug("websocket already closing for session %s", self.context.correlation_id)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bind_websocket(
|
|
116
|
+
router: APIRouter,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
handler_factory: Callable[[], SessionHandler],
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Register a WebSocket route at *path* backed by a fresh `SessionHandler`
|
|
123
|
+
(built by `handler_factory`, called once per connection — a plain
|
|
124
|
+
callable, same convention as `bind_session`).
|
|
125
|
+
|
|
126
|
+
`on_open` runs — and can reject — before `accept()`, so a reject
|
|
127
|
+
becomes a real failed handshake (`websocket.close()` called by
|
|
128
|
+
`open_session` itself via `session.close(CloseReason.ERROR)`), not a
|
|
129
|
+
message sent after the client thinks it's connected.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
async def endpoint(websocket: WebSocket) -> None:
|
|
133
|
+
context = build_request_context(dict(websocket.headers))
|
|
134
|
+
session = WebSocketSession(websocket=websocket, context=context)
|
|
135
|
+
handler = handler_factory()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
await open_session(handler, session)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Already closed/rejected and reported via on_close by
|
|
141
|
+
# open_session itself — nothing left to do here.
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
await session.accept()
|
|
145
|
+
|
|
146
|
+
async def inbound_events():
|
|
147
|
+
try:
|
|
148
|
+
while True:
|
|
149
|
+
# A handler can close the session itself from inside
|
|
150
|
+
# on_event (a clean, self-initiated end, not a client
|
|
151
|
+
# disconnect) — confirmed by testing, not assumed:
|
|
152
|
+
# without this check, the next receive_text() call
|
|
153
|
+
# raises WebSocketDisconnected (a different exception
|
|
154
|
+
# than WebSocketDisconnect below) against the
|
|
155
|
+
# already-closed socket, which this generator doesn't
|
|
156
|
+
# otherwise know how to distinguish from a real error.
|
|
157
|
+
# Ending the generator here instead lets
|
|
158
|
+
# run_opened_session's loop finish normally —
|
|
159
|
+
# CloseReason.COMPLETED, matching what actually
|
|
160
|
+
# happened, not CloseReason.ERROR.
|
|
161
|
+
if session._closed:
|
|
162
|
+
return
|
|
163
|
+
data = await websocket.receive_text()
|
|
164
|
+
try:
|
|
165
|
+
yield json.loads(data)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
yield data
|
|
168
|
+
except WebSocketDisconnect:
|
|
169
|
+
raise asyncio.CancelledError() from None
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
await run_opened_session(handler, session, inbound_events())
|
|
173
|
+
except asyncio.CancelledError:
|
|
174
|
+
pass # client disconnected — on_close/session.close already ran
|
|
175
|
+
|
|
176
|
+
router.add_api_websocket_route(path, endpoint)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openframe-protocol-http"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "OpenFrame Microservice Suite - HTTP/ASGI server binding for openframe.core.inbound handlers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -40,6 +40,7 @@ dev = [
|
|
|
40
40
|
"pytest-mock>=3.14",
|
|
41
41
|
"fastapi>=0.110",
|
|
42
42
|
"httpx>=0.27",
|
|
43
|
+
"uvicorn>=0.30",
|
|
43
44
|
]
|
|
44
45
|
|
|
45
46
|
[[project.authors]]
|