Skip to content
GitHub

Architecture

Internal design of the lexigram-web package.


lexigram-web is a presentation-layer extension. It translates HTTP requests into domain operations via DI-resolved controllers and domain results back into HTTP responses. It depends only on lexigram (core) and lexigram-contracts (protocols) — never on other extensions directly.

flowchart BT
    Contracts[lexigram-contracts<br/>WebProviderProtocol · GuardProtocol<br/>ResponseFactoryProtocol · HTTPApplicationProtocol]
    Core[lexigram<br/>DI · Config · Hooks · Logging<br/>Result · primitives]
    Web[lexigram-web<br/>Controllers · Router · Middleware<br/>Security · Templates · WebSocket]
    Server[Granian / Uvicorn<br/>ASGI Server]

    Web --> Core
    Web --> Contracts
    Core --> Contracts
    Server --> Web

Import direction: Arrows point toward the dependency. lexigram-web imports from lexigram and lexigram-contracts. The ASGI server sits below, invoking the Starlette application built during WebProvider.boot().


Every HTTP request passes through a five-phase pipeline before reaching the handler, and the result flows back through serialization:

sequenceDiagram
    participant Client as Browser / API Client
    participant Server as ASGI Server
    participant MW as Middleware Stack
    participant Router as Router
    participant Pipe as RequestPipeline
    participant Handler as Controller Handler
    participant Serializer as ResponseSerializer

    Client->>Server: HTTP Request
    Server->>MW: ASGI scope
    MW->>MW: CORS · CSRF · Auth<br/>Rate Limit · Logging · Compression
    MW->>Router: Starlette route match
    Router->>Pipe: Create RequestPipeline
    Pipe->>Pipe: Execute Guards
    Pipe->>Pipe: Execute Interceptors
    Pipe->>Handler: Resolved parameters
    Handler->>Handler: Domain call (Result[T,E])
    Handler-->>Pipe: Ok(value) / Err(error)
    Pipe->>Serializer: Serialize response
    Serializer-->>MW: Starlette Response
    MW-->>Server: ASGI response
    Server-->>Client: HTTP Response

Middleware order (outermost → innermost):

  1. DIScopeMiddleware — injects scoped container per request
  2. RequestIDMiddleware — assigns unique request ID
  3. CorsMiddleware — CORS headers
  4. CSRFMiddleware — CSRF token validation
  5. AuthMiddleware — authentication (when enabled)
  6. RateLimitMiddleware — rate limiting
  7. AccessLogMiddleware — structured request logging
  8. CompressionMiddleware — response compression
  9. TimingMiddleware — request duration tracking

Routes are declared via decorators on Controller subclasses, collected by RouteRegistry, and mounted on Starlette during WebProvider.boot().

from lexigram.web import Controller, get, post, put, delete
class UserController(Controller):
prefix = "/users"
@get("/")
async def list(self, limit: int = 20) -> list[User]: ...
@post("/")
async def create(self, body: CreateUserRequest) -> User: ...
@get("/{user_id}")
async def get(self, user_id: str) -> User: ...
flowchart LR
    A["@get(&#39;/users&#39;)<br/>sets func._route_config"] --> B[Controller subclass]
    B --> C[ControllerRegistry<br/>stores class]
    C --> D[RouteRegistry.register_controller<br/>reads collect_routes()]
    D --> E[WebRouterManager.register_routes<br/>mounts on Starlette]
    E --> F[Router._create_endpoint<br/>wraps handler with pipeline]
    F --> G[Starlette routes<br/>added via add_route]

Controllers are discovered through three mechanisms:

MechanismWhen
Explicit listWebProvider(controllers=[...])
Package scanWebProvider.auto_discover("myapp.api")
Entry pointslexigram.web.contributors — extensions like lexigram-auth register automatically

The @api_version decorator supports four strategies:

StrategyMechanismDetected By
URI/v1/usersPath prefix extraction
HeaderX-API-Version: 1Header name configurable via VersioningConfig.header_name
Media typeAccept: application/vnd.api.v1+jsonAccept header parsing
Query?api_version=1Query parameter name configurable via VersioningConfig.query_param
@api_version(1, deprecated=False)
class UserControllerV1(Controller):
prefix = "/users" # mounted at /v1/users
@api_version(2, deprecated=True, sunset="2025-12-31")
class UserControllerV2(Controller):
prefix = "/users" # mounted at /v2/users + Deprecation header

WebModule.configure() creates a DynamicModule with WebProvider and exports the provider plus WebRateLimiterProtocol.

