Metadata-Version: 2.4
Name: easyapi-django
Version: 1.8.1
Summary: Zero-effort REST API (and MCP server) generator from Django models.
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.0
Requires-Dist: redis>=4.5
Requires-Dist: pandas
Requires-Dist: pytz
Requires-Dist: jsonschema>=4
Requires-Dist: pydantic>=2
Provides-Extra: schemas
Requires-Dist: pydantic>=2; extra == "schemas"
Provides-Extra: oauth
Provides-Extra: gen
Requires-Dist: jinja2>=3; extra == "gen"
Requires-Dist: PyYAML>=6; extra == "gen"
Provides-Extra: gen-postgres
Requires-Dist: jinja2>=3; extra == "gen-postgres"
Requires-Dist: PyYAML>=6; extra == "gen-postgres"
Requires-Dist: psycopg2-binary>=2.9; extra == "gen-postgres"
Provides-Extra: gen-mysql
Requires-Dist: jinja2>=3; extra == "gen-mysql"
Requires-Dist: PyYAML>=6; extra == "gen-mysql"
Requires-Dist: PyMySQL>=1.1; extra == "gen-mysql"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Requires-Dist: fakeredis>=2.20; extra == "dev"
Requires-Dist: lupa>=2; extra == "dev"
Requires-Dist: jinja2>=3; extra == "dev"
Requires-Dist: PyYAML>=6; extra == "dev"
Dynamic: license-file

# easyapi

A framework I built for myself. It gives you a production MCP server **and** a REST API on top of Django models. Both surfaces reuse the same engine — auth, rate limiting, edge security, sensitive-field scrubbing and multi-tenant DB routing. REST and MCP each get a dedicated, well-shaped surface — they're no longer welded together. If you don't have Django, `easyapi init` reads a MySQL or Postgres schema and generates the whole project.

I run it in six of my own products. Sharing it because I'd like help making it better — issues, PRs, and "this broke for me" reports are all welcome.

## Install

```bash
pip install easyapi-django                   # framework only
pip install 'easyapi-django[gen-mysql]'      # + generator for MySQL
pip install 'easyapi-django[gen-postgres]'   # + generator for Postgres
```

> The PyPI distribution is `easyapi-django`. Imports use `from easyapi
> import ...`. The CLI is `easyapi`.

---

## What it looks like

REST resources are one class. MCP toolsets are another. They reuse the same engine primitives — auth (`BaseResource._authenticate`), rate limiting (`easyapi.rate_limit`), sensitive-field scrubbing (`easyapi.sensitive`), multi-tenant DB routing (`aset_tenant`, activated during auth via `apply_session_to_request` on both surfaces) and the global `SecurityMiddleware`. Both classes inherit a common `CoreResource` base — today a thin seam, not a policy layer.

**REST** — `BaseResource` exposes a Django model as a CRUD endpoint:

```python
from easyapi import BaseResource
from myapp.models import Space

class SpaceResource(BaseResource):
    model = Space
```

That class gives you:

- REST endpoints (`GET`, `POST`, `PATCH`, `DELETE`) with pagination, filters, search, ordering.
- OpenAPI 3.0.3 spec at `/openapi.json` and an interactive docs page at `/docs`.
- Async dispatch, session + API-key + Bearer auth, per-IP rate limit, scanner blocking, multi-tenant DB routing.

**MCP** — `Tools` collects intent-named methods into an MCP tool registry. Type hints become JSON Schema (Draft 2020-12); docstrings become tool descriptions for the agent:

```python
from typing import Literal
from pydantic import BaseModel
from easyapi import Tools, tool

class OrderOut(BaseModel):
    id: int
    status: str

class Orders(Tools):
    scope = "orders:read"

    async def find(
        self,
        status: Literal["open", "closed"] | None = None,
        limit: int = 50,
    ) -> list[OrderOut]:
        """List the caller's orders, optionally filtered by status."""
        qs = Order.objects.filter(owner_id=self.user_id)
        if status:
            qs = qs.filter(status=status)
        return [OrderOut.from_orm(o) async for o in qs[:limit]]

    @tool(scope="orders:write", destructive=True, rate_limit="10/m")
    async def cancel(self, order_id: int, reason: str) -> OrderOut:
        """Cancel an order and refund the original payment method."""
        order = await Order.objects.aget(pk=order_id)
        await order.cancel(reason)
        return OrderOut.from_orm(order)
```

