Skip to main content

A simple rest api generator for django based on models

Project description

easyapi-django

A REST API generator for Django. Define a class, point it at a model, get full async CRUD endpoints with authentication, filtering, pagination, caching, rate limiting, multi-tenancy, Pydantic validation and OpenAPI docs out of the box.

Why

Most Django REST resources end up as hundreds of lines of plumbing: list/detail views, write handlers with field whitelists, session auth, rate limit, Redis caching with invalidation, multi-tenant DB switching. easyapi packages all of that as a class with attributes — usually under 30 lines per resource.

Install

pip install easyapi-django

Optional Pydantic schemas for input validation and response shaping:

pip install 'easyapi-django[schemas]'

Required environment

REDIS_SERVER=localhost
REDIS_DB=0
REDIS_PREFIX=myapp           # optional; namespaces all Redis keys

Redis is used for sessions, cache, rate limiting and abuse blocking.

Add middleware in Django settings

MIDDLEWARE = [
    ...
    'easyapi.SecurityMiddleware',    # pattern/UA/4xx-flood instant block
    'easyapi.AuthMiddleware',        # session-based auth from Redis
    'easyapi.ExceptionMiddleware',
]

EASYAPI = {
    'TRUSTED_PROXIES': ['10.0.0.0/8'],   # only trust X-Real-IP from these
    # 'COOKIE_ID': 'sessionid', 'ENFORCE_TOKEN': True, ...
}

Create a resource

from easyapi import BaseResource
from your_models import YourModel

class YourResource(BaseResource):
    model = YourModel

Wire up routes

from easyapi import get_routes
from your_resources import YourResource

endpoints = {
    r'yourendpoint(.*)$': YourResource,
}
urlpatterns = [...] + get_routes(endpoints)

GET, POST, PATCH, DELETE are ready. You also get:

  • GET /openapi.json — OpenAPI 3.0.3 spec
  • GET /docs — interactive Scalar UI

Configuration cheat sheet

class YourResource(BaseResource):
    model = YourModel

    authenticated = True               # default; set False to allow anonymous
    allowed_methods = ['get', 'post', 'patch', 'delete']

    # Listing
    list_fields = ['id', 'name']
    list_related_fields = {'account': ['name', 'plan']}
    list_exclude_fields = []
    normalize_list = False             # return {id: {...}} instead of [{...}]

    # Filtering / searching / ordering
    filter_fields = ['name', 'active']
    search_fields = ['name', 'email']
    search_operator = 'icontains'
    order_fields = ['id', 'name']

    # Detail / write
    edit_fields = ['id', 'name']
    update_fields = ['name']
    create_fields = ['name']
    normalize_obj = False              # return {id: {...}} from PATCH/POST

    # Ownership (DELETE/PATCH scoped to rows owned by user)
    owner_field = 'owner_id'

    # Pagination
    limit = 25                         # 0 returns everything
    order_by = 'id'

    # Cache
    cache = True
    cache_ttl = 600                    # default 120s; settings.CACHE_TTL overrides

Querystrings

Param Effect
?count=true Return only {count: N}
?search=value Search across search_fields with OR
?field=value / ?field__gte=... Filter on whitelisted fields
?fields=a,b Restrict returned fields (filtered by list_fields)
?filter=<json> Advanced filter expression on whitelisted fields
?segment_id=N Apply a saved segment (see below)
?page=N&limit=M&order_by=field Pagination + order
?normalize=true Return list as {id: {...}} instead of array

Saved segments

A segment is a saved JSON filter expression — the same boolean tree ?filter=<json> accepts, stored under a stable id. Useful for CRM-style "saved views", marketing audiences, dashboard filters.

Wire it up by pointing EASYAPI.SEGMENT_MODEL at a model that exposes a .conditions attribute returning the Layer-2 dict:

# settings.py
EASYAPI = {
    'SEGMENT_MODEL': 'modules.segment.models.Segment',
}

# modules/segment/models.py
class Segment(models.Model):
    name = models.CharField(max_length=120)
    conditions = models.JSONField()      # the Layer-2 boolean tree

Segment.objects.create(
    name='Active demo accounts',
    conditions={
        'logical_operator': 'AND',
        'rules': [
            {'field': 'active', 'operator': 'exact',     'value': True},
            {'field': 'name',   'operator': 'icontains', 'value': 'demo'},
        ],
    },
)

