First project: a ticket desk¶
Build a small support-ticket API from an installed Aksara package. This is the first stage of the application tutorial: model → migration → REST → identity → tests. You need Python 3.11–3.14 and a disposable local PostgreSQL database.
The example uses a single local bearer secret to teach server-owned identity. It is a development adapter, not a login service. Keep the server on loopback; a production app needs its real identity source and the restricted-role deployment profile.
1. Install and create the project¶
python -m venv .venv
source .venv/bin/activate
python -m pip install "aksara-framework==0.7.2"
aksara --version
aksara startproject ticket_desk
cd ticket_desk
aksara dbsetup
dbsetup asks for PostgreSQL connection details and writes DATABASE_URL to
.env. Use a database reserved for this tutorial; migrations will create tables.
You may edit .env directly instead. If both are set, AKSARA_DATABASE_URL
takes priority over DATABASE_URL. See database setup if
you need to provision PostgreSQL first.
Keep the generated settings and application entry point. They connect settings to Aksara's database lifespan. Set a local API secret in the same terminal:
Keep that terminal for the server. Export the same token in the terminal used for API calls/tests. Do not commit it or paste it into source files.
2. Define a model¶
Replace app/models.py with:
from aksara import Model, fields
class Ticket(Model):
subject = fields.String(max_length=200)
description = fields.Text(default="")
resolved = fields.Boolean(default=False)
class Meta:
table_name = "tutorial_tickets"
A model maps Python fields to a PostgreSQL table. The default primary key is a UUID. A field definition is not a schema update by itself; migrations record and apply changes to the database.
3. Protect the generated API¶
Replace app/views.py with:
from aksara import ModelViewSet
from aksara.permissions import IsAuthenticated
from .models import Ticket
class TicketViewSet(ModelViewSet):
model = Ticket
prefix = "/api/tickets"
permission_classes = [IsAuthenticated]
ai_exposed = False
stream_enabled = False
The ViewSet provides list, create, retrieve, update and delete routes.
IsAuthenticated requires identity for this API, including reads. The next
step supplies that identity; accepting a role in client JSON would not do so.
Replace app/urls.py with:
from aksara import include_viewset
from .views import TicketViewSet
urlpatterns = [TicketViewSet]
def register_routes(app):
for viewset in urlpatterns:
include_viewset(app, viewset)
Create app/auth.py:
import hmac
import os
from types import SimpleNamespace
from starlette.middleware.base import BaseHTTPMiddleware
from aksara.security.principal import Principal
class LocalAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
scheme, _, token = request.headers.get("authorization", "").partition(" ")
expected = os.environ.get("APP_API_TOKEN", "")
valid = (
scheme.lower() == "bearer"
and bool(expected)
and hmac.compare_digest(token, expected)
)
principal = (
Principal.for_user("local-developer", auth_method="api_key")
if valid else Principal.anonymous()
)
request.state.principal = principal
request.state.user = SimpleNamespace(
id=principal.user_id,
is_authenticated=principal.is_authenticated,
is_active=principal.is_authenticated,
is_staff=False,
is_superuser=False,
)
return await call_next(request)
The token is checked on the server, which maps it to one ordinary human Principal. It does not grant administrator or system access. A missing token cannot match an unset environment secret.
Append these lines to the generated main.py, after app is constructed:
4. Create and apply the migration¶
Inspect and keep the generated migration file. Run migrate before the server,
not independently inside every request or worker. launch-check diagnoses the
local project. With Studio and AI disabled, it can report PARTIAL and exit
with code 1 solely for those optional recommendations. Confirm that its project,
database and migration checks pass; do not ignore other failures. Those optional
services are not required. Production uses a different Doctor profile.
5. Run and call the API¶
In the other terminal, with the same APP_API_TOKEN exported:
curl -i http://127.0.0.1:8000/api/tickets/
curl -i http://127.0.0.1:8000/api/tickets/ \
-H "Authorization: Bearer $APP_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"Printer offline","description":"Third floor"}'
curl http://127.0.0.1:8000/api/tickets/ \
-H "Authorization: Bearer $APP_API_TOKEN"
The anonymous call is denied. The authenticated POST returns a created ticket
with an id; the list contains it. Open http://127.0.0.1:8000/docs to inspect
the generated API schema. Use the authenticated curl calls below the schema
rather than assuming Swagger supplies your custom bearer adapter automatically.
6. Test the running application¶
Create a tests directory and save this as tests/test_api.py. It uses only
Python's standard library. Tests create and delete their own ticket.
import json
import os
import unittest
from urllib.error import HTTPError
from urllib.request import Request, urlopen
BASE = os.environ.get("APP_BASE_URL", "http://127.0.0.1:8000")
def call(path, method="GET", payload=None, authenticated=True):
headers = {"Content-Type": "application/json"}
if authenticated:
headers["Authorization"] = "Bearer " + os.environ["APP_API_TOKEN"]
data = json.dumps(payload).encode() if payload is not None else None
request = Request(BASE + path, data=data, method=method, headers=headers)
try:
response = urlopen(request, timeout=5)
except HTTPError as error:
response = error
with response:
body = response.read()
return response.status, json.loads(body) if body else None
class TicketAPI(unittest.TestCase):
def test_anonymous_request_is_denied(self):
status, _ = call("/api/tickets/", authenticated=False)
self.assertEqual(status, 403)
def test_authenticated_crud(self):
status, ticket = call("/api/tickets/", "POST", {"subject": "Test ticket"})
self.assertEqual(status, 201)
path = "/api/tickets/" + ticket["id"]
try:
status, loaded = call(path)
self.assertEqual(status, 200)
self.assertEqual(loaded["subject"], "Test ticket")
status, updated = call(path, "PATCH", {"resolved": True})
self.assertEqual(status, 200)
self.assertTrue(updated["resolved"])
finally:
status, _ = call(path, "DELETE")
self.assertIn(status, (200, 204))
status, _ = call(path)
self.assertEqual(status, 404)
def test_field_length_is_validated(self):
status, _ = call("/api/tickets/", "POST", {"subject": "x" * 201})
self.assertEqual(status, 422)
With the server still running, run from the project directory:
These tests prove a small protected CRUD path. They do not test tenant isolation, production identity, or recovery. Use a dedicated database and review permission rules before exposing the application beyond your machine.
What to learn next¶
Continue with relationships and validation in the same application before introducing tenancy, background work or durable actions. The models, serializers and application boundaries explain those concepts.
MCP is optional and remains disabled. When you are ready to add a tool client,
read the MCP guide: /mcp/ is the Streamable HTTP endpoint;
/ai/tools/mcp is the separate inspection catalog. Provider-backed AI and
Studio are experimental and unnecessary for this REST application.