Tools surface as `<namespace>_<method>` — `orders_find`, `orders_cancel`. The framework follows the MCP client name regex `^[a-zA-Z0-9_-]{1,64}$` (dots aren't accepted by Claude.ai). The namespace defaults to the lower-cased class name, trimmed of a trailing `tools` (so `OrdersTools` → `orders`); override via `namespace = '...'` on the Toolset.

Wire both surfaces in `urls.py`:

```python
from easyapi import get_routes

urlpatterns = get_routes(
    endpoints={r'orders(.*)$': OrderResource},
    toolsets=[Orders, Billing],
)
```

You get `/orders…` for REST, `/mcp` for JSON-RPC, `/openapi.json`, `/docs`, `/mcp/tools` (browseable) and `/mcp/tools.json` (machine-readable).

If you don't have Django yet:

```bash
easyapi init
```

The CLI prompts for host, db, credentials. About ten seconds later you have a working Django project — every table is a model with a REST resource. Sensitive columns (`password`, `token`, `api_key`) are auto-masked. Read-only by default. Pass `--writable` when you mean it. Write your MCP tools as `Tools` subclasses on top of the generated models.

---

## Why it exists

Two years ago I got tired of writing the same Django REST API for the tenth time — DRF, Ninja, FastAPI, all powerful, all the same boilerplate. So I wrote a small framework for myself: one class, set some attributes, get the endpoints. I called it easyapi.

When MCP showed up and every project I had needed an agent surface, I expected to write a second codebase. Instead the MCP server fell out of the same engine in a weekend — auth was already there, rate limit was already there, the field whitelists were already there. Only the wire format changed.

REST is mostly a solved problem now. The new pain is MCP — most teams are rebuilding the same scaffolding. So I cleaned up **easyapi** and put it on GitHub — same engine, now with a first-class MCP surface.

---

## What you get

**REST (`BaseResource`)**
- Async CRUD on Django models with pagination, filters, search, ordering.
- OpenAPI 3.0.3 spec + Scalar UI.
- Cache with namespace invalidation. Writes don't blow away unrelated rows.
- **Ownership scoping.** One attribute (`owner_field = 'owner_id'`) restricts every CRUD operation to rows owned by the authenticated user — the cheapest IDOR defense I know.

**MCP (`Tools` + `MCPServer`)**
- Intent-named tools: each public method is a tool; `_underscore` methods stay private. Helpers that must stay public-but-non-tool go in `excluded_methods = (...)`.
- JSON Schema (Draft 2020-12) generated from type hints; raw schema escape hatch when you need it (`@tool(input_schema={...})`).
- Docstrings become tool descriptions for the agent — the full docstring is published, not just the first line.
- MCP `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) via `@tool(...)`. Read-scoped tools get `readOnlyHint` + `idempotentHint` automatically; override per-method when needed.
- Per-tool `scope` and `rate_limit` (`'10/m'` shorthand or `{'limit': N, 'window': seconds}`). `tools/list` filters by the caller's OAuth scope so an agent only sees what it can call.
- Class-level `default_output_schema` (set to `False` when methods return free-shape `dict` so the agent isn't misled by an auto-derived loose schema).
- Pinned envelope: `{tool, code, data}` on success; `{tool, code, message, ...}` on error. Error codes are stable strings: `OK`, `VALIDATION_ERROR`, `NOT_FOUND`, `FORBIDDEN`, `INSUFFICIENT_SCOPE`, `RATE_LIMITED`, `METHOD_NOT_ALLOWED`, `INTERNAL`.
- `before_call` / `after_call` hooks for audit and membership preloading.
- `MCPTestClient` drives the registry in-process — unit tests don't need HTTP.

**Shared engine** — reused by both surfaces (not held on the `CoreResource` base, which is an empty seam today):
- Bearer + `X-Api-Key` + session-cookie auth. (MCP calls `BaseResource._authenticate` via `MCPServer`.)
- Pre-auth per-IP throttling, scanner blocking and semantic invalid-request detection — via the global `SecurityMiddleware`, so both surfaces are covered.
- Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
- Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses (each surface scrubs with its own serializer over the shared `is_sensitive`).
- Multi-tenant DB routing. `apply_session_to_request` is the single activation authority: Bearer, API-key and Redis-loaded sessions reach it inside `_authenticate`; a session already on the request was applied earlier by `AuthMiddleware`. Either way the connection is switched for the request on both surfaces.
- Async end-to-end. Async ORM, async Redis, async dispatch.

**REST-only** — enforced in `BaseResource._dispatch`:
- Authenticated abuse blocking by tenant/user or API-key digest.

**Global read-only mode** — `EASYAPI = {'READ_ONLY': True}` rejects every non-`GET` REST request with `405`; for MCP it allows only tools classified as read-only (a `read` scope or `@tool(read_only=True)`) and rejects all other `tools/call` requests with `METHOD_NOT_ALLOWED`.

Full docs and reference: https://github.com/ssjunior/easyapi-django

---

## Connecting an agent

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "myapp": {
      "command": "python",
      "args": ["manage.py", "mcp_serve", "myapp.mcp.toolsets"],
      "cwd": "/path/to/your/project",
      "env": {
        "DJANGO_SETTINGS_MODULE": "myapp.settings"
      }
    }
  }
}
```

`myapp.mcp.toolsets` is a module-level sequence of `Tools` subclasses, e.g. `toolsets = [Orders, Billing]`. Restart Claude Desktop. The agent now sees every tool you declared.

For HTTP-based agents (Cursor, custom copilots, anything else that speaks JSON-RPC over POST), the same tools live at `POST /mcp`. Auth is whatever you've wired into your `BaseResource` stack — Bearer, `X-Api-Key`, or session cookie all work; per-tool scopes filter `tools/list` automatically.

```bash
curl -X POST http://localhost:8000/mcp \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

---

## When this isn't the right tool

I'd rather you bounce now than get stuck a month in.

- **No Redis available.** Sessions, cache, rate limit and abuse blocking all rely on it. Redis 6.2+ (uses `GETEX` for sliding session TTLs). Non-negotiable.
- **You need complex auth.** OAuth2 server, SAML, intricate permission matrices — DRF or a custom stack will fit better.
- **Your endpoints are mostly RPC, not CRUD.** And you don't want them as MCP tools either.
- **You don't want Django.** easyapi wraps the Django ORM. The `init` command generates a Django project. If that's a dealbreaker, this isn't your tool.
- **You want a big plugin ecosystem.** It's small on purpose.

---

## Hardening before you ship

Defaults are demo-friendly. Production deployments should:

- **Cookie auth.** Set at least one of `ENFORCE_TOKEN = True` (HMAC anti-replay on state-changing requests) or `ALLOWED_ORIGINS = [...]` (Origin allowlist). Without either, the framework logs a startup warning — there is no built-in CSRF defense for `POST/PATCH/DELETE`.
- **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
- **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
- **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.
- **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, semantic invalid-request detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
- **Trust your proxies explicitly.** Pre-auth throttles, scanner blocking and anonymous abuse detection key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
- **Authenticated blocks follow the principal.** The fast pre-auth throttle,
  login defense and path/User-Agent scanner signatures remain IP-based.
  After successful authentication, semantic events and sustained abuse use a
  tenant-scoped user digest or API-key digest; anonymous requests keep using
  IP. This prevents one authenticated user behind a shared NAT from blocking
  everyone else while still stopping the same principal after an IP change.
- **Edge security fails open on Redis outages.** If Redis is unreachable, the
  `SecurityMiddleware` logs (throttled to 1×/60s per process) and lets the
  request through instead of taking the site down with the datastore — auth
  and the `BaseResource` rate limiter still guard the app. Blocked IPs are
  also cached in-process for 5s, which cuts one Redis RTT per request for
  repeat offenders and smooths over short Redis flaps.
- **4xx tripwire is on by default.** `MAX_4XX_PER_MINUTE` (default `20`) /
  `MAX_4XX_PER_HOUR` (default `120`) count 4xx responses per IP in a
  sliding-window ZSET: the minute limit trips the short block
  (`SECURITY_SHORT_BLOCK_SECONDS`, default 1h), the hour limit trips the full
  `BLOCK_DURATION_SECONDS` (24h). `429` counts too, so an abuser pacing just
  over the rate limit escalates from soft throttling to a hard block. OPTIONS
  requests and the middleware's own 403s never count. Set either to `None` to
  disable. **The counter refuses to ban a loopback/private resolved IP** — so
  a reverse-proxy deployment that forgot `TRUSTED_PROXIES` (every client
  collapses onto `127.0.0.1`) can't self-DoS by banning its own front door;
  set `TRUSTED_PROXIES` to get real per-client attribution.
- **Attack signatures in the session cookie, User-Agent and Cookie header.**
  Beyond the path/query, the same injection signatures are matched against
  request headers. **Tier 1** — the framework's own session cookie: its format
  is server-defined, so a value that fails `validate_session_key` *and* carries
  a signature is unambiguous tampering, always blocked (not governed by any
  switch; a benign proxy-truncated cookie fails the format check but matches no
  signature, so it falls through to a normal 401). **Tier 2** — the User-Agent
  and raw `Cookie` header, governed by `SCAN_REQUEST_HEADERS` (default `True`;
  a kill-switch, not opt-in — real browser/library UAs and analytics cookies
  don't match, since the signatures require attack-shaped combinations, not
  lone metacharacters). The `Referer` is deliberately **not** scanned: it is
  third-party controlled (a planted link makes innocent visitors send it), so
  blocking on it would let an attacker DoS real users.
- **URL signatures survive double-encoding.** Path/query/header pattern
  matching tests the raw value plus up to two rounds of percent-decoding, so
  `%252e%252e` (→ `%2e%2e` → `../`) can't slip traversal past the signatures.
- **Security observability feed.** Every block (pattern, User-Agent,
  cookie-tamper, header-match, semantic, blocked-IP re-hit, 4xx-rate) is appended to a bounded Redis list
  (`SECURITY_LOG_MAX_ENTRIES`, default 1000) and every blocked identifier is
  tracked in a ZSET index (no `SCAN` — it cost 10s+ on a large keyspace).
  The framework only writes the feed — dashboards are external tooling:
  read `easyapi.security_log.get_security_events()` /
  `get_blocked_identifiers()` from your own ops app and aggregate across
  all deployments.
- **Central Security Store (lean multi-app projection).** Enforcement stays
  in each application's Redis: rate-limit counters, abuse/4xx windows and
  block keys never leave the request-local hot path. Only a block being
  created, extended, updated or removed is projected to the central store,
  together with the bounded/sanitized request that caused the decision. This
  means a normal request performs zero central Redis operations and central
  storage grows with active blocks, not users. Configure
  `SECURITY_REDIS_SERVER` (defaults to `REDIS_SERVER`) and
  `SECURITY_REDIS_DB` (defaults to `0`). `REDIS_PREFIX` is the required,
  canonical application identifier; there is no separate service-id setting.
  Active projections live under `security:<REDIS_PREFIX>:...`, applications
  register in `security:apps`, and the bounded `security:events` Stream only
  contains `block:created`, `block:extended`, `block:updated` and
  `block:removed`. A central outage never rolls back or prevents the
  authoritative local block. For authenticated user/session decisions,
  the bounded subject snapshot may contain `user_id`, `user_name`,
  `user_email` and `tenant_id`; no complete session, preferences or
  credentials are copied to the Security Store. Every trigger also carries
  the request URL (`easyapi.security_evidence.build_block_trigger`, shared
  by the middleware and `BaseResource.block()` so both paths persist the
  same evidence shape) — scheme, host and path only, **never the query
  string**, so a token or filter value passed as a query parameter can't
  leak into the store; `parameters` (query param names only) remains the
  sole signal about what was in the query. When the block cause is a
  header/cookie signature match (`ua`, `header-match`, `cookie-tamper`),
  the offending header name and raw value are captured too — these three
  checks only fire on attack-shaped patterns (`_scan_value`/known bad
  User-Agent signatures), never on well-formed session or API
  credentials, so no legitimate secret is expected to land in the store.
- **Bound external Bearer resolvers.** Query-contract failures are attributed
  after authentication, so an unknown/protected parameter carrying a Bearer
  token can invoke `BEARER_RESOLVER` before the request is rejected. The
  default `NO_RESOLVER` path performs no external call. Custom network-backed
  resolvers should enforce a short timeout, cache safely where appropriate,
  and be latency/load-tested behind the pre-auth IP throttle.
- **`/metrics` is opt-in.** `get_routes` doesn't mount it unless you pass `metrics_view=Metrics` (the generic, unscoped resource — `from easyapi import Metrics`) or a custom row-scoped subclass. Once mounted, bound which models it can query: set `METRICS_MODELS = ['app_Model', ...]` to allowlist them. References to credential-named columns (in filters, groups, order or calc) are always rejected regardless — the per-resource escape hatch does not reach `/metrics`.
- **Bound the metrics fields, per model.** `METRICS_MODELS` only gates *which models* — every non-credential field on an allowed model is still fair game for `/metrics` (filter, group, order, calc), including arbitrary FK traversal. Set `METRICS_FIELDS = {'app_Model': ['status', 'owner__name', ...]}` to also allowlist *which field paths*, per model. Unlike REST's `filter_fields` (root-segment match), this checks the **full** `__`-joined path — `'owner'` in the list does not imply `'owner__anything'` is allowed. `filter_by` references tolerate one trailing Django lookup (`owner__name__icontains` matches an allowed `owner__name`) since those reach `Q(**kwargs)` directly; `calc`/`group_by`/`order`/`additional_fields`/`conditions` don't take lookups and are matched literally. A model absent from the dict, or the setting left unset, stays unrestricted — opt-in, same as `METRICS_MODELS`. If you already maintain a `bi_fields`/`related_fields` map for a frontend field picker, `easyapi.util.flatten_include_fields(model, bag)` turns that same data into the flat path list this setting expects, so there's one source of truth instead of two.
- **Keep the escape hatch narrow.** Columns whose names look like credentials (`password`, `api_key`, `token`, `otp`, `secret`, …) are masked as `'*********'` on every response, on top of whatever `sensitive_fields` lists. When the heuristic catches a false positive, opt it out per surface: `edit_allow_sensitive_fields = ['token_count']` returns it verbatim on `GET /resource/<id>/` while LIST still masks it. `list_allow_sensitive_fields` does the same for LIST, where every row carries the value — reach for the detail-only form first.
- **Keep query capabilities separate.** Query-string names outside the framework contract are rejected. Declare custom names in `extra_query_params`. A protected name allowed in a LIST response through `list_allow_sensitive_fields` is still forbidden as a direct/JSON filter; add it separately to `allow_protected_query_fields` only when filtering by that value is intentional. Existing resources using the response escape hatch may need this explicit filter capability after upgrading.
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `EASYAPI['OAUTH_CLIENTS']` with no `client_secret`. A client becomes confidential only with an explicit secret; `/register` never hands out a confidential secret (provision those out of band).

---

## Help wanted

If you try it and something breaks, please tell me. The kinds of help that make this better:

- **Bug reports.** Open an issue with what you tried and what happened. Including the Python/Django version helps.
- **PRs.** Small ones welcome. For larger changes, open an issue first so we can talk through the shape.
- **"This is confusing"** feedback on the docs. The doc site needs more eyes.
- **Sharing how you use it.** I'm curious what shapes of projects this actually lands in.

There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and we figure it out.

---

## Project

- **Author** — Stamatios Stamou Jr
- **License** — MIT
- **Python** — 3.10+
- **Django** — 5.0+
- **Repo** — github.com/ssjunior/easyapi-django