Any resource then accepts GET /clients?segment_id=42. Conditions are validated against the resource's filter_fields whitelist; missing rows return 404. When SEGMENT_MODEL is unset, ?segment_id= is a no-op. A bad path raises ImportError at first use — typos fail loudly instead of silently disabling segments.

Pydantic schemas (optional)

Set any of create_schema, update_schema, list_schema and easyapi validates inputs and shapes outputs through the schema. Resources without schemas keep the legacy field-list behaviour.

from pydantic import BaseModel, EmailStr, Field

class UserCreate(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8)

class UserOut(BaseModel):
    id: int
    email: EmailStr
    name: str

class UserResource(BaseResource):
    model = User
    create_schema = UserCreate         # validates POST body, 422 on failure
    list_schema = UserOut              # shapes GET responses

Validation errors are returned as HTTPException(422, [...]):

{
  "success": false,
  "status": 422,
  "detail": [
    {"field": "email", "message": "value is not a valid email address"}
  ]
}

OpenAPI

get_routes() always registers two routes:

  • /openapi.json — generated from your resources. Pydantic schemas are emitted as JSON Schema; resources without schemas fall back to Django model introspection.
  • /docs — Scalar API reference (two-column layout, search, dark mode, try-it-out). The Scalar AI assistant is disabled in this build.

Custom routes can be enriched with the @openapi(...) decorator:

from easyapi import openapi

