API reference¶
Rendered docstrings for every public module. For focused tables of class attributes, decorator options, settings, commands and error codes, use the configuration reference instead; the guides link to the relevant page.
Controllers and operations¶
ninja_devx.routing.controller ¶
Class-based controllers compiled into native Django Ninja routers.
Scope ¶
Bases: StrEnum
How long a controller instance lives.
ControllerOptions ¶
Bases: TypedDict
Defaults for every operation of a controller.
Operation-level auth, throttle, tags, permissions, atomic and
deprecated replace these; decorators and hooks are combined (the
controller's wrap the operation's).
auth
instance-attribute
¶
Ninja authentication for every operation (django_auth, JWTAuth(), a list, or
None).
throttle
instance-attribute
¶
Ninja throttles for every operation (UserRateThrottle("100/min") or a list).
permissions
instance-attribute
¶
Permissions checked before each operation (see Also for adding at operation level).
decorators
instance-attribute
¶
View decorators (paginate(...), conditional()); the first item is outermost.
hooks
instance-attribute
¶
Operation hooks wrapping each call (sync around or async around_async).
atomic
instance-attribute
¶
Run sync operations in transaction.atomic(); "durable" must be the outermost.
errors
instance-attribute
¶
Exception rules for every operation (see ninja_devx.http.errors).
meta
instance-attribute
¶
Typed metadata read with get_operation(request).meta(Kind).
plugins
instance-attribute
¶
ControllerPlugin objects applied to every operation.
middleware
instance-attribute
¶
Router middleware around every operation, including auth and throttling
(RequestIDMiddleware(), DeprecationMiddleware(sunset=...)).
allow_mixed_path
instance-attribute
¶
Silence the warning for a path served by both sync and async operations.
document_errors
instance-attribute
¶
Document 401/403/404/422 responses in OpenAPI (default from settings).
exclude_defaults
instance-attribute
¶
Leave fields equal to their default out of responses (Ninja).
operation_id_prefix
instance-attribute
¶
Prepended to every operation id (mount(prefix=...) sets it for versions).
url_name_prefix
instance-attribute
¶
Prepended (with _) to explicit url_name values.
BuiltRouter
dataclass
¶
What as_router() built a router from (used by system checks).
Controller ¶
Base class for class-based Django Ninja controllers.
Declare operations with @get, @post... on instance methods, receive
dependencies through __init__ and mount with::
api.add_router("/users", UserController.as_router(container=container))
Request data is never stored on self; operations receive request explicitly.
scope
class-attribute
¶
Scope.REQUEST: a controller per request (inside the DI scope); Scope.SINGLETON: one
per as_router() call.
mode
class-attribute
¶
Which implementation to register when an operation has an async_variant;
"auto" follows NINJA_DEVX["ASYNC_MODE"].
routes
class-attribute
¶
Overrides per operation name: {"bulk_create": {"path": "/batch"}, "destroy":
{"enabled": False}, "list": {"summary": "Posts"}}. Unknown names fail at startup.
options
class-attribute
¶
Merged along the MRO over NINJA_DEVX["DEFAULT_OPTIONS"], then
as_router(**options).
as_router
classmethod
¶
as_router(
*,
container: ContainerLike | None = None,
scope: Scope | None = None,
**options: Unpack[ControllerOptions],
) -> Router
Build a new native Ninja Router exposing this controller's operations.
Every call returns an independent router, so the same controller can be mounted on several APIs or prefixes with different containers and scopes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
container
|
ContainerLike | None
|
Builds controllers and |
None
|
scope
|
Scope | None
|
Overrides the class's |
None
|
options
|
Unpack[ControllerOptions]
|
|
{}
|
checks
classmethod
¶
Django system check messages for this controller (manage.py check).
container is the one it was mounted with. Override to add project rules;
extend super().checks(container) to keep the built-in ones.
implementation
classmethod
¶
The method registered for operation name: its async variant in async mode.
customize_operation
classmethod
¶
Adjust an operation before registration; name is the method name.
operation_bindings
classmethod
¶
Extra hidden parameters for an operation (nested resources use this).
documented_errors
classmethod
¶
Extra error status codes to document for an operation.
before_operation ¶
Called after permissions and bindings, right before the method. May be async.
after_operation ¶
Called with the method's result; the return value is sent. May be async.
authorize_replay ¶
authorize_replay(
request: HttpRequest,
operation: OperationInfo,
arguments: Mapping[str, object],
) -> object
Authorize every keyed attempt, including retries, after bindings and preflight.
Move authorization performed inside a custom handler here (or to bindings or
before_operation). May be async. Model controllers also recheck the URL object.
resolve ¶
Resolve key from this call's DI scope (e.g. a service chosen at runtime).
run_sync
async
¶
Run sync (ORM) code from an async operation in one thread hop.
run_atomic
async
¶
Run sync code in transaction.atomic() from an async operation, in one hop.
check_object_permissions ¶
Enforce the current operation's object-level permissions on obj.
built_router ¶
The controller behind router when as_router() created it.
ninja_devx.routing.operations ¶
Operation decorators for controller methods.
The decorators only attach metadata; nothing is registered until
Controller.as_router() runs. Options mirror ninja.Router.api_operation
one-to-one, plus permissions and decorators.
OperationMethod
module-attribute
¶
A controller method: (self, request, *params) -> R.
OperationOptions ¶
Bases: TypedDict
Keyword arguments accepted by every operation decorator.
auth
instance-attribute
¶
Ninja authentication for this operation; overrides the controller's.
operation_id
instance-attribute
¶
OpenAPI operation id (default <controller>_<method>).
description
instance-attribute
¶
OpenAPI description (default: the method docstring).
exclude_unset
instance-attribute
¶
Leave unset fields out of the response (Ninja).
exclude_defaults
instance-attribute
¶
Leave fields equal to their default out of the response (Ninja).
exclude_none
instance-attribute
¶
Leave None fields out of the response (Ninja).
openapi_extra
instance-attribute
¶
Merged into the operation's OpenAPI object.
permissions
instance-attribute
¶
Replaces the controller's permissions; Also(...) adds to them instead.
decorators
instance-attribute
¶
View decorators applied inside the controller's ones; the first item is outermost.
hooks
instance-attribute
¶
Operation hooks run inside the controller's hooks.
atomic
instance-attribute
¶
Run the operation in transaction.atomic() ("durable": must be the outermost).
errors
instance-attribute
¶
Exception rules for this operation, over the controller's.
raises
instance-attribute
¶
Exceptions this operation may raise: documented in OpenAPI, checked against the rules.
meta
instance-attribute
¶
Typed metadata read by permissions and hooks via get_operation(request).meta(Kind).
document_errors
instance-attribute
¶
Document 401/403/404/422 responses in OpenAPI (default from settings).
RouteOptions ¶
Bases: OperationOptions
Per-operation overrides in Controller.routes (paths and options are not hard-coded).
async_variant ¶
Declare the async implementation of an operation, used when the controller runs async.
Reusable bases (like the CRUD mixins) define both and let mode pick::
@get("/")
def list(self, request): ...
@async_variant(list)
async def alist(self, request): ...
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sync_method
|
Callable[..., object]
|
The sync operation this async method implements. |
required |
api_operation ¶
api_operation(
methods: str | Sequence[str],
path: str = "/",
**options: Unpack[OperationOptions],
) -> Callable[
[OperationMethod[C, RequestT, P, R]],
OperationMethod[C, RequestT, P, R],
]
Mark a controller method as an operation for the given HTTP methods.
Can be stacked to expose the same method under several paths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
methods
|
str | Sequence[str]
|
HTTP methods, e.g. |
required |
path
|
str
|
Path relative to the router; |
'/'
|
options
|
Unpack[OperationOptions]
|
|
{}
|
query ¶
query(
path: str = "/", **options: Unpack[OperationOptions]
) -> Callable[
[OperationMethod[C, RequestT, P, R]],
OperationMethod[C, RequestT, P, R],
]
An HTTP QUERY operation: safe and idempotent like GET, with a request body.
Permissions treat it as a read (view). The OpenAPI document lists it under the
query key that OpenAPI 3.2 defines; tools that only know 3.1 skip it.
TODO: Django has no QUERY support of its own yet. CsrfViewMiddleware treats it as
unsafe (session-authenticated calls need the CSRF token) and django.test.Client has
no query() (use client.request("QUERY", ...)). Revisit when Django adds it.
ninja_devx.dependencies.injection ¶
Inject dependencies into operation parameters: service: Inject[OrderService].
::
@post("/", response={201: OrderOut})
async def place(self, request: HttpRequest, payload: OrderIn,
orders: Inject[OrderService]) -> Status[Order]: ...
@get("/whoami")
def whoami(
self, request: HttpRequest, ip: Annotated[str, Resolve(client_ip)]
) -> dict[str, str]: ...
Injected parameters never appear in OpenAPI. Inject resolves from the controller's
container inside the request scope (checked when the router is built); Resolve calls a
function with the request.
Inject
module-attribute
¶
A parameter resolved from the controller's container (not part of the API).
Resolve ¶
Bases: BindingMarker, Generic[T]
Annotated[T, Resolve(fn)]: the value of fn(request) (sync or async fn).
resolve ¶
Resolve key from the current operation's DI scope (for code without parameters).
injected ¶
The runtime form of Inject[key] for dynamically built signatures.
ninja_devx.dependencies.instances ¶
How controller instances (and their DI scope) are provided per call.
Scope ¶
Bases: StrEnum
How long a controller instance lives.
Invocation ¶
One call of an operation: the controller, the request and its DI scope.
InstanceProvider
dataclass
¶
Creates (or reuses) the controller for one call, within a DI scope if available.
factory ¶
A plain callable when no scope has to be entered (the fast path).
get_invocation ¶
The current invocation (controller and DI scope) of request.
ninja_devx.routing.use_cases ¶
Bind an operation to a use case (command handler) without writing a method body.
::
class CreatePost:
def __init__(self, posts: PostRepository) -> None: ...
def __call__(self, command: CreatePostCommand) -> Post: ...
class PostController(Controller):
create = use_case(
post("/", response={201: PostOut}),
CreatePost,
command=PostIn.to_command, # Callable[[PostIn], CreatePostCommand]
status=201,
)
The use case is resolved from the controller's container per request; the payload type is the command mapper's parameter annotation.
QueryHandler ¶
Bases: Protocol[CommandT_contra, ResultT_co]
A read handler: __call__(query) sync or async def __call__.
use_case ¶
use_case(
decorator: Callable[[Method], Method],
handler: Callable[
...,
UseCase[CommandT, ResultT]
| UseCase[CommandT, Awaitable[ResultT]],
],
*,
command: Callable[[PayloadT], CommandT],
status: int | None = None,
) -> Method
An operation method: map the payload with command and call the resolved handler.
An async handler (async def __call__) makes the operation async.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decorator
|
Callable[[Method], Method]
|
The operation decorator, e.g. |
required |
handler
|
Callable[..., UseCase[CommandT, ResultT] | UseCase[CommandT, Awaitable[ResultT]]]
|
A class with |
required |
command
|
Callable[[PayloadT], CommandT]
|
Maps the validated payload to the command; its parameter type is the request body. |
required |
status
|
int | None
|
Status code of the response (default: the operation's first response). |
None
|
use_query ¶
use_query(
decorator: Callable[[Method], Method],
handler: Callable[
...,
QueryHandler[CommandT, ResultT]
| QueryHandler[CommandT, Awaitable[ResultT]],
],
*,
query: Callable[[PayloadT], CommandT],
status: int | None = None,
) -> Method
A read operation: map the query parameters and call the resolved handler.
The read counterpart of :func:use_case. Use a FilterSchema (or any schema) as the
handler's payload; for GET operations its fields become query parameters.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decorator
|
Callable[[Method], Method]
|
The operation decorator, e.g. |
required |
handler
|
Callable[..., QueryHandler[CommandT, ResultT] | QueryHandler[CommandT, Awaitable[ResultT]]]
|
A class with |
required |
query
|
Callable[[PayloadT], CommandT]
|
Maps the validated payload to the query; its parameter type is the query schema. |
required |
status
|
int | None
|
Status code of the response (default: the operation's first response). |
None
|
ninja_devx.routing.plugins ¶
Plugins extend every controller they are given to, without touching the package.
::
class TenantHeader:
def on_operation(
self, controller: type[Controller], name: str, spec: OperationSpec
) -> OperationSpec:
return spec.with_options(openapi_extra={"parameters": [...]})
def bindings(
self, controller: type[Controller], name: str, spec: OperationSpec
) -> Sequence[ParameterBinding]:
return ()
options = ControllerOptions(plugins=[TenantHeader()]) # or NINJA_DEVX["PLUGINS"]
Plugins are listed explicitly; nothing is discovered through entry points.
ninja_devx.routing.bindings ¶
Parameters whose value is computed per call instead of passed by Ninja.
A binding exposes its own parameters to Ninja (e.g. a pk path parameter), or none
(injected services), and turns them into the value the method receives.
ParameterBinding
dataclass
¶
How one method parameter (or a hidden one, when name is None) is produced.
resolve pops its exposed parameters from the arguments and returns the value;
aresolve is used by async operations when given. check validates the binding
against the container when the router is built.
BindingMarker ¶
Bases: LazyAnnotation
Annotated metadata that turns a parameter into a ParameterBinding.
Permissions, users and errors¶
ninja_devx.security.permissions ¶
Typed, composable permissions evaluated after Django Ninja has validated the request.
Authentication stays Ninja's job (auth=); permissions decide what an
authenticated (or anonymous) caller may do::
@get("/{pk}", auth=django_auth, permissions=[IsAuthenticated() & IsStaff()])
Every permission works in sync and async operations: async operations call
ahas_permission/ahas_object_permission, which default to the sync checks and are
overridden by the built-ins to load session users without blocking.
Permissions are immutable and shared between requests, so they must not store state.
PermissionResult
module-attribute
¶
Sync permissions return bool; a coroutine has_permission makes it async-only.
AnyPermission
module-attribute
¶
Any permission, whatever object type it checks (permissions are contravariant).
BasePermission ¶
Bases: Generic[ObjT_contra]
Allow everything by default; override the checks you need.
has_permission runs before the operation. has_object_permission runs when
the operation loads an object (get_object, Instance[...]) or calls
check_object_permissions. Override the a-prefixed versions for native async
checks. Combine with &, | and ~.
message
class-attribute
instance-attribute
¶
detail of the denial response, translated with gettext when sent (so your
own messages can come from your project's catalog).
status_code
class-attribute
instance-attribute
¶
Status of the denial response (403, or 401 for authentication).
combinator
class-attribute
¶
Set by AllOf/AnyOf/Not, which evaluate operands instead of checks.
Also ¶
Bases: tuple['BasePermission[Never]', ...]
Operation permissions added to the controller's instead of replacing them.
@get("/{pk}", permissions=Also(IsOwner()))
AllOf
dataclass
¶
AnyOf
dataclass
¶
Bases: BasePermission[ObjT_contra]
Allowed when at least one permission allows; reports the last denial.
Not
dataclass
¶
Bases: BasePermission[ObjT_contra]
Inverts a permission. Uses its own message/status_code when denying.
AllowAny ¶
DenyAll ¶
IsAuthenticated ¶
Bases: BasePermission[object]
Requires Ninja authentication (request.auth) or an authenticated request.user.
IsAuthenticatedOrReadOnly ¶
Bases: IsAuthenticated
Safe methods (GET, HEAD, OPTIONS) for everyone; other methods need authentication.
IsReadOnly ¶
Bases: BasePermission[object]
Allows safe methods only; combine it: [IsAuthenticated(), IsReadOnly() | IsStaff()].
IsStaff ¶
IsSuperuser ¶
HasDjangoPermission
dataclass
¶
Bases: BasePermission[object]
Requires Django model permissions, e.g. HasDjangoPermission("blog.change_post").
perms
instance-attribute
¶
Django permission names ("app.change_model"), all required.
DjangoModelPermissions
dataclass
¶
Bases: BasePermission[object]
Maps the HTTP method to the model's view/add/change/delete permissions.
The model is the controller's (ModelController) unless given explicitly.
Unknown HTTP methods are denied.
IsOwner
dataclass
¶
Bases: BasePermission[object]
Object-level: obj.<field> must be the current user.
field may follow relations: IsOwner("author"), IsOwner("customer__user").
ModelController selects the related objects so no extra query runs.
PolicyPermission
dataclass
¶
Bases: BasePermission[ObjT], Generic[SubjectT, ObjT]
A Policy enforced on objects loaded by an operation (see as_permission).
as_permission ¶
as_permission(
policy: Policy[SubjectT, ObjT],
subject: type[SubjectT]
| Callable[[HttpRequest], SubjectT],
*,
asubject: Callable[[HttpRequest], Awaitable[SubjectT]]
| None = None,
) -> PolicyPermission[SubjectT, ObjT]
Use a service-layer Policy as an object permission.
subject is the user class (the authenticated user must be one, else 401) or a
function of the request::
permissions=[as_permission(CanEdit(), User)]
permissions=[as_permission(CanEdit(), membership_of, asubject=amembership_of)]
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
policy
|
Policy[SubjectT, ObjT]
|
An object with |
required |
subject
|
type[SubjectT] | Callable[[HttpRequest], SubjectT]
|
The user class (the authenticated user must be one, else 401), or a function of the request. |
required |
asubject
|
Callable[[HttpRequest], Awaitable[SubjectT]] | None
|
Async version of |
None
|
requires_async ¶
Whether permission can only be evaluated asynchronously (coroutine checks).
bind_permissions ¶
Remember the operation's permissions for later object-level checks.
check_object_permissions ¶
Raise HttpError unless the current operation's permissions allow obj.
ninja_devx.security.object_permissions ¶
Object-level permissions: per-row grants, checked on objects and applied to lists.
The model follows django-guardian and DRF's DjangoObjectPermissions: permissions are
Django's app_label.codename strings (blog.change_post), granted to users or groups
on single objects. A backend stores and checks them:
==================== ==================================================================
GrantsBackend ninja_devx.contrib.grants: no extra dependency
GuardianBackend django-guardian (pip install ninja-devx[guardian])
DjangoBackend any authentication backend's user.has_perm(perm, obj); checks
only, lists cannot be filtered
==================== ==================================================================
NINJA_DEVX["OBJECT_PERMISSION_BACKEND"] picks one (an instance or import path). By
default: grants when ninja_devx.contrib.grants is installed, else guardian when
installed, else DjangoBackend.
::
class DocumentController(CRUDController[Document, DocumentOut, DocumentIn]):
object_permissions = ObjectPermissions() # 404 without view, 403 without change
assign_perm("docs.change_document", bob, document)
get_objects_for_user(bob, "docs.view_document", Document.objects.all())
Grant
dataclass
¶
Permissions a user or a group holds on one object.
ObjectPermissionBackend ¶
DjangoBackend ¶
user.has_perm(perm, obj) through AUTHENTICATION_BACKENDS; no list filtering.
GuardianBackend ¶
django-guardian's ObjectPermissionChecker and shortcuts.
ObjectPermissions
dataclass
¶
Bases: BasePermission[Model]
Per-object Django permissions by HTTP method, like DRF's DjangoObjectPermissions.
- Detail operations need the object permissions of the method (
viewfor GET,changefor PUT/PATCH,deletefor DELETE). - A caller who cannot even view the object gets 404, so existence doesn't leak.
- With
filter_lists(used byModelController.object_permissions), queries only return objects the caller can view. model_permissions=Truealso accepts a model-wide permission (user.has_perm("blog.change_post")) for users with global rights.
perms_map
class-attribute
instance-attribute
¶
HTTP method → permission templates (%(app_label)s, %(model_name)s).
model_permissions
class-attribute
instance-attribute
¶
Accept model-wide Django permissions in addition to object grants.
filter_lists
class-attribute
instance-attribute
¶
Restrict querysets to objects the caller can view (needs a filtering backend).
hide_forbidden
class-attribute
instance-attribute
¶
Answer 404 instead of 403 when the caller cannot view the object.
filter ¶
queryset restricted to objects the caller can view.
register_object_permission_backend ¶
Register a backend so get_backend can select it by name.
Apps register in AppConfig.ready (ninja_devx.contrib.grants registers
"grants"); core never imports contrib.
registered_object_permission_backends ¶
Names of the currently registered backends.
assign_perm ¶
Grant perm ("app_label.codename") to a user or group on obj.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
perm
|
str
|
Full permission name, |
required |
holder
|
object
|
A user or a |
required |
obj
|
Model
|
The model instance. |
required |
remove_perm ¶
Revoke perm from a user or group on obj.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
perm
|
str
|
Full permission name, |
required |
holder
|
object
|
A user or a |
required |
obj
|
Model
|
The model instance. |
required |
grants_for ¶
Every user and group holding permissions on obj.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
Model
|
The model instance. |
required |
get_perms ¶
The subset of perms that user holds on obj.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
object
|
The user (anonymous users hold nothing). |
required |
obj
|
Model
|
The model instance. |
required |
perms
|
Sequence[str]
|
Full permission names to test. |
required |
get_objects_for_user ¶
get_objects_for_user(
user: object,
perms: str | Sequence[str],
queryset: QuerySet[ModelT],
) -> QuerySet[ModelT]
Objects of queryset on which user holds every one of perms.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
object
|
The user. |
required |
perms
|
str | Sequence[str]
|
One or several full permission names, all required. |
required |
queryset
|
QuerySet[ModelT]
|
The objects to filter. |
required |
ninja_devx.security.auth ¶
Typed access to the authenticated user, and building a RequestContext.
AuthedRequest ¶
Bases: HttpRequest, Generic[UserT]
An HttpRequest whose auth (set by Ninja authentication) is UserT.
Use it to annotate request on operations that require authentication::
@get("/me")
def me(self, request: AuthedRequest[User]) -> User:
return request.auth
request_user ¶
The authenticated user: request.auth when it is a user, else request.user.
An unevaluated lazy request.user is not loaded inside an event loop (that
would query the session synchronously); use arequest_user there.
arequest_user
async
¶
request_user for async code: loads the session user with request.auser().
current_user ¶
The authenticated user_type instance; raises AuthenticationError (401) otherwise.
authenticated_user ¶
A scoped factory: container.scoped(User, authenticated_user(User)).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_type
|
type[UserT]
|
The user class; the authenticated user must be one, else 401. |
required |
aauthenticated_user ¶
authenticated_user for async containers and as_permission(asubject=...).
request_context ¶
request_context(
user_type: type[UserT],
tenant: Callable[[HttpRequest, UserT], TenantT]
| None = None,
) -> (
Callable[[HttpRequest], RequestContext[UserT, TenantT]]
| Callable[[HttpRequest], RequestContext[UserT, None]]
)
A scoped factory building RequestContext from the request.
::
container.scoped(RequestContext[User, None], request_context(User))
container.scoped(RequestContext[User, Org], request_context(User, tenant=org_of))
The request id comes from X-Request-ID and the trace id from traceparent
when present. In async operations the async container uses arequest_context.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_type
|
type[UserT]
|
The user class; the authenticated user must be one, else 401. |
required |
tenant
|
Callable[[HttpRequest, UserT], TenantT] | None
|
Returns the tenant from |
None
|
arequest_context ¶
arequest_context(
user_type: type[UserT],
tenant: Callable[[HttpRequest, UserT], TenantT]
| None = None,
) -> (
Callable[
[HttpRequest],
Awaitable[RequestContext[UserT, TenantT]],
]
| Callable[
[HttpRequest],
Awaitable[RequestContext[UserT, None]],
]
)
request_context for async operations (loads session users without blocking).
ninja_devx.http.errors ¶
Map exceptions (domain errors, Django errors, your own) to HTTP responses.
Rules layer like options: operation errors → controller errors →
NINJA_DEVX["ERRORS"] → the defaults. Exceptions implementing
ninja_devx.layers.HttpMappable (every DomainError) map themselves.
At the API level ErrorMap.install(api) registers the same rules with Ninja's own
api.add_exception_handler; mount(api, ..., errors=...) does it for you.
ErrorRule
dataclass
¶
Bases: Generic[E]
body
class-attribute
instance-attribute
¶
Builds the body from the exception (default {detail, code}).
ErrorMap ¶
An immutable set of exception → response rules. Later rules win.
django_defaults
classmethod
¶
Django's ValidationError → 422, ObjectDoesNotExist → 404,
PermissionDenied → 403.
map ¶
map(
exception: type[E],
status: int,
*,
code: str | None = None,
body: Callable[[E], Mapping[str, JSONValue]]
| None = None,
) -> ErrorMap
A new map where exception (and subclasses) produce status.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exception
|
type[E]
|
Exception class; subclasses match too. |
required |
status
|
int
|
HTTP status of the response. |
required |
code
|
str | None
|
Machine-readable code (default: the class name in snake_case). |
None
|
body
|
Callable[[E], Mapping[str, JSONValue]] | None
|
Builds the body from the exception (default |
None
|
rule_for ¶
The rule for exception: closest class in its MRO, later rules first.
mask_validation_input ¶
mask_validation_input(
errors: Iterable[Mapping[str, JSONValue]],
schema: type[object] | None = None,
) -> list[dict[str, JSONValue]]
Mask Sensitive field values a validation error body would otherwise echo back.
Ninja's own request validation already drops pydantic's input key from 422 bodies;
this matters for a raw pydantic.ValidationError you map yourself and whose
.errors() keeps it::
ErrorMap().map(
PydanticValidationError,
422,
code="validation_failed",
body=lambda exc: {"detail": mask_validation_input(exc.errors(), MySchema)},
)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
errors
|
Iterable[Mapping[str, JSONValue]]
|
Error items shaped like pydantic's/Ninja's (a |
required |
schema
|
type[object] | None
|
The schema |
None
|
ninja_devx.exceptions ¶
NinjaDevXError ¶
Bases: Exception
Base class for all ninja-devx errors.
ControllerConfigError ¶
Bases: NinjaDevXError
A controller or one of its operations is declared incorrectly.
DependencyResolutionError ¶
Bases: NinjaDevXError
A dependency could not be resolved by the container.
CircularDependencyError ¶
Bases: DependencyResolutionError
The dependency graph contains a cycle.
AsyncLazyAccessError ¶
Bases: NinjaDevXError
Sync-only Django code (usually a lazy relation) ran inside an async operation.
BlockingCallWarning ¶
Bases: RuntimeWarning
Sync code blocked an async operation's event loop (see NINJA_DEVX["WARN_BLOCKING_MS"]).
AsyncDatabaseTestWarning ¶
Bases: UserWarning
An async test uses the database without django_db(transaction=True).
sync_to_async runs ORM calls on another thread with its own connection, outside
the test's transaction: rows leak between tests or are invisible to the view.
MixedPathWarning ¶
Bases: UserWarning
One path is served by both sync and async operations (costs a thread hop per call).
NinjaDevXDeprecationWarning ¶
Bases: DeprecationWarning
A ninja-devx API scheduled for removal; the message names the replacement.
filterwarnings = ["error::ninja_devx.NinjaDevXDeprecationWarning"] turns every use
into a test failure before upgrading.
Cross-cutting¶
ninja_devx.routing.hooks ¶
Operation metadata and hooks around every operation call.
OperationInfo
dataclass
¶
Static description of an operation, built once per registration.
metadata
class-attribute
instance-attribute
¶
Typed metadata from meta= (controller and operation), for permissions and hooks.
database
class-attribute
instance-attribute
¶
Explicit operation/controller database alias, if configured.
meta ¶
The closest metadata item of kind: operation.meta(RequiresScope).
OperationHook ¶
Bases: Protocol
Wraps each call of an operation, including permission checks.
Exceptions (denials, 404s, errors) propagate through the context manager, so
hooks can observe them. Hooks are shared between requests and must not store
per-request state on self.
AsyncOperationHook ¶
Bases: Protocol
A hook with an async context manager, used by async operations.
A hook may implement both around and around_async; async operations prefer
around_async. Sync-only hooks also run in async operations and are assumed not to
block; set blocking = True on them to run their enter/exit in a thread.
LoggingHook
dataclass
¶
Logs one structured record per operation call with its duration and outcome.
get_operation ¶
The operation handling request, available to permissions, services and hooks.
ninja_devx.dependencies.container ¶
Dependency registration and container ownership.
Container ¶
Constructor-injection container with singleton, scoped and transient lifetimes.
- Unregistered concrete classes are auto-wired as transient.
- Abstract classes, protocols and builtins must be registered.
- Factories may be generators (cleanup after
yield), coroutines or async generators; async ones needaresolve/async with container.scope(). scope()opens a scope anywhere (tasks, commands, tests); insiderequest_scope(request)theHttpRequestitself is injectable.
Keys are typed as Callable[..., T] rather than type[T] so abstract classes
and protocols are accepted by mypy.
singleton ¶
One instance per container, built from factory (defaults to key).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What is requested (a class, abstract class, protocol or generic alias). |
required |
factory
|
Factory[T] | None
|
Builds it: a class, function, generator or async variant (default: |
None
|
scoped ¶
One instance per scope; unavailable outside of one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What is requested. |
required |
factory
|
Factory[T] | None
|
Builds it once per scope (request, or |
None
|
transient ¶
A new instance on every resolution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What is requested. |
required |
factory
|
Factory[T] | None
|
Builds a new value every time it is resolved. |
None
|
register ¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What is requested. |
required |
factory
|
Factory[T] | None
|
Builds it (default: |
None
|
lifetime
|
Lifetime
|
|
required |
instance ¶
Register an already constructed object.
value is checked against key when the key is a (runtime-checkable) class:
type checkers cannot infer it from abstract classes and protocols.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[object]
|
What is requested. |
required |
value
|
object
|
The object returned; checked against class keys at registration. |
required |
override ¶
Temporarily replace a dependency, e.g. with a fake in tests.
Singletons built earlier keep the dependency they were built with.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[object]
|
The dependency to replace. |
required |
value
|
object
|
The replacement, until the |
required |
resolve ¶
Resolve outside of a scope (scoped dependencies are rejected).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What to build (sync factories only). |
required |
aresolve
async
¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[T]
|
What to build (sync and async factories). |
required |
scope ¶
A scope for scoped services, closed (with cleanups) when the block exits.
values are available for injection by key, e.g.
container.scope({RequestContext[User, None]: context}).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
Mapping[object, object] | None
|
Values available to scoped factories, e.g. |
None
|
check ¶
Validate the graph of key without building anything.
Raises DependencyResolutionError for keys that cannot be resolved,
singletons depending on scoped services, and async factories needed by a sync
resolution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Factory[object]
|
What will be requested. |
required |
asynchronous
|
bool
|
Whether async operations will request it (allows async factories). |
False
|
plan ¶
Inspect a factory's parameters once and cache the result.
ninja_devx.routing.mounting ¶
Mount many controllers at once, e.g. per API version.
Mount
dataclass
¶
Per-route overrides for mount.
container
class-attribute
instance-attribute
¶
Container for this entry (default: the mount() container).
scope
class-attribute
instance-attribute
¶
Scope for this entry (default: the mount() scope).
options
class-attribute
instance-attribute
¶
ControllerOptions for this entry, over the mount() options.
mount ¶
mount(
target: NinjaAPI | Router,
routes: Mapping[str, type[Controller] | Mount],
*,
prefix: str = "",
container: ContainerLike | None = None,
scope: Scope | None = None,
**options: Unpack[ControllerOptions],
) -> dict[str, Router]
Build and add a router per route; shared container, scope and options.
::
mount(api, {"/articles": ArticleController, "/authors": AuthorController}, container=c)
mount(api, V1_ROUTES, prefix="/v1", deprecated=True)
mount(api, V2_ROUTES, prefix="/v2")
With a prefix, operation ids and URL names get a matching prefix (v1_) so
OpenAPI ids and URL reversing stay unique when the same controllers are mounted twice.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
NinjaAPI | Router
|
The |
required |
routes
|
Mapping[str, type[Controller] | Mount]
|
|
required |
prefix
|
str
|
Prepended to every route; also prefixes operation ids and URL names (versioning). |
''
|
container
|
ContainerLike | None
|
Shared container for every entry. |
None
|
scope
|
Scope | None
|
Shared scope for every entry. |
None
|
options
|
Unpack[ControllerOptions]
|
|
{}
|
ninja_devx.idempotency ¶
Authenticated, database-backed replay of completed controller responses.
idempotent ¶
idempotent(
*,
header: str = "Idempotency-Key",
ttl: int | None = None,
database: str | None = None,
required: bool = False,
scope: Callable[[HttpRequest], str] | None = None,
) -> ViewDecorator
Replay completed responses after authentication, permissions and bindings.
Install ninja_devx and run its migrations. Unfinished requests remain claimed
until explicitly reconciled; they never expire or repeat automatically. Completed
responses (including errors) are replayed for ttl seconds. Custom authorization
belongs in bindings, before_operation or authorize_replay, which run on retries.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
header
|
str
|
Request header carrying a key of at most 255 characters. |
'Idempotency-Key'
|
ttl
|
int | None
|
Completed response retention; defaults to |
None
|
database
|
str | None
|
Durable database alias; defaults to |
None
|
required
|
bool
|
Reject a missing key with HTTP 400. |
False
|
scope
|
Callable[[HttpRequest], str] | None
|
Stable custom principal and tenant identity, if not model/scalar values. |
None
|
ninja_devx.configuration.settings ¶
Project-wide defaults read from settings.NINJA_DEVX.
::
NINJA_DEVX = {
"DEFAULT_OPTIONS": ControllerOptions(auth=JWTAuth()),
"PAGINATION_CLASS": "ninja.pagination.PageNumberPagination",
"BULK_LIMIT": 500,
}
A class attribute set on a user controller always wins over these settings.
NinjaDevXSettings ¶
Bases: TypedDict
DEFAULT_OPTIONS
instance-attribute
¶
Options applied below every controller's own options.
DOCUMENT_ERRORS
instance-attribute
¶
Add 401/403/404/422 responses to OpenAPI (default True).
VALIDATE_MODEL
instance-attribute
¶
Run Model.full_clean() on CRUD writes; errors are 422.
REFRESH_AFTER_WRITE
instance-attribute
¶
Reload objects through scoped_queryset() after writes.
OPTIMIZE_QUERIES
instance-attribute
¶
Derive select_related/prefetch_related from output schemas (default True).
PAGINATION_CLASS
instance-attribute
¶
Default pagination class for CRUD lists (a class or import path).
IDEMPOTENCY_TTL
instance-attribute
¶
Seconds a stored idempotent() response is replayed.
IDEMPOTENCY_DATABASE
instance-attribute
¶
Database alias for durable idempotency records; must use autocommit.
ERRORS
instance-attribute
¶
Project error rules (an ErrorMap or its import path) added to the defaults.
ERROR_FORMAT
instance-attribute
¶
Error body format: "ninja" ({detail, code}) or "problem+json" (RFC 9457).
ASYNC_MODE
instance-attribute
¶
What mode = "auto" model controllers use (default "sync").
WARN_BLOCKING_MS
instance-attribute
¶
Warn when sync code blocks an async operation's event loop longer than this.
ASYNC_FETCH_MODE
instance-attribute
¶
"raise" makes lazy relation loads fail loudly in async operations (Django 6.1+).
PLUGINS
instance-attribute
¶
ControllerPlugin objects (or import paths) applied to every controller.
TENANT_RESOLVER
instance-attribute
¶
A function of the request returning the tenant (sync or async), or its import path.
TENANT_CONTEXT
instance-attribute
¶
A RequestContext[User, Tenant] key (or its import path) whose tenant is used.
THROTTLE_RATES
instance-attribute
¶
Rates for ScopedRateThrottle scopes, e.g. {"uploads": "10/min"}.
THROTTLE_STORAGE
instance-attribute
¶
A ThrottleStorage (or its import path) used by throttles without their own
storage=; default: cache-based fixed windows.
OBJECT_PERMISSION_BACKEND
instance-attribute
¶
An ObjectPermissionBackend (or its import path); default: grants, guardian, Django.
CHECK_APIS
instance-attribute
¶
NinjaAPI import paths validated by manage.py check.
WEBHOOK_SECRET_KEYS
instance-attribute
¶
Fernet keys encrypting webhook signing secrets at rest (ninja-devx[crypto]); the
first encrypts, all decrypt. Empty: secrets are stored as they are.
class_setting ¶
cls.<attribute> when a user class sets it, otherwise the project default.
ninja_devx.serialization.schemas ¶
Schema helpers: typed partial updates and read-only / write-only fields.
Patch[ArticleIn] validates the fields of ArticleIn that were sent (all optional)
and gives the handler a PatchData: a dict with .changed and .apply(obj).
ReadOnly / WriteOnly mark fields on one schema; Input[S] hides read-only
fields from requests, Output[S] hides write-only fields from responses.
ReadOnly
module-attribute
¶
A field clients cannot set: hidden and ignored in Input[S].
WriteOnly
module-attribute
¶
A field never returned: hidden and not serialized in Output[S].
PatchData ¶
Bases: dict[str, object], Generic[SchemaT]
The validated fields a client sent for a partial update of SchemaT.
patch_type ¶
The body type for Patch[schema] (cached; OpenAPI name <Schema>Patch).
ninja_devx.serialization.pydantic ¶
Typed wrapper around pydantic.create_model for schemas built at runtime.
build_schema ¶
build_schema(
name: str,
base: type[SchemaT],
fields: Mapping[str, tuple[object, object]],
) -> type[SchemaT]
A subclass of base named name with fields (name -> (annotation, default)).
ninja_devx.models ¶
Django model discovery for optional core persistence features, and abstract model bases.
SoftDeletable ¶
Bases: Model
deleted_at/deleted_by with the defaults SoftDeleteMixin expects.
A controller using SoftDeleteMixin needs no soft_delete configuration for a
model built on this base: rows are marked with the time and the requesting user.
Stamped ¶
TimeStamped ¶
Bases: Model
created_at (indexed) and updated_at, maintained by Django.
UserStamped ¶
Bases: Model
created_by and updated_by, set from the request user by model controllers.
Both are nullable so anonymous writes and deleted users leave None.
ninja_devx.stamps ¶
Abstract model bases for bookkeeping columns: timestamps, user stamps, soft deletion.
Combine them with a model and the CRUD controllers fill the columns::
from ninja_devx.models import SoftDeletable, Stamped
class Post(Stamped, SoftDeletable):
title = models.CharField(max_length=200)
created_at/updated_at are maintained by Django (auto_now_add/auto_now);
created_by/updated_by are set from the request user on create and update, and
deleted_at/deleted_by by SoftDeleteMixin. All of them are editable=False,
so generated input schemas, scaffolding and drift checks leave them out.
TimeStamped ¶
Bases: Model
created_at (indexed) and updated_at, maintained by Django.
UserStamped ¶
Bases: Model
created_by and updated_by, set from the request user by model controllers.
Both are nullable so anonymous writes and deleted users leave None.
Stamped ¶
SoftDeletable ¶
Bases: Model
deleted_at/deleted_by with the defaults SoftDeleteMixin expects.
A controller using SoftDeleteMixin needs no soft_delete configuration for a
model built on this base: rows are marked with the time and the requesting user.
ninja_devx.plugins ¶
Application plugins: bundle cross-cutting setup for a NinjaAPI.
A plugin contributes middleware, error rules and startup work in one object, so an application installs a coherent unit instead of wiring each piece::
class Observability(APIPlugin):
def middleware(self) -> Sequence[Middleware]:
return [RequestIDMiddleware(), OpenTelemetryMetricsMiddleware()]
def errors(self) -> ErrorMap | None:
return ErrorMap.django_defaults()
def setup(self, api: NinjaAPI) -> None:
api.title = "My API"
install(api, [Observability()])
Like use_middleware, install runs before routers are mounted. It is distinct from
:class:~ninja_devx.routing.plugins.ControllerPlugin, which extends individual
controllers/operations.
APIPlugin ¶
Base class for an application plugin. Override the parts you need.
install ¶
Install plugins on api: error rules, middleware, then setup.
Error rules of all plugins are merged into one ErrorMap before installation, so a
rule for a subclass wins over a base-class rule regardless of plugin order. Call it
before mounting routers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api
|
NinjaAPI
|
The |
required |
plugins
|
Sequence[APIPlugin]
|
Plugins applied in order. |
required |
CRUD¶
ninja_devx.crud.controllers ¶
Generic model controllers and composable CRUD mixins (sync and async from one source).
The generic arguments are the configuration::
class ArticleController(CRUDController[Article, ArticleOut, ArticleIn]):
owner_field = "author"
search_fields = ("title", "body")
ordering_fields = ("created", "title")
mode picks sync or async operations ("auto" follows NINJA_DEVX["ASYNC_MODE"]).
Writes go through a ModelService (service_class) and its repository, so business
rules live outside HTTP; the controller only adds what comes from the request (owner,
parent) and handles permissions, 404s and responses.
PatchData ¶
Bases: dict[str, object], Generic[SchemaT]
The validated fields a client sent for a partial update of SchemaT.
ModelController ¶
Bases: Controller, Generic[ModelT]
Base for controllers backed by a Django model.
Every hook receives request explicitly, so subclasses stay safe with
Scope.SINGLETON. Class attributes left unset fall back to settings.NINJA_DEVX.
mode
class-attribute
¶
Which implementation of each operation to register: "auto" follows
NINJA_DEVX["ASYNC_MODE"].
lookup_field
class-attribute
¶
Model field matched by the lookup path segment ("slug", "uuid").
lookup_param
class-attribute
¶
Name of the lookup path segment: "slug" exposes /{slug}. Declared paths keep
using {pk}.
lookup_converter
class-attribute
¶
Use Django path converters ({int:pk}): invalid ids get Django's 404 instead of a
JSON 422, and static routes of routers mounted later are never shadowed.
owner_field
class-attribute
¶
Path to the user ("author", "customer__user"): enforced with IsAuthenticated
and IsOwner; a direct foreign key is also set on create.
scope_queryset_to_owner
class-attribute
¶
Also restrict every query (including lists) to the current user's objects.
tenant_field
class-attribute
¶
The field pointing at the tenant ("organization", "project__organization"):
every query is filtered by the current tenant, which is also set on create.
tenant_resolver
class-attribute
¶
Returns the request's tenant (sync or async); defaults to
NINJA_DEVX["TENANT_RESOLVER"]. Assign it with staticmethod(...).
tenant_context
class-attribute
¶
A RequestContext[User, Tenant] key resolved from the container; its tenant is
used. Defaults to NINJA_DEVX["TENANT_CONTEXT"].
object_permissions
class-attribute
¶
Per-object Django permissions (grants or django-guardian): object checks by HTTP method
and, with filter_lists, querysets limited to viewable objects.
sparse_fields
class-attribute
¶
Let clients pick response fields with ?fields=id,title (list and retrieve); the
output schema needs the FieldVisibility mixin. Expandable relations add
?expand=.
expand_param
class-attribute
¶
Query parameter listing the Expandable relations to embed.
etag
class-attribute
¶
Conditional requests: ETag and 304 on reads, If-Match and 412 on writes.
parent
class-attribute
¶
Nest under a parent from the URL, e.g. Parent(Author, field="author").
service_class
class-attribute
¶
The ModelService subclass handling writes, resolved from the container when there
is one. Defaults to a ModelService over a ModelRepository of the model.
validate_model
class-attribute
¶
Run Model.full_clean() before saving (for the default service); errors are 422.
refresh_after_write
class-attribute
¶
Re-fetch through scoped_queryset() after writes so responses see its joins.
optimize_queries
class-attribute
¶
Join/prefetch what the output schema renders; "only" also restricts columns.
related
class-attribute
¶
Explicit lookups the N+1 planner always loads (("author", "comments__user")),
for resolvers or properties it cannot analyse. Also set with @requires_related.
expand_rules
class-attribute
¶
How an expanded to-many relation is loaded ({"comments": ExpandRule(limit=5)}); keys
must be Expandable fields of the output schema.
openapi_examples
class-attribute
¶
Fill an OpenAPI example into the input and output schemas from
ninja_devx.testing.sample (see ninja_devx.serialization.examples.with_examples).
authorize_replay ¶
authorize_replay(
request: HttpRequest,
operation: OperationInfo,
arguments: Mapping[str, object],
) -> object
Recheck current ownership, grants, tenancy and deletion before a replay.
check_write_visibility ¶
check_write_visibility(
request: HttpRequest,
schema: type[BaseModel] | None,
sent: object,
) -> None
Reject writes to fields the caller may not set (WriteVisibleTo).
get_queryset ¶
Override to scope and optimize queries (tenancy, annotations...).
get_tenant ¶
The current tenant (resolved once per request); 403 when there is none.
aprepare_request
async
¶
Load what sync code will need without blocking the event loop: the user and tenant.
scoped_queryset ¶
get_queryset() restricted to the tenant, parent and owner, with N+1 optimizations.
get_object ¶
Fetch one object (404 when missing) and enforce object permissions.
object_etag ¶
The entity tag of instance (etag must be set).
conditional_object ¶
instance, or a 304 when the client's If-None-Match still matches it.
check_preconditions ¶
Before a write: 412 when If-Match is stale, 428 when it is required and missing.
written ¶
After a write: send the new ETag.
refresh ¶
Verify persisted scope and optionally reload the result through its write database.
write_database ¶
Operation alias, explicit queryset alias, or the model's write router.
get_service ¶
The write service: service_class from this call's container, or the default.
context_data ¶
Fields set from the request on create: tenant, owner, parent and user stamps.
update_context_data ¶
Fields set from the request on every update: updated_by for stamped models.
perform_update ¶
Update through the service with the data plus update_context_data.
on_change ¶
on_change(
request: HttpRequest,
instance: ModelT,
changes: Mapping[str, tuple[object, object]],
) -> None
After a successful update, inside the transaction: changes maps field name to
(old, new) (see changed_fields). No-op by default.
check_input_schema
classmethod
¶
Fail at startup when the input schema does not match the model.
Skipped when hook or service_class is customized, since that code may
map the fields itself.
ListConfig ¶
Bases: ModelController[ModelT], Generic[ModelT, OutT]
Filtering, search, ordering, pagination and selectors for list endpoints.
filter_schema
class-attribute
¶
An explicit Ninja FilterSchema for GET / (instead of generated filters).
search_fields
class-attribute
¶
Fields searched with icontains by the search_param query parameter.
search_backend
class-attribute
¶
Replace the default icontains search (for example PostgresSearch()).
filter_fields
class-attribute
¶
Generated typed filters: {"status": ("exact",), "created": ("gte", "lte")}.
filterset_class
class-attribute
¶
A django-filter FilterSet whose filters become query parameters of GET /
(pip install ninja-devx[filters]); it runs with the request.
ordering_fields
class-attribute
¶
Fields the client may order by (?ordering=-created), validated as an enum.
ordering_param
class-attribute
¶
Name of the ordering query parameter.
default_ordering
class-attribute
¶
Ordering when the client sends none; must be among ordering_fields.
pagination_class
class-attribute
¶
A Ninja pagination class (or CursorPagination); defaults to
NINJA_DEVX["PAGINATION_CLASS"].
pagination_options
class-attribute
¶
Keyword arguments for the pagination class ({"page_size": 50}).
selector_class
class-attribute
¶
A selector (resolved from the container when there is one) replacing get_queryset
for lists. Querysets it returns are still ordered, paginated and optimized.
ListMixin ¶
Bases: ListConfig[ModelT, OutT], Generic[ModelT, OutT]
GET / with filtering, search, ordering and pagination.
RetrieveMixin ¶
CreateHooks ¶
Bases: ModelController[ModelT], Generic[ModelT, InT]
perform_create typed with the input schema, shared by every create operation.
perform_create ¶
Create through the service with the payload plus the owner and parent.
CreateMixin ¶
UpdateMixin ¶
Bases: ModelController[ModelT], Generic[ModelT, OutT, InT]
PUT /{pk} (full) and PATCH /{pk} (only the fields sent).
DestroyMixin ¶
ReadOnlyModelController ¶
Bases: ListMixin[ModelT, OutT], RetrieveMixin[ModelT, OutT], Generic[ModelT, OutT]
GET / and GET /{pk}.
CRUDController ¶
Bases: ListMixin[ModelT, OutT], RetrieveMixin[ModelT, OutT], CreateMixin[ModelT, OutT, InT], UpdateMixin[ModelT, OutT, InT], DestroyMixin[ModelT], Generic[ModelT, OutT, InT]
List, retrieve, create, update, partial update and destroy.
ninja_devx.crud.async_controllers ¶
Async CRUD controllers: the same classes as the sync ones, with mode = "async".
Every CRUD mixin carries both implementations (see async_variant); these aliases
just pick the async ones, so routes, operation ids and hooks are identical.
ninja_devx.crud.annotations ¶
Annotations resolved per controller when its router is built.
Lookup
module-attribute
¶
Path parameter typed like the controller's lookup_field (the primary key by default).
Instance
module-attribute
¶
A model instance loaded from the {pk} path segment, with 404 and object permissions.
Locked
module-attribute
¶
Like Instance but loaded with select_for_update() (use on atomic=True operations).
Filters
module-attribute
¶
Query parameters from filter_schema or search_fields/filter_fields.
Ordering
module-attribute
¶
Query parameter ordering restricted to the controller's ordering_fields.
OrderingSchema ¶
Bases: Schema
Ordering chosen by the client; the base class exposes no parameters.
controller_lookup_type ¶
The Python type of the controller's lookup_field.
lookup_param ¶
The URL name of the lookup segment (lookup_param, "pk" by default).
with_path_converter ¶
/{pk} -> /{<lookup_param>}, or /{int:<lookup_param>} with lookup_converter.
Without a converter an invalid id reaches Ninja and gets a JSON 422; with one, Django
answers 404 itself, which keeps routes of routers mounted later (/posts/me) reachable.
query_schema ¶
A query-parameter schema; schemas without required fields may be omitted entirely.
ordering_schema ¶
The ordering schema of a controller, cached so OpenAPI component names stay stable.
ninja_devx.crud.bulk ¶
Bulk create, update and delete with a size limit, in one transaction each.
BulkResultOut
module-attribute
¶
{"results": [{"index", "status", "data"} | {"index", "status", "errors"}]}: the body
of a partial-success (207) bulk response, its data typed like the controller's output.
BulkPatch ¶
Bases: Schema, Generic[SchemaT]
{"pks": [...], "data": {...}}: apply the same partial update to many objects.
BulkDelete ¶
Bases: Schema
{"pks": [...]}.
BulkErrorDetail ¶
Bases: Schema
One item of a bulk 207 entry's errors, in Ninja's validation error shape.
BulkCreateMixin ¶
Bases: _BulkBase[ModelT], CreateHooks[ModelT, InT], Generic[ModelT, OutT, InT]
POST /bulk: create many objects (validation and signals run per object).
bulk_partial
class-attribute
¶
Create each item in its own savepoint: failures become 207 entries (results)
instead of rolling back the whole request.
BulkUpdateMixin ¶
Bases: _BulkBase[ModelT], Generic[ModelT, OutT, InT]
POST /bulk-update: apply one partial update to many objects.
bulk_partial
class-attribute
¶
Update each item in its own savepoint: an unknown pk or a per-item failure becomes a
207 entry (results) instead of rolling back the whole request.
BulkDestroyMixin ¶
Bases: _BulkBase[ModelT], Generic[ModelT]
POST /bulk-delete: delete many objects (perform_destroy per object).
ninja_devx.crud.soft_delete ¶
Soft deletion: hide instead of delete, with a restore endpoint.
Nothing is hard-coded: the field, the values that mean "deleted" and "active", who deleted it, and when, are configured per controller::
class PostController(SoftDeleteMixin[Post, PostOut], CRUDController[Post, PostOut, PostIn]):
soft_delete = SoftDelete("deleted_at") # nullable DateTimeField
soft_delete = SoftDelete("is_deleted") # BooleanField
soft_delete = SoftDelete("is_active", deleted=False, active=True)
soft_delete = SoftDelete("status", deleted="archived", active="published")
soft_delete = SoftDelete("is_deleted", deleted_at="removed_on", deleted_by="removed_by")
The restore route is POST /{pk}/restore; rename or remove it with routes.
SoftDelete
dataclass
¶
How a model marks deleted rows.
deleted is the value (or a zero-argument callable, like timezone.now) written on
delete; active is the value of rows that are not deleted, written on restore. Both
are inferred for a BooleanField (True/False) and a nullable
DateTimeField (now/None); other fields need them explicitly.
field
class-attribute
instance-attribute
¶
The field marking deleted rows.
deleted
class-attribute
instance-attribute
¶
Value written on delete (or a zero-argument callable); inferred for boolean and nullable datetime fields.
active
class-attribute
instance-attribute
¶
Value of rows that are not deleted, written on restore; inferred like deleted.
deleted_at
class-attribute
instance-attribute
¶
Also set this DateTimeField to now on delete (and clear it on restore).
deleted_by
class-attribute
instance-attribute
¶
Also set this foreign key to the current user on delete (and clear it on restore).
Defaults to "deleted_by" for models built on SoftDeletable.
SoftDeleteMixin ¶
Bases: ModelController[ModelT], Generic[ModelT, OutT]
Marks objects deleted instead of deleting them and adds POST /{pk}/restore.
List it before the CRUD base so its perform_destroy wins:
class X(SoftDeleteMixin[...], CRUDController[...]).
soft_delete
class-attribute
¶
A SoftDelete or just the field name.
soft_delete_cascade
class-attribute
¶
Related accessor names marked deleted/restored with this object
(("comments", "attachments")). Only the configured marker field is cascaded.
queryset_with_deleted ¶
scoped_queryset including deleted objects (parent, owner and tenant still apply).
soft_delete_unique ¶
soft_delete_unique(
model: type[Model],
*fields: str,
config: SoftDelete | None = None,
name: str | None = None,
) -> UniqueConstraint
A partial UniqueConstraint that only applies to active (not deleted) rows.
Add it to the model so a deleted row's value can be reused::
class Post(models.Model):
slug = models.SlugField()
deleted_at = models.DateTimeField(null=True)
class Meta:
constraints = [soft_delete_unique(Post, "slug")]
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
type[Model]
|
The model (used to resolve the marker field and its active value). |
required |
fields
|
str
|
The constrained fields. |
()
|
config
|
SoftDelete | None
|
The |
None
|
name
|
str | None
|
Constraint name (default derived from the table and fields). |
None
|
ninja_devx.crud.nested ¶
Nested resources: /authors/{author_pk}/articles.
Parent
dataclass
¶
Scope a model controller to a parent object taken from the URL.
Parent(Author, field="author") expects the router to be mounted under a prefix
containing {author_pk}; every operation then 404s for unknown authors, lists
only that author's objects and assigns the author on create.
lookup_field
class-attribute
instance-attribute
¶
Parent field matched by the URL segment.
param
class-attribute
instance-attribute
¶
Name of the URL segment (default <field>_pk).
tenant_field
class-attribute
instance-attribute
¶
Only find parents of the current tenant ("organization"): other tenants' parents 404.
get_parent ¶
The parent object of a nested operation (loaded before the controller runs).
ninja_devx.crud.nested_writes ¶
Write a parent and its child collections together, in one request and transaction.
::
class OrderController(
NestedWritesMixin[Order, OrderIn], CRUDController[Order, OrderOut, OrderIn]
):
nested = {"items": Nested(OrderItem, "order", OrderItemIn)}
OrderIn must declare items: list[OrderItemIn]; OrderItem.order is the foreign
key back to Order. On create, the parent is saved and then every item, all inside the
operation's write_scope. On update (PUT/PATCH), an item sent with its key
field (default "id") is matched against the parent's existing children and updated in
place; an item sent without it is created; existing children whose key does not appear in
the payload are deleted unless Nested(..., remove_missing=False). A PATCH that
does not send the field at all leaves the children untouched. Validation errors on an
item are reported at <field>.<index>.<...>, e.g. items.2.quantity.
Nested
dataclass
¶
One child collection written alongside its parent.
Nested(OrderItem, "order", OrderItemIn): OrderItem.order is the foreign key to
the parent, OrderItemIn validates each item.
NestedWritesMixin ¶
Bases: ModelController[ModelT], Generic[ModelT, InT]
Adds child collections to create and update, declared with nested.
List it before the CRUD base so its perform_create/perform_update win, like
SoftDeleteMixin.
nested
class-attribute
¶
Child collections keyed by their field on the input schema.
nested_config
classmethod
¶
nested, validated once against the model and input schema.
perform_create ¶
Create the parent, then every declared child collection, one transaction.
perform_update ¶
Update the parent's own fields, then sync any child collections present in data.
ninja_devx.crud.transitions ¶
State transitions exposed as API operations, with permissions, guards and a listing route.
Declare the allowed moves; each becomes POST /{pk}/<name>::
class ArticleController(TransitionsMixin[Article, ArticleOut], CRUDController[...]):
state_field = "status"
transitions = {
"publish": Transition(source=("draft",), target="published", permissions=[IsStaff()]),
"archive": Transition(source=("published",), target="archived"),
}
GET /{pk}/transitions lists the names allowed from the object's current state for the
caller (permissions and guards evaluated, without performing anything). Rename or disable
routes with routes (operation names are transition_<name> and transitions_list).
This stays API-level: it does not integrate with model-level FSMs (django-fsm-2 and
similar). Call a @transition-decorated model method from Transition.on_transition
or on_transition() if a project already has one.
InvalidTransition ¶
Transition
dataclass
¶
One named move from a set of source states to a target state.
source
instance-attribute
¶
States the object must be in for this transition to apply.
permissions
class-attribute
instance-attribute
¶
Added to the controller's permissions for this transition's route only.
guard
class-attribute
instance-attribute
¶
Extra precondition beyond the source state; False also answers 409.
on_transition
class-attribute
instance-attribute
¶
Called after the new state is saved, inside the write transaction.
TransitionsMixin ¶
Bases: ModelController[ModelT], Generic[ModelT, OutT]
Adds POST /{pk}/<name> for each declared transition and GET /{pk}/transitions.
transitions
class-attribute
¶
Transition name to :class:Transition.
transition_allowed ¶
Whether instance's current state and config.guard allow the transition.
Does not check permissions; see allowed_transitions for that.
perform_transition ¶
Write config.target to state_field and run the transition hooks.
Raises InvalidTransition (409) when the current state or the guard refuses.
on_transition ¶
Called after any transition, after Transition.on_transition. No-op by default.
allowed_transitions ¶
Names of the transitions instance's state, guards and permissions allow.
ninja_devx.crud.meta ¶
GET /meta describing a model controller's fields, for building forms and admin UIs.
::
class NoteController(MetaMixin[Note], CRUDController[Note, NoteOut, NoteIn]):
filter_fields = {"status": ("exact",)}
ordering_fields = ("priority",)
search_fields = ("text",)
The response lists every input and output schema field with its type name, whether it is
required or read-only, its max length and its choices (from the matching model field's
choices, or from an enum.Enum schema type), plus the controller's filter_fields,
ordering_fields and search_fields names. A field is read-only when the output schema
carries it but the input schema does not. The structure needs no database access, so it is
computed once and cached per controller class.
ChoiceOut ¶
Bases: Schema
One value/label choice.
FieldMeta ¶
Bases: Schema
What a form or admin UI needs to know about one schema field.
ControllerMeta ¶
Bases: Schema
The body of GET /meta.
MetaMixin ¶
Bases: ModelController[ModelT], Generic[ModelT]
Adds GET /meta, describing the input and output schemas.
ninja_devx.crud.aggregates ¶
GET /stats grouping and aggregating a model controller's scoped list queryset.
::
class OrderController(
AggregateMixin[Order, OrderOut], CRUDController[Order, OrderOut, OrderIn]
):
aggregate_fields = ("status", "region")
aggregate_metrics = {"total": Sum("amount"), "count": Count("id")}
GET /stats?group_by=status&metrics=total&metrics=count groups the same queryset the list
operation would serve (tenant, owner, soft deletion and search/filter_fields all
apply) and answers [{"status": "done", "total": "120.00", "count": 3}, ...]. Omitting
group_by aggregates the whole queryset into one row. group_by is restricted to
aggregate_fields and metrics to the keys of aggregate_metrics (default
["count"]); anything else is a 422.
AggregateParams
module-attribute
¶
Query parameters group_by (from aggregate_fields) and metrics (from
aggregate_metrics, default ["count"]).
AggregateMixin ¶
Bases: ListConfig[ModelT, OutT], Generic[ModelT, OutT]
GET /stats: grouped counts and sums over the controller's scoped queryset.
aggregate_schema_for ¶
The group_by/metrics query schema of a controller (cached).
ninja_devx.crud.optimization ¶
Derive select_related/prefetch_related from output schemas to avoid N+1 queries.
Forward foreign keys rendered as nested schemas are joined. Many-to-many and reverse
relations are prefetched with a Prefetch whose queryset joins their nested foreign
keys, so comments: list[CommentOut] with CommentOut.author: UserOut costs one
query per relation, not one per level.
optimize_queryset(queryset, schema, only=True) also restricts the selected columns
to the fields the schema reads; it is skipped automatically when the schema reads
anything that is not a model field (resolvers, properties), since those could load
deferred columns one row at a time.
ExpandRule shapes an expanded to-many relation (filter, order, a per-parent cap)
through a Prefetch queryset.
ExpandRule
dataclass
¶
How ?expand= loads a to-many relation: filtered, ordered and capped per parent.
::
class ArticleController(ReadOnlyModelController[Article, ArticleOut]):
expand_rules = {
"comments": ExpandRule(filter=Q(published=True), order_by=("-created",), limit=5),
}
Rule keys must name an Expandable field of the output schema (ControllerConfigError
otherwise). limit ranks rows per parent with a window function (a QUALIFY-style
filter on Django 5.0+) or, on Django 4.2, an equivalent correlated subquery; it is skipped,
with ninja_devx.W007, when the relation is not a plain reverse foreign key (a
many-to-many, for example).
QueryPlan
dataclass
¶
How to load model for schema: joins, prefetches (each with its own plan).
lookups ¶
Flat (select_related, prefetch_related) lookups, for inspection.
requires_related ¶
Declare relations a schema resolver reads, so the planner loads them explicitly.
@requires_related("author", "comments__user") on a resolve_<field> method adds
the lookups to the query plan even though a resolver body cannot be analysed.
query_plan ¶
query_plan(
model: type[Model],
schema: type[BaseModel],
expand: frozenset[str] = frozenset(),
hints: Sequence[str] = (),
) -> QueryPlan
The (cached) loading plan for serializing model instances with schema.
Expandable relations are loaded only when named in expand. hints are extra
select_related/prefetch_related lookups applied on top of the derived plan.
related_lookups ¶
related_lookups(
model: type[Model], schema: type[BaseModel]
) -> tuple[tuple[str, ...], tuple[str, ...]]
(select_related, prefetch_related) lookups to serialize model with schema.
resolve_expand_attribute ¶
The model attribute an Expandable schema field name reads (its alias, if any).
expand_limit_target ¶
(partition column, related model) for capping lookup per parent, or None
when lookup is not a plain reverse foreign key of model.
ninja_devx.crud.pagination ¶
Pagination built on Ninja's paginators: cursor and limit/offset.
CursorPagination is the cursor paginator that follows the list's ordering.
Ninja's CursorPagination orders by a fixed tuple chosen when the view is decorated.
This subclass takes the ordering of each request instead (?ordering= restricted to
ordering_fields, else default_ordering, else the model's Meta.ordering, else
-pk), adds the primary key as a tiebreaker, and rejects a cursor created for a
different ordering::
class EventController(ReadOnlyModelController[Event, EventOut]):
pagination_class = CursorPagination
pagination_options = {"page_size": 50}
ordering_fields = ("created", "priority")
default_ordering = ("-created",)
Cursors compare the first ordering field, so ordering fields must not be nullable; that is checked at startup.
CursorPagination ¶
LimitOffsetPagination ¶
Bases: LimitOffsetPagination
?limit=&offset= pages with stable ordering, links and a configurable count.
::
class EventController(ReadOnlyModelController[Event, EventOut]):
pagination_class = LimitOffsetPagination
pagination_options = {"limit": 50, "max_limit": 200, "count": False}
Compared with Ninja's LimitOffsetPagination:
- pages never overlap or skip rows: the primary key is added to the ordering;
next/previouslinks keep the other query parameters;limitabovemax_limitis clamped instead of rejected, andmax_offsetturns deep offsets (slow on large tables) into a 422 suggesting cursor pagination.
count picks how the total is produced (the response body's count is always a
plain int or null):
True(default): an exactCOUNT(*).False: no count query (countisnull); one extra row is fetched instead, to know whether there is a next page."estimate": on PostgreSQL,pg_class.reltuples(instant, approximate) for an unfiltered queryset; an exact count otherwise, and on every other database.- an integer
N: exact while there are at mostNrows; beyond that,countisNandPaginationHeadersMiddlewaresendsX-Total-Count: N+instead ofN.
ninja_devx.crud.auto ¶
CRUD controllers whose schemas are generated from the model by Ninja's create_schema.
::
class PostController(AutoCRUDController[Post]):
read_only_fields = ("author", "created")
write_only_fields = ("secret",)
- The output schema (
PostOut) hasschema_fieldsminusschema_excludeandwrite_only_fields. - The input schema (
PostIn) also leaves out the primary key, non-editable fields (auto_now,GeneratedField) andread_only_fields. Fields with adb_defaultare optional; left out, the database supplies the value. - Everything else (filters, permissions, tenancy, pagination...) is configured as on
CRUDController. Switch to explicit schemas once they need custom fields.
AutoSchemas ¶
Which model fields the generated schemas contain.
schema_fields
class-attribute
¶
Model fields in the schemas (field names; "__all__": every concrete and m2m field).
schema_exclude
class-attribute
¶
Fields left out of both schemas.
read_only_fields
class-attribute
¶
Fields in responses only. The primary key and non-editable fields always are.
write_only_fields
class-attribute
¶
Fields in requests only (password).
AutoReadOnlyController ¶
Bases: ReadOnlyModelController[ModelT, BaseModel], AutoSchemas, Generic[ModelT]
GET / and GET /{pk} with a generated output schema.
AutoCRUDController ¶
Bases: CRUDController[ModelT, BaseModel, BaseModel], AutoSchemas, Generic[ModelT]
Full CRUD with generated <Model>Out / <Model>In schemas.
model_schemas ¶
model_schemas(
model: type[Model],
*,
fields: Sequence[str] | Literal["__all__"] = "__all__",
exclude: Sequence[str] = (),
read_only: Sequence[str] = (),
write_only: Sequence[str] = (),
) -> tuple[type[Schema], type[Schema]]
(<Model>Out, <Model>In) generated with Ninja's create_schema (cached).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
type[Model]
|
The Django model. |
required |
fields
|
Sequence[str] | Literal['__all__']
|
Field names to include, or |
'__all__'
|
exclude
|
Sequence[str]
|
Field names left out of both schemas. |
()
|
read_only
|
Sequence[str]
|
Field names only in the output schema. |
()
|
write_only
|
Sequence[str]
|
Field names only in the input schema. |
()
|
ninja_devx.crud.transfer ¶
CSV and JSON Lines export and import for model controllers.
::
class ContactController(ExportMixin[Contact, ContactOut], ImportMixin[Contact, ContactIn],
CRUDController[Contact, ContactOut, ContactIn]):
export_formats = ("csv", "jsonl")
GET /export?format=csvstreams every object the list operation would return (same filters, ordering, scoping and field visibility), without pagination.POST /importtakes a CSV or JSONL upload, validates every row with the input schema and creates the objects throughperform_createin one transaction: either all rows are imported or none (?dry_run=trueonly validates). Errors are 422 withloc: ["file", <row>, <field>].
ExportMixin ¶
Bases: ListConfig[ModelT, OutT], Generic[ModelT, OutT]
GET /export: the filtered list as CSV or JSON Lines, streamed.
export_formats
class-attribute
¶
Formats clients may ask for; the first is the default.
export_filename
class-attribute
¶
Download name without extension (default: the model's plural name).
csv_escape_formulas
class-attribute
¶
Prefix text cells starting with = + - @ with ' so spreadsheets don't run them.
export_sensitive
class-attribute
¶
Include Sensitive output fields unmasked (default: exported as "***").
ImportMixin ¶
Bases: CreateHooks[ModelT, InT], Generic[ModelT, InT]
POST /import: create objects from a CSV or JSON Lines file, all or nothing.
ninja_devx.crud.sharing ¶
Share objects over the API: list, grant and revoke object permissions per user or group.
::
class DocumentController(ObjectSharingMixin[Document], CRUDController[Document, Out, In]):
object_permissions = ObjectPermissions()
shareable_permissions = ("view", "change") # codename prefixes clients may grant
Routes (rename or disable them with routes):
GET /{pk}/permissions— who holds what on the object;PUT /{pk}/permissions— set a user's or group's permissions (replaces them);POST /{pk}/permissions/revoke— remove a user's or group's permissions.
Managing sharing requires sharing_permission on the object (change by default).
ObjectSharingMixin ¶
Bases: ModelController[ModelT], Generic[ModelT]
Endpoints managing object permissions (grants backend or django-guardian).
shareable_permissions
class-attribute
¶
Permission actions clients may grant (view means <app>.view_<model>).
sharing_permission
class-attribute
¶
Action the caller needs on the object to see or change its sharing.
validate_holder ¶
Override to restrict who objects may be shared with (raise HttpError(422)),
e.g. members of the same workspace. Revoking is always allowed.
ninja_devx.crud.search ¶
Pluggable full-text search for list endpoints.
Without a backend, search_fields become icontains lookups in the generated filter
schema. Set search_backend on a controller to replace that, for example with
PostgreSQL's SearchVector; the search parameter stays documented either way::
from ninja_devx.crud import PostgresSearch
class ArticleController(CRUDController[Article, ArticleOut, ArticleIn]):
search_fields = ("title", "body")
search_backend = PostgresSearch(config="english")
SearchBackend ¶
Bases: Protocol[ModelT]
Turns a search term and field names into a filtered queryset (lazy).
IContainsSearch ¶
Case-insensitive substring match across the fields, as a backend.
Equivalent to the built-in behaviour; a starting point for custom backends.
PostgresSearch ¶
PostgreSQL full-text search across the fields with a shared language config.
The vector is computed per query. Large tables need a stored SearchVectorField
with a GIN index and a backend that filters on it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
str
|
|
'english'
|
ninja_devx.crud.fields ¶
Django model field introspection shared by lookups, filters, nesting and scaffolding.
resolve_field ¶
The field at path ("pk", "title", "author__username").
field_type ¶
The Python type values of field have; relations use the target's key type.
With choices=True a field with choices becomes Literal[...] of its values.
lookup_type ¶
The Python type of model.<lookup_field> (used for {pk} path parameters).
ninja_devx.crud.filters ¶
ninja_devx.crud.shaping ¶
?fields= and ?expand= query parameters for model controllers.
partial_schema ¶
schema with every field optional in OpenAPI (<Name>Partial), for responses
shaped by ?fields=. Validation and serialization are unchanged.
ninja_devx.crud.persistence ¶
Model persistence: re-exports from ninja_devx.layers.persistence, plus changed_fields.
model_field ¶
The concrete or many-to-many field called name (or whose attname is name).
save_instance ¶
save_instance(
instance: ModelT,
data: Mapping[str, object],
*,
validate: bool = True,
using: str | None = None,
) -> ModelT
Assign data to instance, validate, save and set many-to-many relations.
Foreign keys accept an instance or a primary key. On a new instance, None for a
non-null field with db_default leaves the value to the database. Model.full_clean
errors are raised as ValidationFailed. Runs in one transaction (a savepoint when
nested).
validation_failed ¶
A Django ValidationError as a domain ValidationFailed (HTTP 422 when mapped).
changed_fields ¶
{field: (old, new)} for the keys in data that differ from instance.
Call before writing data to instance: old is read from instance as it
stands, new is the incoming value. Foreign keys are compared by primary key;
many-to-many fields are not compared (call this before .set() has any effect anyway).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instance
|
Model
|
The object about to be updated. |
required |
data
|
Mapping[str, object]
|
The fields being written. |
required |
Layers¶
ninja_devx.layers.errors ¶
Domain errors that carry their HTTP meaning without importing any web code.
Raise them from services, repositories and policies; ninja_devx.http.errors.ErrorMap
turns anything with http_status and code into a response. Your own exceptions
can do the same by defining those two class attributes (see HttpMappable).
HttpMappable ¶
DomainError ¶
Bases: Exception
Base class for business errors: 400 unless a subclass says otherwise.
http_status
class-attribute
¶
HTTP status when mapped (400 unless overridden).
default_message
class-attribute
¶
detail when none is given, translated with gettext (default: the docstring).
__init__ ¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
The |
''
|
details
|
JSON
|
Extra JSON fields merged into the body. |
{}
|
NotFound ¶
Conflict ¶
PermissionDenied ¶
ValidationFailed ¶
ninja_devx.layers.repository ¶
Repositories: persistence behind a small, HTTP-free interface.
Repository ¶
Bases: Protocol[ModelT]
Loads and stores one aggregate type. Implement it however your project stores data.
transaction ¶
The unit of work services wrap writes in (transaction.atomic for Django).
ModelRepository ¶
Bases: Generic[ModelT]
The Django ORM repository, sync and async.
Subclass with the model as argument (class PostRepository(ModelRepository[Post]))
or pass model=. Async methods use the async ORM for reads and one thread hop
(with the transaction) for writes.
ninja_devx.layers.services ¶
Services: business operations over repositories, callable from sync and async code.
ModelService ¶
Bases: Generic[ModelT]
Create, update and delete through a repository; override to add business rules.
CRUDController sends its writes here when service_class is set::
class PostService(ModelService[Post]):
@dual
def create(self, data: Mapping[str, object]) -> Post:
return super().create({**data, "slug": slugify(data["title"])})
Dependencies come from the container (PostService(repository: PostRepository));
without a registered repository a ModelRepository for the model is used.
ninja_devx.layers.dual ¶
Write a method once, call it from sync and async code.
::
class OrderService:
@dual
def place(self, command: PlaceOrder) -> Order: ...
service.place(command) # sync
await service.place.a(command) # async: one thread hop, or the native implementation
@place.native
async def _place_async(self, command: PlaceOrder) -> Order: ...
Django's ORM is synchronous underneath, so the default .a runs the sync body in a
single sync_to_async(thread_sensitive=True) hop. Provide native only when the
async version really avoids blocking (HTTP calls, asyncio libraries...).
dual ¶
Bases: Generic[S, P, R]
A method with a sync implementation and an optional native async one.
native ¶
native(
function: Callable[Concatenate[S, P], Awaitable[R]],
) -> Callable[Concatenate[S, P], Awaitable[R]]
Register the native async implementation used by .a.
BoundDual ¶
Bases: Generic[P, R]
dual bound to an instance: call it, or await .a(...).
ninja_devx.layers.context ¶
Who is acting, for which tenant, in which request: passed to services without HTTP.
RequestContext
dataclass
¶
Bases: Generic[UserT, TenantT]
The acting user and tenant plus correlation ids.
Register a factory for the concrete key and inject it where needed::
container.scoped(RequestContext[User, None], request_context(User))
Outside HTTP (tasks, commands, tests) build one directly and open a scope::
with container.scope({RequestContext[User, None]: RequestContext(user, None)}) as scope:
scope.resolve(OrderService).place(...)
ninja_devx.layers.tasks ¶
Work that must happen after the transaction commits: tasks, events, emails.
Enqueueable ¶
Bases: Protocol[P]
django.tasks tasks (and anything similar): task.enqueue(*args, **kwargs).
TaskQueue ¶
Bases: Protocol
Where services send background work; swap implementations in tests.
OnCommitTaskQueue
dataclass
¶
Enqueues after commit (django.tasks arguments must be JSON-serializable: pass ids).
using
class-attribute
instance-attribute
¶
Database alias whose commit triggers the work.
ImmediateTaskQueue
dataclass
¶
Runs work right away (scripts, or tests that want the side effects).
RecordingTaskQueue
dataclass
¶
Records work instead of running it; run_all() executes it in order.
calls
class-attribute
instance-attribute
¶
calls: list[
tuple[object, tuple[object, ...], dict[str, object]]
] = field(
default_factory=list[
tuple[object, tuple[object, ...], dict[str, object]]
]
)
Recorded (task_or_function, args, kwargs) in order.
after_commit ¶
Run function when the current transaction commits, or now outside one.
ninja_devx.layers.policies ¶
Policies: one authorization rule usable from services and (via permissions) HTTP.
ninja_devx.layers.selectors ¶
Selectors: read queries with a name, shared by endpoints, tasks and services.
Selector ¶
Bases: Protocol[FilterT_contra, ModelT_co]
Returns a queryset (paginated, ordered and optimized by list endpoints) or a list.
ninja_devx.layers.persistence ¶
Writing validated data to Django models, without any web dependency.
model_field ¶
The concrete or many-to-many field called name (or whose attname is name).
validation_failed ¶
A Django ValidationError as a domain ValidationFailed (HTTP 422 when mapped).
save_instance ¶
save_instance(
instance: ModelT,
data: Mapping[str, object],
*,
validate: bool = True,
using: str | None = None,
) -> ModelT
Assign data to instance, validate, save and set many-to-many relations.
Foreign keys accept an instance or a primary key. On a new instance, None for a
non-null field with db_default leaves the value to the database. Model.full_clean
errors are raised as ValidationFailed. Runs in one transaction (a savepoint when
nested).
ninja_devx.layers.testing ¶
Fakes for testing services without a database.
RecordingTaskQueue
dataclass
¶
Records work instead of running it; run_all() executes it in order.
calls
class-attribute
instance-attribute
¶
calls: list[
tuple[object, tuple[object, ...], dict[str, object]]
] = field(
default_factory=list[
tuple[object, tuple[object, ...], dict[str, object]]
]
)
Recorded (task_or_function, args, kwargs) in order.
InMemoryRepository ¶
Bases: Generic[ModelT]
A Repository keeping unsaved model instances in a dict (no database, no signals).
make_context ¶
A RequestContext for tests: make_context(user, None).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
UserT
|
The acting user. |
required |
tenant
|
TenantT
|
The tenant, or |
required |
metadata
|
str
|
String metadata. |
{}
|
CQRS and DDD¶
ninja_devx.cqrs.messages ¶
Application messages: optional markers for commands and queries.
Subclassing is optional; any frozen dataclass works with use_case and use_query.
The markers document intent and let type checkers (and devx_inspect) tell a write from
a read.
ninja_devx.cqrs.events ¶
Domain events and an explicit, in-app event bus.
There is no global registry or automatic scanning: the application owns an EventBus
(usually a container singleton), subscribes handlers in its wiring, and publishes events
from use cases. Delivery goes through a :class:~ninja_devx.layers.TaskQueue, so handlers
run after the current transaction commits.
DomainEvent ¶
Base for a domain event; use a frozen dataclass subclass.
EventBus ¶
A registry that delivers domain events after the current transaction commits.
Register it in the container and publish through it::
container.singleton(EventBus, EventBus(OnCommitTaskQueue()))
bus = container.resolve(EventBus)
bus.subscribe(OrderPlaced, notify_followers)
bus.publish(OrderPlaced(order_id=order.pk))
Handlers are plain callables (event) -> object. Run in-process, they enqueue
django.tasks work or defer to the outbox for durable, cross-process delivery.
subscribe ¶
Call handler for every published instance of event.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
type[EventT]
|
The event class (subclasses are not matched automatically). |
required |
handler
|
Callable[[EventT], object]
|
A callable receiving the event. |
required |
publish ¶
Run every handler subscribed to type(event) after commit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
DomainEvent
|
The event instance to deliver. |
required |
ninja_devx.cqrs.unit_of_work ¶
One transaction across several repositories: a unit of work.
ModelService already wraps each write in repository.transaction(). Use a
UnitOfWork when a use case combines several repository or ORM operations that must
commit or roll back together, independent of any single repository. It is synchronous;
async code runs it through sync_to_async like any other transaction.atomic block.
UnitOfWork ¶
Wrap a block in one transaction.atomic.
::
with UnitOfWork():
orders.add(...)
stock.change(...)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str | None
|
Database alias (default: Django's write router per model). |
None
|
durable
|
bool
|
|
False
|
HTTP features¶
ninja_devx.security.tenancy ¶
Multi-tenancy: find the current tenant once per request and scope queries with it.
Model controllers with tenant_field filter every query by the tenant, set it on create,
reject input schemas that accept it, and 404 for other tenants' objects::
class ProjectController(CRUDController[Project, ProjectOut, ProjectIn]):
tenant_field = "organization"
The tenant comes from the first source that is configured:
tenant_resolveron the controller, thenNINJA_DEVX["TENANT_RESOLVER"]: a function of the request (sync orasync def) returning the tenant orNone;tenant_contexton the controller, thenNINJA_DEVX["TENANT_CONTEXT"]: aRequestContext[User, Tenant]key resolved from the controller's container;request.tenant, as set by tenant middleware (django-tenants and similar).
A request without a tenant gets 403. The tenant is cached on the request, so services can
read it with current_tenant(request).
MissingTenant ¶
current_tenant ¶
The tenant already resolved for this request, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
HttpRequest
|
The current request. |
required |
current_tenant_for ¶
current_tenant_for(
request: HttpRequest,
*,
resolver: TenantResolver | None,
context: object | None,
invocation: Invocation | None,
) -> object
Resolve (once) and return the tenant; raises MissingTenant (403) without one.
acurrent_tenant_for
async
¶
acurrent_tenant_for(
request: HttpRequest,
*,
resolver: TenantResolver | None,
context: object | None,
invocation: Invocation | None,
) -> object
current_tenant_for for async operations: sync resolvers run in one thread hop.
ninja_devx.http.conditional ¶
Conditional requests (RFC 9110): ETag, If-None-Match → 304, If-Match → 412.
Model controllers enable them with etag::
class PostController(CRUDController[Post, PostOut, PostIn]):
etag = ETag() # tag = hash of the rendered representation
etag = ETag(field="updated_at") # tag = the version field: no extra serialization
etag = ETag(require_if_match=True) # writes without If-Match get 428
GET /{pk}sendsETagand answersIf-None-Matchwith 304 before serializing.GET /sends anETagof the rendered page and answersIf-None-Matchwith 304.PUT/PATCH/DELETEcompareIf-Matchwith the current object and fail with 412 when it changed in between (optimistic locking); successful writes send the new tag.
Any other operation can use the response-level version: decorators=[conditional()].
Weak comparison is used for If-None-Match (RFC 9110 §13.1.2); If-Match: * always passes.
PreconditionFailed ¶
PreconditionRequired ¶
ETag
dataclass
¶
field
class-attribute
instance-attribute
¶
A field that changes on every write (updated_at, version). None hashes the
output schema's representation instead, which costs one extra serialization per check.
weak
class-attribute
instance-attribute
¶
Weak tags (W/"...") survive compression and JSON formatting differences.
require_if_match
class-attribute
instance-attribute
¶
Writes without If-Match fail with 428 Precondition Required.
lists
class-attribute
instance-attribute
¶
Also tag list responses (from their rendered body).
entity_tag ¶
A quoted entity tag for value (strings, numbers, dates, JSON-like data).
remember_etag ¶
Send tag as the response's ETag (used by conditional()-wrapped views).
not_modified ¶
A 304 when If-None-Match matches tag on a safe request.
check_if_match ¶
412 when If-Match does not match tag; 428 when required and missing.
conditional ¶
An operation decorator adding ETag and 304 handling to successful GET responses.
With from_body the tag hashes the rendered response; otherwise only tags set with
remember_etag are used.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
from_body
|
bool
|
Hash the rendered body when no tag was set with |
True
|
ninja_devx.http.throttling ¶
Rate limits for Ninja's throttle= option: per user, per client, per tenant, per scope.
::
NINJA_DEVX = {"THROTTLE_RATES": {"uploads": "10/min", "search": "120/min"}}
class UploadController(Controller):
options = ControllerOptions(throttle=[UserRateThrottle("1000/day")])
@post("/", throttle=[ScopedRateThrottle("uploads"), ClientRateThrottle(anon="5/min")])
def upload(self, request: HttpRequest, file: UploadedFile) -> Upload: ...
They plug into Ninja's own throttling, so rejected requests get Ninja's 429 with a
Retry-After header. Unlike Ninja's built-ins they:
- keep no per-request state on the (shared) throttle object, so they are thread-safe;
- count in fixed windows through a
ThrottleStorage, so limits hold across processes; - read
NINJA_DEVX["THROTTLE_RATES"]at request time, sooverride_settingsworks; - accept rates like
"100/min","1000/day"or"20/5min".
The default storage counts with cache.add + cache.incr, which is atomic on Redis
and Memcached but not elsewhere (a crash between the two calls loses one hit). Pass
storage= (or set NINJA_DEVX["THROTTLE_STORAGE"]) to plug in something stricter,
such as ninja_devx.contrib.redis_throttle.RedisThrottleStorage.
ThrottleStorage ¶
Bases: Protocol
Where throttles count hits. key already identifies the current fixed window.
hit ¶
Count one hit against key, creating its window (window_seconds TTL) on
the first hit, and return (count, retry_after): the count including this hit,
and the seconds remaining until the window resets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
Unique to the throttle, identity and current window. |
required |
window_seconds
|
int
|
The window's length, used as the new key's TTL. |
required |
CacheThrottleStorage
dataclass
¶
Default storage: a Django cache, via cache.add + cache.incr.
Atomic on Redis and Memcached, so limits hold across processes; on other backends a crash between the two calls can lose one hit.
cache_alias
class-attribute
instance-attribute
¶
Cache alias (settings.CACHES) counters are stored under.
RateThrottle ¶
Bases: BaseThrottle
Base class: rate requests per window for each identity from identify().
identify ¶
The identity to count for, or None to not throttle this request.
UserRateThrottle ¶
AnonRateThrottle ¶
ClientRateThrottle ¶
ScopedRateThrottle ¶
Bases: ClientRateThrottle
A named limit whose rate comes from NINJA_DEVX["THROTTLE_RATES"][scope].
Each user (or anonymous client IP) has its own counter per scope, so
ScopedRateThrottle("uploads") on several operations shares one budget.
TenantRateThrottle ¶
parse_rate ¶
"100/min" → (100, 60); "20/5min" → (20, 300).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rate
|
str
|
Requests per period, e.g. |
required |
ninja_devx.serialization.visibility ¶
Field visibility by role: one output schema, fields shown only to who may see them.
::
class EmployeeOut(FieldVisibility, Schema):
id: int
name: str
salary: Annotated[Decimal | None, VisibleTo(IsStaff())] = None
email: Annotated[str | None, VisibleTo(IsStaff() | IsOwner("user"), hidden="omit")] = None
VisibleTotakes the same permissions as operations (&,|,~work).- A hidden field is serialized as
null(hidden="null", the default) or left out (hidden="omit"). Declare it optional (T | None = None) so OpenAPI and clients know. - Request-level checks (
IsStaff,HasDjangoPermission) work on anySchema. - Object-level checks (
IsOwner, aPolicy) andhidden="omit"need theFieldVisibilitymixin, which knows the object being serialized. Without it an object-level rule hides the field: it fails closed. - Checks run during serialization, with the request Ninja passes to pydantic, so they are
synchronous; in async operations the user is loaded before (
aprepare_request).
VisibleTo
dataclass
¶
__init__ ¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
permissions
|
BasePermission[Never]
|
All must allow; each counts as its request check and its object check. |
()
|
hidden
|
Literal['null', 'omit']
|
|
'null'
|
WriteVisibleTo
dataclass
¶
A field only some callers may write; checked by model controllers before persisting.
Use request-level permissions (IsStaff, a policy on request); object-level
checks fail closed because the object is not known when the payload arrives::
class ArticleIn(Schema):
title: str
featured: Annotated[bool, WriteVisibleTo(IsStaff())] = False
A non-staff create or update that sends featured is rejected with 403.
__init__ ¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
permissions
|
BasePermission[Never]
|
All must allow the request, or the field is rejected. |
()
|
Expandable
dataclass
¶
A relation rendered as its key, or as a nested schema when the client asks.
author: Annotated[int | UserOut, Expandable()] is 1 by default and
{"id": 1, "username": "ada"} with ?expand=author. The unexpanded form reads
only the foreign key column (author_id), and the query planner joins the relation
only when it is expanded. Needs the FieldVisibility mixin on the schema.
source
class-attribute
instance-attribute
¶
Attribute holding the key when not expanded (default <field>_id).
ResponseShape
dataclass
¶
What the client asked for (?fields= and ?expand=) for one response schema.
FieldVisibility ¶
Schema mixin for response shaping: object-level VisibleTo rules,
hidden="omit", ?fields= sparse fieldsets and Expandable relations.
List it before Schema: class EmployeeOut(FieldVisibility, Schema).
write_markers ¶
{field name: WriteVisibleTo} of an input schema.
forbidden_writes ¶
Names of sent fields the caller may not write for schema.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[object]
|
The input schema. |
required |
sent
|
object
|
Field names actually submitted. |
required |
request
|
HttpRequest | None
|
The current request. |
required |
expandable_fields ¶
{field name: Expandable} of a pydantic schema.
ninja_devx.serialization.privacy ¶
Mark schema fields that must never appear in logs, error echoes or plain exports.
::
class UserIn(Schema):
email: str
password: Annotated[str, Sensitive()]
Sensitive is metadata only; it does not change validation or serialization by itself.
Callers that might otherwise echo raw field values (the CRUD CSV/JSONL export, a custom
error body) call redact_payload on the data they are about to emit.
Sensitive
dataclass
¶
Field metadata: Annotated[str, Sensitive()]. Carries no configuration.
sensitive_fields ¶
Field and alias names on schema annotated with Sensitive.
Both spellings are returned because a dump may be keyed by either, depending on
whether it used by_alias.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[object]
|
A pydantic model (or |
required |
mask ¶
Replace a sensitive value with "***" (None stays None; lists are masked
element-wise).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
object
|
The value to mask. |
required |
redact_payload ¶
A copy of data with every Sensitive field of schema masked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[object]
|
The pydantic model |
required |
data
|
Mapping[str, object]
|
A mapping keyed by field name or alias, such as a |
required |
ninja_devx.serialization.examples ¶
OpenAPI examples generated from ninja_devx.testing.sample.
::
ArticleOut = with_examples(ArticleOut)
Built field by field, so one field with no sample rule (no default, no example, no type
ninja_devx.testing.sample knows how to derive) is just left out instead of failing the
whole schema. Model controllers do this automatically for their input and output schemas
with openapi_examples = True (see ninja_devx.crud.controllers.ModelController).
with_examples ¶
schema, with an OpenAPI example filled in from its fields' derived sample values.
Mutates schema.model_config in place and returns the same class, so it composes
with the rest of a schema's definition: ArticleOut = with_examples(ArticleOut).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[SchemaT]
|
The schema to add an example to. |
required |
ninja_devx.http.middleware ¶
Middleware for Ninja routers and APIs, including plain function views.
Built on Ninja's add_decorator(..., mode="view"): a middleware wraps the whole
operation (authentication, throttling, validation and the view), so it also sees the
401/422/429 responses::
use_middleware(api, RequestIDMiddleware(), ServerTimingMiddleware()) # every operation
use_middleware(router, DeprecationMiddleware(sunset=datetime(2027, 1, 1, tzinfo=UTC)))
ControllerOptions(middleware=[RateLimitHeadersMiddleware()]) # one controller
mount(api, V1, prefix="/v1", middleware=[DeprecationMiddleware(...)]) # a version
Write your own by subclassing Middleware and overriding process_request (return a
response to short-circuit) and/or process_response. Override the a-prefixed
methods too when the work is I/O in async operations.
Middleware ¶
Hooks around an operation. Both methods are optional.
process_request ¶
Runs first; return a response to skip the operation.
process_response ¶
Runs last, on every response (including errors).
process_exception ¶
Release per-request resources when request, handler or response processing fails.
This is a cleanup hook; the original exception (including cancellation) is re-raised.
RequestIDMiddleware
dataclass
¶
Bases: Middleware
Accept or create a request id, expose it to the request and echo it in the response.
ServerTimingMiddleware ¶
DeprecationMiddleware
dataclass
¶
Bases: Middleware
Deprecation (RFC 9745), Sunset (RFC 8594) and Link headers for old versions.
RateLimitHeadersMiddleware ¶
Bases: Middleware
RateLimit-Limit/Remaining/Reset and RateLimit-Policy from ninja-devx throttles.
The most restrictive throttle of the request is reported. Added automatically to
controllers whose throttle uses ninja_devx.http.throttling classes.
get_request_id ¶
The id RequestIDMiddleware accepted or created for request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
HttpRequest
|
The current request. |
required |
middleware_decorator ¶
A Ninja mode="view" decorator running middlewares (first is outermost).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
middlewares
|
Middleware
|
Middleware instances; the first one is outermost. |
()
|
use_middleware ¶
Apply middlewares to every operation of an API or router (call before mounting).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
NinjaAPI | Router
|
A |
required |
middlewares
|
Middleware
|
Middleware instances; the first one is outermost. |
()
|
record_rate_limit ¶
record_rate_limit(
request: HttpRequest,
*,
policy: str,
limit: int,
remaining: int,
reset: float,
window: int,
) -> None
Remember a throttle's state for RateLimitHeadersMiddleware (throttles call it).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
HttpRequest
|
The current request. |
required |
policy
|
str
|
Throttle name, shown in |
required |
limit
|
int
|
Requests allowed per window. |
required |
remaining
|
int
|
Requests left in the current window. |
required |
reset
|
float
|
Seconds until the window resets. |
required |
window
|
int
|
Window length in seconds. |
required |
ninja_devx.http.versioning ¶
Serve an older response shape, negotiated by a request header.
Declare the schemas a controller used to return, keyed by the version number clients
asked for; the current shape is whatever the operation already declares with response=::
class PostOutV1(Schema):
id: int
title: str
class PostController(VersionedResponseMixin, CRUDController[Post, PostOut, PostIn]):
response_versions = {1: PostOutV1}
A client sending Accept-Version: 1 gets PostOutV1 (and list[PostOutV1] for list
operations), re-validated from the very same rendered response, so there is no second
database round trip. Omitting the header serves the latest shape. Every response carries
X-API-Version naming the version actually served; an unknown Accept-Version answers
406 in the project's error format.
Only single-schema and list[...] responses can be downgraded; other shapes (status-keyed
responses without one 2xx entry, dict bodies...) still get the header and 406 handling,
but are served unchanged since there is no schema to validate them against.
VersionedResponseMiddleware ¶
Bases: Middleware
Negotiate Accept-Version and downgrade the rendered response.
Added automatically by VersionedResponseMixin when response_versions is set;
construct it directly only to use it without the mixin.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
response_versions
|
Mapping[int, type[BaseModel]]
|
|
required |
header
|
str
|
Request header naming the wanted version. |
'Accept-Version'
|
response_header
|
str
|
Response header naming the version actually served. |
'X-API-Version'
|
VersionedResponseMixin ¶
Bases: Controller
Adds Accept-Version negotiation to every operation of a controller.
List it first so its merged_options/customize_operation see the final
response=: class PostController(VersionedResponseMixin, CRUDController[...]).
response_versions
class-attribute
¶
Older response schemas, keyed by the version clients ask for with Accept-Version.
The latest version is implicitly one more than the highest key here.
response_version_header
class-attribute
¶
Request header naming the wanted version.
response_version_response_header
class-attribute
¶
Response header naming the version actually served.
ninja_devx.http.explain ¶
X-Query-Count, X-Query-Time and X-Query-Plan headers, for development.
Install it where a paginated or optimized controller is mounted::
use_middleware(api, QueryExplainMiddleware()) # active only when DEBUG
use_middleware(api, QueryExplainMiddleware(enabled=True)) # a staff-only diagnostics API
Counts and durations come from django.db.connection.queries (CaptureQueriesContext
semantics: force_debug_cursor is set for the request, so this works even when DEBUG
is off). X-Query-Plan lists the select_related/prefetch_related lookups the N+1
planner chose, read from the request attribute it records them on (QUERY_PLAN_ATTR, filled
the same way pagination metadata is). SQL text never reaches a header, and every hook is a
no-op when the middleware is inactive, so nothing is captured in production by default.
Database connections are thread-local: an async list evaluated off-thread (run_sync,
or the plain sync_to_async fallback for a queryset without an async paginator) runs its
queries against a connection this middleware never touched, so the count can undercount for
async operations whose ORM access hops threads.
QUERY_PLAN_ATTR
module-attribute
¶
Request attribute the N+1 planner fills with (select_related, prefetch_related)
lookup names it chose for the current request.
QueryExplainMiddleware ¶
Bases: Middleware
Add query diagnostics headers to matching responses.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enabled
|
bool | None
|
Forces the middleware on or off; |
None
|
databases
|
Sequence[str]
|
Database aliases to count; defaults to every configured alias. |
()
|
ninja_devx.http.requestlog ¶
One structured log record per request: identity, timing and outcome, never the body.
::
install(api, [RequestLogPlugin()])
LOGGING = {
"version": 1,
"formatters": {"json": {"()": "ninja_devx.http.requestlog.JSONFormatter"}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json"}},
"loggers": {"ninja_devx.request": {"handlers": ["console"], "level": "INFO"}},
}
Fields: request_id, method, path, status, duration_ms, user_id,
tenant (the resolved tenant, or request.tenant as tenant middleware sets it; None
without one), query_count (queries on the default database connection), operation
("Controller.method"), and api_key_prefix when ninja_devx.contrib.apikeys
authenticated the request. naming="otel" (the default) spells the four fields with an
OpenTelemetry semantic convention name (http.request.method, url.path,
http.response.status_code, user.id); naming="flat" keeps the plain names above.
When structlog <https://www.structlog.org/>_ is installed, the identity fields
(request_id, method, path, user id, tenant) are also bound with
structlog.contextvars.bind_contextvars as soon as the request starts and cleared once
the final record is logged, so the application's own structlog calls carry them too;
fields that are only known once the handler returns (status, duration, query count,
operation) are not part of that binding.
RequestLogMiddleware
dataclass
¶
Bases: Middleware
Logs one record per request. Install directly, or through RequestLogPlugin.
logger
class-attribute
instance-attribute
¶
Logger receiving one record per request.
naming
class-attribute
instance-attribute
¶
"otel": OpenTelemetry semantic convention field names; "flat": plain ones.
bind_structlog
class-attribute
instance-attribute
¶
Bind the identity fields to structlog.contextvars when structlog is installed.
RequestLogPlugin
dataclass
¶
Bases: APIPlugin
RequestLogMiddleware plus RequestIDMiddleware, so every request gets an id.
::
install(api, [RequestLogPlugin(naming="flat")])
logger
class-attribute
instance-attribute
¶
Logger receiving one record per request.
naming
class-attribute
instance-attribute
¶
"otel": OpenTelemetry semantic convention field names; "flat": plain ones.
bind_structlog
class-attribute
instance-attribute
¶
Bind the identity fields to structlog.contextvars when structlog is installed.
include_request_id
class-attribute
instance-attribute
¶
Add RequestIDMiddleware too, so request_id is never empty.
JSONFormatter ¶
Bases: Formatter
Render each LogRecord as one JSON object, extra fields included.
::
"formatters": {"json": {"()": "ninja_devx.http.requestlog.JSONFormatter"}}
register_api_key_reader ¶
Let ninja_devx.contrib.apikeys (or your own auth) contribute api_key_prefix.
Apps register in AppConfig.ready; core never imports contrib.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reader
|
ApiKeyReader
|
Returns the authenticating key's prefix, or |
required |
ninja_devx.http.security ¶
Security response headers as a middleware.
Applies conservative, API-friendly defaults and never overwrites a header the view already set. HSTS is opt-in because it requires HTTPS::
use_middleware(api, SecurityHeadersMiddleware(hsts="max-age=31536000; includeSubDomains"))
SecurityHeadersMiddleware ¶
Bases: Middleware
Set security headers on every response unless the response already has them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hsts
|
str | None
|
|
None
|
csp
|
str | None
|
|
None
|
referrer_policy
|
str
|
|
'same-origin'
|
frame_options
|
str | None
|
|
'DENY'
|
permissions_policy
|
str | None
|
|
_PERMISSIONS_POLICY
|
nosniff
|
bool
|
set |
True
|
extra
|
Mapping[str, str] | None
|
additional headers, applied after the defaults. |
None
|
ninja_devx.http.cache ¶
Cache whole HTTP responses for read operations.
ResponseCacheMiddleware caches the body, status and content type of matching
responses and adds Cache-Control/Vary. Install it on an API, router or controller::
use_middleware(api, ResponseCacheMiddleware(ttl=60, vary_on=("Authorization",)))
Invalidate every cached response sharing a prefix with invalidate_cache after a write.
Cache calls are synchronous, also for async operations.
ResponseCacheMiddleware ¶
Bases: Middleware
Serve matching responses from the cache and store new ones.
Cache-Control is private when vary_on names Authorization or Cookie
and public otherwise, unless the response already carries the header.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ttl
|
int
|
Cache lifetime in seconds. |
60
|
vary_on
|
Sequence[str]
|
Header names that participate in the cache key (for example
|
()
|
cache
|
str
|
Cache alias. |
'default'
|
key_prefix
|
str
|
Prefix used by |
''
|
methods
|
Sequence[str]
|
HTTP methods to cache (others pass through). |
('GET', 'HEAD')
|
invalidate_cache ¶
Invalidate every cached response under prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
str
|
The same prefix passed to |
''
|
cache
|
str
|
Cache alias. |
'default'
|
ninja_devx.http.hardening ¶
Request hardening: body size, content type and JSON nesting limits.
Each is a :class:~ninja_devx.http.middleware.Middleware returning a short-circuiting
response in the package's error format::
use_middleware(
api,
MaxBodySizeMiddleware(5_000_000),
EnforceContentTypeMiddleware({"application/json"}),
JsonDepthMiddleware(max_depth=32),
)
MaxBodySizeMiddleware ¶
Bases: Middleware
Reject requests whose Content-Length exceeds max_bytes with 413.
Only the declared length is checked; chunked bodies without one are bounded by
Django's DATA_UPLOAD_MAX_MEMORY_SIZE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_bytes
|
int
|
Maximum accepted body size in bytes. |
required |
EnforceContentTypeMiddleware ¶
Bases: Middleware
Reject write requests whose media type is not allowed, with 415.
A request without a Content-Type header passes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
media_types
|
Collection[str]
|
Accepted media types (parameters such as |
required |
methods
|
Collection[str]
|
Methods to check (default: POST/PUT/PATCH/QUERY). |
_BODY_METHODS
|
JsonDepthMiddleware ¶
Bases: Middleware
Reject JSON bodies nested deeper than max_depth with 400.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_depth
|
int
|
Maximum nesting depth of objects and arrays. |
32
|
ninja_devx.http.pagination_headers ¶
Expose pagination metadata as response headers (RFC 8288 Link, X-Total-Count).
Add it where the paginated controller is mounted::
use_middleware(api, PaginationHeadersMiddleware())
Link carries rel="next"/rel="prev" for both paginators; X-Total-Count is set
when limit/offset pagination computed a count. A count option that only counts up to a
threshold sends X-Total-Count: N+ past it, instead of N.
ninja_devx.http.health ¶
Liveness and readiness endpoints for load balancers and orchestrators.
::
mount(api, {"/health": HealthController}) # GET /health/live, GET /health/ready
class Health(HealthController):
health_checks = (DatabaseCheck(), CacheCheck(), MigrationsCheck(), MyQueueCheck())
/live only proves the process answers. /ready runs every check and returns 200 or
503 with the failing checks, so traffic stops before users see errors. Neither requires
authentication.
ninja_devx.serialization.renderers ¶
Faster JSON renderers for Ninja: NinjaAPI(renderer=ORJSONRenderer()).
Serialization of the response body is often the largest per-request cost after the
database. Both renderers accept what Ninja's JSONRenderer does (datetimes, UUIDs,
Decimal, pydantic models) and produce the same JSON, except that datetimes keep their
microseconds (Django's encoder truncates them to milliseconds).
ORJSONRendererneedsorjson(pip install ninja-devx[orjson]).MsgspecRendererneedsmsgspec(pip install ninja-devx[msgspec]).
ORJSONRenderer ¶
Bases: BaseRenderer
JSON with orjson (ninja-devx[orjson]): NinjaAPI(renderer=ORJSONRenderer()).
Output matches Ninja's encoder (Z datetimes, decimals as strings) except that
datetimes keep microseconds.
options
class-attribute
¶
Extra orjson.OPT_* flags (orjson.OPT_INDENT_2...).
MsgspecRenderer ¶
Bases: BaseRenderer
JSON with msgspec (ninja-devx[msgspec]); same output rules as ORJSONRenderer.
Contrib apps¶
ninja_devx.contrib.apikeys.auth ¶
Creating, authenticating and scoping API keys.
KEEP
module-attribute
¶
Sentinel: keep the current value when rotating a key.
APIKeyAuth ¶
Bases: APIKeyHeader
X-API-Key: ndx_...; request.auth is the key's user.
In async operations Ninja runs it in one thread hop (lookup and last_used_at
together), which is cheaper than two async ORM calls.
APIKeyBearer ¶
Bases: HttpBearer
Authorization: Bearer ndx_....
RequiresScope
dataclass
¶
Operation metadata: meta=(RequiresScope("orders:write"),).
HasScopes
dataclass
¶
Bases: BasePermission[object]
Every RequiresScope of the operation must be granted to the request's API key.
Requests authenticated otherwise (sessions, JWT) pass when allow_unscoped is set,
since their scopes are the user's permissions.
allow_unscoped
class-attribute
instance-attribute
¶
Let requests without an API key through (e.g. session users of the same API).
APIKeyRateThrottle ¶
Bases: RateThrottle
Per API key: the key's own rate_limit, else rate; other requests pass.
ControllerOptions(throttle=[APIKeyRateThrottle("1000/hour"), UserRateThrottle(...)])
create_api_key ¶
create_api_key(
user: object,
name: str,
*,
scopes: Iterable[str] = (),
expires_at: datetime | None = None,
rate_limit: str = "",
) -> tuple[APIKey, str]
Create a key and return it with its raw value, which is never stored or shown again.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
object
|
The key's owner; requests authenticated with it act as this user. |
required |
name
|
str
|
A label for humans ( |
required |
scopes
|
Iterable[str]
|
What the key may do ( |
()
|
expires_at
|
datetime | None
|
When the key stops working ( |
None
|
rate_limit
|
str
|
This key's rate for |
''
|
revoke_api_key ¶
Stop accepting key (kept for audit).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
APIKey
|
The key to revoke. |
required |
rotate_api_key ¶
rotate_api_key(
key: APIKey,
*,
scopes: Iterable[str] | None = None,
expires_at: object = KEEP,
rate_limit: object = KEEP,
) -> tuple[APIKey, str]
Replace key's secret in place and return the new raw value.
The old secret stops working immediately. The row (owner, name, creation time) is kept;
pass scopes to update them, and expires_at/rate_limit (including None to
clear them) to change the expiry or the limit. A revoked key stays revoked: create a
new one instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
APIKey
|
The key to rotate. |
required |
scopes
|
Iterable[str] | None
|
New scopes, or |
None
|
expires_at
|
object
|
New expiry; omit to keep the current one. |
KEEP
|
rate_limit
|
object
|
New rate limit; omit to keep the current one. |
KEEP
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
current_api_key ¶
The API key that authenticated request, if any.
scope_allows ¶
Whether granted covers required: exact, "orders:*" or "*".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
granted
|
Sequence[str]
|
Scopes of the key. |
required |
required
|
str
|
The scope an operation needs ( |
required |
ninja_devx.contrib.apikeys.api ¶
Self-service key management for the authenticated user.
APIKeyController ¶
Bases: Controller
GET / my keys, POST / create one (raw key in the response), DELETE /{id}.
grantable_scopes
class-attribute
¶
Scopes users may put on their keys (None: any).
ninja_devx.contrib.grants.backends ¶
Object-permission backends backed by ObjectGrant.
GrantBackend is a Django authentication backend answering object-level has_perm.
GrantsBackend implements the ninja_devx.security.object_permissions backend protocol
and is registered by GrantsConfig.ready so core does not import this app.
GrantBackend ¶
Add to AUTHENTICATION_BACKENDS next to ModelBackend; it never authenticates.
GrantsBackend ¶
Object permission backend stored in ninja_devx.contrib.grants.ObjectGrant.
object_permissions_of ¶
{"app_label.codename", ...} granted to user (directly or via groups) on obj.
Cached on the user object for its lifetime (usually one request), like Django's own permission caches.
ninja_devx.contrib.audit.log ¶
Recording audit entries and diffing model snapshots.
AuditMixin ¶
Bases: ModelController[ModelT], Generic[ModelT]
Record creates, updates and deletes of a model controller (bulk ones too).
List it first: class Invoices(AuditMixin[Invoice], CRUDController[...]). Your own
perform_* overrides on the controller are audited as well.
audit_privacy
class-attribute
¶
Storage allowlists, credential redaction and safe object labels.
audit_fields
class-attribute
¶
Fields kept in snapshots and diffs (None: every concrete field).
audit_redact
class-attribute
¶
Fields recorded as "***": a change is visible, the value is not.
audit_metadata ¶
Extra data stored with every entry (override: {"tenant": ...}).
audit ¶
audit(
request: HttpRequest,
action: str,
instance: ModelT,
*,
changes: Mapping[str, list[object]],
object_pk: object = None,
) -> AuditEntry
Write one entry; override to enrich or filter what is recorded.
snapshot ¶
snapshot(
instance: Model,
*,
fields: Sequence[str] | None = None,
exclude: Sequence[str] = (),
redact: Sequence[str] = (),
) -> dict[str, object]
JSON-safe values of instance's concrete fields (foreign keys as their key).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instance
|
Model
|
The model instance. |
required |
fields
|
Sequence[str] | None
|
Field names to keep ( |
None
|
exclude
|
Sequence[str]
|
Field names to leave out. |
()
|
redact
|
Sequence[str]
|
Field names whose values become |
()
|
diff ¶
{field: [old, new]} for the fields whose value changed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
before
|
Mapping[str, object]
|
A snapshot taken before the change. |
required |
after
|
Mapping[str, object]
|
A snapshot taken after it. |
required |
redact ¶
Replace the values of fields by "***" (a change stays visible).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
changes
|
Mapping[str, list[object]]
|
|
required |
fields
|
Sequence[str]
|
Field names to hide. |
required |
record ¶
record(
request: HttpRequest | None,
action: str,
obj: Model | None = None,
*,
object_pk: object = None,
changes: Mapping[str, object] | None = None,
metadata: Mapping[str, object] | None = None,
using: str | None = None,
privacy: AuditPrivacy | None = None,
) -> AuditEntry
Write an audit entry for action on obj, attributed to request's user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
HttpRequest | None
|
The request (actor, request id, method, path, IP); |
required |
action
|
str
|
A short verb: |
required |
obj
|
Model | None
|
The object acted on, if any. |
None
|
object_pk
|
object
|
The key when |
None
|
changes
|
Mapping[str, object] | None
|
|
None
|
metadata
|
Mapping[str, object] | None
|
Anything else worth keeping (JSON-serializable). |
None
|
using
|
str | None
|
Database alias; defaults to the object database, then the audit write router. |
None
|
privacy
|
AuditPrivacy | None
|
Storage allowlists/redaction policy; default protects common credential fields. |
None
|
ninja_devx.contrib.audit.api ¶
Read-only audit log endpoints.
AuditFilters ¶
Bases: FilterSchema
Query parameters of GET / on AuditLogController.
action
class-attribute
instance-attribute
¶
create, update, delete or a custom action.
actor_id
class-attribute
instance-attribute
¶
Entries by this user.
model
class-attribute
instance-attribute
¶
Entries about this model: app_label.model.
object_pk
class-attribute
instance-attribute
¶
Entries about this object (with model).
request_id
class-attribute
instance-attribute
¶
Everything recorded during one request.
since
class-attribute
instance-attribute
¶
Entries at or after this time.
until
class-attribute
instance-attribute
¶
Entries before this time.
AuditLogController ¶
Bases: ReadOnlyModelController[AuditEntry, AuditEntryOut]
GET / (filterable) and GET /{pk}; staff only by default.
AuditHistoryMixin ¶
Bases: ModelController[ModelT], Generic[ModelT]
GET /{pk}/history: the object's audit entries, newest first.
The object is loaded like retrieve (scoping, object permissions: 404/403), so
staff access is additionally required by default. An explicit routes["history"]
permission override can implement a project-specific audit-reader policy.
ninja_devx.contrib.audit.privacy ¶
Explicit storage policy for audit changes and metadata.
AuditPrivacy
dataclass
¶
Allowlist business fields and redact credential values before persistence.
Field names are exact; metadata credential keys are matched case-insensitively. Use an allowlist for models containing personal or application-specific secrets.
fields
class-attribute
instance-attribute
¶
Allowlist of change field names to keep (None: keep every field not excluded).
exclude
class-attribute
instance-attribute
¶
Change field names dropped entirely, never stored even redacted.
redact
class-attribute
instance-attribute
¶
redact: tuple[str, ...] = (
"secret",
"token",
"access_token",
"refresh_token",
"api_key",
"authorization",
)
Change field names stored as "***" instead of their value.
metadata_fields
class-attribute
instance-attribute
¶
Allowlist of metadata keys to keep (None: keep every key).
object_repr
class-attribute
instance-attribute
¶
Opt in to calling the model's potentially sensitive __str__.
schema
class-attribute
instance-attribute
¶
A schema (ninja_devx.serialization.privacy.Sensitive-annotated) whose marked
fields are redacted like redact names.
ninja_devx.contrib.webhooks.outbox ¶
Publishing events and delivering them to endpoints.
RETRY_SCHEDULE
module-attribute
¶
RETRY_SCHEDULE: Final = (
timedelta(seconds=5),
timedelta(minutes=5),
timedelta(minutes=30),
timedelta(hours=2),
timedelta(hours=5),
timedelta(hours=10),
timedelta(hours=10),
)
Delays before the retries (Standard Webhooks' schedule): 8 attempts over ~27 hours.
Transport ¶
Bases: Protocol
Sends one request; returns the status code or raises OSError.
event_matches ¶
"*", an exact type, or a "order.*" prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
patterns
|
Sequence[str]
|
An endpoint's subscriptions. |
required |
event_type
|
str
|
The published event type. |
required |
publish ¶
publish(
event_type: str,
payload: Mapping[str, object],
*,
owner: Model | None = None,
tenant_key: str = "",
broadcast: bool = False,
using: str | None = None,
queue: TaskQueue | None = None,
task: Enqueueable[[str, str]] | None = None,
) -> OutboxEvent
Store an event and a pending delivery per subscribed endpoint, in the current
transaction (DjangoJSONEncoder serializes dates, decimals and UUIDs).
Without queue a worker (devx_webhooks deliver) sends it. With a queue, delivery
is also enqueued right away, after commit with OnCommitTaskQueue; the worker still
retries failures.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
Dotted event name ( |
required |
payload
|
Mapping[str, object]
|
The event data, sent as |
required |
owner
|
Model | None
|
Only this user's endpoints, within |
None
|
tenant_key
|
str
|
Server-controlled tenant scope; empty means personal endpoints. |
''
|
broadcast
|
bool
|
Explicitly send to every subscribed endpoint; cannot combine with a target. |
False
|
using
|
str | None
|
Database alias shared by business writes and the outbox. |
None
|
queue
|
TaskQueue | None
|
Where to enqueue the delivery ( |
None
|
task
|
Enqueueable[[str, str]] | None
|
The task receiving the event id and database alias. Default:
|
None
|
deliver_event ¶
Send the due deliveries of one event now (what enqueued tasks run).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_id
|
str
|
The |
required |
using
|
str | None
|
Database alias containing the outbox event. |
None
|
body_of ¶
The JSON request body: {"type", "timestamp", "data"}.
deliver_due ¶
deliver_due(
*,
limit: int = 100,
transport: Transport | None = None,
timeout: float = 10.0,
schedule: Sequence[timedelta] = RETRY_SCHEDULE,
disable_after: timedelta | None = timedelta(days=5),
url_policy: URLPolicy | None = None,
event_id: str | None = None,
now: datetime | None = None,
using: str | None = None,
) -> DeliveryReport
Send up to limit due deliveries. Safe to run from several workers
(each row uses a conditional claim and a token-checked finalization).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int
|
Deliveries handled in this call. |
100
|
transport
|
Transport | None
|
How requests are sent (default |
None
|
url_policy
|
URLPolicy | None
|
Where the default transport may send requests. |
None
|
timeout
|
float
|
Seconds per request. |
10.0
|
schedule
|
Sequence[timedelta]
|
Delays before each retry; the delivery fails after the last one. |
RETRY_SCHEDULE
|
disable_after
|
timedelta | None
|
Disable an endpoint failing continuously for this long. |
timedelta(days=5)
|
event_id
|
str | None
|
Only deliveries of this event. |
None
|
now
|
datetime | None
|
The current time (tests). |
None
|
using
|
str | None
|
Database containing the outbox. |
None
|
ninja_devx.contrib.webhooks.signing ¶
Standard Webhooks signatures (https://www.standardwebhooks.com).
InvalidSignature ¶
Bases: Exception
The webhook request is not authentic, or too old.
sign ¶
v1,<base64 HMAC-SHA256 of "id.timestamp.body">.
signature_headers ¶
signature_headers(
secret: str,
message_id: str,
body: bytes,
*,
timestamp: int | None = None,
) -> dict[str, str]
The webhook-id, webhook-timestamp and webhook-signature headers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secret
|
str
|
The endpoint secret ( |
required |
message_id
|
str
|
Unique message id, the same across retries. |
required |
body
|
bytes
|
The exact bytes that are sent. |
required |
timestamp
|
int | None
|
Unix time of the attempt (default: now). |
None
|
verify_signature ¶
verify_signature(
secrets_: str | list[str],
headers: Mapping[str, str],
body: bytes,
*,
tolerance: int = 300,
now: float | None = None,
) -> None
Raise InvalidSignature unless body was signed by one of secrets_.
For receivers (including Django views: verify_signature(secret, request.headers,
request.body)). Several secrets allow rotation; tolerance (seconds) rejects
replayed old requests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secrets_
|
str | list[str]
|
The endpoint secret, or several during a rotation. |
required |
headers
|
Mapping[str, str]
|
Request headers (any case); |
required |
body
|
bytes
|
The raw request body ( |
required |
tolerance
|
int
|
Maximum age of the timestamp, in seconds. |
300
|
now
|
float | None
|
The current Unix time (tests). |
None
|
ninja_devx.contrib.webhooks.api ¶
Managing webhook endpoints over the API.
WebhookEndpointController ¶
Bases: CRUDController[WebhookEndpoint, WebhookEndpointOut, WebhookEndpointIn]
The user's endpoints: CRUD, deliveries, retry, ping and secret rotation.
ninja_devx.contrib.webhooks.network ¶
Outbound URL safety for webhooks: no requests to internal networks (SSRF).
Endpoint URLs come from API clients, so a URL like http://169.254.169.254/ (cloud
metadata), http://localhost:8000/admin or http://10.0.0.5/ would make the worker
call internal services. Two layers stop that:
check_urlrejects bad schemes, credentials in URLs, and literal private addresses orlocalhostnames when an endpoint is saved (no DNS lookup, so it works offline);SafeHTTPTransportchecks the address it actually connected to, after DNS resolution, so a public name resolving to a private address (DNS rebinding) is refused too. Proxies from the environment are ignored.
UnsafeURL ¶
Bases: OSError
The URL or the address it resolves to is not allowed.
URLPolicy
dataclass
¶
Where webhooks may be sent.
SafeHTTPTransport
dataclass
¶
The default webhook transport: urllib, no redirects, no environment proxies,
and policy enforced on the URL and on the connected address.
is_public_address ¶
Whether address is a globally routable unicast IP (IPv4-mapped IPv6 unwrapped).
ninja_devx.contrib.webhooks.secrets ¶
Encrypting webhook signing secrets at rest.
Signing needs the raw secret, so it cannot be hashed like an API key. With
NINJA_DEVX["WEBHOOK_SECRET_KEYS"] set (pip install "ninja-devx[crypto]"), secrets
are stored as fernet:<token>; a database dump alone no longer lets anyone forge
webhooks. Generate a key with manage.py devx_webhooks generate-key.
Rotation: put the new key first, keep the old ones after it, and run
manage.py devx_webhooks encrypt-secrets to re-encrypt with the new key.
encrypt_secret ¶
The value to store for raw: encrypted when keys are configured.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
str
|
The signing secret ( |
required |
decrypt_secret ¶
The raw secret from a stored value (plain values are returned as they are).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stored
|
str
|
The database value. |
required |
reencrypt_secret ¶
stored encrypted with the first key (plain values get encrypted).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stored
|
str
|
The database value. |
required |
ninja_devx.contrib.webhooks.maintenance ¶
Bounded retention and retry operations for workers, APIs and administrators.
retry_deliveries ¶
Reset unclaimed rows; a running worker's ownership is never revoked.
prune_events ¶
Delete at most limit old events whose deliveries are all terminal.
Lock deliveries before testing their state, so a concurrent retry cannot be deleted after becoming pending. Events with even one pending delivery are retained.
ninja_devx.contrib.uploads ¶
Presigned uploads with durable ownership and completion tracking.
FakeSigner
dataclass
¶
In-memory signer for tests: signer.store(key, size, content_type) simulates the
client's upload.
S3Signer
dataclass
¶
UploadController ¶
Bases: Controller
POST / signs an upload, POST /complete verifies it. Set signer.
signer
class-attribute
¶
Storage backend (S3Signer, FakeSigner or your own).
upload_database
class-attribute
¶
Alias containing core UploadRecord rows (run migrations on it).
key_prefix
class-attribute
¶
Added before <user id>/<uuid>/<filename> (after the signer's own prefix).
owner_prefix ¶
The part of the key reserved to the caller (override for tenants).
cleanup_expired ¶
Delete current objects from expired pending uploads; completed records are retained.
UploadPolicy
dataclass
¶
What may be uploaded.
content_types
class-attribute
instance-attribute
¶
Allowed media types; "image/*" allows a family.
max_bytes
class-attribute
instance-attribute
¶
Largest accepted size.
expires_in
class-attribute
instance-attribute
¶
How long the signed form stays valid.
cleanup_grace
class-attribute
instance-attribute
¶
Grace after form expiry, allowing in-flight uploads to finish before cleanup.
require_checksum
class-attribute
instance-attribute
¶
Require a base64 SHA-256 digest in the signed request and stored object.
require_version
class-attribute
instance-attribute
¶
Require storage versioning; consumers must read the returned version_id.
UploadSigner ¶
Bases: Protocol
Signs uploads for a storage backend and inspects what was stored.
safe_filename ¶
ASCII, no directories, no leading dots: "../Résumé 2024.pdf" → "Resume_2024.pdf".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The client's file name. |
required |
max_length
|
int
|
Longest result, extension included. |
100
|
ninja_devx.contrib.otel ¶
OpenTelemetry tracing (one span per operation call) and HTTP server metrics.
::
options = ControllerOptions(hooks=[OpenTelemetryHook()])
use_middleware(api, OpenTelemetryMetricsMiddleware())
OpenTelemetryHook
dataclass
¶
tracer
class-attribute
instance-attribute
¶
Tracer starting one span per call (default: the global tracer).
OpenTelemetryMetricsMiddleware ¶
Bases: Middleware
HTTP server metrics per operation (OpenTelemetry semantic conventions).
http.server.request.duration (histogram, seconds) and http.server.active_requests
(up-down counter), with http.request.method, http.route and
http.response.status_code. Install on an API or router::
use_middleware(api, OpenTelemetryMetricsMiddleware())
ninja_devx.contrib.tasks ¶
TaskQueue adapters for Celery, Dramatiq, RQ, Taskiq, Temporal and FastStream.
The built-in :class:~ninja_devx.layers.OnCommitTaskQueue defers work through
django.tasks. These adapters let services keep depending on the TaskQueue protocol
while a framework executes the work.
Framework tasks are callables exposing a deferral method: Celery .delay, Dramatiq
.send and Taskiq .kiq are detected automatically. RQ and Temporal need a
queue/client, passed to the constructor::
container.singleton(TaskQueue, CeleryTaskQueue())
container.singleton(TaskQueue, RQTaskQueue(redis_queue))
container.singleton(TaskQueue, TemporalTaskQueue(temporal_client))
container.singleton(TaskQueue, FastStreamTaskQueue(broker, queue="tasks"))
DeferredTaskQueue adapts any defer(function, args, kwargs) callable when no
framework-specific adapter fits.
DeferredTaskQueue ¶
TaskQueue over any defer(function, args, kwargs) callable.
Use it to adapt an async broker or a custom transport without a framework-specific adapter.
FastStreamTaskQueue ¶
Bases: DeferredTaskQueue
FastStream: publish {"task", "args", "kwargs"} through a broker.
broker.publish is async; the adapter wraps it with async_to_sync so a sync
service can enqueue. Pass a FastStream broker (KafkaBroker, NatsBroker, ...)::
from faststream.nats import NatsBroker
container.singleton(TaskQueue, FastStreamTaskQueue(broker, queue="tasks"))
FrameworkTaskQueue ¶
Defer work using a task framework's callable conventions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
queue
|
object | None
|
An RQ-style queue exposing |
None
|
client
|
object | None
|
A Temporal-style client exposing |
None
|
methods
|
Sequence[str] | None
|
Attribute names tried on the target callable, in order. |
None
|
CeleryTaskQueue ¶
DramatiqTaskQueue ¶
TaskiqTaskQueue ¶
RQTaskQueue ¶
TemporalTaskQueue ¶
ninja_devx.contrib.jobs.runner ¶
Starting, registering and running background jobs.
::
@job("reports.export")
def export_report(ctx: JobContext, report_id: int) -> dict[str, object]:
ctx.set_progress(50)
return {"rows": 1000}
@post("/reports/{pk}/export", response={202: dict})
def export(self, request: HttpRequest, pk: int) -> JsonResponse:
row = start_job(request, "Export report", export_report, report_id=pk)
return accepted(request, row, prefix="/jobs")
start_job records the call (function name and arguments) on the Job row and
enqueues run_job through a TaskQueue, so nothing is pickled: a worker (or
devx_jobs retry) resolves the function by the name it was registered or imported
with. @job is optional for functions importable by a dotted path; use it for
closures, local functions, or a stable name independent of where the function lives.
JobFunction
module-attribute
¶
A registered job: (ctx: JobContext, *args, **kwargs) -> object.
JobContext ¶
Given to a job function: progress reporting and cooperative cancellation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
job_id
|
str
|
Primary key of the |
required |
job ¶
Register a function so workers resolve it by name instead of pickling it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The name |
required |
resolve_job ¶
The function registered, or importable, as target.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
str
|
A name passed to |
required |
start_job ¶
start_job(
request: HttpRequest,
name: str,
function: JobFunction | str,
*args: object,
queue: TaskQueue | None = None,
tenant: str = "",
**kwargs: object,
) -> Job
Create a queued job for the caller and enqueue its run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
HttpRequest
|
The current request; |
required |
name
|
str
|
Recorded on the job; shown in the API and admin. |
required |
function
|
JobFunction | str
|
A |
required |
args
|
object
|
Positional arguments passed to the function after its |
()
|
queue
|
TaskQueue | None
|
Where |
None
|
tenant
|
str
|
Recorded on the job (server-controlled; not read from the request body). |
''
|
kwargs
|
object
|
Keyword arguments passed to the function. |
{}
|
run_job ¶
Run a job's function inside a JobContext, storing the outcome on its row.
Never raises: a failure is stored as error and status failed instead of
propagating, so a task framework will not retry the call itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
job_id
|
str
|
Primary key of the |
required |
target
|
str
|
A name registered with |
required |
retry
|
int
|
Extra attempts on failure, made in this same call before giving up. |
0
|
args
|
object
|
Positional arguments passed to the function after its |
()
|
kwargs
|
object
|
Keyword arguments passed to the function. |
{}
|
ninja_devx.contrib.jobs.api ¶
Checking on and cancelling jobs started with start_job.
JobOut ¶
JobsController ¶
Bases: Controller
GET / (the caller's jobs, paginated), GET /{id} and POST /{id}/cancel.
Mount next to the endpoints that call start_job::
api.add_router("/jobs", JobsController.as_router())
GET /{id} and cancelling are open to the job's owner or staff; the list only ever
shows the caller's own jobs.
ninja_devx.contrib.jobs.models ¶
Persistence for background jobs started from a request and finished by a worker.
Job ¶
Bases: Model
One run of a function started with start_job and executed by run_job.
ninja_devx.contrib.redis_throttle ¶
Exact, atomic fixed-window throttle counting on Redis.
Needs pip install ninja-devx[redis]. Point a throttle's own storage=, or the
project-wide NINJA_DEVX["THROTTLE_STORAGE"], at an instance::
# myapp/throttling.py
from ninja_devx.contrib.redis_throttle import RedisThrottleStorage
redis_throttle_storage = RedisThrottleStorage(url="redis://cache:6379/1")
NINJA_DEVX = {"THROTTLE_STORAGE": "myapp.throttling.redis_throttle_storage"}
INCR and a conditional EXPIRE (NX: only on the window's first hit) run in one
MULTI/EXEC transaction, so counting is atomic even under concurrent requests, unlike
the default cache-based storage's unprotected add then incr.
RedisThrottleStorage ¶
ThrottleStorage counting fixed windows on Redis with one atomic round trip.
__init__ ¶
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
_RedisClient | None
|
An existing |
None
|
url
|
str
|
Connection URL used when |
'redis://localhost:6379/0'
|
ninja_devx.contrib.nplusone ¶
Runtime N+1 detection: an adapter over django-zeal
<https://github.com/taobojlen/django-zeal>_.
Detection stays entirely in zeal; nothing here re-implements query counting. Install
the middleware so every request runs inside zeal's tracking context (zeal.setup()/
zeal.teardown(), the primitives zeal.zeal_context() wraps), and when zeal raises
NPlusOneError the middleware rewrites its message to name the controller and point at
the fix::
install(api, [NPlusOnePlugin()])
or, without the plugin system::
use_middleware(api, NPlusOneMiddleware())
Requires "zeal" in INSTALLED_APPS and the zeal extra
(pip install ninja-devx[zeal], django-zeal>=2); tune detection with zeal's own
settings (ZEAL_RAISE, ZEAL_NPLUSONE_THRESHOLD, ZEAL_ALLOWLIST, ...). Everything
here is a no-op when zeal is not installed, so it is safe to add unconditionally.
NPlusOneMiddleware ¶
Bases: Middleware
Runs the request inside zeal's tracking context and explains its errors.
A no-op when zeal is not installed.
explain_n_plus_one ¶
Rewrite a zeal NPlusOneError message to name controller and suggest a fix.
zeal's own message is "N+1 detected on <app>.<Model>.<field> at <file>:<line> in
<function>"; this appends the controller (when known) and the
ninja_devx.crud.optimization names that fix it. Messages zeal did not produce (no
model.field) are returned unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
|
required |
controller
|
str | None
|
Qualified name of the controller handling the request, if known. |
None
|
zeal_strict ¶
zeal.zeal_context() forced to raise, regardless of settings.ZEAL_RAISE.
Backs the strict_queries pytest fixture (ninja_devx.testing). Call
:func:zeal_installed first: this assumes zeal is installed and does not skip.
Tooling¶
ninja_devx.testing.clients ¶
Test helpers: exercise controllers through Django Ninja's test clients.
AuthenticatedClient ¶
Bases: TestClient
A TestClient that sends every request as user unless one is given.
AuthenticatedAsyncClient ¶
Bases: TestAsyncClient
A TestAsyncClient that sends every request as user unless one is given.
HopCounter
dataclass
¶
Thread hops (sync_to_async calls) seen inside assert_max_hops.
client_for ¶
client_for(
controller: type[Controller],
*,
user: object | None = None,
container: ContainerLike | None = None,
scope: Scope | None = None,
**options: Unpack[ControllerOptions],
) -> AuthenticatedClient
A test client for controller.as_router(...); paths are relative to the router.
user becomes request.user for every request (override per request with user=).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
controller
|
type[Controller]
|
The controller class. |
required |
user
|
object | None
|
Sent as |
None
|
container
|
ContainerLike | None
|
Container for |
None
|
scope
|
Scope | None
|
Scope for |
None
|
options
|
Unpack[ControllerOptions]
|
|
{}
|
async_client_for ¶
async_client_for(
controller: type[Controller],
*,
user: object | None = None,
container: ContainerLike | None = None,
scope: Scope | None = None,
**options: Unpack[ControllerOptions],
) -> AuthenticatedAsyncClient
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
controller
|
type[Controller]
|
The controller class. |
required |
user
|
object | None
|
Sent as |
None
|
container
|
ContainerLike | None
|
Container for |
None
|
scope
|
Scope | None
|
Scope for |
None
|
options
|
Unpack[ControllerOptions]
|
|
{}
|
assert_max_queries ¶
assert_max_queries(
limit: int, *, using: str = DEFAULT_DB_ALIAS
) -> Generator[CaptureQueriesContext]
Fail when the block runs more than limit queries (catches N+1 regressions).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int
|
Maximum number of queries in the block. |
required |
using
|
str
|
Database alias to watch. |
DEFAULT_DB_ALIAS
|
assert_max_hops ¶
Fail when the block switches from the event loop to a thread more than limit times.
Each hop costs tens of microseconds and serializes on the request's thread::
with assert_max_hops(1):
await client.post("/", json=payload) # async CRUD create: one hop
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int
|
Maximum |
required |
capture_commits ¶
capture_commits(
*, using: str = DEFAULT_DB_ALIAS, execute: bool = True
) -> Generator[list[Callable[[], object]]]
Collect transaction.on_commit callbacks (after_commit, OnCommitTaskQueue).
In a test transaction nothing ever commits; with execute=True the callbacks run
when the block exits, as they would after a real commit::
with capture_commits() as callbacks:
client.post("/orders", json=payload)
assert len(callbacks) == 1 # the confirmation email was scheduled
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str
|
Database alias. |
DEFAULT_DB_ALIAS
|
execute
|
bool
|
Run the callbacks when the block exits. |
True
|
ninja_devx.testing.factories ¶
Synthetic request payloads and records for tests.
sample(Schema) builds a valid, JSON-serialisable payload from a pydantic schema:
declared defaults and examples win, otherwise a value is derived from the field type.
samples(Schema, n) varies strings and numbers so rows are distinguishable::
from ninja_devx.testing import sample, samples
payload = sample(ArticleIn) # {"title": "title", ...}
rows = samples(ArticleIn, 3) # three distinct payloads
A required field whose type has no derivation rule raises TypeError; give it an
example or a default instead.
sample ¶
A payload for schema built from defaults, examples and field types.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[SchemaT]
|
The pydantic schema (usually the request body). |
required |
samples ¶
count payloads for schema with distinguishable scalar values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
schema
|
type[SchemaT]
|
The pydantic schema (usually the request body). |
required |
count
|
int
|
How many payloads to build. |
required |
ninja_devx.testing.plugin ¶
pytest plugin (auto-loaded): controller clients and OpenAPI snapshots.
Fixtures:
ninja_client(ControllerOrRouter, user=None, **as_router_kwargs)-> test clientninja_async_client(...)-> async test clientninja_contract(api, include=None)-> a schemathesis schema calling the app in-process (pip install ninja-devx[contract])captured_commits(execute=True)->ninja_devx.testing.clients.capture_commitsopenapi_snapshot(api, name="openapi")comparesapi's schema with__snapshots__/<test module>/<name>.json; runpytest --update-snapshotsto accept changes.strict_queriesruns the test insidedjango-zeal, raising on any N+1; skips with a clear reason whenzeal(theninja-devx[zeal]extra) is not installed.
Async tests that use the database without django_db(transaction=True) get an
AsyncDatabaseTestWarning: their ORM calls run on another thread and connection,
outside the test transaction. Disable with ninja_devx_warn_async_db = false (ini).
Query-count fixtures come from pytest-django (django_assert_max_num_queries).
Django and Ninja are imported lazily: plugins load before settings are configured.
ninja_client ¶
ninja_client(ControllerOrRouter, user=None, container=None, scope=None, **options).
ninja_async_client ¶
ninja_async_client(ControllerOrRouter, user=None, **options): an async test client.
ninja_contract ¶
ninja_contract(api): a schemathesis schema for schemathesis.pytest.from_fixture.
captured_commits ¶
with captured_commits() as callbacks: ... runs on-commit work at the end.
openapi_snapshot ¶
openapi_snapshot(api, name="openapi") compares the schema with a stored snapshot.
strict_queries ¶
Raise zeal.NPlusOneError for any N+1 in this test, regardless of ZEAL_RAISE.
Skips with a clear reason when django-zeal is not installed
(pip install ninja-devx[zeal]).
ninja_devx.configuration.checks ¶
Django system checks for controllers: manage.py check reports them in CI.
Checked controllers are those behind the APIs listed in NINJA_DEVX["CHECK_APIS"]
(dotted paths to NinjaAPI instances), or, when it is empty, every router built by
as_router() once the URLconf is loaded.
Messages come from Controller.checks() (overridable) and from plugins that define
checks(controller). Startup errors that make a controller unusable are still raised
by as_router() itself.
======================== ===========================================================
ninja_devx.E001 a CHECK_APIS entry cannot be imported or is not an API
ninja_devx.E002 search_fields/filter_fields/ordering_fields name
a missing model field
ninja_devx.W003 an output schema field is neither a model field, a model
attribute nor resolved by the schema
ninja_devx.E004 service_class needs constructor arguments but the
controller is mounted without a container
ninja_devx.W005 an output field VisibleTo can hide is required instead
of optional
ninja_devx.W006 a related/@requires_related hint is not a relation
path of the model
ninja_devx.W007 expand_rules[...].limit targets a relation that is not a
plain reverse foreign key
ninja_devx.E007 aggregate_fields names a missing model field
======================== ===========================================================
CHECKS
module-attribute
¶
CHECKS: Final[Mapping[str, tuple[str, str]]] = (
MappingProxyType(
{
"ninja_devx.E001": (
"error",
"A ``CHECK_APIS`` entry cannot be imported or is not a ``NinjaAPI``.",
),
"ninja_devx.E002": (
"error",
"``search_fields``, ``filter_fields``, ``ordering_fields`` or ``default_ordering`` names a missing model field.",
),
"ninja_devx.W003": (
"warning",
"An output schema field is not a model field or attribute, and the schema does not resolve it.",
),
"ninja_devx.E004": (
"error",
"``service_class`` needs constructor arguments, but the controller has no container.",
),
"ninja_devx.W005": (
"warning",
"An output field that ``VisibleTo`` can hide is required instead of optional.",
),
"ninja_devx.W006": (
"warning",
"A ``related`` hint or ``@requires_related`` lookup is not a relation of the model.",
),
"ninja_devx.W007": (
"warning",
"``expand_rules[...].limit`` targets a relation that is not a plain reverse foreign key, so it is prefetched without a cap.",
),
"ninja_devx.E007": (
"error",
"``aggregate_fields`` names a field that does not exist on the model.",
),
}
)
)
Every system check id with its level and meaning (also rendered in the docs).
load_urlconf ¶
Import the URLconf so every API and controller module is loaded.
ninja_devx.tooling.inspect ¶
Resolve a mounted controller into its effective policy, for humans and tooling.
devx_inspect prints the tree; inspect_target returns the same data as dataclasses so
it can be rendered as JSON or asserted in tests. Everything is read from the live controller
and its settings, never from a static declaration.
OperationInspection
dataclass
¶
One registered operation of a controller.
ControllerInspection
dataclass
¶
The resolved configuration and operations of one mounted controller.
resolve ¶
Mounted controllers matching target (a controller path or a route prefix).
Raises:
| Type | Description |
|---|---|
LookupError
|
|
inspect_target ¶
Inspect every mounted controller matching target (all of the APIs without one).
as_dict ¶
The inspections as plain dictionaries, ready for json.dumps.
render ¶
A box-drawing tree of one controller inspection.
ninja_devx.tooling.openapi_diff ¶
Semantic OpenAPI diff for CI: what changed, and what breaks clients.
devx_openapi --against baseline.json fails on breaking changes. Additive changes
(new paths, new optional fields, new enum values) are reported but do not fail.
ninja_devx.tooling.drift ¶
Detect drift between models and the schemas of the controllers exposing them.
manage.py devx_scaffold --check runs this for every mounted ModelController
(or the models given) and fails when a schema no longer matches its model::
NoteController NoteOut.priority error model IntegerField is int, schema says str
NoteController NoteIn error required field 'title' is not accepted
NoteController NoteOut info model field 'archived' is not exposed
Only clear incompatibilities are errors: a value the model can hold that the schema
would reject (type, None, choices), a required field create cannot fill, or a
schema field the model does not have.
ninja_devx.tooling.scaffold ¶
Generate explicit, typed schemas, a controller and tests from a Django model.
Used by manage.py devx_scaffold. The output is plain Python that type checkers
understand, so generated schemas can be used as generic arguments.
render_resource ¶
Schemas (<Model>In/<Model>Out) and a controller for model.
ninja_devx.tooling.startproject ¶
Post-render edits for manage.py devx_startproject that Django's template engine
cannot express as a plain variable substitution: wiring a first app into the generated
settings and API module, and dropping the Docker files for --no-docker.
Plain string edits over the rendered project, not a second templating pass::
wire_app(directory, "blog") # after ``call_command("devx_startapp", "blog", ...)``
drop_docker(directory) # deletes compose.yaml and its README section
camel_case ¶
blog_posts -> BlogPosts, matching devx_startapp's controller names.
wire_app ¶
Add app_name to INSTALLED_APPS and mount its controller in config/api.py.
Call after devx_startapp has written app_name under directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
directory
|
Path
|
The generated project's root. |
required |
app_name
|
str
|
The app just scaffolded by |
required |
drop_docker ¶
Remove compose.yaml and the matching section of README.md for --no-docker.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
directory
|
Path
|
The generated project's root. |
required |
target_directory ¶
Create directory for Django's startproject/startapp, which only create a
missing destination themselves from Django 6.0.
The directory is removed again if the command fails before writing into it.
ninja_devx.tooling.doctor ¶
Findings beyond the system checks, across every mounted controller.
devx_doctor inspects the same mounted controllers as manage.py check
(:mod:ninja_devx.configuration.checks), through :func:ninja_devx.tooling.inspect.resolve,
but looks for things that are valid yet risky rather than broken: a filter, search or
ordering field with no database index, an owner_field/tenant_field with nothing
actually enforcing it, an output field the N+1 planner cannot resolve statically, a list
endpoint with no pagination, a controller with no permission at all, and a soft-deleted
model whose unique fields are not scoped to active rows.
Each check is one small function added to :data:CHECKS, so adding a new one is one
function::
@_register
def _check_something(target: _Target) -> Iterator[Finding]:
if ...:
yield Finding("warn", target.inspection.controller, "message", "fix hint")
:func:run_doctor runs every registered check over every controller resolve(target)
finds.
Finding
dataclass
¶
One thing devx_doctor noticed about a mounted controller.
run_doctor ¶
Run every registered check over every controller resolve(target) finds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
str | None
|
A controller path or a mounted route prefix; every controller of the
configured APIs when |
None
|
ninja_devx.tooling.unasync ¶
Generate the sync twin of async code, and check that it has not drifted.
Write the async implementation once; the sync one is derived token by token
(async def → def, await x → x, async for/with → for/with,
aget → get, AsyncIterator → Iterator...)::
python -m ninja_devx.tooling.unasync src/orders/aio.py:src/orders/sync.py
python -m ninja_devx.tooling.unasync src/orders/aio.py:src/orders/sync.py --check # CI
python -m ninja_devx.tooling.unasync a.py:b.py --replace AsyncOrderRepository=OrderRepository
Names are replaced as whole tokens (strings and comments are left alone, except the
generated-file header). Django's async ORM methods, this package's a-prefixed
helpers and the standard async protocol names are mapped by default.
unasync_source ¶
unasync_source(
code: str,
replacements: Mapping[str, str] = DEFAULT_REPLACEMENTS,
*,
header: str = "",
) -> str
The sync version of code.
ninja_devx.testing.contracts ¶
Contract tests generated from the OpenAPI schema, with schemathesis.
Install the extra (pip install ninja-devx[contract]), then::
import schemathesis
@pytest.fixture
def api_schema(ninja_contract, db):
return ninja_contract(api)
schema = schemathesis.pytest.from_fixture("api_schema") # .include(path_regex=...) to filter
@schema.parametrize()
def test_api_contract(case):
case.call_and_validate(headers={"Authorization": "Bearer test-token"})
Every operation gets property-based requests (valid and invalid input). Responses are
checked against the documented status codes, content types and schemas, so an
undocumented 500 or a body that doesn't match response= fails the test. Requests go
through Django's WSGI application in-process, inside the test database transaction.
contract_schema ¶
A schemathesis schema for api, calling the Django WSGI app in-process.
path_prefix defaults to where the API is mounted in the URLconf. Filter operations
on the lazy schema: schemathesis.pytest.from_fixture("api_schema").include(...).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api
|
NinjaAPI
|
The |
required |
path_prefix
|
str | None
|
API root path (default: where it is mounted in the URLconf). |
None
|
ninja_devx.codegen ¶
Typed API clients generated from a Django Ninja OpenAPI schema.
Operation
dataclass
¶
response
instance-attribute
¶
Union of all documented 2xx JSON, text and binary representations.
errors
class-attribute
instance-attribute
¶
Documented non-2xx responses that carry a body.
streaming
class-attribute
instance-attribute
¶
A 2xx text/event-stream response, generated as an event iterator.
load_api ¶
Import a NinjaAPI from a dotted path, e.g. config.urls.api.
ninja_devx.contrib.dishka ¶
Use a dishka <https://dishka.readthedocs.io>_ container with controllers.
Every request opens a dishka scope (Scope.REQUEST by default) in which
HttpRequest is available as context; declare it with
from_context(provides=HttpRequest, scope=Scope.REQUEST)::
provider = AppProvider()
provide_controllers(provider, [UserController, OrderController])
resolver = DishkaResolver(
make_container(provider),
async_container=make_async_container(provider), # only for async operations
)
api.add_router("/users", UserController.as_router(container=resolver))
One resolver serves sync and async operations. as_router() fails at startup when
the controller (or an Inject[T]) is not provided, instead of on the first request.
DishkaResolver
dataclass
¶
AsyncDishkaResolver
dataclass
¶
provide_controllers ¶
provide_controllers(
provider: Provider,
controllers: Iterable[type[object]],
*,
scope: BaseScope = Scope.REQUEST,
) -> None
Register controller classes with provider (their __init__ is autowired).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
provider
|
Provider
|
The dishka provider to register on. |
required |
controllers
|
Iterable[type[object]]
|
Controller classes (their |
required |
scope
|
BaseScope
|
dishka scope of the controllers. |
REQUEST
|
ninja_devx.contrib.svcs ¶
Use an svcs <https://svcs.hynek.me>_ registry with controllers.
Every request gets its own svcs.Container (closed, with cleanups, when the
request ends) in which HttpRequest is registered as a local value::
registry = svcs.Registry()
registry.register_factory(Database, connect)
api.add_router("/users", UserController.as_router(container=SvcsResolver(registry)))
svcs containers are per request, so controllers must use Scope.REQUEST.