# 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. ```json { "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`): ```json { "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. ```python @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: ```json { "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}}` | ```json { "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 ```json { "command": "register", "event": "document-ready", "param_set": {"project_id": 10} } ``` ### Unregister ```json { "command": "unregister", "event": "document-ready", "param_set": {"project_id": 10} } ``` ### Server → client event push Server → client pushes use the same envelope: ```json { "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). ### 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: 1. WS: request a ticket 2. WS: register for status events filtered by that ticket 3. HTTP: upload the file and include the ticket 4. WS: receive progress/completion events #### 1) WS: request a ticket Client: ```json { "command": "upload-ticket" } ``` Server response: ```json { "command": "upload-ticket", "response": { "data": { "ticket": "c3b7b2c0-7b9b-4c5d-8b52-1f94d98b9e2a" }, "status": "OK" } } ``` #### 2) WS: register before upload ```json { "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 ```json { "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 * `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.