Ravyn Introspection Graph (ApplicationGraph)¶
- What: A structural, immutable graph of your Ravyn application nodes for apps, routers, routes, middleware, permissions, and includes; edges for relationships like
WRAPSandDISPATCHES_TO. It's designed for introspection, auditing, and tooling, not routing or runtime matching. - Why: To answer questions such as: Which middlewares wrap my app and in what order?; What permissions apply to a route?; How do includes compose with child apps?; Can I export my architecture to JSON for visualization and CI checks?
- How: Access
app.graphand use theApplicationGraphhelpers (middlewares(),routes(),permissions_for(),include_layers(),to_dict(),to_json(), etc.). Ravyn lazily builds the graph the first time you accessapp.graph.
Quickstart¶
from ravyn import Ravyn
app = Ravyn()
print(app.graph) # Builds once, then reuses the same immutable graph
The property app.graph constructs the graph on first access using GraphBuilder().build(app) and caches it on app._graph. Subsequent accesses return the same instance.
Core Concepts¶
Nodes & Kinds¶
The graph contains typed nodes, including:
- APPLICATION – The Ravyn app itself (exactly one).
- ROUTER – The dispatching router discovered from the app.
- ROUTE – Path-like entries (e.g., Gateway, WebSocketGateway) with metadata such as path and methods.
- MIDDLEWARE – Classes wrapping either the application, includes, or routes. Metadata keeps the class name.
- PERMISSION – Classes wrapping includes or routes; metadata keeps the class name.
- INCLUDE – Entries that compose child apps or raw routes under a prefix.
Edges & Relationships¶
WRAPS– ordered chain (outer -> inner) for middleware and permissions around an app, include, or route. The builder preserves declaration order.ApplicationGraphtraverses the firstWRAPStarget in a linear fashion.DISPATCHES_TO– dispatch relationship (e.g., app -> router, router -> route/include, include -> child router or raw routes).
Building the Graph (under the hood)¶
Router discovery¶
GraphBuilder discovers a router-like object with the following preference order:
1. app.router if present and None.
2. Fallbacks: app._router, app.routes (if the object has a routes attribute).
Global middleware chain¶
For each entry in app.user_middleware, the builder resolves the class (supports both raw classes and DefineMiddleware) and creates a WRAPS chain from the APPLICATION node to each MIDDLEWARE in declaration order.
Router traversal¶
From the discovered ROUTER, the builder walks router.routes and:
- If the entry is an INCLUDE: adds the include, attaches its local middlewares then permissions as a
WRAPSchain, and, if a child app is present, descends into its router (with cycle protection via visited app IDs). If the include has raw routes (no child app), those are attached under the include. - Otherwise, treats the entry as a ROUTE, attaches it via
DISPATCHES_TO, then attaches route-level middlewares followed by permissions usingWRAPS.
Determinism & safety¶
- Dangling edges (where source/target nodes aren't present) are ignored defensively during
ApplicationGraphconstruction. - Adjacency lists for outgoing/incoming edges are frozen. Edge insertion order is preserved.
- JSON serialization uses
_to_json_safewhich converts Enums to values, recurses into mappings, lists/tuples, and sorts sets for deterministic output.
ApplicationGraph API Reference¶
Properties¶
nodes: Mapping[str, GraphNode]– All nodes by ID (read-only).edges: tuple[GraphEdge, ...]– All edges in insertion order (read-only).
Queries & helpers¶
by_kind(kind: NodeKind) -> tuple[GraphNode, ...]– Filter nodes by kind.application() -> GraphNode– Return the single APPLICATION node. RaisesRuntimeErrorif missing or duplicated.middlewares() -> tuple[GraphNode, ...]– Global middlewares wrapping the application, outer->inner order, by traversing the linearWRAPSchain.routes() -> tuple[GraphNode, ...]– All route nodes.route_by_path(path: str) -> GraphNode | None– Structural lookup by exactmetadata['path']. Not a runtime matcher.permissions_for(route: GraphNode) -> tuple[GraphNode, ...]– Permission chain wrapping a route (outer->inner). Requires a ROUTE node.route_middlewares(route: GraphNode) -> tuple[GraphNode, ...]– Middleware chain wrapping a route. Requires a ROUTE node.includes() -> tuple[GraphNode, ...]– All include nodes.include_layers(include: GraphNode) -> {"middlewares": ..., "permissions": ...}– Layers attached directly to an include. Requires an INCLUDE node.explain(path: str) -> dict– Structural explanation for a route that includes appdebug, global middlewares (by class name), route{path, methods}, and route permissions (by class name).
Export¶
to_dict() -> dict– JSON-friendly dict withnodesandedges. Noderefis intentionally excluded. Metadata is normalized via_to_json_safe.to_json(indent: int | None = 2, sort_keys: bool = False) -> str– JSON string export. Pairs with your favorite visualization tools (Mermaid, Graphviz, etc.).
Real‑World Scenarios & Recipes¶
Audit global middleware order¶
app = Ravyn(middleware=[MiddlewareA, MiddlewareB])
order = [n.metadata["class"] for n in app.graph.middlewares()]
assert order == ["MiddlewareA", "MiddlewareB"]
This preserves the declaration order (outer->inner) of global middlewares. '
Inspect route methods & HEAD insertion¶
app = Ravyn(routes=[Gateway("/r", handler=handler)])
route = app.graph.route_by_path("/r")
assert set(route.metadata["methods"]) == {"GET", "HEAD", "POST"}
The methods metadata captures the effective methods for the route, including implicit HEAD.
Explain a route end‑to‑end¶
app = Ravyn(middleware=[MiddlewareA], routes=[Gateway("/ping", handler=handler)])
info = app.graph.explain("/ping")
# info = {"app": {"debug": False}, "middlewares": ("MiddlewareA",),
# "route": {"path": "/ping", "methods": ("GET", "HEAD")},
# "permissions": ()}
explain() combines the app debug flag, global middleware classes, route {path, methods}, and route permissions into one compact dict.
Verify route‑level middleware chain¶
class RouteMW1: ...
class RouteMW2: ...
app = Ravyn(routes=[Gateway("/with-mw", handler=handler,
middleware=[DefineMiddleware(RouteMW1), DefineMiddleware(RouteMW2)])])
route = app.graph.route_by_path("/with-mw")
chain = app.graph.route_middlewares(route)
names = [n.metadata["class"] for n in chain]
assert names == ["RouteMW1", "RouteMW2"]
Route-level middlewares are attached as a linear WRAPS chain in the declared order.
Check a route's permission chain¶
class Allow(BasePermission): ...
class Deny(BasePermission): ...
app = Ravyn(routes=[Gateway("/users/{id}", handler=handler, permissions=[Allow, Deny])])
route = app.graph.route_by_path("/users/{id}")
perms = app.graph.permissions_for(route)
assert [p.metadata["class"] for p in perms] == ["Allow", "Deny"]
permissions_for() returns the ordered permission classes wrapping a specific route. '
Compose includes with child apps¶
async def inner():
return "child"
child = ChildRavyn(routes=[Gateway("/inner", handler=inner)])
app = Ravyn(routes=[Include("/child", app=child)])
inc_nodes = app.graph.includes()
assert inc_nodes[0].metadata["path"] == "/child"
# Child routes appear exactly once under the include
paths = [r.metadata["path"] for r in app.graph.routes()]
assert paths.count("/inner") == 1
Includes attach, and child app routers are traversed safely with cycle protection; child routes aren't duplicated.
Include with local layers (middlewares & permissions)¶
class IncMW: ...
class IncAllow(BasePermission): ...
child = ChildRavyn(routes=[Gateway("/i", handler=handler)])
inc = Include("/inc", app=child,
middleware=[DefineMiddleware(IncMW)],
permissions=[DefinePermission(IncAllow)])
app = Ravyn(routes=[inc])
layers = app.graph.include_layers(app.graph.includes()[0])
assert [n.metadata["class"] for n in layers["middlewares"]] == ["IncMW"]
assert [n.metadata["class"] for n in layers["permissions"]] == ["IncAllow"]
Include-level layers are attached as a WRAPS chain (middlewares first, then permissions) in the declared order.
WebSocket route presence¶
app = Ravyn(routes=[WebSocketGateway("/ws", handler=ws_handler)])
ws_route = app.graph.route_by_path("/ws")
assert ws_route is not None
WebSocket paths are represented as ROUTE nodes and can be looked up by exact path.
Export for tooling & CI¶
from ravyn.serializers import serializer
# Dict export
data = app.graph.to_dict()
assert "nodes" in data and "edges" in data
# JSON export
json_data = app.graph.to_json()
loaded = serializer.loads(json_data)
assert loaded == app.graph.to_dict()
to_dict() returns a tooling-friendly shape (no ref), and to_json() round-trips cleanly with Ravyn's serializer. '
Best Practices¶
- Use
DefineMiddleware/DefinePermissionwhen you need to pass constructor args. The builder resolves the class correctly even if wrappers vary. - Prefer exact path lookups with
route_by_path()for static analysis. Remember this is structural, not a runtime matcher. - Keep chains linear: The traversal assumes a first
WRAPStarget per step and preserves insertion order. - Export JSON for visualization:
_to_json_safeguarantees deterministic ordering (e.g., sorted sets), which is ideal for diffs in PRs.
Troubleshooting & FAQs¶
Q: ApplicationGraph has no APPLICATION node?
- Ensure you're building the graph from a valid Ravyn instance. The API raises if the node is missing or duplicated.
Q: My route isn't found by route_by_path()
- The lookup is an exact match against metadata['path']. Confirm the path string, including braces for parameters (e.g., "/users/{id}").
Q: Why do I see HEAD among methods?
- Ravyn's routing may implicitly include HEAD for GET routes. The graph reflects effective methods from the route entry. Validate using tests as shown.
Q: How do includes with child apps work? - The builder descends into the child app's router and marks visited apps to prevent cycles. Child routes are attached under the include correctly without duplication.
Data Shapes¶
Node (dict form)¶
{
"id": "route:8f3e...",
"kind": "route",
"metadata": {
"path": "/users/{id}",
"methods": ["GET", "HEAD"]
}
}
Nodes are exported without runtime ref. Metadata is normalized to JSON-safe values.
Edge (dict form)¶
{
"source": "router:...",
"target": "route:...",
"kind": "dispatches_to"
}
Edges preserve insertion order and reference valid node IDs.
Complete Example¶
from ravyn import get, websocket, Ravyn, ChildRavyn, Include, Gateway, WebSocketGateway, WebSocket
from ravyn.permissions import BasePermission
from lilya.middleware.base import DefineMiddleware
from lilya.permissions.base import DefinePermission
# Middlewares
class GlobalMW: ...
class RouteMW1: ...
class RouteMW2: ...
class IncMW: ...
# Permissions
class Allow(BasePermission): ...
class Deny(BasePermission): ...
class IncAllow(BasePermission): ...
@get()
async def handler() -> str:
return "Hello"
@websocket()
async def ws_handler(socket: WebSocket):
await socket.accept()
await socket.close()
async def inner():
return "child"
child = ChildRavyn(routes=[Gateway("/inner", handler=inner)])
app = Ravyn(
middleware=[GlobalMW],
routes=[
Gateway("/users/{id}", handler=handler,
middleware=[DefineMiddleware(RouteMW1), DefineMiddleware(RouteMW2)],
permissions=[Allow, Deny]),
Include("/inc", app=child,
middleware=[DefineMiddleware(IncMW)],
permissions=[DefinePermission(IncAllow)]),
WebSocketGateway("/ws", handler=ws_handler),
],
)
g = app.graph
print(g.explain("/users/{id}"))
print(g.to_json())
This example exercises global middleware, route-level middleware and permissions, include-local layers with a child app, and a WebSocket route—all reflected in ApplicationGraph and exportable to JSON.