django-morest
django-morest is a small API utility package for Django and Django REST Framework that standardizes:
- JSON response envelopes
- exception handling
- request ids
- reusable query serializers for pagination, search, and ordering
- list/filter API views
- schema and Swagger helpers for
drf-yasg - JWT and bearer-token authentication helpers
- Cloudflare Turnstile captcha validation helpers
- a simple encrypted text model field
Installation
pip install django-morest
If you use EncryptedTextField, also install pycryptodome.
pip install pycryptodome
Requirements
- Django
- djangorestframework
- drf-yasg
- PyJWT
Quick Start
Add morest to installed apps and merge the provided presettings into your Django settings.
from morest.core import presettings
INSTALLED_APPS = [
...,
"morest",
"drf_yasg",
]
MIDDLEWARE = [
*presettings.MOREST_MIDDLEWARES,
...,
]
REST_FRAMEWORK = {
**presettings.MOREST_REST_FRAMEWORK,
}
LOGGING = presettings.LOGGING
If you want the built-in healthcheck endpoint:
from morest.core import presettings
urlpatterns = [
*presettings.HEALTHCHECK_URLPATTERNS,
...,
]
If you want the built-in Swagger UI, connect morest.core.docs.schema_view in your project URLs.
from django.urls import path
from morest.core import docs
urlpatterns = [
path("swagger/", docs.schema_view.with_ui("swagger")),
...,
]
Presettings
morest.core.presettings exposes reusable defaults for Django projects.
MOREST_MIDDLEWARES
MIDDLEWARE = [
*presettings.MOREST_MIDDLEWARES,
...,
]
This currently enables:
morest.middlewares.RequestIDMiddlewaremorest.middlewares.ExceptionMiddleware
RequestIDMiddleware reads the incoming Request-ID header when present, otherwise generates one, and adds it to the response headers.
ExceptionMiddleware converts unhandled API errors, Http404, and plain 404 responses into the standard JSON envelope.
MOREST_REST_FRAMEWORK
REST_FRAMEWORK = {
**presettings.MOREST_REST_FRAMEWORK,
}
This currently sets:
{
"EXCEPTION_HANDLER": "morest.middlewares.DRFExceptionMiddleware",
}
That keeps DRF exceptions inside the same morest JSON response format.
INSTALLED_APPS
For the core response, middleware, and view helpers:
INSTALLED_APPS = [
...,
"morest",
]
Add drf_yasg when you use morest.core.docs.schema_view, docs.schema(...), or the Swagger UI.
INSTALLED_APPS = [
...,
"morest",
"drf_yasg",
]
Add morest.bearertoken only when you want the bundled database-backed bearer token model.
INSTALLED_APPS = [
...,
"morest",
"morest.bearertoken",
"drf_yasg",
]
After adding morest.bearertoken, run migrations for your project.
LOGGING
LOGGING = presettings.LOGGING
The provided logging config:
- logs to console
- only emits through the handler when
DEBUG = False - attaches
request_idto log records throughRequestIDLogFilter - exposes ready logger objects in
presettings:django_logger,django_request_logger,django_server_logger,morest_logger
HEALTHCHECK_URLPATTERNS
urlpatterns = [
*presettings.HEALTHCHECK_URLPATTERNS,
...,
]
This provides:
GET /healthcheck/
Response:
{
"data": null,
"status": "ok",
"status_code": 200,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "OK"
}
Standard Response Envelope
The central API primitive is morest.api.Response.
from morest.api import Response
All successful and error responses share the same JSON shape:
{
"data": {
"key": "value"
},
"status": "ok",
"status_code": 200,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "OK"
}
For non-2xx responses, error_details is included.
{
"data": null,
"status": "validation_error",
"status_code": 400,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "Request body or query params validation error",
"error_details": {
"email": ["This field is required."]
}
}
Using Response
Basic success response
from django.http import HttpRequest
from rest_framework.views import APIView
from morest.api import Response
class TestView(APIView):
def get(self, request: HttpRequest):
return Response({
"key": "value",
})
Explicit status code and message
return Response(
data={"id": 10},
status_code=201,
status="created",
message="User created",
)
Validation errors from serializers
Use Response.validation_error(serializer.errors) whenever request input validation fails.
from rest_framework import serializers
from rest_framework.views import APIView
from morest.api import Response
class CreateUserSerializer(serializers.Serializer):
email = serializers.EmailField()
username = serializers.CharField()
class CreateUserView(APIView):
def post(self, request):
serializer = CreateUserSerializer(data=request.data)
if not serializer.is_valid():
return Response.validation_error(serializer.errors)
return Response(serializer.validated_data, status_code=201, status="created", message="Created")
Status-only responses
return Response.from_status("ok")
return Response.from_status("fail")
return Response.from_status("error")
Using Errors
The package exposes a base error type plus reusable application errors.
from morest.errors import (
BaseError,
InternalError,
ValidationError,
NotFoundError,
AlreadyExistsError,
InsufficientBalanceError,
FieldNotFoundError,
)
All BaseError subclasses can be raised inside views or helper functions. ExceptionMiddleware catches them and returns a Response automatically.
Built-in errors
InternalError-> HTTP500ValidationError-> HTTP400NotFoundError-> HTTP404AlreadyExistsError-> HTTP409InsufficientBalanceError-> HTTP402FieldNotFoundError-> HTTP406AccessTokenIsInvalidError-> HTTP401RefreshTokenIsInvalidError-> HTTP401CaptchaTokenIsInvalidError-> HTTP406
Raise a built-in error
from morest.errors import NotFoundError
def get_user_or_fail(user_id: int):
...
raise NotFoundError(message="User not found")
Convert an error manually
from morest.errors import ValidationError
error = ValidationError(
message="Invalid payload",
error_details={"field": ["This field is required."]},
)
return error.to_response()
Helper constructors
from morest.errors import AlreadyExistsError, InsufficientBalanceError, NotFoundError
raise AlreadyExistsError.with_object_details("User", "email", "john@example.com")
raise InsufficientBalanceError.with_balance_details(current_balance=10, required_balance=25)
raise NotFoundError.with_object_details("User", {"id": 15})
Create your own error type
from morest.errors import BaseError
class PermissionDeniedError(BaseError):
status_code = 403
status = "permission_denied"
message = "You do not have permission to perform this action"
Exception Handling
When you enable both presettings:
MIDDLEWARE = [
*presettings.MOREST_MIDDLEWARES,
...,
]
REST_FRAMEWORK = {
**presettings.MOREST_REST_FRAMEWORK,
}
morest normalizes these cases into JSON:
- raised
BaseError - DRF
APIException Http404- unresolved routes and plain Django 404 responses
- unexpected exceptions as
InternalError
Example 404 body:
{
"data": null,
"status": "not_found",
"status_code": 404,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "The requested resource was not found on this server.",
"error_details": {
"path": "/missing-url/",
"method": "GET"
}
}
Note: place morest.middlewares.ExceptionMiddleware near the top of MIDDLEWARE. Exceptions raised by middleware that runs before it will not be normalized by morest.
Cloudflare Turnstile Captcha
morest includes a small serializer and service for validating Cloudflare Turnstile tokens before the rest of a request body is processed.
Configure your Cloudflare Turnstile secret key in Django settings.
CLOUDFLARE_CAPTCHA_SECRET_KEY = "your-cloudflare-turnstile-secret-key"
For local development, tests, or trusted internal clients, you can optionally configure a super token. When the incoming captcha_token matches this value, the remote Cloudflare verification request is skipped.
CLOUDFLARE_CAPTCHA_SUPER_TOKEN = "local-development-token"
Use CloudflareCaptchaSerializer as a base class for request serializers that must include a captcha token.
from rest_framework import serializers
from morest.serializers import CloudflareCaptchaSerializer
class ContactFormSerializer(CloudflareCaptchaSerializer):
email = serializers.EmailField()
message = serializers.CharField()
Validate it in a view the same way as any DRF serializer.
from rest_framework.views import APIView
from morest.api import Response
class ContactFormView(APIView):
def post(self, request):
serializer = ContactFormSerializer(data=request.data)
if not serializer.is_valid():
return Response.validation_error(serializer.errors)
data = serializer.validated_data
...
return Response.from_status("ok")
Expected request body:
{
"captcha_token": "turnstile-client-token",
"email": "john@example.com",
"message": "Hello"
}
captcha_token is validated first in to_internal_value() and is removed from validated_data. If the token is invalid, CaptchaTokenIsInvalidError is raised and normalized by ExceptionMiddleware into a 406 response.
Example invalid-token response:
{
"data": null,
"status": "captcha_token_is_invalid",
"status_code": 406,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "Captcha token is invalid",
"error_details": {
"captcha_token": "Captcha token is invalid"
}
}
You can also use the service directly when serializer inheritance is not suitable.
from django.conf import settings
from morest.services import CloudflareCaptchaService
captcha = CloudflareCaptchaService(
secret_key=settings.CLOUDFLARE_CAPTCHA_SECRET_KEY,
super_token=getattr(settings, "CLOUDFLARE_CAPTCHA_SUPER_TOKEN", None),
)
is_valid = captcha.verify(token="turnstile-client-token")
List and Filter Views
morest.views.ListFilterView is a reusable DRF APIView for list endpoints that support:
- plain filtering through serializer fields
- text search
- ordering
- pagination
- generated
drf-yasgquery docs when available
Import it from:
from morest.views import ListFilterView
Basic filtered list view
from rest_framework import serializers
from morest.api import Response
from morest.views import ListFilterView
from morest.serializers import PaginationSerializer, SearchSerializer, OrderSerializer
from app.models import Product
class ProductSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
category = serializers.CharField()
price = serializers.DecimalField(max_digits=10, decimal_places=2)
class ProductListQuerySerializer(PaginationSerializer, SearchSerializer, OrderSerializer):
category = serializers.CharField(required=False)
is_active = serializers.BooleanField(required=False)
class ProductListView(ListFilterView):
queryset = Product.objects.filter(is_deleted=False)
serializer = ProductSerializer
filter_serializer = ProductListQuerySerializer
search_fields = ["name", "category", "=sku", "^name"]
order_fields = ["id", "name", "price", "created_at"]
rows_name = "products"
This gives you a GET endpoint that can accept query params like:
/products/?category=books&is_active=true&page=1&limit=20&q=django&order_by=name&order_by=-created_at
How filtering works
ListFilterView.get() executes in this order:
- load the base queryset from
get_queryset() - validate
request.GETwithfilter_serializer - strip pagination/search/order fields out of the validated data
- call
filter_queryset()with the remaining fields - apply search through
SearchSerializer - apply ordering through
OrderSerializer - paginate if the query serializer inherits
PaginationSerializer - serialize and return
Response(...)
Overriding queryset filtering
By default, filter_queryset() performs qs.filter(**filters).all().
Override it when you need custom logic.
class ProductListView(ListFilterView):
...
def filter_queryset(self, request, qs, filters, **kwargs):
if category := filters.pop("category", None):
qs = qs.filter(category__slug=category)
return qs.filter(**filters)
Search behavior
SearchSerializer reads the q query param and applies search_in_queryset() using search_fields.
Supported prefixes in search_fields:
"name"->name__icontains"^name"->name__istartswith"=email"->email__iexact"@body"->body__search
Example:
search_fields = ["name", "description", "^sku", "=slug"]
Ordering behavior
OrderSerializer reads order_by as a list.
/products/?order_by=name&order_by=-created_at
Restrict allowed fields with order_fields:
order_fields = ["name", "created_at", "price"]
If a field outside order_fields is requested, FieldNotFoundError is raised and returned as a 406 JSON error.
Pagination behavior
PaginationSerializer supports:
pagewith minimum1limitwith minimum1
Default values:
page = 1limit = 20
Paginated response shape:
{
"data": {
"products": [],
"rows_count": 0,
"total_count": 0,
"pages_count": 0
},
"status": "ok",
"status_code": 200,
"request_id": "4d06538d2d7b40db8e729757a363fe55",
"message": "OK"
}
Use rows_name on the serializer or the view to rename the collection key.
class ProductListQuerySerializer(PaginationSerializer):
rows_name = "products"
or:
class ProductListView(ListFilterView):
rows_name = "products"
Validation failures in list views
ListFilterView already uses Response.validation_error(...) for invalid query params.
That means invalid pagination, search, or custom filter serializer input automatically returns a standardized 400 response.
Query Serializer Utilities
Import these from morest.serializers:
from morest.serializers import PaginationSerializer, SearchSerializer, OrderSerializer, PaginationSearchSerializer
PaginationSerializer
Adds:
pagelimit.paginate(qs, serializer, rows_name=...)
SearchSerializer
Adds:
q.filter(qs, search_fields)
OrderSerializer
Adds:
order_by.order(qs, order_fields=...)
PaginationSearchSerializer
Convenience serializer that combines:
PaginationSerializerSearchSerializer
If you need all three features, inherit all three explicitly.
class QuerySerializer(PaginationSerializer, SearchSerializer, OrderSerializer):
...
Object Lookup Helpers
Import from:
from morest.generics.get_object import get_object_or_404
get_object_or_404
user = get_object_or_404(User.objects, id=user_id)
Raises morest.errors.NotFoundError instead of Django's default 404 type.
Swagger and Schema Helpers
Import from:
from morest.core import docs
Swagger UI
morest.core.docs exposes a ready drf-yasg schema view.
from django.urls import path
from morest.core import docs
urlpatterns = [
path("swagger/", docs.schema_view.with_ui("swagger")),
]
Make sure drf_yasg is enabled in your Django settings.
INSTALLED_APPS = [
...,
"morest",
"drf_yasg",
]
The built-in schema_view is configured with:
- title:
Swagger API - version:
v1 SessionAuthenticationIsAdminUser
That means the Swagger UI is intended for authenticated admin users by default.
You can override the defaults with MOREST_SWAGGER_CONFIG:
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAdminUser
MOREST_SWAGGER_CONFIG = {
"title": "Swagger API",
"default_version": "v1",
"public": True,
"authentication_classes": (SessionAuthentication,),
"permission_classes": (IsAdminUser,),
}
View method schemas
Use docs.schema(...) to wrap view methods with a response envelope schema.
from rest_framework import serializers
from rest_framework.views import APIView
from morest.api import Response
from morest.core import docs
class UserSerializer(serializers.Serializer):
id = serializers.IntegerField()
username = serializers.CharField()
class CreateUserSerializer(serializers.Serializer):
username = serializers.CharField()
class UserCreateView(APIView):
@docs.schema(request_body=CreateUserSerializer, response=UserSerializer)
def post(self, request):
...
return Response({"id": 1, "username": "john"}, status_code=201, status="created", message="Created")
If the request serializer inherits PaginationSerializer, docs.schema() wraps the response in a paginated envelope automatically.
ListFilterView.as_view() also uses docs.schema(...) for generated list endpoint docs when drf-yasg is installed.
Authentication
The package ships session auth views, JWT helpers, and an optional database-backed bearer token app.
Session auth views
Session-based auth views live under morest.views.auth.session.
LoginView
GETreturns the current authenticated user serialized byUserSerializerPOSTexpectsusernameandpassword
Serializer validation errors are returned via:
Response.validation_error(serializer.errors)
LogoutView
POSTlogs out the current session user- returns
Response.from_status("ok")
JWT authentication
Import from:
from morest.authentication import JWTAuthentication, get_jwt_service
from morest.views import RefreshTokenView
Configure token secrets in Django settings.
JWT_ACCESS_TOKEN_SECRET = "change-me-access"
JWT_REFRESH_TOKEN_SECRET = "change-me-refresh"
# Optional, in seconds. If omitted, tokens do not expire.
JWT_ACCESS_TOKEN_TTL = 60 * 15
JWT_REFRESH_TOKEN_TTL = 60 * 60 * 24 * 30
Use JWTAuthentication in DRF settings or on a view.
REST_FRAMEWORK = {
**presettings.MOREST_REST_FRAMEWORK,
"DEFAULT_AUTHENTICATION_CLASSES": [
"morest.authentication.JWTAuthentication",
],
}
Clients authenticate with:
Authorization: JWT <access_token>
Create a token pair for a user:
from morest.authentication import JWTAuthentication
jwt_pair = JWTAuthentication().authorize(user)
Expose the refresh endpoint in your URLs:
from django.urls import path
from morest.views import RefreshTokenView
urlpatterns = [
path("auth/refresh/", RefreshTokenView.as_view()),
]
Request body:
{
"refresh_token": "..."
}
Response data:
{
"access_token": "...",
"refresh_token": "..."
}
Invalid access or refresh tokens raise AccessTokenIsInvalidError or RefreshTokenIsInvalidError and return a standardized 401 response.
Bearer token authentication
Import from:
from morest.authentication import BearerTokenAuthentication
Enable the bundled token model when you want database-backed bearer tokens.
INSTALLED_APPS = [
...,
"morest",
"morest.bearertoken",
]
Then run migrations.
python manage.py migrate
Use the authentication class in DRF settings or on a view.
REST_FRAMEWORK = {
**presettings.MOREST_REST_FRAMEWORK,
"DEFAULT_AUTHENTICATION_CLASSES": [
"morest.authentication.BearerTokenAuthentication",
],
}
Clients authenticate with:
Authorization: Bearer <token>
BearerTokenAuthentication accepts active tokens whose expires_at is empty or in the future, and rejects inactive users.
Admin Form View
morest.views.AdminFormView is a DRF APIView that renders a Django admin-styled form page.
Use it when you need a custom admin action page backed by a Django form.
from django import forms
from django.http import HttpResponseRedirect
from morest.views import AdminFormView
class SendCreditsForm(forms.Form):
user_id = forms.IntegerField()
amount = forms.DecimalField()
class SendCreditsView(AdminFormView):
form = SendCreditsForm
action_name = "Send Credits"
breadcrumbs = (("Home", "/admin/"), ("Send Credits", ""))
def handle(self, request, form, **kwargs):
data = form.cleaned_data
...
return HttpResponseRedirect("/admin/")
Override these hooks when needed:
get_template()get_action_name()get_breadcrumbs()get_context()handle()
Encrypted Text Field
Import from:
from morest.db.fields import EncryptedTextField
Example:
from django.db import models
from morest.db.fields import EncryptedTextField
class SecretNote(models.Model):
title = models.CharField(max_length=255)
content = EncryptedTextField()
You must provide a secret key either:
- per field
- or globally through
DEFAULT_ENCRYPTED_TEXT_FIELD_SECRET_KEY
Per field:
content = EncryptedTextField(secret_key="your-secret-key")
Global setting:
DEFAULT_ENCRYPTED_TEXT_FIELD_SECRET_KEY = "your-secret-key"
Behavior:
- values are encrypted before saving to the database
- values are decrypted when loaded from the database
- empty strings are stored as
NULL
JSON Encoding
Response uses morest.core.MorestJSONEncoder, which supports:
datetimedatetimetimedeltadecimal.Decimaluuid.UUID- Django lazy
Promise - dataclasses
That means you can safely return many Python-native values inside Response(data=...) without hand-converting them first.
Minimal End-to-End Example
from rest_framework import serializers
from morest.api import Response
from morest.views import ListFilterView
from morest.serializers import PaginationSerializer, SearchSerializer, OrderSerializer
from morest.errors import NotFoundError
from app.models import Product
class ProductSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
price = serializers.DecimalField(max_digits=10, decimal_places=2)
class ProductQuerySerializer(PaginationSerializer, SearchSerializer, OrderSerializer):
category = serializers.CharField(required=False)
class ProductListView(ListFilterView):
queryset = Product.objects.all()
serializer = ProductSerializer
filter_serializer = ProductQuerySerializer
search_fields = ["name", "category"]
order_fields = ["id", "name", "price"]
rows_name = "products"
def create_product(request):
serializer = ProductSerializer(data=request.data)
if not serializer.is_valid():
return Response.validation_error(serializer.errors)
return Response(serializer.validated_data, status_code=201, status="created", message="Created")
def delete_product(product):
if product is None:
raise NotFoundError(message="Product not found")
Export Summary
Common imports:
from morest.api import Response
from morest.authentication import BearerTokenAuthentication, JWTAuthentication, get_jwt_service
from morest.core import docs, get_queryset, search_in_queryset, MorestJSONEncoder, presettings
from morest.errors import *
from morest.serializers import CloudflareCaptchaSerializer, PaginationSerializer, SearchSerializer, OrderSerializer, PaginationSearchSerializer
from morest.services import CloudflareCaptchaService, JWTService, JWTPair
from morest.views import ListFilterView, AdminFormView, RefreshTokenView
Metadata
Release files for django-morest 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| django_morest-0.3.0.tar.gz | 40.4 kB | Without provenance | Details |
Release files / django_morest-0.3.0.tar.gz
| Download URL | django_morest-0.3.0.tar.gz |
|---|---|
| Size | 40.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a5f8de5cb6dbe9f1907e9475d27c7b86141000ed69e380148e2fe4fafe1905ec
|
|
BLAKE2b-256 checksum How to use checksums |
353e67e4008f24af5b12485c1b3d20da1274335dbb706eb5bbfc24de5e76fc40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.0.1 CPython/3.11.12
|
Security for django-morest 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.