PhaseWhat Happens
__init__Accepts controllers, middleware, config. Builds WebMiddlewareManager and WebRouterManager.
register()Registers 20+ singletons in the container: security configs, route/controller registries, filter/interceptor pipelines, Router, ResponseSerializer, BackgroundTaskRunner. Auto-discovers contributors from entry points.
boot()Five-phase init: OpenAPI generator → Starlette app → middleware pipeline → integrations (auth, rate limit, GraphQL, SQL, cache) → route registration.
shutdown()Clears references. ASGI server lifecycle cleanup is handled by the lifespan context manager.

WebProvider runs at ProviderPriority.PRESENTATION (80) — after infrastructure, security, and domain providers. This guarantees database, cache, and auth are ready before routes mount.

# lexigram/web/di/provider.py (WebProvider.register)
container.singleton(WebProvider, self)
container.singleton(WebProviderProtocol, self)
container.singleton(RouteRegistry, route_registry) # global singleton
container.singleton(ControllerRegistry, controller_registry) # global singleton
container.singleton(Router, Router())
container.singleton(ResponseSerializer, ResponseSerializer())
container.singleton(InterceptorPipeline, InterceptorPipeline())
container.singleton(FilterPipeline, filter_pipeline)
container.singleton(SecurityConfig, self.web_config.security)
container.singleton(CORSConfig, self.web_config.cors)
container.singleton(CSRFProtection, CSRFProtection(config=...))
container.singleton(ResponseFactoryProtocol, StarletteResponseAdapter)
container.transient(BackgroundTaskRunnerProtocol, StarletteBackgroundTaskRunner)

Controllers registered as singletons are pre-resolved and cached by the Router at startup for per-request injection.


lexigram-web provides a Jinja2-based template engine via Jinja2Templates:

from lexigram.web.templates import Jinja2Templates
templates = Jinja2Templates(
directory="templates",
context_processors=[add_request_context],
)
# Controller usage
class MyController(Controller):
def __init__(self, templates: Jinja2Templates) -> None:
self._templates = templates
@get("/")
async def index(self) -> HTMLResponse:
return self._templates.render_response(
"index.html", {"title": "Home"}
)

Template Loading: Uses jinja2.FileSystemLoader with autoescape for HTML/XML. Templates are resolved relative to the configured directory (defaults to "templates").

Default filters/globals installed on every environment:

NameTypePurpose
tojsonFilterSafe JSON serialization returning Markup
format_datetimeFilterDatetime formatting with fallback
nowGlobalCurrent UTC datetime
static_urlGlobalBuild /static/... URLs

Context Processors: Optional callables that receive and can mutate the template context before rendering. Registered at Jinja2Templates construction time.


Guards implement GuardProtocol from lexigram-contracts and execute before the handler:

from lexigram.web.security.guards import AuthGuard, RoleGuard, PermissionGuard
from lexigram.web import use_guards
@use_guards(AuthGuard)
async def authenticated_only(self): ...
@use_guards(AuthGuard, RoleGuard("admin", authorizer=authorizer))
async def admin_only(self): ...
@use_guards(PermissionGuard("users:write", authorizer=authorizer))
async def create_user(self): ...
GuardPurpose
AuthGuardChecks request.state.user is set (authenticated)
RoleGuardChecks user has required role(s) via AuthorizerProtocol
PermissionGuardChecks user has required permission(s) via AuthorizerProtocol

CSRF middleware validates a token on all state-modifying methods (POST, PUT, PATCH, DELETE) for non-API paths. HTMX requests (hx-request header present) bypass CSRF validation (same-origin by default).

Configurable via WebConfig.security.csrf:

CSRFConfig(
enabled=True,
excluded_paths=["/api/", "/health", "/metrics"],
cookie_name="csrf_token",
header_name="X-CSRF-Token",
)

Configured via WebConfig.cors:

CORSConfig(
allowed_origins=["https://app.example.com"],
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
allow_credentials=True,
)

Production validation blocks wildcard * origins in production environments.

The security module applies HSTS, CSP, and security headers via dedicated middleware. The CSPConfig supports per-route CSP directives, and the APIDocsConfig automatically injects CSP rules for Swagger UI and ReDoc endpoints.


lexigram-web provides an HTMXResponse convenience class for HTMX endpoints:

from lexigram.web import HTMXResponse
class MyController(Controller):
@post("/save")
async def save(self, data: dict) -> HTMXResponse:
# Return HTML fragment with HX-Trigger header
return HTMXResponse(
"<div>Saved</div>",
hx_trigger={"showToast": "Saved successfully"},
)

HTMX requests (HX-Request header) are detected by the CSRF middleware and bypassed (same-origin). The response serializer treats bare strings as HTML when the request appears to be an HTMX swap.

