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.
Files changed (80) hide show
  1. {easyapi_django-0.35/easyapi_django.egg-info → easyapi_django-0.37}/PKG-INFO +159 -13
  2. {easyapi_django-0.35 → easyapi_django-0.37}/README.md +157 -11
  3. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/__init__.py +1 -1
  4. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/auth.py +15 -3
  5. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/base.py +141 -128
  6. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/calc.py +0 -3
  7. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/client_ip.py +3 -4
  8. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/filters.py +41 -1
  9. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/middleware.py +5 -3
  10. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/openapi.py +3 -4
  11. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/rate_limit.py +49 -43
  12. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/redis_config.py +32 -7
  13. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/routes.py +2 -4
  14. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/security.py +12 -29
  15. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/serializer.py +1 -2
  16. easyapi_django-0.37/easyapi/settings_helper.py +49 -0
  17. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/db_router.py +0 -2
  18. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/tenant.py +6 -10
  19. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/ws.py +2 -5
  20. {easyapi_django-0.35 → easyapi_django-0.37/easyapi_django.egg-info}/PKG-INFO +159 -13
  21. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/SOURCES.txt +3 -0
  22. {easyapi_django-0.35 → easyapi_django-0.37}/pyproject.toml +8 -3
  23. easyapi_django-0.37/tests/test_allowed_domain.py +50 -0
  24. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_api_key_resolver.py +4 -2
  25. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_auth.py +17 -0
  26. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_block.py +24 -1
  27. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache.py +89 -0
  28. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_openapi.py +2 -2
  29. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_redis_config.py +23 -0
  30. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_serializer.py +11 -0
  31. easyapi_django-0.37/tests/test_settings_helper.py +80 -0
  32. {easyapi_django-0.35 → easyapi_django-0.37}/LICENSE +0 -0
  33. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/blocking.py +0 -0
  34. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/calc_resource.py +0 -0
  35. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/constants.py +0 -0
  36. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/dates.py +0 -0
  37. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/exception.py +0 -0
  38. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/helpers.py +0 -0
  39. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/__init__.py +0 -0
  40. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/commands/__init__.py +0 -0
  41. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/management/commands/mcp_serve.py +0 -0
  42. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/__init__.py +0 -0
  43. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/bridge.py +0 -0
  44. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/protocol.py +0 -0
  45. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/resource.py +0 -0
  46. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/mcp/tools.py +0 -0
  47. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/orm/__init__.py +0 -0
  48. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/schemas.py +0 -0
  49. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/__init__.py +0 -0
  50. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/tenant/registry.py +0 -0
  51. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi/util.py +0 -0
  52. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/dependency_links.txt +0 -0
  53. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/requires.txt +0 -0
  54. {easyapi_django-0.35 → easyapi_django-0.37}/easyapi_django.egg-info/top_level.txt +0 -0
  55. {easyapi_django-0.35 → easyapi_django-0.37}/setup.cfg +0 -0
  56. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_base_init.py +0 -0
  57. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache_auto_account_scope.py +0 -0
  58. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_cache_scope.py +0 -0
  59. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_client_ip.py +0 -0
  60. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_dispatch_error_handling.py +0 -0
  61. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_exception_render.py +0 -0
  62. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_exports.py +0 -0
  63. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_filter_validation.py +0 -0
  64. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_filters.py +0 -0
  65. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_get_obj_m2m_only.py +0 -0
  66. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_helpers.py +0 -0
  67. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_index_route.py +0 -0
  68. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_mcp.py +0 -0
  69. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_mcp_middleware_chain.py +0 -0
  70. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_normalize_field.py +0 -0
  71. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_owner_field.py +0 -0
  72. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_return_result_falsy.py +0 -0
  73. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_routes_gate.py +0 -0
  74. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_schemas.py +0 -0
  75. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_security.py +0 -0
  76. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_tenant_connection.py +0 -0
  77. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_tenant_registry.py +0 -0
  78. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_util.py +0 -0
  79. {easyapi_django-0.35 → easyapi_django-0.37}/tests/test_ws_channels.py +0 -0
  80. {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.35
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
- TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
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
- | `?segment_id=N` | Apply a stored segment |
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
- `AUTO_SCOPE_CACHE_BY_ACCOUNT = False` if you have a single-tenant
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=` and `segment_id` are validated against `filter_fields`.
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
- 216 tests covering util, redis, cache, filters, filter validation, init,
486
- auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
487
- client_ip, SecurityMiddleware, dispatch error handling, tenant connection
488
- and registry, MCP middleware chain, route gating, WS subscription
489
- hardening, public exports, and WebSocket optional import.
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
- TRUSTED_PROXIES = ['10.0.0.0/8'] # only trust X-Real-IP from these
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
- | `?segment_id=N` | Apply a stored segment |
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
- `AUTO_SCOPE_CACHE_BY_ACCOUNT = False` if you have a single-tenant
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=` and `segment_id` are validated against `filter_fields`.
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
- 216 tests covering util, redis, cache, filters, filter validation, init,
459
- auth tokens (incl. nonce replay), schemas, openapi, helpers, serializer,
460
- client_ip, SecurityMiddleware, dispatch error handling, tenant connection
461
- and registry, MCP middleware chain, route gating, WS subscription
462
- hardening, public exports, and WebSocket optional import.
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=5000):
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=5000):
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=5000):
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)