WebSocket

EDRI supports WebSocket (WS) endpoints via @api(resource=...).

A WS client typically:

  1. connects to the EDRI WebSocket endpoint

  2. sends WS "API events" (JSON messages) addressed to a resource

  3. can register (subscribe) to server-emitted events

  4. receives events pushed by the server

  5. can unregister to stop receiving them

This document describes:

  • how to connect

  • how WS message routing via resource works

  • the 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 name

  • response: object containing status and (with default API_RESPONSE_WRAPPED=True) a data sub-object

Example (default API_RESPONSE_WRAPPED=True):

{
  "command": "upload-ticket",
  "response": {
    "data": {
      "ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a"
    },
    "status": "OK"
  }
}

Note: API_RESPONSE_WRAPPED only controls the data wrapper: with API_RESPONSE_WRAPPED=False the response fields sit at the same level as status (no data sub-object). The camelCase field-name transform (e.g. document_id → documentId) is applied independently of wrapped — it happens in both modes (inside data when 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 command routes to a WS resource

  • the 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 the Manage group event, so the API layer routes them by their resource name (register / unregister) — event and param_set are the only client-supplied fields.

Notes:

  • event is the resource name of the target event (string).

  • param_set is 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 with command="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_set values are compared directly against event field names.

  • If param_set is empty, the client receives all events of that type.

  • If param_set is non-empty, the client receives only events where the event field equals the expected value.

Example meaning:

  • {"project_id": 10} means: deliver only events where event.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 an int field),

  • 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

eq

equal (the default for a bare scalar)

{"project_id": {"eq": 10}} ≡ {"project_id": 10}

ne

not equal

{"kind": {"ne": "internal"}}

in

value is in the list

{"status": {"in": ["A", "B"]}}

gt / gte

greater than / or equal

{"priority": {"gte": 5}}

lt / lte

less than / or equal

{"version": {"lt": 3}}

{
  "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 in against a UUID/Enum field 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), SPECIFIC delivery 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).


Gotchas & Notes

  • resource is the WS routing key (matches @api(resource=...)).

  • event (in register/unregister) is the resource name, not a Python class name.

  • param_set supports only top-level keys (no deep paths).

  • An empty param_set is a broad subscription.