class UserResource(BaseResource):
    routes = [{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get']}]

    @openapi(summary='Current user', response=UserOut)
    async def me(self, request, match=None):
        return {'id': self.user['id'], 'email': self.user['email']}

Custom routes

class YourResource(BaseResource):
    model = YourModel
    routes = [
        {'path': r'(\d+)/accept$', 'func': 'accept', 'allowed_methods': ['patch']},
        {'path': r'me$',           'func': 'get_me', 'cache': True},
    ]

    async def accept(self, request, match=None, body=None):
        ...

    async def get_me(self, request, match=None):
        ...

Cache

Per-resource opt-in Redis cache. Namespaced invalidation — editing row 5 does not drop the cache for row 7.

Operation Cache effect
GET /spaces Cached under list:<model> namespace
GET /spaces/5 Cached under detail:<model>:5
PATCH /spaces/5 Invalidates list:<model> + detail:<model>:5
DELETE /spaces/5 Same as PATCH
POST /spaces Invalidates list:<model> only

Cache key includes a hash of the querystring, so different filters do not collide.

Tenant isolation is automatic. Multi-tenant deployments share Redis, so _build_cache_key folds self.account_id into the key whenever it is set — different tenants hitting the same path get different keys. No configuration needed; it just works for any project that uses aset_tenant. The auto-fold is keyed by account_id is not None, so an explicit account_id = 0 still produces a per-tenant key (real value, not absence). Disable globally via EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False} if you have a single-tenant deployment and want the legacy key shape.

If you override _build_cache_key in a project, call self._account_cache_segment() and append the result so the override inherits the tenant isolation.

TTL settings. Two project-level knobs in the EASYAPI bag:

  • CACHE_TTL — default 120s; overrides the framework default for resources that don't declare an explicit cache_ttl.
  • CACHE_TTL_ENABLE — default True; flip to False for a global kill switch (every cache=True resource becomes cache=False at runtime, no Redis read or write).

Every easyapi setting lives inside the EASYAPI = {...} dict (DRF/Celery-style namespace):

# settings.py
EASYAPI = {
    'CACHE_TTL': 300,
    'CACHE_TTL_ENABLE': True,
    'ENFORCE_TOKEN': True,
    'COOKIE_ID': 'sessionid',
    'RATE_LIMITS': {...},
}

Inside the bag the historical EASYAPI_ prefix is redundant — EASYAPI_API_KEY_RESOLVER and API_KEY_RESOLVER resolve to the same setting.

CACHE_TTL only sets the default — resources that declare cache_ttl = N keep that explicit value. CACHE_TTL_ENABLE = False is a kill switch that forces self.cache = False for every request, useful for incident response without code edits.

Per-scope caching. When the response varies on a user/account dimension inside the same tenant — role, space, plan, country — declare it with cache_scope_fields so users sharing the same scope share the cache and different scopes get isolated keys:

class TaskResource(BaseResource):
    model = Task
    cache = True
    # Strings are shorthand for `self.user[field]`. Tuples select the
    # source explicitly: ('user', ...) or ('account', ...).
    # Don't add ('account', 'id') — tenant isolation is already automatic.
    cache_scope_fields = ['space_id', ('account', 'plan_id')]

When a request has authenticated context but a configured scope field is missing from the session payload, the framework logs a WARNING (logger easyapi.base) and disables cache for that request — the response is neither read from nor written to Redis. Sharing a key across users when the scope can't be resolved would be a silent leak across whatever dimension the operator was trying to protect. Anonymous requests skip the fold cleanly (no warning, no leak). None, 0 and '' count as present (a real value).

Use before_cache for the rare case that needs context outside self.user / self.account:

async def before_cache(self, request):
    """Escape hatch for scope sources not covered by cache_scope_fields."""
    feature = await get_feature_flag(self.user)
    self.cache_key += f':flag={feature}'

Hit/miss stats:

from easyapi import get_cache_stats

stats = await get_cache_stats()
# {'hits': ..., 'misses': ..., 'total': ..., 'ratio': ..., 'by_model': {...}}

Authentication

Two mechanisms, both Redis-backed:

  • Session cookieCookie: <COOKIE_ID>=<key>, validated against a strict regex before any Redis lookup.
  • API keyX-Api-Key: <token>. Format is your project's choice; easyapi resolves the token to a session via your UserApi model. See the docs for the default resolution flow and how to issue keys.

When both are present, the API key wins. authenticated = False opts a resource out of authentication while keeping rate limit and security middleware in effect.

Security defaults

  • Session cookie validated against ^[a-zA-Z0-9_\-:]{5,100}$.
  • ?fields= is filtered against list_fields to prevent attribute leakage.
  • ?filter= and segment_id are validated against filter_fields.
  • owner_field scopes PATCH/DELETE to rows owned by the authenticated user.
  • Request rate limiting runs inside BaseResource.dispatch; edge scanner blocking runs in SecurityMiddleware before the view.
  • Both layers converge on the same blocked-IP store in Redis (rate_limit:blocked:<ip>), with automatic 24h blocking.
  • SecurityMiddleware instant-blocks scanner paths/UAs and 4xx floods.
  • get_client_ip honours X-Real-IP only from TRUSTED_PROXIES.
  • Unhandled handler exceptions return a sanitized JSON 500 in production (no stack trace in the response). Full trace still goes to logger.exception.
  • Optional anti-replay token via ENFORCE_TOKEN=True (X-Token header). Server validates HMAC, timestamp drift and a Redis-tracked nonce (SET NX PX, TTL = 2× drift). Replayed nonces inside the window are rejected. Helpers: make_token (mint), validate_token (sync HMAC check), validate_token_async (HMAC + nonce reservation).

Tenancy

Multi-tenant database routing through easyapi.DBRouter and aset_tenant(account_id). Configure in your settings:

DEFAULT_DATABASE = DATABASES['default']
TENANT_ACCOUNT_MODEL = 'core.Account'
TENANT_USER_MODEL = 'core.User'
TENANT_USER_API_MODEL = 'core.UserApi'
TENANT_DB_PREFIX = 'tenant'
HASH_LENGTH = 32
DATABASE_ROUTERS = ['easyapi.DBRouter']

set_default(account_id) and unset_default(account_id) are script-only — they mutate the global default connection and are unsafe inside ASGI request handling. They are not re-exported from the top-level easyapi package; import them from easyapi.tenant.tenant when you really need them in a management command or one-off script. For per-request tenant switching, use aset_tenant.

MCP server (agent-callable tools)

Optional. Expose every resource as a typed tool that LLM agents can call — same auth, same rate limit, same Pydantic schemas, same dispatch.

pip install 'easyapi-django[mcp]'

One liner — adds POST /api/mcp:

urlpatterns = [
    path('api/', include(get_routes(endpoints, mcp=True))),
]

Or subclass for custom behaviour:

from easyapi import MCPResource

class MyMCP(MCPResource):
    endpoints = my_endpoints
    summary = 'agent-tools'

    async def post_process(self, response):
        await audit_log(self.user, self.body, response)
        return response

urlpatterns = [path('mcp/', MyMCP.as_view())]

For desktop agents (Claude Desktop, Cursor) over stdio:

EASYAPI_MCP_API_KEY="<key>" python manage.py mcp_serve myapp.urls.endpoints

Tool calls run through the same BaseResource.dispatch as REST — no parallel handlers, no schema duplication. The bridge also wraps the view in your project's settings.MIDDLEWARE, so SecurityMiddleware, AuthMiddleware, ExceptionMiddleware and any custom async-capable middleware run exactly as on a REST hit. Sync-only middleware is skipped — mark it async_capable = True or enforce the equivalent invariant inside dispatch if it is critical. Hide a resource from MCP with mcp_expose = False; restrict to read-only with mcp_expose = ['list', 'get']. See the docs for details.

Metrics endpoint

get_routes() automatically registers POST /metrics for aggregations and group-bys. Useful for charts, dashboards and reports — one endpoint covers what would otherwise be dozens of bespoke routes.

POST /metrics
{
  "model": "myapp.Order",
  "calc": {"formula": ["sum"], "field": "total"},
  "group_by": {"date": {"field": "created_at", "group_by": "month"}},
  "filter_by": {"period": "this_year"}
}

Supports count, sum, avg, min, max, variance, std dev, with optional grouping by field and date period (year/quarter/month/day/ weekday/hour).

WebSocket consumer

from easyapi import BaseWSConsumer

class MyConsumer(BaseWSConsumer):
    # Defaults — override per consumer when needed
    allow_unauthenticated = False        # UUID-based connections opt-in
    track_online = False                 # Redis-backed presence tracking

    async def on_connect(self, user):
        await self.send_state(['ready'], True)

    async def allowed_channels(self, user):
        # Return an iterable of channel suffix names this user may
        # subscribe to. Channel names are also gated server-side by
        # ^[A-Za-z0-9_\-.]{1,64}$. Return None to allow any well-formed
        # name (legacy default), an empty list to block extra subs.
        return ['inbox', 'alerts']

Requires Django Channels. allow_unauthenticated defaults to False since 0.30 — set it to True explicitly on consumers that need the UUID-based signup flow.

Hooks

Override on your resource:

Hook When
pre_process After auth, before body parsing
before_cache Before the cache lookup (GET)
hydrate(body) Before write (POST/PATCH)
dehydrate(row) Per row before serialize
alter_list Mutate list result
alter_detail Mutate detail result
post_process Last chance before save_cache + response
add_m2m(result) Custom M2M handling

BaseTagsResource and BaseCustomResource are ready-made subclasses for projects that use tags and user-defined custom attributes.

Tests

pip install -r requirements-dev.txt
pytest

301 tests covering util, redis, cache (incl. per-account auto-fold and per-scope keys), filters, filter validation, init, auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer (incl. per-call timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware, dispatch error handling, tenant connection and registry, MCP middleware chain, route gating, WS subscription hardening, public exports, and WebSocket optional import.

Author

Stamatios Stamou Jr — github.com/ssjunior

Project details


Release history Release notifications | RSS feed

This version

0.36

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

easyapi_django-0.36.tar.gz (101.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

easyapi_django-0.36-py3-none-any.whl (78.9 kB view details)

Uploaded Python 3

File details

Details for the file easyapi_django-0.36.tar.gz.

File metadata

  • Download URL: easyapi_django-0.36.tar.gz
  • Upload date:
  • Size: 101.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.1

File hashes

Hashes for easyapi_django-0.36.tar.gz
Algorithm Hash digest
SHA256 1c383243f4f74e79b5b20d9900e92b02cc454f6f56c1a729689b96c71aa21c64
MD5 7086aa1f1acd6e39ead5c57a8896a605
BLAKE2b-256 f438c2431b6d9d3ce4edd05d4df7c567822f7c99899ee5806eeba66c19d20787

See more details on using hashes here.

File details

Details for the file easyapi_django-0.36-py3-none-any.whl.

File metadata

  • Download URL: easyapi_django-0.36-py3-none-any.whl
  • Upload date:
  • Size: 78.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.12.1

File hashes

Hashes for easyapi_django-0.36-py3-none-any.whl
Algorithm Hash digest
SHA256 4d17b0fc8522fefe08b53e0f24dc9318866c9c56441cd0581dc95cb011a0428d
MD5 cb4bf03aec710158ac152f55a5560ff2
BLAKE2b-256 4c2a6d61ba84ef5d7587663daa6af45eb91433bc47ffa50a822ab858d259a43d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page