Services, the API and databases¶
Services¶
A Service groups server operations. It is bound to the current
request context (self.user, self.session, self.db).
from rational.backend import Service, remote
@app.service
class TodoService(Service):
@remote(method="GET", auth="required")
async def list_todos(self) -> list[TodoOut]: ...
@remote(auth="required")
async def add(self, title: str) -> TodoOut: ...
async def _owned(self, session, todo_id: int) -> Todo: ... # not exposed
Components call services in-process:
todos = await self.use(TodoService).list_todos()
The JSON API¶
Only @remote methods are reachable over HTTP, at
{api_prefix}/{service}/{method} — /api/todo/list-todos and /api/todo/add
above. Rational generates a Pydantic body model from the parameters, uses the return
annotation as the response model, and publishes everything in OpenAPI at /api/docs.
auth="required"rejects anonymous callers with 401.Requests carrying a session cookie must send the
X-RW-CSRFheader.Raise
RemoteError(orNotFound,Forbidden) for a user-facing error; any other exception becomes a generic 500 and is logged.GETis for safe, idempotent reads only.
From custom browser code: await rational.call("todo", "add", {title: "Write docs"}).
Swagger UI¶
Every app with services has interactive API documentation at /api/docs (the OpenAPI schema
is at /api/openapi.json). Services appear as sections, named after the class without
Service, and their docstrings become descriptions. Requests sent from Swagger UI carry your
session cookie and its CSRF token, so “Try it out” works while you are signed in.
Set the API’s title, description and version on the app, and turn the documentation off if you don’t want it public:
app = App(title="Shop", description="Catalog and orders.", version="1.2.0")
app = App(api_docs=False) # no /api/docs, no /api/openapi.json
app = App(api_prefix="/v1") # /v1/todo/add, /v1/docs
Errors users can fix¶
raise FieldError("email", "This email is already registered") from a service or handler
reports a problem with one form field. The browser shows the message under that field, exactly
like a validation error, and the API answers 422 with {"field", "message"} details.
FieldError({"password": "...", "confirm": "..."}) reports several fields at once.
Authentication¶
By default the authenticated principal is whatever you store with
session.login(user) (which also rotates the session id). Replace it with your own:
@app.authenticate
async def current_user(ctx):
token = ctx.request.headers.get("authorization")
return await lookup(token)
Pages can require it: @app.page("/admin", auth="required"), with
App(login_path="/login") to redirect instead of answering 401.
Databases¶
SQLAlchemy stays the source of truth for ORM semantics; Rational only creates the engine and hands out sessions.
app = App(database="sqlite+aiosqlite:///app.db") # or "postgresql+asyncpg://..."
async with app.db.transaction() as session: # commits, or rolls back on error
session.add(Todo(title="x"))
async with app.db.session() as session: # commit explicitly
...
rational.backend.Model is an optional declarative base; any base works.
await app.db.create_all(Base) is a development convenience — use Alembic migrations
in production. Prefer explicit loading (selectinload) to avoid N+1 queries and lazy
loads outside a session.