EDE Framework — HTTP Layer¶
Overview¶
The HTTP layer is thin: it translates HTTP → Command and JSON ← result. Business logic belongs in DomainModel handlers, not controllers.
HTTP Request
└─► FastAPI (ASGI)
└─► AuthMiddleware (JWT → principal)
└─► LogMiddleware (timing/logging)
└─► FastApiHttpAdapter
└─► RouteController.method()
└─► env.dispatch(Command(...))
└─► DomainModel handler
└─► return dict
◄─── JSONResponse
RouteController¶
src/ede/core/services/http/controller.py
All HTTP controllers inherit RouteController:
from ede.core.services.http.controller import RouteController
from ede.core.services.http.decorators import route_config, route
from ede.core import api
@api.route_config(prefix="/api/shipments", tags=["Shipments"])
class ShipmentController(RouteController):
# self.env is injected by FastApiHttpAdapter before each call
@api.route("/", methods=["GET"], auth="user")
def list_shipments(self) -> list:
result = self.env.dispatch(Command(
name="ede.search",
payload={"domain": [("status", "!=", "cancelled")]},
model_key="logistics.shipment",
))
return result
@api.route("/{id}", methods=["GET"], auth="user")
def get_shipment(self, id: str) -> dict:
result = self.env.dispatch(Command(
name="ede.read_one",
payload={},
model_key="logistics.shipment",
model_id=id,
))
return result
@api.route("/", methods=["POST"], auth="user")
def create_shipment(self, body: dict) -> dict:
return self.env.dispatch(Command(
name="shipment.create",
payload=body,
))
RouteController is a dataclass with a single attribute:
The adapter instantiates a new controller instance per request and injects the
request-scoped Env.
Route Decorators¶
@api.route_config — Controller-Level¶
@api.route_config(
prefix="/api/shipments", # URL prefix for all routes in this controller
tags=["Shipments"], # OpenAPI tags
version=None, # Optional API version string
description=None, # Optional description for OpenAPI
)
This is equivalent to @route_config from ede.core.services.http.decorators.
@api.route — Method-Level¶
@api.route(
path="/", # Path relative to controller prefix
methods=["GET"], # HTTP methods. Default: ["GET"]
name=None, # Optional route name (for URL reversal)
tags=None, # Optional additional tags (merged with controller tags)
request_type="json", # Request body type: "json" (default) | "form"
auth="user", # Auth level: "public" | "user" | "portal" | "application"
)
Auth Levels¶
| Level | Enforcement |
|---|---|
"public" |
No authentication required |
"user" |
Valid JWT required. Principal injected into env.principal. |
"portal" |
Valid JWT + portal-level access |
"application" |
Valid JWT + application/API key level access |
The auth value is registered with the route and enforced by AuthMiddleware.
HttpRouteRegistry¶
src/ede/core/services/http/registry.py
The HttpRouteRegistry is populated by HttpRouteScanner during boot. It holds all
discovered routes as HttpRouteSpec objects.
@dataclass(frozen=True)
class HttpRouteSpec:
controller_cls: Type[RouteController]
handler_name: str
meta: RouteMeta # path, methods, name, tags, auth
def stable_key(self) -> str:
return f"{self.controller_cls.__module__}.{self.controller_cls.__name__}:{self.handler_name}"
Duplicate (method, path) combinations raise an error at registration time.
HttpRouteScanner¶
src/ede/core/services/http/scanner.py
Called during app loading. Scans imported modules for RouteController subclasses:
- Iterates
sys.modulesmatching the app's module prefix - For each module, finds all
RouteControllersubclasses - For each subclass, scans methods decorated with
@api.route - Merges controller-level
prefix+tagswith method-levelpath+tags - Registers the
HttpRouteSpecintoHttpRouteRegistry
Path merging rules:
- prefix="/api/shipments" + path="/" → /api/shipments/
- prefix="/api/shipments" + path="/{id}" → /api/shipments/{id}
- No double slashes
FastApiHttpAdapter¶
src/ede/core/adapters/http/fastapi/fastapi.py
Mounts the HttpRouteRegistry onto a FastAPI application:
- For each
HttpRouteSpec, creates a FastAPI route handler closure -
The closure: a. Resolves
tenant_id(from Host header orX-Tenant-Idheader) b. Retrievesrequest.state.principal(set byAuthMiddleware) c. ClonesEnvwithwith_tenant_id()andwith_principal()d. Instantiates the controller class with the request-scopedEnve. Extracts path parameters, query parameters, and JSON body f. Callscontroller.method(**kwargs)g. ReturnsJSONResponse(result) -
Adds all middlewares via
add_all_middlewares(app, settings=settings)
Middleware Stack¶
src/ede/core/adapters/http/fastapi/middlewares.py
Middleware is applied in this order (innermost first):
1. AuthMiddleware — JWT validation, principal injection (runs closest to handler)
2. LogMiddleware — request timing and structured logging (outermost)
AuthMiddleware¶
src/ede/core/adapters/http/fastapi/auth_middleware.py
Always bypassed for:
- Paths starting with /wc (web client static assets)
- Paths starting with /assets
- /health endpoint
- Any path not starting with /api or /public
For all other paths:
1. Looks up auth requirement via get_route_auth(method, path) (default: "user")
2. "public" routes pass through immediately
3. "user" / "portal" / "application" routes:
a. Extract Authorization: Bearer <token> header
b. Decode JWT via JwtService.decode_token(token) → claims dict
c. Validate session is still active via DB lookup (ir.session check)
d. Set request.state.principal = claims
e. If validation fails: return 401 Unauthorized
Principal shape:
{
"auth_type": "user",
"user_id": "uuid-...",
"email": "user@example.com",
"tenant_id": "acme",
"session_id": "uuid-...",
"identity": "user@example.com",
}
Route Auth Registration¶
Routes are registered with their auth level during boot:
register_route_auth("GET", "/api/shipments/", "user")
register_route_auth("POST", "/api/shipments/", "user")
register_route_auth("GET", "/api/health", "public")
get_route_auth(method, path) looks up the registered level. Returns "user" if not found
(fail secure).
Request Context in Controllers¶
Inside a controller method:
- self.env — request-scoped Env (tenant + principal already set)
- self.env.tenant_id — resolved tenant ID
- self.env.principal — dict with user claims (or None for public routes)
@api.route("/me", methods=["GET"], auth="user")
def get_current_user(self) -> dict:
principal = self.env.principal
user = self.env.models["res.user"].browse(principal["user_id"])
return user.read()[0]
RouteMeta Reference¶
@dataclass
class RouteMeta:
path: str # relative path, e.g. "/{id}"
methods: list[str] # e.g. ["GET", "POST"]
name: str | None # optional route name
tags: list[str] # merged from controller + method
request_type: str # "json" | "form"
auth: str # "public" | "user" | "portal" | "application"
URL Patterns¶
| Pattern | Method | Purpose | Auth |
|---|---|---|---|
/api/{model} |
GET | List records | user |
/api/{model} |
POST | Create record | user |
/api/{model}/{id} |
GET | Read one record | user |
/api/{model}/{id} |
PUT | Update record | user |
/api/{model}/{id} |
DELETE | Delete record | user |
/api/auth/login |
POST | Login → JWT | public |
/api/auth/logout |
POST | Invalidate session | user |
/api/auth/refresh |
POST | Refresh JWT | public |
/api/auth/me |
GET | Current user | user |
/health |
GET | Health check | public |
| /api/web-push/events | GET | SSE push stream | user |
Web Push (SSE)¶
EDE ships a Server-Sent Events endpoint that allows the backend to push messages to all connected browser tabs in real time.
Endpoint¶
The browser opens a persistent fetch-based SSE connection (not the native
EventSource API, which cannot send Authorization headers). The server streams
data: {...}\n\n lines. A ": keepalive" comment is sent every 25 seconds to
prevent proxy timeouts on idle connections.
Controller¶
src/ede/foundation/presentation/api/web_push.py — WebPushController
@api.route_config(prefix="", tags=["foundation.presentation.web_push"], version="v1")
class WebPushController(RouteController):
@api.route("/api/web-push/events", methods=["GET"], request_type="sse")
async def stream_events(self, is_disconnected) -> AsyncGenerator[str, None]:
push_reg = getattr(self.env, "web_push_registry", None)
tenant_id = self.env.tenant_id or ""
entry = push_reg.subscribe(tenant_id)
_, q = entry
try:
yield 'data: {"type":"connected"}\n\n'
while True:
if await is_disconnected():
break
try:
msg = await asyncio.wait_for(q.get(), timeout=25.0)
yield f"data: {json.dumps(msg)}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
push_reg.unsubscribe(tenant_id, entry)
The controller is an async generator function (request_type="sse"). The
FastAPI adapter detects this via inspect.isasyncgenfunction() and wraps the
result in a StreamingResponse with media_type="text/event-stream".
is_disconnected is injected by the HTTP adapter from request.is_disconnected —
the controller itself has no FastAPI dependency.
WebPushRegistry¶
src/ede/core/services/push/web_push_registry.py
Thread-safe registry that maps tenant_id → [(event_loop, asyncio.Queue)].
| Method | Description |
|---|---|
subscribe(tenant_id) |
Called from the async SSE handler; registers a new queue |
unsubscribe(tenant_id, entry) |
Called in finally; removes the queue |
broadcast(tenant_id, message) |
Called from the sync EventWorker thread; uses loop.call_soon_threadsafe to bridge into each subscriber's asyncio queue |
Injected into Env at boot: base_env.web_push_registry = WebPushRegistry().
Push event handlers¶
src/ede/foundation/presentation/events/web_client_events.py
@api.on_event("web.client.reload")
def handle_web_client_reload(event, env) -> None:
push_reg = getattr(env, "web_push_registry", None)
if push_reg:
push_reg.broadcast(event.tenant_id or "", {
"type": "web.client.reload",
**dict(event.payload),
})
@api.on_event("web.client.logout")
def handle_web_client_logout(event, env) -> None: ...
Any backend code can emit web.client.reload or web.client.logout via
env.emit("web.client.reload", {...}) and the push handler will broadcast
to all connected tabs for that tenant.
SSE request_type adapter contract¶
When request_type="sse" is declared on a route:
- The adapter passes
is_disconnected=request.is_disconnectedas a kwarg. - The handler method must be an async generator function (contains
yield). _handle_request_dispatcher_ssewraps the generator inStreamingResponse.
Structured Request Logging¶
Every request produces two log lines from different loggers:
ede.http.tenant | [dharmangsoni] PresentationController:action_records action_slug=lm-countries
ede.http.access | GET /api/web/lm-countries/records -> 200 (22.63ms) client=127.0.0.1 request_id=2dbf5940c3864f3e8182a751e66dc15e
| Logger | Source | What it shows |
|---|---|---|
ede.http.tenant |
handler.py — emitted at handler entry |
Resolved tenant, controller:method, path params |
ede.http.access |
log_middleware.py — emitted after response |
HTTP method, path, status code, elapsed ms, client IP, request ID |
The two lines are intentionally distinct: ede.http.tenant leads with [tenant] (routing context), while ede.http.access leads with METHOD /path (HTTP outcome). This lets you grep each logger independently.
ede.http.tenant format:
ede.http.access format (normal):
ede.http.access format (exception):
Error-level logs on ede.http.tenant fire for handler exceptions (business rule violations, tenant DB not found, uncaught errors) and include tenant_id, route, and full traceback.
Controller Best Practices¶
- Controllers are thin adapters — no business logic. Translate HTTP → Command → return.
- Use
env.dispatch(Command(...))for named actions — not raw ORM calls in controllers. - For CRUD operations, dispatch
ede.create,ede.read_one,ede.search, etc. - Never import HTTP-specific types into DomainModel — the domain layer must stay clean.
- One controller per app is recommended, though multiple are fine.
- Path parameters map to FastAPI path params (e.g.
{id}→id: str). - Request body is injected as
body: dictwhenrequest_type="json".
Why the HTTP Layer Is Designed This Way¶
Thin controllers protect the domain¶
A controller that contains if record.status == "pending" and invoice_count > 0: raise ...
is a controller that can't be tested without a running HTTP server, can't be reused from a
CLI command, and can't be moved without dragging that logic with it. In EDE, that logic
belongs in a command handler or a hook. The controller's only job is to know which command
to dispatch and what to pass as payload.
env.dispatch() is the stable contract¶
When a controller calls env.dispatch(Command("shipment.confirm", ...)), it is calling
a contract — not an implementation. The command handler can change its implementation,
be replaced, or have new hooks attached, without the controller changing at all. This
is what makes the system surviveble: HTTP and domain evolve independently.
Middleware as transparent cross-cutting concerns¶
Authentication, logging, and tenant resolution are middleware — they run on every request
without any controller knowing about them. The controller receives a pre-populated env
with tenant_id and principal already set. Adding a new cross-cutting concern (rate
limiting, request tracing) adds a new middleware and nothing else changes.
SSE as a first-class HTTP pattern¶
The request_type="sse" route type shows that the HTTP layer is not just REST. Streaming
responses, long-lived connections, and push patterns are first-class citizens. The domain
never knows how its events reach the browser — it just calls env.emit("web.client.reload").
The HTTP layer handles the transport.