easyapi-django 0.25__tar.gz → 0.30__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easyapi_django-0.30/PKG-INFO +488 -0
- easyapi_django-0.30/README.md +461 -0
- easyapi_django-0.30/easyapi/__init__.py +21 -0
- easyapi_django-0.30/easyapi/auth.py +82 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/base.py +457 -378
- easyapi_django-0.30/easyapi/blocking.py +32 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/calc.py +68 -33
- easyapi_django-0.30/easyapi/client_ip.py +53 -0
- easyapi_django-0.30/easyapi/exception.py +11 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/filters.py +151 -124
- easyapi_django-0.30/easyapi/helpers.py +119 -0
- easyapi_django-0.30/easyapi/management/commands/__init__.py +0 -0
- easyapi_django-0.30/easyapi/management/commands/mcp_serve.py +100 -0
- easyapi_django-0.30/easyapi/mcp/__init__.py +23 -0
- easyapi_django-0.30/easyapi/mcp/bridge.py +216 -0
- easyapi_django-0.30/easyapi/mcp/protocol.py +160 -0
- easyapi_django-0.30/easyapi/mcp/resource.py +88 -0
- easyapi_django-0.30/easyapi/mcp/tools.py +208 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/middleware.py +5 -15
- easyapi_django-0.30/easyapi/openapi.py +324 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/rate_limit.py +19 -22
- easyapi_django-0.30/easyapi/redis_config.py +64 -0
- easyapi_django-0.30/easyapi/routes.py +277 -0
- easyapi_django-0.30/easyapi/schemas.py +74 -0
- easyapi_django-0.30/easyapi/security.py +157 -0
- easyapi_django-0.30/easyapi/serializer.py +17 -0
- easyapi_django-0.30/easyapi/tenant/__init__.py +0 -0
- easyapi_django-0.30/easyapi/tenant/registry.py +114 -0
- easyapi_django-0.30/easyapi/tenant/tenant.py +314 -0
- easyapi_django-0.30/easyapi/util.py +54 -0
- easyapi_django-0.30/easyapi/ws.py +384 -0
- easyapi_django-0.30/easyapi_django.egg-info/PKG-INFO +488 -0
- easyapi_django-0.30/easyapi_django.egg-info/SOURCES.txt +74 -0
- easyapi_django-0.30/easyapi_django.egg-info/requires.txt +15 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/pyproject.toml +20 -1
- easyapi_django-0.30/tests/test_api_key_resolver.py +101 -0
- easyapi_django-0.30/tests/test_auth.py +146 -0
- easyapi_django-0.30/tests/test_base_init.py +62 -0
- easyapi_django-0.30/tests/test_block.py +40 -0
- easyapi_django-0.30/tests/test_cache.py +108 -0
- easyapi_django-0.30/tests/test_cache_auto_account_scope.py +139 -0
- easyapi_django-0.30/tests/test_cache_scope.py +191 -0
- easyapi_django-0.30/tests/test_client_ip.py +93 -0
- easyapi_django-0.30/tests/test_dispatch_error_handling.py +85 -0
- easyapi_django-0.30/tests/test_exports.py +28 -0
- easyapi_django-0.30/tests/test_filter_validation.py +84 -0
- easyapi_django-0.30/tests/test_filters.py +211 -0
- easyapi_django-0.30/tests/test_get_obj_m2m_only.py +133 -0
- easyapi_django-0.30/tests/test_helpers.py +57 -0
- easyapi_django-0.30/tests/test_index_route.py +43 -0
- easyapi_django-0.30/tests/test_mcp.py +264 -0
- easyapi_django-0.30/tests/test_mcp_middleware_chain.py +98 -0
- easyapi_django-0.30/tests/test_normalize_field.py +42 -0
- easyapi_django-0.30/tests/test_openapi.py +120 -0
- easyapi_django-0.30/tests/test_owner_field.py +41 -0
- easyapi_django-0.30/tests/test_redis_config.py +72 -0
- easyapi_django-0.30/tests/test_return_result_falsy.py +91 -0
- easyapi_django-0.30/tests/test_routes_gate.py +69 -0
- easyapi_django-0.30/tests/test_schemas.py +80 -0
- easyapi_django-0.30/tests/test_security.py +141 -0
- easyapi_django-0.30/tests/test_serializer.py +30 -0
- easyapi_django-0.30/tests/test_tenant_connection.py +56 -0
- easyapi_django-0.30/tests/test_tenant_registry.py +269 -0
- easyapi_django-0.30/tests/test_util.py +64 -0
- easyapi_django-0.30/tests/test_ws_channels.py +95 -0
- easyapi_django-0.30/tests/test_ws_optional.py +31 -0
- easyapi_django-0.25/PKG-INFO +0 -215
- easyapi_django-0.25/README.md +0 -200
- easyapi_django-0.25/easyapi/__init__.py +0 -7
- easyapi_django-0.25/easyapi/exception.py +0 -18
- easyapi_django-0.25/easyapi/routes.py +0 -204
- easyapi_django-0.25/easyapi/tenant/tenant.py +0 -313
- easyapi_django-0.25/easyapi/util.py +0 -21
- easyapi_django-0.25/easyapi_django.egg-info/PKG-INFO +0 -215
- easyapi_django-0.25/easyapi_django.egg-info/SOURCES.txt +0 -23
- {easyapi_django-0.25 → easyapi_django-0.30}/LICENSE +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/constants.py +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/dates.py +0 -0
- {easyapi_django-0.25/easyapi/tenant → easyapi_django-0.30/easyapi/management}/__init__.py +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.25 → easyapi_django-0.30}/setup.cfg +0 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyapi_django
|
|
3
|
+
Version: 0.30
|
|
4
|
+
Summary: A simple rest api generator for django based on models
|
|
5
|
+
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.7
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: Django>=5.0
|
|
15
|
+
Requires-Dist: redis
|
|
16
|
+
Requires-Dist: pandas
|
|
17
|
+
Requires-Dist: pytz
|
|
18
|
+
Provides-Extra: schemas
|
|
19
|
+
Requires-Dist: pydantic>=2; extra == "schemas"
|
|
20
|
+
Provides-Extra: mcp
|
|
21
|
+
Requires-Dist: jsonschema>=4; extra == "mcp"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
25
|
+
Requires-Dist: fakeredis>=2.20; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# easyapi-django
|
|
29
|
+
|
|
30
|
+
A REST API generator for Django. Define a class, point it at a model, get
|
|
31
|
+
full async CRUD endpoints with authentication, filtering, pagination,
|
|
32
|
+
caching, rate limiting, multi-tenancy, Pydantic validation and OpenAPI
|
|
33
|
+
docs out of the box.
|
|
34
|
+
|
|
35
|
+
- **Docs** — full guide and reference
|
|
36
|
+
- **GitHub** — https://github.com/ssjunior/easyapi-django
|
|
37
|
+
- **License** — MIT
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Most Django REST resources end up as hundreds of lines of plumbing:
|
|
42
|
+
list/detail views, write handlers with field whitelists, session auth,
|
|
43
|
+
rate limit, Redis caching with invalidation, multi-tenant DB switching.
|
|
44
|
+
easyapi packages all of that as a class with attributes — usually under
|
|
45
|
+
30 lines per resource.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install easyapi-django
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Optional Pydantic schemas for input validation and response shaping:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install 'easyapi-django[schemas]'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Required environment
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
REDIS_SERVER=localhost
|
|
63
|
+
REDIS_DB=0
|
|
64
|
+
REDIS_PREFIX=myapp # optional; namespaces all Redis keys
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Redis is used for sessions, cache, rate limiting and abuse blocking.
|
|
68
|
+
|
|
69
|
+
## Add middleware in Django settings
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
MIDDLEWARE = [
|
|
73
|
+
...
|
|
74
|
+
'easyapi.SecurityMiddleware', # pattern/UA/4xx-flood instant block
|
|
75
|
+
'easyapi.AuthMiddleware', # session-based auth from Redis
|
|
76
|
+
'easyapi.ExceptionMiddleware',
|
|
77
|
+
]
|
|
78
|
+
TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Create a resource
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from easyapi import BaseResource
|
|
85
|
+
from your_models import YourModel
|
|
86
|
+
|
|
87
|
+
class YourResource(BaseResource):
|
|
88
|
+
model = YourModel
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Wire up routes
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from easyapi import get_routes
|
|
95
|
+
from your_resources import YourResource
|
|
96
|
+
|
|
97
|
+
endpoints = {
|
|
98
|
+
r'yourendpoint(.*)$': YourResource,
|
|
99
|
+
}
|
|
100
|
+
urlpatterns = [...] + get_routes(endpoints)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
GET, POST, PATCH, DELETE are ready. You also get:
|
|
104
|
+
|
|
105
|
+
- `GET /openapi.json` — OpenAPI 3.0.3 spec
|
|
106
|
+
- `GET /docs` — interactive Scalar UI
|
|
107
|
+
|
|
108
|
+
## Configuration cheat sheet
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
class YourResource(BaseResource):
|
|
112
|
+
model = YourModel
|
|
113
|
+
|
|
114
|
+
authenticated = True # default; set False to allow anonymous
|
|
115
|
+
allowed_methods = ['get', 'post', 'patch', 'delete']
|
|
116
|
+
|
|
117
|
+
# Listing
|
|
118
|
+
list_fields = ['id', 'name']
|
|
119
|
+
list_related_fields = {'account': ['name', 'plan']}
|
|
120
|
+
list_exclude_fields = []
|
|
121
|
+
normalize_list = False # return {id: {...}} instead of [{...}]
|
|
122
|
+
|
|
123
|
+
# Filtering / searching / ordering
|
|
124
|
+
filter_fields = ['name', 'active']
|
|
125
|
+
search_fields = ['name', 'email']
|
|
126
|
+
search_operator = 'icontains'
|
|
127
|
+
order_fields = ['id', 'name']
|
|
128
|
+
|
|
129
|
+
# Detail / write
|
|
130
|
+
edit_fields = ['id', 'name']
|
|
131
|
+
update_fields = ['name']
|
|
132
|
+
create_fields = ['name']
|
|
133
|
+
normalize_obj = False # return {id: {...}} from PATCH/POST
|
|
134
|
+
|
|
135
|
+
# Ownership (DELETE/PATCH scoped to rows owned by user)
|
|
136
|
+
owner_field = 'owner_id'
|
|
137
|
+
|
|
138
|
+
# Pagination
|
|
139
|
+
limit = 25 # 0 returns everything
|
|
140
|
+
order_by = 'id'
|
|
141
|
+
|
|
142
|
+
# Cache
|
|
143
|
+
cache = True
|
|
144
|
+
cache_ttl = 600
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Querystrings
|
|
148
|
+
|
|
149
|
+
| Param | Effect |
|
|
150
|
+
|------------------------------------|-------------------------------------------------|
|
|
151
|
+
| `?count=true` | Return only `{count: N}` |
|
|
152
|
+
| `?search=value` | Search across `search_fields` with OR |
|
|
153
|
+
| `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
|
|
154
|
+
| `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
|
|
155
|
+
| `?filter=<json>` | Advanced filter expression on whitelisted fields |
|
|
156
|
+
| `?segment_id=N` | Apply a stored segment |
|
|
157
|
+
| `?page=N&limit=M&order_by=field` | Pagination + order |
|
|
158
|
+
| `?normalize=true` | Return list as `{id: {...}}` instead of array |
|
|
159
|
+
|
|
160
|
+
## Pydantic schemas (optional)
|
|
161
|
+
|
|
162
|
+
Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
|
|
163
|
+
validates inputs and shapes outputs through the schema. Resources without
|
|
164
|
+
schemas keep the legacy field-list behaviour.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
168
|
+
|
|
169
|
+
class UserCreate(BaseModel):
|
|
170
|
+
email: EmailStr
|
|
171
|
+
password: str = Field(min_length=8)
|
|
172
|
+
|
|
173
|
+
class UserOut(BaseModel):
|
|
174
|
+
id: int
|
|
175
|
+
email: EmailStr
|
|
176
|
+
name: str
|
|
177
|
+
|
|
178
|
+
class UserResource(BaseResource):
|
|
179
|
+
model = User
|
|
180
|
+
create_schema = UserCreate # validates POST body, 422 on failure
|
|
181
|
+
list_schema = UserOut # shapes GET responses
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Validation errors are returned as `HTTPException(422, [...])`:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"success": false,
|
|
189
|
+
"status": 422,
|
|
190
|
+
"detail": [
|
|
191
|
+
{"field": "email", "message": "value is not a valid email address"}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## OpenAPI
|
|
197
|
+
|
|
198
|
+
`get_routes()` always registers two routes:
|
|
199
|
+
|
|
200
|
+
- `/openapi.json` — generated from your resources. Pydantic schemas are
|
|
201
|
+
emitted as JSON Schema; resources without schemas fall back to Django
|
|
202
|
+
model introspection.
|
|
203
|
+
- `/docs` — Scalar API reference (two-column layout, search, dark mode,
|
|
204
|
+
try-it-out). The Scalar AI assistant is disabled in this build.
|
|
205
|
+
|
|
206
|
+
Custom routes can be enriched with the `@openapi(...)` decorator:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from easyapi import openapi
|
|
210
|
+
|
|
211
|
+
class UserResource(BaseResource):
|
|
212
|
+
routes = [{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get']}]
|
|
213
|
+
|
|
214
|
+
@openapi(summary='Current user', response=UserOut)
|
|
215
|
+
async def me(self, request, match=None):
|
|
216
|
+
return {'id': self.user['id'], 'email': self.user['email']}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Custom routes
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
class YourResource(BaseResource):
|
|
223
|
+
model = YourModel
|
|
224
|
+
routes = [
|
|
225
|
+
{'path': r'(\d+)/accept$', 'func': 'accept', 'allowed_methods': ['patch']},
|
|
226
|
+
{'path': r'me$', 'func': 'get_me', 'cache': True},
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
async def accept(self, request, match=None, body=None):
|
|
230
|
+
...
|
|
231
|
+
|
|
232
|
+
async def get_me(self, request, match=None):
|
|
233
|
+
...
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Cache
|
|
237
|
+
|
|
238
|
+
Per-resource opt-in Redis cache. Namespaced invalidation — editing row 5
|
|
239
|
+
does not drop the cache for row 7.
|
|
240
|
+
|
|
241
|
+
| Operation | Cache effect |
|
|
242
|
+
|------------------------------------|----------------------------------------------------|
|
|
243
|
+
| GET `/spaces` | Cached under `list:<model>` namespace |
|
|
244
|
+
| GET `/spaces/5` | Cached under `detail:<model>:5` |
|
|
245
|
+
| PATCH `/spaces/5` | Invalidates `list:<model>` + `detail:<model>:5` |
|
|
246
|
+
| DELETE `/spaces/5` | Same as PATCH |
|
|
247
|
+
| POST `/spaces` | Invalidates `list:<model>` only |
|
|
248
|
+
|
|
249
|
+
Cache key includes a hash of the querystring, so different filters do not
|
|
250
|
+
collide.
|
|
251
|
+
|
|
252
|
+
**Tenant isolation is automatic.** Multi-tenant deployments share Redis,
|
|
253
|
+
so `_build_cache_key` folds `self.account_id` into the key whenever it is
|
|
254
|
+
set — different tenants hitting the same path get different keys. No
|
|
255
|
+
configuration needed; it just works for any project that uses
|
|
256
|
+
`aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
|
|
257
|
+
explicit `account_id = 0` still produces a per-tenant key (real value,
|
|
258
|
+
not absence). Disable globally via
|
|
259
|
+
`AUTO_SCOPE_CACHE_BY_ACCOUNT = False` if you have a single-tenant
|
|
260
|
+
deployment and want the legacy key shape.
|
|
261
|
+
|
|
262
|
+
If you override `_build_cache_key` in a project, call
|
|
263
|
+
`self._account_cache_segment()` and append the result so the override
|
|
264
|
+
inherits the tenant isolation.
|
|
265
|
+
|
|
266
|
+
**Per-scope caching.** When the response varies on a user/account
|
|
267
|
+
dimension *inside* the same tenant — role, space, plan, country —
|
|
268
|
+
declare it with `cache_scope_fields` so users sharing the same scope
|
|
269
|
+
share the cache and different scopes get isolated keys:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
class TaskResource(BaseResource):
|
|
273
|
+
model = Task
|
|
274
|
+
cache = True
|
|
275
|
+
# Strings are shorthand for `self.user[field]`. Tuples select the
|
|
276
|
+
# source explicitly: ('user', ...) or ('account', ...).
|
|
277
|
+
# Don't add ('account', 'id') — tenant isolation is already automatic.
|
|
278
|
+
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Strict by default: if the request has authenticated context but a
|
|
282
|
+
configured field is **missing** from the session payload, the request
|
|
283
|
+
fails with `HTTPException(500)` instead of silently merging caches
|
|
284
|
+
across scopes. Anonymous requests skip the fold. `None`, `0` and `''`
|
|
285
|
+
count as present (a real value). Use `before_cache` for the rare case
|
|
286
|
+
that needs context outside `self.user` / `self.account`:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
async def before_cache(self, request):
|
|
290
|
+
"""Escape hatch for scope sources not covered by cache_scope_fields."""
|
|
291
|
+
feature = await get_feature_flag(self.user)
|
|
292
|
+
self.cache_key += f':flag={feature}'
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Hit/miss stats:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from easyapi import get_cache_stats
|
|
299
|
+
|
|
300
|
+
stats = await get_cache_stats()
|
|
301
|
+
# {'hits': ..., 'misses': ..., 'total': ..., 'ratio': ..., 'by_model': {...}}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Authentication
|
|
305
|
+
|
|
306
|
+
Two mechanisms, both Redis-backed:
|
|
307
|
+
|
|
308
|
+
- **Session cookie** — `Cookie: <COOKIE_ID>=<key>`, validated against a
|
|
309
|
+
strict regex before any Redis lookup.
|
|
310
|
+
- **API key** — `X-Api-Key: <token>`. Format is your project's choice;
|
|
311
|
+
easyapi resolves the token to a session via your `UserApi` model.
|
|
312
|
+
See the docs for the default resolution flow and how to issue keys.
|
|
313
|
+
|
|
314
|
+
When both are present, the API key wins. `authenticated = False` opts a
|
|
315
|
+
resource out of authentication while keeping rate limit and security
|
|
316
|
+
middleware in effect.
|
|
317
|
+
|
|
318
|
+
## Security defaults
|
|
319
|
+
|
|
320
|
+
- Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
|
|
321
|
+
- `?fields=` is filtered against `list_fields` to prevent attribute leakage.
|
|
322
|
+
- `?filter=` and `segment_id` are validated against `filter_fields`.
|
|
323
|
+
- `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
|
|
324
|
+
- Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
|
|
325
|
+
blocking runs in `SecurityMiddleware` before the view.
|
|
326
|
+
- Both layers converge on the same blocked-IP store in Redis
|
|
327
|
+
(`rate_limit:blocked:<ip>`), with automatic 24h blocking.
|
|
328
|
+
- `SecurityMiddleware` instant-blocks scanner paths/UAs and 4xx floods.
|
|
329
|
+
- `get_client_ip` honours `X-Real-IP` only from `TRUSTED_PROXIES`.
|
|
330
|
+
- Unhandled handler exceptions return a sanitized JSON 500 in production
|
|
331
|
+
(no stack trace in the response). Full trace still goes to
|
|
332
|
+
`logger.exception`.
|
|
333
|
+
- Optional anti-replay token via `ENFORCE_TOKEN=True` (`X-Token` header).
|
|
334
|
+
Server validates HMAC, timestamp drift and a Redis-tracked nonce
|
|
335
|
+
(`SET NX PX`, TTL = 2× drift). Replayed nonces inside the window are
|
|
336
|
+
rejected. Helpers: `make_token` (mint), `validate_token` (sync HMAC
|
|
337
|
+
check), `validate_token_async` (HMAC + nonce reservation).
|
|
338
|
+
|
|
339
|
+
## Tenancy
|
|
340
|
+
|
|
341
|
+
Multi-tenant database routing through `easyapi.DBRouter` and
|
|
342
|
+
`aset_tenant(account_id)`. Configure in your settings:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
DEFAULT_DATABASE = DATABASES['default']
|
|
346
|
+
TENANT_ACCOUNT_MODEL = 'core.Account'
|
|
347
|
+
TENANT_USER_MODEL = 'core.User'
|
|
348
|
+
TENANT_USER_API_MODEL = 'core.UserApi'
|
|
349
|
+
TENANT_DB_PREFIX = 'tenant'
|
|
350
|
+
HASH_LENGTH = 32
|
|
351
|
+
DATABASE_ROUTERS = ['easyapi.DBRouter']
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`set_default(account_id)` and `unset_default(account_id)` are
|
|
355
|
+
**script-only** — they mutate the global `default` connection and are
|
|
356
|
+
unsafe inside ASGI request handling. They are *not* re-exported from the
|
|
357
|
+
top-level `easyapi` package; import them from `easyapi.tenant.tenant`
|
|
358
|
+
when you really need them in a management command or one-off script.
|
|
359
|
+
For per-request tenant switching, use `aset_tenant`.
|
|
360
|
+
|
|
361
|
+
## MCP server (agent-callable tools)
|
|
362
|
+
|
|
363
|
+
Optional. Expose every resource as a typed tool that LLM agents can
|
|
364
|
+
call — same auth, same rate limit, same Pydantic schemas, same dispatch.
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
pip install 'easyapi-django[mcp]'
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
One liner — adds `POST /api/mcp`:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
urlpatterns = [
|
|
374
|
+
path('api/', include(get_routes(endpoints, mcp=True))),
|
|
375
|
+
]
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Or subclass for custom behaviour:
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
from easyapi import MCPResource
|
|
382
|
+
|
|
383
|
+
class MyMCP(MCPResource):
|
|
384
|
+
endpoints = my_endpoints
|
|
385
|
+
summary = 'agent-tools'
|
|
386
|
+
|
|
387
|
+
async def post_process(self, response):
|
|
388
|
+
await audit_log(self.user, self.body, response)
|
|
389
|
+
return response
|
|
390
|
+
|
|
391
|
+
urlpatterns = [path('mcp/', MyMCP.as_view())]
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
For desktop agents (Claude Desktop, Cursor) over stdio:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
EASYAPI_MCP_API_KEY="<key>" python manage.py mcp_serve myapp.urls.endpoints
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Tool calls run through the **same `BaseResource.dispatch` as REST** —
|
|
401
|
+
no parallel handlers, no schema duplication. The bridge also wraps the
|
|
402
|
+
view in your project's `settings.MIDDLEWARE`, so `SecurityMiddleware`,
|
|
403
|
+
`AuthMiddleware`, `ExceptionMiddleware` and any custom **async-capable**
|
|
404
|
+
middleware run exactly as on a REST hit. Sync-only middleware is
|
|
405
|
+
skipped — mark it `async_capable = True` or enforce the equivalent
|
|
406
|
+
invariant inside `dispatch` if it is critical. Hide a resource from MCP
|
|
407
|
+
with `mcp_expose = False`; restrict to read-only with
|
|
408
|
+
`mcp_expose = ['list', 'get']`. See the docs for details.
|
|
409
|
+
|
|
410
|
+
## Metrics endpoint
|
|
411
|
+
|
|
412
|
+
`get_routes()` automatically registers `POST /metrics` for aggregations
|
|
413
|
+
and group-bys. Useful for charts, dashboards and reports — one endpoint
|
|
414
|
+
covers what would otherwise be dozens of bespoke routes.
|
|
415
|
+
|
|
416
|
+
```json
|
|
417
|
+
POST /metrics
|
|
418
|
+
{
|
|
419
|
+
"model": "myapp.Order",
|
|
420
|
+
"calc": {"formula": ["sum"], "field": "total"},
|
|
421
|
+
"group_by": {"date": {"field": "created_at", "group_by": "month"}},
|
|
422
|
+
"filter_by": {"period": "this_year"}
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Supports `count`, `sum`, `avg`, `min`, `max`, `variance`, `std dev`, with
|
|
427
|
+
optional grouping by field and date period (year/quarter/month/day/
|
|
428
|
+
weekday/hour).
|
|
429
|
+
|
|
430
|
+
## WebSocket consumer
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from easyapi import BaseWSConsumer
|
|
434
|
+
|
|
435
|
+
class MyConsumer(BaseWSConsumer):
|
|
436
|
+
# Defaults — override per consumer when needed
|
|
437
|
+
allow_unauthenticated = False # UUID-based connections opt-in
|
|
438
|
+
track_online = False # Redis-backed presence tracking
|
|
439
|
+
|
|
440
|
+
async def on_connect(self, user):
|
|
441
|
+
await self.send_state(['ready'], True)
|
|
442
|
+
|
|
443
|
+
async def allowed_channels(self, user):
|
|
444
|
+
# Return an iterable of channel suffix names this user may
|
|
445
|
+
# subscribe to. Channel names are also gated server-side by
|
|
446
|
+
# ^[A-Za-z0-9_\-.]{1,64}$. Return None to allow any well-formed
|
|
447
|
+
# name (legacy default), an empty list to block extra subs.
|
|
448
|
+
return ['inbox', 'alerts']
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Requires Django Channels. `allow_unauthenticated` defaults to `False`
|
|
452
|
+
since 0.30 — set it to `True` explicitly on consumers that need the
|
|
453
|
+
UUID-based signup flow.
|
|
454
|
+
|
|
455
|
+
## Hooks
|
|
456
|
+
|
|
457
|
+
Override on your resource:
|
|
458
|
+
|
|
459
|
+
| Hook | When |
|
|
460
|
+
|---------------------|--------------------------------------------|
|
|
461
|
+
| `pre_process` | After auth, before body parsing |
|
|
462
|
+
| `before_cache` | Before the cache lookup (GET) |
|
|
463
|
+
| `hydrate(body)` | Before write (POST/PATCH) |
|
|
464
|
+
| `dehydrate(row)` | Per row before serialize |
|
|
465
|
+
| `alter_list` | Mutate list result |
|
|
466
|
+
| `alter_detail` | Mutate detail result |
|
|
467
|
+
| `post_process` | Last chance before save_cache + response |
|
|
468
|
+
| `add_m2m(result)` | Custom M2M handling |
|
|
469
|
+
|
|
470
|
+
`BaseTagsResource` and `BaseCustomResource` are ready-made subclasses for
|
|
471
|
+
projects that use tags and user-defined custom attributes.
|
|
472
|
+
|
|
473
|
+
## Tests
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
pip install -r requirements-dev.txt
|
|
477
|
+
pytest
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
216 tests covering util, redis, cache, filters, filter validation, init,
|
|
481
|
+
auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
|
|
482
|
+
client_ip, SecurityMiddleware, dispatch error handling, tenant connection
|
|
483
|
+
and registry, MCP middleware chain, route gating, WS subscription
|
|
484
|
+
hardening, public exports, and WebSocket optional import.
|
|
485
|
+
|
|
486
|
+
## Author
|
|
487
|
+
|
|
488
|
+
Stamatios Stamou Jr — [github.com/ssjunior](https://github.com/ssjunior)
|