WebSocket support is built on AbstractWebSocketHandler:

from lexigram.web.websocket import AbstractWebSocketHandler
class ChatHandler(AbstractWebSocketHandler):
rooms: dict[str, set[WebSocket]] = {}
async def on_connect(self, websocket):
await websocket.accept()
room_id = websocket.path_params["room_id"]
self.rooms.setdefault(room_id, set()).add(websocket)
async def on_message(self, websocket, message):
room_id = websocket.path_params["room_id"]
for ws in self.rooms.get(room_id, set()):
await ws.send_json(message)
async def on_disconnect(self, websocket):
room_id = websocket.path_params["room_id"]
self.rooms.get(room_id, set()).discard(websocket)

Handler lifecycle: on_connecton_message loop → on_disconnect. Built-in support for:

FeatureConfig
Connection capsmax_connections, max_connections_per_user
Rate limitingmax_messages_per_second (token bucket)
Broadcastbroadcast(), broadcast_text() to all connections
Ping/keepaliveping_interval, ping_timeout class attributes

The lexigram.web.sse package provides SSE handler support with backpressure management and heartbeat keepalive. Decorated via @sse_event:

from lexigram.web.sse.handler import SSEHandler
class NotificationSSE(SSEHandler):
async def event_generator(self, request):
while True:
yield {"event": "notification", "data": "..."}
await asyncio.sleep(1)

flowchart LR
    subgraph Contracts[lexigram-contracts]
        LE[LexigramError]
    end
    subgraph Web[lexigram-web]
        HE[HTTPError]
        NFE[NotFoundError]
        BRE[BadRequestError]
        UE[UnauthorizedError]
        FE[ForbiddenError]
        MNE[MethodNotAllowedError]
        CE[ConflictError]
        UEE[UnprocessableEntityError]
        ISE[InternalServerError]
        RLE[RateLimitError]
        TCE[TooManyConnectionsError]
    end

    LE --> HE
    HE --> NFE
    HE --> BRE
    HE --> UE
    HE --> FE
    HE --> MNE
    HE --> CE
    HE --> UEE
    HE --> ISE
    HE --> RLE
    HE --> TCE
flowchart LR
    H[Handler raises exception] --> FP{FilterPipeline}
    FP --> VF{ValidationErrorFilter}
    FP --> DRF{DependencyResolutionFilter}
    FP --> DEF{DefaultExceptionFilter}
    VF -->|422| R[JSON / HTML response]
    DRF -->|500| R
    DEF -->|status from exception| R
    R --> Client

Error response format (JSON): Uses RFC 7807 Problem Details (ProblemDetail dataclass):

{
"type": "urn:lexigram:not-found",
"title": "Not Found",
"status": 404,
"detail": "User 'abc' not found"
}

Debug mode: When debug=True and the client prefers HTML, DebugHtmlErrorRenderer produces an interactive error page with traceback, source context, and redacted request details.

HTTP Error classes all carry:

  • status_code — HTTP status
  • detail — human-readable message
  • headers — response headers (e.g. Retry-After for rate limits)
  • code — error code string ("NOT_FOUND", "RATE_LIMIT_EXCEEDED", etc.)
  • __cause__ — optional chained exception

Protocols that lexigram-web implements/consumes from lexigram-contracts:

ProtocolImportRole
WebProviderProtocollexigram.contracts.webProvider contract — implements full web lifecycle
HTTPApplicationProtocollexigram.contracts.webASGI app contract — allows mounting sub-apps
GuardProtocollexigram.contracts.web.guardPre-handler authorization check
ResponseFactoryProtocollexigram.contracts.webResponse creation abstraction
BackgroundTaskRunnerProtocollexigram.contracts.webPost-response background tasks
WebRateLimiterProtocollexigram.contracts.webRate limiting interface
WebMiddlewareProtocollexigram.contracts.webASGI middleware contract
ExceptionFilterProtocollexigram.contracts.webException → response conversion
WebContributorProtocollexigram.contracts.webEntry-point based contribution
ConnectionManagerProtocollexigram.contracts.webWebSocket/SSE connection tracking
CRUDServiceProtocollexigram.contracts.webGeneric CRUD service interface
HookRegistryProtocollexigram.contracts.coreLifecycle hook registration
HealthCheckResultlexigram.contracts.coreHealth check types

