easyapi-django 0.35__tar.gz → 0.37__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.35/easyapi_django.egg-info → easyapi_django-0.37}/PKG-INFO +159 -13
- {easyapi_django-0.35 → easyapi_django-0.37}/README.md +157 -11
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/__init__.py +1 -1
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/auth.py +15 -3
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/base.py +141 -128
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/calc.py +0 -3
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/client_ip.py +3 -4
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/filters.py +41 -1
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/middleware.py +5 -3
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/openapi.py +3 -4
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/rate_limit.py +49 -43
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/redis_config.py +32 -7
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/routes.py +2 -4
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/security.py +12 -29
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/serializer.py +1 -2
- easyapi_django-0.37/easyapi/settings_helper.py +49 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/db_router.py +0 -2
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/tenant.py +6 -10
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/ws.py +2 -5
- {easyapi_django-0.35 → easyapi_django-0.37/easyapi_django.egg-info}/PKG-INFO +159 -13
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/SOURCES.txt +3 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/pyproject.toml +8 -3
- easyapi_django-0.37/tests/test_allowed_domain.py +50 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_api_key_resolver.py +4 -2
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_auth.py +17 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_block.py +24 -1
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache.py +89 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_openapi.py +2 -2
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_redis_config.py +23 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_serializer.py +11 -0
- easyapi_django-0.37/tests/test_settings_helper.py +80 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/LICENSE +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/blocking.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/constants.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/dates.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/exception.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/helpers.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/schemas.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/util.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/setup.cfg +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_base_init.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_exports.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_filters.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_helpers.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_index_route.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_mcp.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_schemas.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_security.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_util.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_ws_optional.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi_django
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37
|
|
4
4
|
Summary: A simple rest api generator for django based on models
|
|
5
5
|
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/ssjunior/easyapi-django
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/ssjunior/easyapi-django/issues
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -75,7 +75,11 @@ MIDDLEWARE = [
|
|
|
75
75
|
'easyapi.AuthMiddleware', # session-based auth from Redis
|
|
76
76
|
'easyapi.ExceptionMiddleware',
|
|
77
77
|
]
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
EASYAPI = {
|
|
80
|
+
'TRUSTED_PROXIES': ['10.0.0.0/8'], # only trust X-Real-IP from these
|
|
81
|
+
# 'COOKIE_ID': 'sessionid', 'ENFORCE_TOKEN': True, ...
|
|
82
|
+
}
|
|
79
83
|
```
|
|
80
84
|
|
|
81
85
|
## Create a resource
|
|
@@ -141,7 +145,7 @@ class YourResource(BaseResource):
|
|
|
141
145
|
|
|
142
146
|
# Cache
|
|
143
147
|
cache = True
|
|
144
|
-
cache_ttl = 600
|
|
148
|
+
cache_ttl = 600 # default 120s; settings.CACHE_TTL overrides
|
|
145
149
|
```
|
|
146
150
|
|
|
147
151
|
## Querystrings
|
|
@@ -153,10 +157,118 @@ class YourResource(BaseResource):
|
|
|
153
157
|
| `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
|
|
154
158
|
| `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
|
|
155
159
|
| `?filter=<json>` | Advanced filter expression on whitelisted fields |
|
|
156
|
-
|
|
|
160
|
+
| `?<stored_filter_param>=N` | Apply a server-side stored filter expression (see below) |
|
|
157
161
|
| `?page=N&limit=M&order_by=field` | Pagination + order |
|
|
158
162
|
| `?normalize=true` | Return list as `{id: {...}}` instead of array |
|
|
159
163
|
|
|
164
|
+
## Saved filter expressions
|
|
165
|
+
|
|
166
|
+
Layer-2 expressions can be persisted server-side and reapplied by id —
|
|
167
|
+
saved views, marketing audiences, dashboard presets. The framework owns
|
|
168
|
+
the URL plumbing; the project owns the storage, lookup and policy.
|
|
169
|
+
|
|
170
|
+
Two pieces on the resource:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
class ClientResource(BaseResource):
|
|
174
|
+
model = Client
|
|
175
|
+
filter_fields = ['active', 'name']
|
|
176
|
+
|
|
177
|
+
stored_filter_param = 'segment_id' # any name — view_id, audience_id, …
|
|
178
|
+
|
|
179
|
+
async def resolve_stored_filter(self, value):
|
|
180
|
+
seg = await Segment.objects.filter(
|
|
181
|
+
id=value, account=self.account_id # tenant scoping etc.
|
|
182
|
+
).afirst()
|
|
183
|
+
return seg.conditions if seg else None
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
When a request carries `?segment_id=42`, easyapi calls the hook, trusts
|
|
187
|
+
its return value, and applies it through the same Layer-2 pipeline.
|
|
188
|
+
Returning `None` raises `404`. The hook can raise `HTTPException(403,
|
|
189
|
+
...)` itself for "exists but you can't access it."
|
|
190
|
+
|
|
191
|
+
`?segment_id=` and `?filter=` compose with AND — saved view *plus*
|
|
192
|
+
ad-hoc narrowing. The URL JSON still validates against `filter_fields`;
|
|
193
|
+
the stored conditions skip that check because the hook owns them.
|
|
194
|
+
|
|
195
|
+
For projects that let end users author stored expressions, validate at
|
|
196
|
+
write time with the public helper:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from easyapi import validate_conditions
|
|
200
|
+
|
|
201
|
+
class SegmentResource(BaseResource):
|
|
202
|
+
model = Segment
|
|
203
|
+
create_fields = ['name', 'conditions', 'context_id']
|
|
204
|
+
|
|
205
|
+
async def hydrate(self, body):
|
|
206
|
+
conditions = body.get('conditions')
|
|
207
|
+
if conditions:
|
|
208
|
+
allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
|
|
209
|
+
validate_conditions(conditions, allowed)
|
|
210
|
+
return body
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Server-side admin-only expressions can skip the write-time check; only
|
|
214
|
+
user-authored ones need it.
|
|
215
|
+
|
|
216
|
+
For projects with one `Segment` row type targeting many list resources,
|
|
217
|
+
factor out a mixin so each resource opts in with two lines and the
|
|
218
|
+
`filter_fields` whitelist auto-extends from a project registry:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# modules/segment/mixin.py
|
|
222
|
+
from django.db.models import Q
|
|
223
|
+
from .constants import INCLUDE_FIELDS
|
|
224
|
+
from .models import Segment, CONTEXT
|
|
225
|
+
|
|
226
|
+
class SegmentMixin:
|
|
227
|
+
stored_filter_param = 'segment_id'
|
|
228
|
+
segment_context_id = None
|
|
229
|
+
|
|
230
|
+
def __init__(self):
|
|
231
|
+
super().__init__()
|
|
232
|
+
if self.segment_context_id is None:
|
|
233
|
+
return
|
|
234
|
+
label = CONTEXT.LABEL[self.segment_context_id]
|
|
235
|
+
bag = INCLUDE_FIELDS.get(label, {})
|
|
236
|
+
extra = list(bag.get('segment_fields') or [])
|
|
237
|
+
extra += [m.split('__')[0] for m in bag.get('related_models') or []]
|
|
238
|
+
self.filter_fields = list(
|
|
239
|
+
dict.fromkeys((self.filter_fields or []) + extra)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def resolve_stored_filter(self, value, **kwargs):
|
|
243
|
+
is_master = bool(self.user and (
|
|
244
|
+
self.user.get('is_admin') or self.user.get('is_owner')
|
|
245
|
+
))
|
|
246
|
+
qs = Segment.objects.filter(
|
|
247
|
+
id=value, context_id=self.segment_context_id,
|
|
248
|
+
)
|
|
249
|
+
if not is_master:
|
|
250
|
+
qs = qs.filter(
|
|
251
|
+
Q(public=True) | Q(created_by_id=self.user['id'])
|
|
252
|
+
)
|
|
253
|
+
seg = await qs.afirst()
|
|
254
|
+
return seg.conditions if seg else None
|
|
255
|
+
|
|
256
|
+
class AgentResource(SegmentMixin, BaseResource):
|
|
257
|
+
segment_context_id = CONTEXT.AGENT
|
|
258
|
+
model = Agent
|
|
259
|
+
filter_fields = ['agency_id'] # explicit URL shorthands
|
|
260
|
+
# The mixin unions in the segment_fields whitelist + related prefixes,
|
|
261
|
+
# so ?filter=<json> and segment authoring share one source of truth.
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The minimum storage model is a JSON column:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
class Segment(models.Model):
|
|
268
|
+
name = models.CharField(max_length=120)
|
|
269
|
+
conditions = models.JSONField()
|
|
270
|
+
```
|
|
271
|
+
|
|
160
272
|
## Pydantic schemas (optional)
|
|
161
273
|
|
|
162
274
|
Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
|
|
@@ -256,13 +368,45 @@ configuration needed; it just works for any project that uses
|
|
|
256
368
|
`aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
|
|
257
369
|
explicit `account_id = 0` still produces a per-tenant key (real value,
|
|
258
370
|
not absence). Disable globally via
|
|
259
|
-
`
|
|
260
|
-
deployment and want the legacy key shape.
|
|
371
|
+
`EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
|
|
372
|
+
single-tenant deployment and want the legacy key shape.
|
|
261
373
|
|
|
262
374
|
If you override `_build_cache_key` in a project, call
|
|
263
375
|
`self._account_cache_segment()` and append the result so the override
|
|
264
376
|
inherits the tenant isolation.
|
|
265
377
|
|
|
378
|
+
**TTL settings.** Two project-level knobs in the `EASYAPI` bag:
|
|
379
|
+
|
|
380
|
+
- `CACHE_TTL` — default 120s; overrides the framework default for
|
|
381
|
+
resources that don't declare an explicit `cache_ttl`.
|
|
382
|
+
- `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
|
|
383
|
+
kill switch (every `cache=True` resource becomes `cache=False` at
|
|
384
|
+
runtime, no Redis read or write).
|
|
385
|
+
|
|
386
|
+
Every easyapi setting lives inside the `EASYAPI = {...}` dict
|
|
387
|
+
(DRF/Celery-style namespace):
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
# settings.py
|
|
391
|
+
EASYAPI = {
|
|
392
|
+
'CACHE_TTL': 300,
|
|
393
|
+
'CACHE_TTL_ENABLE': True,
|
|
394
|
+
'ENFORCE_TOKEN': True,
|
|
395
|
+
'COOKIE_ID': 'sessionid',
|
|
396
|
+
'RATE_LIMITS': {...},
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Inside the bag the historical `EASYAPI_` prefix is redundant —
|
|
401
|
+
`EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
|
|
402
|
+
same setting.
|
|
403
|
+
|
|
404
|
+
`CACHE_TTL` only sets the default — resources that declare
|
|
405
|
+
`cache_ttl = N` keep that explicit value.
|
|
406
|
+
`CACHE_TTL_ENABLE = False` is a kill switch that forces
|
|
407
|
+
`self.cache = False` for every request, useful for incident response
|
|
408
|
+
without code edits.
|
|
409
|
+
|
|
266
410
|
**Per-scope caching.** When the response varies on a user/account
|
|
267
411
|
dimension *inside* the same tenant — role, space, plan, country —
|
|
268
412
|
declare it with `cache_scope_fields` so users sharing the same scope
|
|
@@ -324,7 +468,7 @@ middleware in effect.
|
|
|
324
468
|
|
|
325
469
|
- Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
|
|
326
470
|
- `?fields=` is filtered against `list_fields` to prevent attribute leakage.
|
|
327
|
-
- `?filter=`
|
|
471
|
+
- `?filter=` is validated against `filter_fields`. Stored filter expressions are validated by the project (typically at write time via `validate_conditions`).
|
|
328
472
|
- `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
|
|
329
473
|
- Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
|
|
330
474
|
blocking runs in `SecurityMiddleware` before the view.
|
|
@@ -482,11 +626,13 @@ pip install -r requirements-dev.txt
|
|
|
482
626
|
pytest
|
|
483
627
|
```
|
|
484
628
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
629
|
+
301 tests covering util, redis, cache (incl. per-account auto-fold and
|
|
630
|
+
per-scope keys), filters, filter validation, init, auth tokens (incl.
|
|
631
|
+
nonce replay), schemas, openapi, helpers, serializer (incl. per-call
|
|
632
|
+
timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
|
|
633
|
+
dispatch error handling, tenant connection and registry, MCP middleware
|
|
634
|
+
chain, route gating, WS subscription hardening, public exports, and
|
|
635
|
+
WebSocket optional import.
|
|
490
636
|
|
|
491
637
|
## Author
|
|
492
638
|
|
|
@@ -48,7 +48,11 @@ MIDDLEWARE = [
|
|
|
48
48
|
'easyapi.AuthMiddleware', # session-based auth from Redis
|
|
49
49
|
'easyapi.ExceptionMiddleware',
|
|
50
50
|
]
|
|
51
|
-
|
|
51
|
+
|
|
52
|
+
EASYAPI = {
|
|
53
|
+
'TRUSTED_PROXIES': ['10.0.0.0/8'], # only trust X-Real-IP from these
|
|
54
|
+
# 'COOKIE_ID': 'sessionid', 'ENFORCE_TOKEN': True, ...
|
|
55
|
+
}
|
|
52
56
|
```
|
|
53
57
|
|
|
54
58
|
## Create a resource
|
|
@@ -114,7 +118,7 @@ class YourResource(BaseResource):
|
|
|
114
118
|
|
|
115
119
|
# Cache
|
|
116
120
|
cache = True
|
|
117
|
-
cache_ttl = 600
|
|
121
|
+
cache_ttl = 600 # default 120s; settings.CACHE_TTL overrides
|
|
118
122
|
```
|
|
119
123
|
|
|
120
124
|
## Querystrings
|
|
@@ -126,10 +130,118 @@ class YourResource(BaseResource):
|
|
|
126
130
|
| `?field=value` / `?field__gte=...` | Filter on whitelisted fields |
|
|
127
131
|
| `?fields=a,b` | Restrict returned fields (filtered by `list_fields`) |
|
|
128
132
|
| `?filter=<json>` | Advanced filter expression on whitelisted fields |
|
|
129
|
-
|
|
|
133
|
+
| `?<stored_filter_param>=N` | Apply a server-side stored filter expression (see below) |
|
|
130
134
|
| `?page=N&limit=M&order_by=field` | Pagination + order |
|
|
131
135
|
| `?normalize=true` | Return list as `{id: {...}}` instead of array |
|
|
132
136
|
|
|
137
|
+
## Saved filter expressions
|
|
138
|
+
|
|
139
|
+
Layer-2 expressions can be persisted server-side and reapplied by id —
|
|
140
|
+
saved views, marketing audiences, dashboard presets. The framework owns
|
|
141
|
+
the URL plumbing; the project owns the storage, lookup and policy.
|
|
142
|
+
|
|
143
|
+
Two pieces on the resource:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
class ClientResource(BaseResource):
|
|
147
|
+
model = Client
|
|
148
|
+
filter_fields = ['active', 'name']
|
|
149
|
+
|
|
150
|
+
stored_filter_param = 'segment_id' # any name — view_id, audience_id, …
|
|
151
|
+
|
|
152
|
+
async def resolve_stored_filter(self, value):
|
|
153
|
+
seg = await Segment.objects.filter(
|
|
154
|
+
id=value, account=self.account_id # tenant scoping etc.
|
|
155
|
+
).afirst()
|
|
156
|
+
return seg.conditions if seg else None
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When a request carries `?segment_id=42`, easyapi calls the hook, trusts
|
|
160
|
+
its return value, and applies it through the same Layer-2 pipeline.
|
|
161
|
+
Returning `None` raises `404`. The hook can raise `HTTPException(403,
|
|
162
|
+
...)` itself for "exists but you can't access it."
|
|
163
|
+
|
|
164
|
+
`?segment_id=` and `?filter=` compose with AND — saved view *plus*
|
|
165
|
+
ad-hoc narrowing. The URL JSON still validates against `filter_fields`;
|
|
166
|
+
the stored conditions skip that check because the hook owns them.
|
|
167
|
+
|
|
168
|
+
For projects that let end users author stored expressions, validate at
|
|
169
|
+
write time with the public helper:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from easyapi import validate_conditions
|
|
173
|
+
|
|
174
|
+
class SegmentResource(BaseResource):
|
|
175
|
+
model = Segment
|
|
176
|
+
create_fields = ['name', 'conditions', 'context_id']
|
|
177
|
+
|
|
178
|
+
async def hydrate(self, body):
|
|
179
|
+
conditions = body.get('conditions')
|
|
180
|
+
if conditions:
|
|
181
|
+
allowed = ALLOWED_FIELDS_BY_CONTEXT[body['context_id']]
|
|
182
|
+
validate_conditions(conditions, allowed)
|
|
183
|
+
return body
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Server-side admin-only expressions can skip the write-time check; only
|
|
187
|
+
user-authored ones need it.
|
|
188
|
+
|
|
189
|
+
For projects with one `Segment` row type targeting many list resources,
|
|
190
|
+
factor out a mixin so each resource opts in with two lines and the
|
|
191
|
+
`filter_fields` whitelist auto-extends from a project registry:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# modules/segment/mixin.py
|
|
195
|
+
from django.db.models import Q
|
|
196
|
+
from .constants import INCLUDE_FIELDS
|
|
197
|
+
from .models import Segment, CONTEXT
|
|
198
|
+
|
|
199
|
+
class SegmentMixin:
|
|
200
|
+
stored_filter_param = 'segment_id'
|
|
201
|
+
segment_context_id = None
|
|
202
|
+
|
|
203
|
+
def __init__(self):
|
|
204
|
+
super().__init__()
|
|
205
|
+
if self.segment_context_id is None:
|
|
206
|
+
return
|
|
207
|
+
label = CONTEXT.LABEL[self.segment_context_id]
|
|
208
|
+
bag = INCLUDE_FIELDS.get(label, {})
|
|
209
|
+
extra = list(bag.get('segment_fields') or [])
|
|
210
|
+
extra += [m.split('__')[0] for m in bag.get('related_models') or []]
|
|
211
|
+
self.filter_fields = list(
|
|
212
|
+
dict.fromkeys((self.filter_fields or []) + extra)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def resolve_stored_filter(self, value, **kwargs):
|
|
216
|
+
is_master = bool(self.user and (
|
|
217
|
+
self.user.get('is_admin') or self.user.get('is_owner')
|
|
218
|
+
))
|
|
219
|
+
qs = Segment.objects.filter(
|
|
220
|
+
id=value, context_id=self.segment_context_id,
|
|
221
|
+
)
|
|
222
|
+
if not is_master:
|
|
223
|
+
qs = qs.filter(
|
|
224
|
+
Q(public=True) | Q(created_by_id=self.user['id'])
|
|
225
|
+
)
|
|
226
|
+
seg = await qs.afirst()
|
|
227
|
+
return seg.conditions if seg else None
|
|
228
|
+
|
|
229
|
+
class AgentResource(SegmentMixin, BaseResource):
|
|
230
|
+
segment_context_id = CONTEXT.AGENT
|
|
231
|
+
model = Agent
|
|
232
|
+
filter_fields = ['agency_id'] # explicit URL shorthands
|
|
233
|
+
# The mixin unions in the segment_fields whitelist + related prefixes,
|
|
234
|
+
# so ?filter=<json> and segment authoring share one source of truth.
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The minimum storage model is a JSON column:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
class Segment(models.Model):
|
|
241
|
+
name = models.CharField(max_length=120)
|
|
242
|
+
conditions = models.JSONField()
|
|
243
|
+
```
|
|
244
|
+
|
|
133
245
|
## Pydantic schemas (optional)
|
|
134
246
|
|
|
135
247
|
Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
|
|
@@ -229,13 +341,45 @@ configuration needed; it just works for any project that uses
|
|
|
229
341
|
`aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
|
|
230
342
|
explicit `account_id = 0` still produces a per-tenant key (real value,
|
|
231
343
|
not absence). Disable globally via
|
|
232
|
-
`
|
|
233
|
-
deployment and want the legacy key shape.
|
|
344
|
+
`EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
|
|
345
|
+
single-tenant deployment and want the legacy key shape.
|
|
234
346
|
|
|
235
347
|
If you override `_build_cache_key` in a project, call
|
|
236
348
|
`self._account_cache_segment()` and append the result so the override
|
|
237
349
|
inherits the tenant isolation.
|
|
238
350
|
|
|
351
|
+
**TTL settings.** Two project-level knobs in the `EASYAPI` bag:
|
|
352
|
+
|
|
353
|
+
- `CACHE_TTL` — default 120s; overrides the framework default for
|
|
354
|
+
resources that don't declare an explicit `cache_ttl`.
|
|
355
|
+
- `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
|
|
356
|
+
kill switch (every `cache=True` resource becomes `cache=False` at
|
|
357
|
+
runtime, no Redis read or write).
|
|
358
|
+
|
|
359
|
+
Every easyapi setting lives inside the `EASYAPI = {...}` dict
|
|
360
|
+
(DRF/Celery-style namespace):
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
# settings.py
|
|
364
|
+
EASYAPI = {
|
|
365
|
+
'CACHE_TTL': 300,
|
|
366
|
+
'CACHE_TTL_ENABLE': True,
|
|
367
|
+
'ENFORCE_TOKEN': True,
|
|
368
|
+
'COOKIE_ID': 'sessionid',
|
|
369
|
+
'RATE_LIMITS': {...},
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Inside the bag the historical `EASYAPI_` prefix is redundant —
|
|
374
|
+
`EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
|
|
375
|
+
same setting.
|
|
376
|
+
|
|
377
|
+
`CACHE_TTL` only sets the default — resources that declare
|
|
378
|
+
`cache_ttl = N` keep that explicit value.
|
|
379
|
+
`CACHE_TTL_ENABLE = False` is a kill switch that forces
|
|
380
|
+
`self.cache = False` for every request, useful for incident response
|
|
381
|
+
without code edits.
|
|
382
|
+
|
|
239
383
|
**Per-scope caching.** When the response varies on a user/account
|
|
240
384
|
dimension *inside* the same tenant — role, space, plan, country —
|
|
241
385
|
declare it with `cache_scope_fields` so users sharing the same scope
|
|
@@ -297,7 +441,7 @@ middleware in effect.
|
|
|
297
441
|
|
|
298
442
|
- Session cookie validated against `^[a-zA-Z0-9_\-:]{5,100}$`.
|
|
299
443
|
- `?fields=` is filtered against `list_fields` to prevent attribute leakage.
|
|
300
|
-
- `?filter=`
|
|
444
|
+
- `?filter=` is validated against `filter_fields`. Stored filter expressions are validated by the project (typically at write time via `validate_conditions`).
|
|
301
445
|
- `owner_field` scopes PATCH/DELETE to rows owned by the authenticated user.
|
|
302
446
|
- Request rate limiting runs inside `BaseResource.dispatch`; edge scanner
|
|
303
447
|
blocking runs in `SecurityMiddleware` before the view.
|
|
@@ -455,11 +599,13 @@ pip install -r requirements-dev.txt
|
|
|
455
599
|
pytest
|
|
456
600
|
```
|
|
457
601
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
602
|
+
301 tests covering util, redis, cache (incl. per-account auto-fold and
|
|
603
|
+
per-scope keys), filters, filter validation, init, auth tokens (incl.
|
|
604
|
+
nonce replay), schemas, openapi, helpers, serializer (incl. per-call
|
|
605
|
+
timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
|
|
606
|
+
dispatch error handling, tenant connection and registry, MCP middleware
|
|
607
|
+
chain, route gating, WS subscription hardening, public exports, and
|
|
608
|
+
WebSocket optional import.
|
|
463
609
|
|
|
464
610
|
## Author
|
|
465
611
|
|
|
@@ -8,7 +8,7 @@ from easyapi.tenant.db_router import DBRouter # noqa
|
|
|
8
8
|
from easyapi.tenant.tenant import aset_tenant, db_state, get_account, get_master_user, get_tenant, set_tenant # noqa
|
|
9
9
|
# set_default / unset_default are SCRIPT-ONLY (mutate global default DB);
|
|
10
10
|
# import them directly from easyapi.tenant.tenant when really needed.
|
|
11
|
-
from easyapi.filters import Filter as OrmFilter # noqa
|
|
11
|
+
from easyapi.filters import Filter as OrmFilter, validate_conditions # noqa
|
|
12
12
|
from easyapi.redis_config import get_cache_stats, reset_cache_stats # noqa: F401
|
|
13
13
|
from easyapi.schemas import openapi # noqa: F401
|
|
14
14
|
from easyapi.openapi import build_spec # noqa: F401
|
|
@@ -5,10 +5,17 @@ from time import time
|
|
|
5
5
|
|
|
6
6
|
from .exception import HTTPException
|
|
7
7
|
from .redis_config import KEY_PREFIX, get_redis
|
|
8
|
+
from .settings_helper import get_token_max_drift_ms
|
|
8
9
|
|
|
9
10
|
_NONCE_RE = re.compile(r'^[A-Za-z0-9_\-]{1,64}$')
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _resolve_max_drift_ms(max_drift_ms):
|
|
14
|
+
if max_drift_ms is None:
|
|
15
|
+
return get_token_max_drift_ms()
|
|
16
|
+
return max_drift_ms
|
|
17
|
+
|
|
18
|
+
|
|
12
19
|
def make_token(session_token, nonce, timestamp_ms=None):
|
|
13
20
|
"""Build an X-Token value from the session token, a nonce and a
|
|
14
21
|
timestamp. Format: ``<timestamp_ms>.<nonce>.<hex_hmac>``.
|
|
@@ -25,7 +32,7 @@ def make_token(session_token, nonce, timestamp_ms=None):
|
|
|
25
32
|
return f'{timestamp_ms}.{nonce}.{digest}'
|
|
26
33
|
|
|
27
34
|
|
|
28
|
-
def validate_token(token, session_token, max_drift_ms=
|
|
35
|
+
def validate_token(token, session_token, max_drift_ms=None):
|
|
29
36
|
"""Validate the X-Token anti-replay header.
|
|
30
37
|
|
|
31
38
|
Token format: ``<timestamp_ms>.<nonce>.<hex_hmac>``.
|
|
@@ -33,6 +40,8 @@ def validate_token(token, session_token, max_drift_ms=5000):
|
|
|
33
40
|
|
|
34
41
|
Raises HTTPException(403) on any failure.
|
|
35
42
|
"""
|
|
43
|
+
max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
|
|
44
|
+
|
|
36
45
|
if not token:
|
|
37
46
|
raise HTTPException(403, 'Not allowed, missing token')
|
|
38
47
|
if not session_token:
|
|
@@ -61,12 +70,14 @@ def validate_token(token, session_token, max_drift_ms=5000):
|
|
|
61
70
|
return nonce, timestamp_ms
|
|
62
71
|
|
|
63
72
|
|
|
64
|
-
async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=
|
|
73
|
+
async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=None):
|
|
65
74
|
"""Reserve nonce in Redis to prevent replay within the drift window.
|
|
66
75
|
|
|
67
76
|
Keyed by sha256(session_token):nonce. TTL = 2 * max_drift_ms (ms).
|
|
68
77
|
Raises HTTPException(403) if nonce was already used.
|
|
69
78
|
"""
|
|
79
|
+
max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
|
|
80
|
+
|
|
70
81
|
redis = get_redis()
|
|
71
82
|
sess_hash = hashlib.sha256(session_token.encode('utf-8')).hexdigest()[:16]
|
|
72
83
|
key = f'{KEY_PREFIX}nonce:{sess_hash}:{nonce}'
|
|
@@ -76,7 +87,8 @@ async def consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms=5000):
|
|
|
76
87
|
raise HTTPException(403, 'Not allowed, replayed token')
|
|
77
88
|
|
|
78
89
|
|
|
79
|
-
async def validate_token_async(token, session_token, max_drift_ms=
|
|
90
|
+
async def validate_token_async(token, session_token, max_drift_ms=None):
|
|
80
91
|
"""Validate X-Token and consume nonce atomically (Redis SETNX)."""
|
|
92
|
+
max_drift_ms = _resolve_max_drift_ms(max_drift_ms)
|
|
81
93
|
nonce, timestamp_ms = validate_token(token, session_token, max_drift_ms)
|
|
82
94
|
await consume_nonce(session_token, nonce, timestamp_ms, max_drift_ms)
|