Django api manager and unifier
Project description
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
- 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-> HTTP401
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.
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.utils 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.utils:
from morest.utils 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_objects_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.
get_objects_or_404
user = get_objects_or_404(User.objects, with_error_details=True, id=user_id)
If with_error_details=True, the raised NotFoundError includes the lookup filters.
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.
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_manager
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.utils 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_manager
from morest.core import JWTManager, JWTPair, docs, get_queryset, search_in_queryset, MorestJSONEncoder, presettings
from morest.errors import *
from morest.utils import PaginationSerializer, SearchSerializer, OrderSerializer, PaginationSearchSerializer
from morest.views import ListFilterView, AdminFormView, RefreshTokenView
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file django_morest-0.2.2.tar.gz.
File metadata
- Download URL: django_morest-0.2.2.tar.gz
- Upload date:
- Size: 37.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.0.1 CPython/3.11.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b193e32c279757533dd2b9f4f3b3aeb9556d4df3f2e395f5d6dcc4bbdddce138
|
|
| MD5 |
4bdfca1bb74f56ba05935ec490cd5e22
|
|
| BLAKE2b-256 |
4ea5dcbfeb98b798a4d114da7fb8cf693d179be04df8a79df11956a5deadae23
|