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.
Files changed (21) hide show
  1. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/PKG-INFO +2 -1
  2. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/__init__.py +30 -0
  3. openframe_protocol_http-0.8.0/openframe/protocol/http/sse.py +140 -0
  4. openframe_protocol_http-0.8.0/openframe/protocol/http/webhook.py +152 -0
  5. openframe_protocol_http-0.8.0/openframe/protocol/http/websocket.py +176 -0
  6. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/pyproject.toml +2 -1
  7. openframe_protocol_http-0.8.0/tests/test_bind_session.py +184 -0
  8. openframe_protocol_http-0.8.0/tests/test_bind_webhook.py +177 -0
  9. openframe_protocol_http-0.8.0/tests/test_bind_websocket.py +136 -0
  10. openframe_protocol_http-0.8.0/tests/test_session_conformance.py +191 -0
  11. openframe_protocol_http-0.8.0/tests/test_websocket_session_conformance.py +233 -0
  12. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/.gitignore +0 -0
  13. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/README.md +0 -0
  14. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/client.py +0 -0
  15. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/openframe/protocol/http/server.py +0 -0
  16. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/conftest.py +0 -0
  17. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_bind_command_query_model.py +0 -0
  18. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_client.py +0 -0
  19. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_conformance.py +0 -0
  20. {openframe_protocol_http-0.5.0 → openframe_protocol_http-0.8.0}/tests/test_create_app.py +0 -0
  21. {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.5.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
@@ -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.5.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]]