description: karni.api: BaseViewSet with a mandatory access declaration, generated serializers, filtering, ordering, pagination, search, expansion, query budgets and the OpenAPI document.¶
karni.api¶
Posture — read first. Every
BaseViewSetmust declareaccess; it is the only gate. A viewset without it answers 403 to every request (the message names the fix) and fails the build (viewset-access). Nothing is anonymously readable unless the declaration saysPublic(). Setauto_register = Falseto keep a viewset off the router.
A model becomes a full API resource with four lines:
# myapp/viewsets.py (or myapp/api/viewsets.py)
from karni.api import Access, Authenticated, BaseViewSet, Public
from myapp.models import Event
class EventViewSet(BaseViewSet):
model = Event
access = Access(read=Public(), write=Authenticated())
search_fields = ["title"]
That yields CRUD, filtering, pagination, ordering, search, access control,
N+1-free queries and a correct OpenAPI schema at /api/events/. Mount once:
BaseViewSet¶
Declarative attributes (all optional except model and access):
| attribute | default | meaning |
|---|---|---|
model |
— | drives queryset, serializer, filters, route |
access |
— (required) | Access(read=, write=, create=) or Unscoped(reason=) — see Access control |
serializer_class |
None |
explicit class always beats generation |
queryset |
model._default_manager.all() |
replace to pre-filter |
search_fields |
() |
enables ?search= via the search provider |
expand_depth |
0 |
server-side default expansion depth |
expandable_fields |
auto (forward FK/O2O/M2M) | mapping of expand key → relation; reverse relations only when listed explicitly |
auto_register |
True |
False → auto-router skips it |
route |
kebab-plural of the model name | router prefix |
select_related / prefetch_related |
() |
query hints merged into the derived lookups — see Performance |
optimize |
True |
False → only the hints apply |
query_budget |
None (KARNI_QUERY_BUDGET) |
Budget(list=25, reason=...) |
pagination |
None (pagination_class) |
Unpaginated(max_rows=N, reason=...) — the only way to return plain lists |
cache |
None (KARNI_API_CACHE) |
Uncached(reason=...) or Cache(ttl=..., depends_on=[...]) |
Default ordering: model Meta.ordering, else -created_at when the field
exists, else -pk. Every normal DRF hook (get_queryset,
get_serializer_class, permission_classes, filterset_class, …) remains
overridable — BaseViewSet is a plain ModelViewSet underneath, plus
ObjectPermissionsMixin (filter_queryset_for_user) from karni.accounts.
Overriding get_queryset (with or without super()) never loses the access
scope: BaseViewSet.__init_subclass__ wraps it.
Access control¶
One declaration per viewset (ADR 0001, Decision 2), compiled into both enforcement layers:
- a request gate — a DRF permission (
karni.api.access.AccessPermission) thatget_permissions()always puts first: 401/403 before any query; - a row filter — the
readpredicate as aQ, applied inget_queryset(). It is authoritative: lists, details, custom actions, relation-field PK validation and?expand=all go through it.
The default declaration — what karni make viewset writes — is the roles'
grants (ADR 0002, P2):
from karni.api import BaseViewSet, Granted
class EventViewSet(BaseViewSet):
model = Event
access = Granted() # whatever KARNI_ROLES grants for Event, row-scoped
Granted() resolves to the viewset's model and the current action: reads
need events.event:view, create needs add, writes need change /
delete on a row that is also readable, and a custom @action declared in
class Karni: actions is its own permission (an undeclared unsafe one is
E211). The rows are the union of the matching grants' scopes (all,
own, related:<key>, custom:<name>, see roles as data),
so an agent writing a viewset gets row scoping without naming it. Bare
access = Granted() is Access(read=Granted(), write=Granted()); it
combines like any predicate (read=Granted() | HasRole("auditor")). The
admin applies the same grants to its rows.
A custom rule names the predicates itself:
from karni.api import Access, Authenticated, BaseViewSet, HasRole, Owned
class EventViewSet(BaseViewSet):
model = Event
access = Access(
read=Owned("organizer") | HasRole("staff"),
write=Owned("organizer"),
create=Authenticated(), # organizer is stamped server-side
)
The vocabulary is closed — every predicate has a request-level and a row-level answer:
| predicate | gate | rows |
|---|---|---|
Granted() |
holds a grant for the viewset's model and the current action | the union of those grants' scopes; the model's Karni.owner_field is stamped on create |
Public() |
everyone | all |
Authenticated() |
authenticated users | all (none for anonymous) |
HasRole("a", "b") |
holds any of the roles (Django groups, one query per request) | all for holders, none otherwise |
Owned("organizer") |
authenticated users | organizer = request.user; traversals ("event__organizer") and to-many paths ("members", automatic distinct()) work; Owned("pk") on the user model |
Owned("user", bypass=HasRole("x")) |
same as Owned("user") \| HasRole("x") |
|
Where(lambda request: Q(...)) |
passes (or the function returns a bool for both) |
the returned Q |
a \| b, a & b |
any / all | OR / AND of the row filters |
Can("events.event:approve") |
holds a grant for any of the permissions (app_label.model:action; roles as data, one query per request) |
the union of the grants' scopes: all, own (Karni.owner_field = user), related:<key>, custom:<name> — see roles as data; the permission must name the viewset's model |
Owned() without a field is a class-definition error that names the fix
(created_by only when written explicitly). An Owned path that does not
lead to the user model fails at class definition too. Python's or/and
on predicates raise TypeError — use |/&.
Which predicate governs which action:
| action | predicate | out of scope |
|---|---|---|
list |
read |
row not listed |
retrieve, safe custom @action |
read |
404 (the message names the read scope; no existence oracle) |
create |
create (default: write) |
401/403 at the gate |
update / partial_update / destroy, unsafe custom @action |
write, row looked up through read |
unreadable → 404; readable but not writable → 403 |
@action(detail=True, methods=["post"], access=HasRole("x")) |
that predicate; the lookup still goes through read |
as above |
Owner fields: a direct FK to the user model named by Owned in write or
create is stamped from request.user on create (perform_create) and is
read-only in the generated serializer — the body never chooses the owner.
Posture and DRF permissions. REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]
(Karni default: IsAuthenticated) is not consulted for a viewset with an
Access declaration — the declaration is the gate, so a permissive project
default cannot open one. permission_classes set on a viewset (or an
@action) are ANDed after the access gate: they can only narrow. The
DRF default still covers plain DRF views and Unscoped viewsets.
Model scopes cannot be lifted. The row filter is only ever filter()-ed
onto the model's default manager, so no predicate (HasRole bypasses
included) can reach soft-deleted rows or, later, another tenant's rows.
Lifting a model scope takes Model.objects.without(name, reason=...).
Relations and expansion follow the target's scope. A writable relation
field of a generated serializer (KarniPrimaryKeyRelatedField) accepts only
primary keys of rows the requester can read through the target model's
viewset; an out-of-scope PK fails exactly like a missing one. ?expand=
embeds only readable rows: to-many levels are scoped Prefetches, and a
to-one level into a narrowed target becomes a scoped Prefetch too (one
extra query; an unreadable target renders as null). The target viewset is
the first viewset registered for the target model on the KARNI_API_ROUTER
router (karni.api.routers.viewset_for_model, the same map
x-karni-relation uses); its access.read and filter_queryset_for_user
apply, its own get_queryset does not. A target with no routed viewset
(auto_register = False and not registered explicitly, or simply no
viewset) is not an API resource and has no row policy: PKs and expansion use
its default manager, so model scopes still hold. A target whose viewset has
no access accepts no PK and expands to nothing. Serializers used without a
request in their context (server-side code) are not narrowed.
Escape hatches, least to most drastic:
- change the declaration;
Where(...)for arbitrary row logic,@action(access=...)for one action,get_access()to compute the declaration per request;scope_exempt = Truewithscope_exempt_reason = "..."— the gate stays, the row filter goes: every row the gate admits is visible. The reason is required (E210) andkarni checkprints every exemption in the project on each run, so they stay visible instead of accumulating;- override
filter_queryset_for_user(queryset)— it receives the access-scoped queryset; theaccess-overriderule requires# karni: allow(access-override): <reason>; access = Unscoped(reason="...")— no compiled gate, no row policy: the viewset'spermission_classes(else the DRF default) decide, rows are limited only by model scopes andfilter_queryset_for_user; the reason is published asx-karni-access;auto_register = Falseplus your own view (theviewset-baserule still applies).
Deprecated: object_permission_classes on a BaseViewSet warns
(KarniDeprecationWarning) and is removed in 1.0 — it still runs, after the
access object check. Express the rule in access instead
(migration note; karni upgrade
lists every viewset that sets it). IsOwner,
RolePermission and ObjectPermissionsMixin remain for plain DRF views
(see accounts).
Field visibility¶
A role can see a record without seeing every field of it (ADR 0002, P3). Declare it once on the model; no serializer per role anywhere:
class Customer(BaseModel):
...
class Karni:
field_roles = {
"phone": {"read": ["staff", "support"], "write": ["staff"]},
"internal_note": ["staff"], # shorthand: read and write
}
The role slugs are the project's own (KARNI_ROLES); a field not listed
stays visible to whoever may read the row, and superusers see everything.
The generated serializer applies the rule per request:
- a field the user may not read is absent from the payload, never
null(anullwould say the field exists). It is absent from?expand=embeddings too, because nested serializers are generated the same way; - a field the user may read but not write is published read-only, and an
explicit attempt to send it is refused with a 400 naming the field and
the roles that may:
{"phone": ["Only roles staff may change phone."]}; ?ordering=, filters and?search=on a hidden field are ignored — a ranking or a match is a value oracle on a column the user cannot see. The same applies toAPIMeta.write_onlyfields, which never order and never search (karni.E131refuses asearch_fieldsentry that names one).
The OpenAPI document follows: /api/schema/ describes the fields the
requesting user may see, and marks the ones they may not write read-only,
so ResourceTable, ResourceForm and ResourceDetail render only those.
@karni/ui invalidates its cached schema when the identity changes, so a
login or logout refetches the right document. The admin
honours the same rules. karni check refuses a field_roles entry naming
a field the model does not have or a role KARNI_ROLES does not declare
(E212): a role nobody holds would hide the field from everyone.
assert_crud(ViewSet, "role") checks all of it in its visibility step.
Import and export¶
Every BaseViewSet serves GET /<resource>/export/?format=csv|xlsx — the
list as this request sees it (filters, search, ordering, row scope, field
visibility), relations written as natural keys — and the import flow under
/<resource>/import/: GET describes the importable fields, POST uploads
a file, …/<job>/preview/ is the mandatory dry run, …/<job>/commit/ writes
in one transaction and one audit batch, …/import/template/ is the
downloadable template. The gate is the declaration's create or write
(write_or_create_actions); each row is then checked on its own. See
import and export.
Deferred writes¶
When a karni.signals.pre_write receiver defers a write (the
karni-approvals module does, for
a guarded change), create, update, partial_update and destroy answer
202 with the receiver's payload — the pending change — instead of
writing. BaseViewSet.deferred_response(exc) shapes that answer; see
write interception.
Vetoes¶
A veto refuses an action at the gate after the access declaration admitted
it — for a reason that is not about who the user is but about the state the
request runs in. karni.vetoes.register_veto(fn, name=...) registers a
callable that receives a VetoContext (request, kind — "api" or
"admin" — the action, the model, the resolved permission key and the
view) and returns a reason string to refuse, or None. AccessPermission
consults every registered veto after the predicate passes (the refusal is
the 403's detail); the admin consults them in has_add/change/delete_permission,
drops vetoed names from the actions menu and hides Import. context.names()
is every name a pattern may match: the action, the permission, the model
label and model:action.
The karni-impersonation module is
built on it: while a support user acts as someone else, deleting, exporting
and payment changes are refused whatever the target may do. Projects use the
same hook for a maintenance freeze or a read-only tenant; unregister_veto
and vetoes() manage the registry.
APIMeta — model-side API metadata¶
class Event(BaseModel):
...
class APIMeta:
fields = [...] # or exclude = [...] (mutually exclusive)
read_only = ["status"]
write_only = ["internal_note"]
schema_name = "Event" # optional: replaces the AppLabelModel stem
select = ["venue"] # optional query hints for properties listed in fields
prefetch = ["tags"] # (see Performance → Query optimisation)
validate = True # run Model.clean() on create/update (default)
Model validation. A domain invariant lives in the model's clean():
class Booking(BaseModel):
def clean(self):
if self.quantity > self.tier.remaining:
raise ValidationError({"quantity": f"Only {self.tier.remaining} seats left."})
The generated serializer runs clean() on the would-be row before every
create and update — including the values the viewset stamps on save, such as
owner fields — and a django.core.exceptions.ValidationError becomes the 400
response, keyed by field. No serializer is needed for a cross-field rule.
Opt out per model with APIMeta.validate = False; an explicit
serializer_class built on KarniModelSerializer keeps the behaviour and can
override run_model_clean().
Rules enforced at build time (violations raise ImproperlyConfigured naming
the model):
fieldsandexcludeare mutually exclusive;read_only/write_onlymust be subsets of the effective fields.id,created_at,updated_at(fromBaseModel) are always read-only.- Password-named
CharFields are never auto-exposed. User-like models (AbstractBaseUser/PermissionsMixinsubclasses) additionally forcelast_login,is_staff,is_superuser,is_active,groups,user_permissionsread-only. - M2M fields are writable by default — mark them in
read_onlyto freeze.
Generated serializers get deterministic app-qualified component names
(MyappEventSerializer, expanded variants suffixed per expand path), so
schema components never collide across apps. An explicit serializer_class
(karni make serializer) is named by drf-spectacular from its class name
(EventSerializer → Event), so switching to one changes the schema
snapshot and the generated frontend types once; x-karni-access.owned is
published only when the read predicate is a single Owned(...). Top-level generated classes are
cached per (model, expand-set, max-depth); build_serializer.cache_clear()
resets the cache (it also resets when KARNI_API_MAX_EXPAND_DEPTH changes
under override_settings).
Filtering¶
KarniFilterBackend auto-derives a FilterSet when a BaseViewSet (or a view
setting karni_auto_filterset = True) declares no filterset_class/
filterset_fields — for any other view it behaves exactly like stock
DjangoFilterBackend. Derived lookups:
| field type | lookups |
|---|---|
| Char/Slug/Email | exact, icontains |
| Int/Float/Decimal/Date/DateTime | exact, gte, lte |
| Boolean | exact |
| FK/O2O | exact (typed by the target pk — UUID pks get a UUID filter) |
fields with choices |
exact via ChoiceFilter (stays an enum) |
Excluded from derivation: TextField (unindexed seq scans — opt back in
with explicit filterset_fields), File/Image/JSON/binary fields,
APIMeta.write_only/excluded fields, and password-named fields. The
type→lookups map is the overridable lookup_map class attribute on
KarniFilterBackend. Explicit filterset_* on the viewset always wins,
untouched.
Expansion¶
?expand=organizer,venue inlines related objects; dotted paths
(?expand=venue.city_ref) nest. Multiple expand params merge.
- Allowed keys per level:
expandable_fieldsor all forward FK/O2O/M2M names; each dotted segment is validated against the model at that level and an unknown key answers 400 listing the allowed keys there. - Depth is capped by
KARNI_API_MAX_EXPAND_DEPTH(env, default 2) — deeper paths answer 400. Cycles stop at the PK. - Expanded relations are just nested serializers to the
query optimiser:
select_relatedfor FK/O2O paths into targets whose read scope keeps every row, a scopedPrefetchfor any M2M/reverse segment or narrowed target — so expansion adds no N+1. - Expanded nested representations are read-only; writes keep using PKs.
- Only rows the requester can read are embedded — every level goes through
its target's read scope (see Access control); a to-one
target outside it renders as
null.
karni.api.expansion.related_lookups() and scoped_related_lookups() are
deprecated (they warn; removed in 1.0): the optimiser derives the same
lookups from the expanded serializer — call karni.api.optimizer.optimize().
v0.1 bound: each distinct expand-set builds (and caches) one serializer
class; pathological clients can grow that cache breadth-wise. The cap on
depth bounds each entry's size; clear with build_serializer.cache_clear().
Performance¶
ADR 0001 Decision 3. Four defaults, all on without project code, each with
an escape hatch that is a declaration on the viewset — never silence. karni
context lists every declared exception (PERF: line) so you see the
existing ones before adding another.
Query optimisation¶
Every list and retrieve runs the queryset through
karni.api.optimizer.optimize() after the access scope: it walks the
serializer that will render the response — generated, expanded or
hand-written, nested to any depth — and derives the lookups:
| Serializer field | Derived |
|---|---|
| PK relation over a forward FK/O2O | nothing (DRF reads the *_id column) |
| PK list over an M2M / reverse FK | Prefetch(name, queryset=<target>.only("pk")) |
| nested serializer on a FK/O2O | select_related(name), recursing with the prefix |
nested serializer many=True |
Prefetch(name, queryset=<optimised target queryset>) |
dotted source="venue.city.name" |
select_related("venue__city") |
SlugRelatedField, StringRelatedField, … |
select_related / a full-row Prefetch |
SerializerMethodField, a model property |
opaque — needs a hint |
Every Prefetch queryset starts at the target's default manager (soft delete
and other model scopes) narrowed by the target viewset's read scope, and
a to-one level into a narrowed target is a scoped Prefetch instead of a
join, so an unreadable row renders as null (ADR 0001 R3). Measured: a
publisher list nesting writers → novels → chapters (+ an M2M PK list and an
M2M slug list) costs 6 queries at 2 or 12 publishers; with
optimize = False it costs 32 and 182 (tests/api/test_optimizer.py).
Hints for what the walker cannot see — merged with the derived lookups, never replacing them:
class NovelViewSet(BaseViewSet):
model = Novel
access = Access(read=Public(), write=Authenticated())
serializer_class = NovelSerializer # has chapter_count = SerializerMethodField()
prefetch_related = ["chapters"] # what get_chapter_count reads
select_related = [] # same, for to-one reads
class Novel(BaseModel):
...
class APIMeta:
select = ["writer"] # for the writer_name property in APIMeta.fields;
prefetch = [] # applied wherever Novel is rendered, at any depth
A hint string is applied as written (model scopes apply, the target viewset's
read scope does not); a hinted Prefetch(...) owns its whole subtree; and a
lookup already on the incoming queryset (queryset = ...prefetch_related(...))
wins over a derived one at the same level. optimize = False keeps only the
hints; override optimize_queryset(queryset, serializer_class=None) for a
hand-built strategy. Custom actions are not optimised automatically — return
self.paginated_response(qs) (which is) or call self.optimize_queryset(qs).
Query budgets¶
Every BaseViewSet request is counted (auth, role lookups and the
pagination COUNT included — ADR 0001 R5) against its action's budget:
| Action | Budget (KARNI_QUERY_BUDGET) |
|---|---|
list, retrieve |
8 |
create, update, partial_update |
12 |
destroy |
10 |
anything else ("*") |
15 |
KARNI_QUERY_BUDGET_MODE decides what an overrun does: raise in tests (the
karni pytest plugin sets it — see testing), warn in the dev
profile (a log line with the grouped duplicate report), off in production
(no wrapper at all). Both counting modes set X-Karni-Queries (and
X-Karni-Query-Budget). The failure names the repeated statement and what
rendered it:
EventViewSet.list ran 14 queries; budget is 8.
14 queries, 12 duplicated, 3.1 ms in SQL
12× SELECT … FROM "app_tag" INNER JOIN "app_event_tags" ON … WHERE … = %s
← EventSerializer.tag_names (app/serializers.py:41 in get_tag_names)
Raising a budget is a declaration with a reason:
class ReportViewSet(BaseViewSet):
model = Report
access = Access(read=HasRole("staff"), write=HasRole("staff"))
query_budget = Budget(list=25, reason="per-row signed URLs, measured 2026-10")
# Budget(export=None, reason=...) stops checking one action;
# Budget(default=20, reason=...) covers every action not named.
Budget keys are action names (typos fail at class definition); unnamed
actions keep the project budget. Project-wide: KARNI_QUERY_BUDGET = {"list":
10} (a partial dict merged over the defaults). Non-viewset code: with
karni.testing.query_budget(n): ....
Dev query log. karni.middleware.QueryLogMiddleware (first in Karni's
MIDDLEWARE; on when KARNI_QUERY_LOG is — the dev profile) logs every
request that ran SQL on the karni.queries logger: query count, duplicated
count, SQL time and total time, plus one line per repeated statement with
what rendered it — WARNING when anything repeated, INFO otherwise. Off, it
removes itself from the chain at startup.
Pagination¶
Every list is paginated by a karni.pagination.KarniPagination subclass —
KarniPageNumberPagination (default: ?page=, ?page_size=) or
KarniCursorPagination (?cursor=; no COUNT(*) for very large tables).
- Hard cap.
?page_size=and the defaultPAGE_SIZE(envKARNI_PAGE_SIZE, 50) are capped atKARNI_MAX_PAGE_SIZE(env, default 100). A paginator subclass may set a lowermax_page_size, never a higher one. - Floor.
KARNI_MAX_PAGE_SIZEmay not be configured belowKARNI_MAX_PAGE_SIZE_FLOOR(env, default 25 — the page size@karni/ui'sResourceTablerequests; the floor itself is at least 1). A cap of0/Nonewould switch DRF's cap off, and a cap below what the UI requests silently hides rows from its page count — boot validation refuses both. Lowering the floor is the explicit, project-wide way to allow a smaller cap. - Cannot be switched off by accident.
pagination_class = Noneon a viewset is a class-definition error, a non-Karni paginator class too, and boot refusesREST_FRAMEWORK["PAGE_SIZE"] = None(or missing) and aDEFAULT_PAGINATION_CLASSthat is missing or not aKarniPagination. - The opt-out states a reason and stays bounded:
class ExportViewSet(BaseViewSet):
model = Event
access = Access(read=HasRole("staff"), write=HasRole("staff"))
pagination = Unpaginated(max_rows=2000, reason="CSV export")
The list is a plain JSON array of at most max_rows rows;
X-Karni-Truncated: true says rows were cut.
- Custom actions that return lists use the helper (the
unpaginated-action rule fails the build on Serializer(qs,
many=True).data in an action — see enforcement):
@action(detail=False)
def upcoming(self, request):
return self.paginated_response(self.get_queryset().filter(starts_at__gte=now()))
paginated_response(queryset, serializer_class=None) paginates (same
envelope and cap as list), optimises for the serializer, and orders an
unordered queryset by the viewset's default ordering. Pass
self.filter_queryset(qs) to honour the request's filters too.
Response caching¶
Off by default; one setting turns it on: KARNI_API_CACHE = True (env).
Then the data of every successful GET list and retrieve of every
BaseViewSet is cached (X-Karni-Cache: hit|miss) for KARNI_API_CACHE_TTL
seconds (300) in the KARNI_API_CACHE_ALIAS cache ("default").
- Invalidation is automatic: a save, delete or M2M change of any model
the response reads — the models of the optimiser's plan, the models
crossed by
Ownedpaths, plusCache(depends_on=[...])— replaces that model's version token (and the row's, for details), so every affected entry becomes unreachable at once. Details are invalidated per row. - Per requester: keys include the full URL (host,
?page=,?expand=, filters), the user, the user's roles and the language; the access gate runs before the cache is read. Details are cached only for viewsets whose gate is theAccessdeclaration alone (extrapermission_classeswould be skipped on a hit). - Not seen:
QuerySet.update(),bulk_create()/bulk_update()and raw SQL send no signals — callkarni.api.caching.invalidate(Model, pk=None)after them. ASerializerMethodFieldreading a model the serializer does not render needscache = Cache(depends_on=[ThatModel]).
class StockViewSet(BaseViewSet):
model = Stock
access = Access(read=Authenticated(), write=HasRole("ops"))
cache = Uncached(reason="levels change every second") # opt out
class CatalogueViewSet(BaseViewSet):
model = Product
access = Access(read=Public(), write=HasRole("ops"))
cache = Cache(ttl=3600, depends_on=[Price]) # tune
Search¶
?search= delegates to the provider named by KARNI_SEARCH_PROVIDER:
karni.api.search.postgres.PostgresSearchProvider(default) — Postgres full-text (SearchVector/SearchRank) OR trigram similarity in a single lazy queryset. Class attrs to tune (subclass + point the setting at yours):similarity_threshold(0.3),rank_threshold,use_trigram(False drops the trigram leg).karni.api.search.simple.SimpleSearchProvider— OR oficontainsacrosssearch_fields; works on any database (karni's own test suite uses it on sqlite).
Rank ordering vs ?ordering=: search runs after ordering in the filter
pipeline; when the client did not pass an explicit ?ordering=, results are
rank-ordered by the provider, otherwise the requested ordering wins.
Note: unlike DRF's stock SearchFilter, provider search does not interpret
the ^, =, @, $ field prefixes — search_fields are plain field
names.
Postgres setup (pg_trgm)¶
The trigram leg needs the pg_trgm extension and, for speed, a GIN index.
karni migrate creates the extension (karni.accounts migration
0004_pg_trgm, a no-op on other databases); pg_trgm is a trusted
extension since PostgreSQL 13, so the database owner may. If your role may
not, create it once as a superuser and migrate again — the migration says
so when it is refused.
One migration does both — karni.api.search.postgres re-exports the pieces:
from django.db import migrations
from karni.api.search.postgres import GinTrgmIndex, TrigramExtension
class Migration(migrations.Migration):
dependencies = [("myapp", "0001_initial")]
operations = [
TrigramExtension(),
migrations.AddIndex(
model_name="event",
index=GinTrgmIndex(fields=["title"], name="event_title_trgm"),
),
]
Postgres-only behaviour is exercised in CI by a job with a Postgres service; locally the suite runs the simple provider on sqlite.
Custom providers¶
Subclass karni.api.search.SearchProvider, implement
search(*, queryset, query, fields, view=None, …) returning a queryset of
the same model, and set KARNI_SEARCH_PROVIDER. Swapping to Elasticsearch /
Meilisearch later happens behind this interface with no project code change.
Auto-router¶
At the first import of karni.api.urls, the factory named by
KARNI_API_ROUTER (default karni.api.routers.get_default_router, a
zero-argument callable) builds the router and runs autodiscover():
- Every installed app's
viewsets/api.viewsetsmodule is imported;BaseViewSetsubclasses withmodelset andauto_register = Trueare registered atrouteor the kebab-plural of the model name (Company→companies,Box→boxes,Day→days; the helperkarni.api.routers.pluralizeis public). - Basenames are
"<app_label>-<kebab-model>", unique across apps. - Explicit wins: viewsets already registered and prefixes already taken
are skipped (debug log); an auto-vs-auto prefix collision logs a warning
naming both viewsets.
autodiscover()is idempotent.
The setting is consumed once, at first URLconf import — overriding
KARNI_API_ROUTER afterwards (e.g. in a test) is a no-op; router tests
should build fresh KarniRouter instances instead. To pre-register explicit
routes:
# myproject/api.py
from karni.api.routers import KarniRouter
def get_router():
router = KarniRouter()
router.register("events", MySpecialEventViewSet, basename="myapp-event")
router.autodiscover() # everything else, explicit registration wins
return router
Schema¶
KarniAutoSchema (the conf default DEFAULT_SCHEMA_CLASS) keeps
drf-spectacular's native introspection (choices → enums, null=True →
nullable, required from blank/default) and adds:
- an
expandquery parameter on list/retrieve (an enum of allowed keys when derivable) — the schema documents the unexpanded response shape only in v0.1; search(contributed byKarniSearchFilter, list ops only, and only when the view actually hassearch_fields);- vendor extensions for typed frontends:
x-karni-resource(router prefix, on list operations of auto-registered viewsets),x-karni-relation({"resource", "many"}on relation fields whose target has a registered route),x-karni-accesson everyBaseViewSetoperation (the predicate governing it:{"public": true},{"authenticated": true},{"role": [...]},{"owned": "<path>"},{"where": "<fn>"},{"any": [...]}/{"all": [...]},{"unscoped": "<reason>"},{"deny": ...}— display hints only, the server enforces), andx-karni-auth-modeat the schema root; - operations whose access can admit anonymous callers (
Public,Where) list the empty security requirement{}; SPECTACULAR_SETTINGS["COMPONENT_SPLIT_REQUEST"] = Trueso file-upload fields keepformat: binaryon the request side.
Generate a TypeScript client straight off /api/schema/, e.g.:
Ordering and pagination parameters come from the stock DRF classes
(OrderingFilter, KarniPageNumberPagination — ?page, ?page_size
capped at KARNI_MAX_PAGE_SIZE, default 100). An Unpaginated viewset's list
is documented as a plain array with no paging parameters.