WebSocket¶
EDRI supports WebSocket (WS) endpoints via @api(resource=...).
A WS client typically:
connects to the EDRI WebSocket endpoint
sends WS "API events" (JSON messages) addressed to a
resourcecan register (subscribe) to server-emitted events
receives events pushed by the server
can unregister to stop receiving them
This document describes:
how to connect
how WS message routing via
resourceworksthe registration (subscription) protocol
Connecting¶
WebSocket URL¶
Clients connect to the root of the server.
Example:
ws://localhost:8000/Example (TLS):
wss://api.example.com/
Authentication via middleware¶
If your events require authentication via middleware (e.g. SessionEvent, BearerEvent, AuthenticatedEvent), ensure the required inputs are available.
The most common setup is a query-parameter token on connect.
The reason: the token is loaded for every WS event during event creation, so middleware can validate authentication for every event regardless of its origin.
Message format and mapping¶
This framework uses a simple JSON envelope where command selects what the server should do (mapped to a WS resource exposed via @api(resource=...)).
Sending a command¶
Client → server messages are JSON objects that include a command key.
{
"command": "upload-ticket"
}
Response shape¶
Responses use the same envelope and include:
command: command nameresponse: object containingstatusand (with defaultAPI_RESPONSE_WRAPPED=True) adatasub-object
Example (default API_RESPONSE_WRAPPED=True):
{
"command": "upload-ticket",
"response": {
"data": {
"ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a"
},
"status": "OK"
}
}
Note:
API_RESPONSE_WRAPPEDonly controls thedatawrapper: withAPI_RESPONSE_WRAPPED=Falsethe response fields sit at the same level asstatus(nodatasub-object). The camelCase field-name transform (e.g.document_id→documentId) is applied independently ofwrapped— it happens in both modes (insidedatawhen wrapped, at the top level when not).
How command maps to API events¶
At the API layer, WebSocket endpoints are defined using @api(resource=...).
Conceptually:
incoming
commandroutes to a WS resourcethe corresponding
@api(resource=...)event is constructed and handled
If your deployment uses different naming (e.g. command values differ from resource names), document the mapping in your application-level WS reference.
WS-only resources: register / unregister¶
The subscription system is exposed via WS-only commands/resources.
@api(resource="register", exclude=[ApiType.REST, ApiType.HTML])
class EdriRegister(Manage):
event: str
param_set: dict[str, Any]
response: RegisterResponse
@api(resource="unregister", exclude=[ApiType.REST, ApiType.HTML])
class EdriUnregister(Manage):
event: str
param_set: dict[str, Any]
response: UnregisterResponse
These are the framework's built-in WS-only events (
edri.events.api.manage). They subclass theManagegroup event, so the API layer routes them by theirresourcename (register/unregister) —eventandparam_setare the only client-supplied fields.
Notes:
eventis the resource name of the target event (string).param_setis a field filter map (top-level keys only).
Unregister behavior¶
Unregister removes a previously created subscription.
If a matching subscription exists, it is removed and the client stops receiving those events.
If no matching subscription exists, the request fails (FAILED status).
A subscription is identified by the tuple:
event(resource name)param_set(top-level filter map)
To reliably unregister, send the same event and param_set you used when registering.
Register/unregister responses are status-only (they only carry a response.status).
How subscription matching works¶
A subscription is defined by:
Resource name: the WebSocket resource name (
event), matching@api(resource=...)Parameter filter:
param_set(optional)
event¶
event must match the resource name of an event type that exists in the system.
Example:
event="document-ready"subscribes to outgoing messages withcommand="document-ready".
param_set¶
param_set is a mapping of event field name → expected value.
Only top-level keys are supported.
Keys must be snake_case (Python field names), not camelCase. The WebSocket parser converts incoming camelCase keys to snake_case at the top level, but
param_setvalues are compared directly against event field names.If
param_setis empty, the client receives all events of that type.If
param_setis non-empty, the client receives only events where the event field equals the expected value.
Example meaning:
{"project_id": 10}means: deliver only events whereevent.project_id == 10.
Matching is equality-based on the event field value.
Each filter value is coerced to its field type¶
At registration the Broker coerces every param_set value to the declared type of the matching event field, using the same conversion the API applies when building that field from an HTTP request. So a value sent as a JSON string is compared against the event's typed attribute correctly — a UUID, Enum, datetime, or int field matches even though JSON only carries strings/numbers:
{ "command": "register", "event": "document-ready",
"param_set": {"ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a"} }
matches an event whose ticket field holds the corresponding UUID object.
A subscription that can never match is rejected at registration (response status: FAILED) rather than silently accepted:
the key is not a field of the target event,
the value cannot be coerced to the field's type (e.g.
"abc"for anintfield),the coerced value is not a scalar (lists/dicts are not valid filter values).
Subscribing to the exact event type only: matching is keyed by the concrete event class, so a subscriber of a base class does not receive subclass events (and vice versa) — register for the exact type you expect to be pushed.
Operators¶
A filter value may be a bare scalar (shorthand for equality) or a single-key operator object. Multiple keys are combined with AND.
Operator |
Meaning |
Example |
|---|---|---|
|
equal (the default for a bare scalar) |
|
|
not equal |
|
|
value is in the list |
|
|
greater than / or equal |
|
|
less than / or equal |
|
{
"command": "register",
"event": "document-ready",
"param_set": {
"project_id": 10,
"state": {"in": ["processing", "ready"]},
"priority": {"gte": 5}
}
}
Operands are coerced to the field type just like equality values (so
inagainst aUUID/Enumfield works, and each list element is coerced).An unrecognised operator key is treated as an ordinary equality value — which is then rejected if it cannot be coerced to the field (so a typo like
{"gtee": 5}fails loudly rather than matching nothing silently).Equality-only filters use a fast lookup; filters that use any other operator are evaluated per event. Functionally they behave identically.
JSON examples¶
Register¶
{
"command": "register",
"event": "document-ready",
"param_set": {"project_id": 10}
}
Unregister¶
{
"command": "unregister",
"event": "document-ready",
"param_set": {"project_id": 10}
}
Server → client event push¶
Server → client pushes use the same envelope:
{
"command": "document-ready",
"projectId": 10,
"response": {
"data": {
"state": "awaiting"
},
"status": "OK"
}
}
The top-level payload fields depend on the command/event. Application-specific state (like state: "awaiting") belongs in response.data, not in response.status — ResponseStatus only has OK, FAILED, or NONE.
Lifecycle and reconnection¶
Subscriptions are tied to the lifetime of a single WS connection.
On reconnect, the client must re-register subscriptions.
If you need persistent subscriptions across reconnects, build it at the application layer.
Event delivery modes (handling)¶
WebSocket (and other async-capable APIs) can control how outgoing events are delivered using handling=....
EventHandlingType is an IntFlag, so values can be combined with bitwise OR.
Modes¶
SPECIFIC
1:1 delivery: the event is sent only to the client that triggered it.
Typical for direct request/response flows.
SUBSCRIBED
Delivery only to clients that subscribed (registered) for that event type (and optional
param_set).
ALL
Broadcast delivery to all connected clients capable of receiving async events, regardless of subscription.
Common combinations¶
Because this is an IntFlag, you may combine modes:
SPECIFIC | SUBSCRIBED
If the event is created as a direct API-bound event (i.e., tied to a client request),
SPECIFICdelivery is used.If the event is created internally (not bound to a single client), it may be delivered to subscribed clients.
SPECIFIC | ALL
If the event is created as a direct API-bound event, it is delivered using
SPECIFIC.If the event is created internally, it may be broadcast to all connected clients.
It is either specific or broadcast, not both simultaneously.
Example: Upload + async processing without polling¶
WebSocket is most useful when the client needs live progress (or completion) for a task that may take time — without polling or long polling.
A common case is file processing:
uploading the file is done over HTTP (REST)
the processing pipeline runs asynchronously
progress and completion are delivered to the client over WS
The race condition¶
If the client uploads first and subscribes later, it can miss early events (or even the final event if processing is very fast).
Ticket pattern (recommended)¶
Before uploading, the client asks over WS for an upload ticket. The ticket is then used to:
subscribe to status events before upload
bind the HTTP upload to the same processing job
Flow:
WS: request a ticket
WS: register for status events filtered by that ticket
HTTP: upload the file and include the ticket
WS: receive progress/completion events
1) WS: request a ticket¶
Client:
{
"command": "upload-ticket"
}
Server response:
{
"command": "upload-ticket",
"response": {
"data": {
"ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a"
},
"status": "OK"
}
}
2) WS: register before upload¶
{
"command": "register",
"event": "document-ready",
"param_set": {"ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a"}
}
3) HTTP: upload with ticket¶
Upload the file over REST and include the ticket so the server can bind the processing job to your WS subscription.
Pick one convention in your application and document it (header, query param, or form field).
4) WS: receive status events¶
{
"command": "document-ready",
"response": {
"data": {
"ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a",
"projectId": 10
},
"status": "OK"
}
}
Later updates may include more fields in response.data such as documentId. Application-specific progress states (e.g. processing, ready) should be sent as a field in response.data, not in response.status.
Gotchas & Notes¶
resourceis the WS routing key (matches@api(resource=...)).event(in register/unregister) is the resource name, not a Python class name.param_setsupports only top-level keys (no deep paths).An empty
param_setis a broad subscription.