PointMechanismExample
Custom controllerSubclass Controller, use @get/@post decoratorsclass MyController(Controller):
Custom middlewareImplement ASGI middleware classclass TimingMiddleware:
Custom guardImplement GuardProtocolclass CustomGuard:
Custom interceptorImplement WebInterceptorProtocolclass LoggingInterceptor:
Custom exception filterImplement ExceptionFilterProtocolclass MyFilter:
Custom template engineSubclass/inject Jinja2TemplatesJinja2Templates(directory=...)
Custom response typeSubclass Starlette responseclass CSVResponse(Response):
Web contributorImplement WebContributorProtocol, register entry pointlexigram.web.contributors
Custom rate limiterImplement WebRateLimiterProtocolclass RedisRateLimiter:
Custom auth providerImplement authenticator, register in containercontainer.singleton(AuthHandler, ...)
Custom pipeImplement PipeProtocolclass ParseIntPipe:

ModulePurpose
lexigram.web.di.providerWebProvider — registers all web services in the container
lexigram.web.moduleWebModuleconfigure() / stub() module wrapper
lexigram.web.configWebConfig, ServerConfig, RateLimitConfig — configuration models
lexigram.web.routing.routerRouter — controller DI strategy, route registration, endpoint creation
lexigram.web.routing.registryRouteRegistry — stores route metadata
lexigram.web.routing.decorators@get, @post, @put, @delete, @patch, @websocket
lexigram.web.routing.controllerController base, GenericController[T] with CRUD patterns
lexigram.web.routing.managerWebRouterManager — mounts routes on Starlette
lexigram.web.routing.discoverydiscover_controllers() — finds Controller subclasses in packages
lexigram.web.routing.pipelineRequestPipeline — orchestrates guards, interceptors, handler, serialization
lexigram.web.routing.versioning@api_version, VersioningMiddleware, VersionExtractor
lexigram.web.routing.parameter_binderParameterBinder — resolves handler parameters from request
lexigram.web.middleware.stackDefaultMiddlewareStack — ordered middleware pipeline
lexigram.web.security.guardsAuthGuard, RoleGuard, PermissionGuard, @use_guards
lexigram.web.security.csrfCSRF token generation and validation middleware
lexigram.web.security.corsCORS middleware and configuration
lexigram.web.transport.responsesJSONResponse, HTMLResponse, HTMXResponse, StreamingResponse
lexigram.web.transport.requestsRequest wrapper
lexigram.web.templates.coreJinja2Templates, TemplateResponse, render_template
lexigram.web.websocket.handlerAbstractWebSocketHandler — WebSocket lifecycle base class
lexigram.web.sse.handlerSSEHandler — Server-Sent Events base class
lexigram.web.errors.html_error_rendererDebugHtmlErrorRenderer — debug HTML error pages
lexigram.web.errors.problem_detailProblemDetail — RFC 7807 error format
lexigram.web.exceptionsHTTPError, NotFoundError, BadRequestError, etc.
lexigram.web.hooksWebRequestReceivedHook, WebResponsePreparedHook
lexigram.web.protocolsPipeProtocol, WebInterceptorProtocol, WebProviderProtocol helpers
lexigram.web.integrationsAuth, rate limit, GraphQL, SQL, cache integration setup
lexigram.web.docs.generatorOpenAPIGenerator — auto-generates OpenAPI spec
lexigram.web.contributorsWebContributorRegistry — entry-point-based extension
lexigram.web.filters.pipelineFilterPipeline — exception filter chain
lexigram.web.interceptors.pipelineInterceptorPipeline — request/response interceptor chain
lexigram.web.server.runnerrun_server() — Granian-based ASGI server launcher

SymbolValueDescription
ENV_PREFIXLEX_WEB__Environment variable prefix for config
DEFAULT_HOST0.0.0.0Default bind host
DEFAULT_PORT8000Default bind port
DEFAULT_HEALTH_PATH/healthHealth check endpoint
DEFAULT_DOCS_PATH/docsSwagger UI endpoint
DEFAULT_OPENAPI_PATH/openapi.jsonOpenAPI schema endpoint
DEFAULT_PAGE_SIZE20Default pagination page size
DEFAULT_MAX_PAGE_SIZE100Maximum pagination page size
DEFAULT_RATE_LIMIT_REQUESTS100Default rate limit per window
DEFAULT_RATE_LIMIT_WINDOW60Default rate limit window (seconds)

  • Router._signature_cache — LRU cache of handler parameter signatures computed once at registration time
  • _cached_get_type_hints@lru_cache wrapper around typing.get_type_hints() to avoid repeated forward-ref resolution
  • ResponseSerializer — configurable serialization with Pydantic model-dump-json fast path
  • WebProvider.boot() — pre-resolves singleton controllers and caches them in a WeakValueDictionary
  • Controller discovery — happens once at boot, never at request time