easyapi-django 0.35__tar.gz → 0.36__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.36}/PKG-INFO +88 -12
- {easyapi_django-0.35 → easyapi_django-0.36}/README.md +86 -10
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/auth.py +15 -3
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/base.py +109 -87
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/calc.py +0 -3
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/client_ip.py +3 -4
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/filters.py +0 -1
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/middleware.py +5 -3
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/openapi.py +3 -4
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/rate_limit.py +49 -43
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/redis_config.py +32 -7
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/routes.py +2 -4
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/security.py +12 -29
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/serializer.py +1 -2
- easyapi_django-0.36/easyapi/settings_helper.py +49 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/tenant/db_router.py +0 -2
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/tenant/tenant.py +6 -10
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/ws.py +2 -5
- {easyapi_django-0.35 → easyapi_django-0.36/easyapi_django.egg-info}/PKG-INFO +88 -12
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi_django.egg-info/SOURCES.txt +3 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/pyproject.toml +8 -3
- easyapi_django-0.36/tests/test_allowed_domain.py +50 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_api_key_resolver.py +4 -2
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_auth.py +17 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_block.py +24 -1
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_cache.py +89 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_openapi.py +2 -2
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_redis_config.py +23 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_serializer.py +11 -0
- easyapi_django-0.36/tests/test_settings_helper.py +80 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/LICENSE +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/blocking.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/constants.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/dates.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/exception.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/helpers.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/schemas.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi/util.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/setup.cfg +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_base_init.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_exports.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_filters.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_helpers.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_index_route.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_mcp.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_schemas.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_security.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_util.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.35 → easyapi_django-0.36}/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.36
|
|
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,48 @@ 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
|
|
160
|
+
| `?segment_id=N` | Apply a saved segment (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 segments
|
|
165
|
+
|
|
166
|
+
A segment is a saved JSON filter expression — the same boolean tree
|
|
167
|
+
`?filter=<json>` accepts, stored under a stable id. Useful for CRM-style
|
|
168
|
+
"saved views", marketing audiences, dashboard filters.
|
|
169
|
+
|
|
170
|
+
Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
|
|
171
|
+
`.conditions` attribute returning the Layer-2 dict:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# settings.py
|
|
175
|
+
EASYAPI = {
|
|
176
|
+
'SEGMENT_MODEL': 'modules.segment.models.Segment',
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# modules/segment/models.py
|
|
180
|
+
class Segment(models.Model):
|
|
181
|
+
name = models.CharField(max_length=120)
|
|
182
|
+
conditions = models.JSONField() # the Layer-2 boolean tree
|
|
183
|
+
|
|
184
|
+
Segment.objects.create(
|
|
185
|
+
name='Active demo accounts',
|
|
186
|
+
conditions={
|
|
187
|
+
'logical_operator': 'AND',
|
|
188
|
+
'rules': [
|
|
189
|
+
{'field': 'active', 'operator': 'exact', 'value': True},
|
|
190
|
+
{'field': 'name', 'operator': 'icontains', 'value': 'demo'},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Any resource then accepts `GET /clients?segment_id=42`. Conditions are
|
|
197
|
+
validated against the resource's `filter_fields` whitelist; missing rows
|
|
198
|
+
return 404. When `SEGMENT_MODEL` is unset, `?segment_id=` is a no-op. A
|
|
199
|
+
bad path raises `ImportError` at first use — typos fail loudly instead of
|
|
200
|
+
silently disabling segments.
|
|
201
|
+
|
|
160
202
|
## Pydantic schemas (optional)
|
|
161
203
|
|
|
162
204
|
Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
|
|
@@ -256,13 +298,45 @@ configuration needed; it just works for any project that uses
|
|
|
256
298
|
`aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
|
|
257
299
|
explicit `account_id = 0` still produces a per-tenant key (real value,
|
|
258
300
|
not absence). Disable globally via
|
|
259
|
-
`
|
|
260
|
-
deployment and want the legacy key shape.
|
|
301
|
+
`EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
|
|
302
|
+
single-tenant deployment and want the legacy key shape.
|
|
261
303
|
|
|
262
304
|
If you override `_build_cache_key` in a project, call
|
|
263
305
|
`self._account_cache_segment()` and append the result so the override
|
|
264
306
|
inherits the tenant isolation.
|
|
265
307
|
|
|
308
|
+
**TTL settings.** Two project-level knobs in the `EASYAPI` bag:
|
|
309
|
+
|
|
310
|
+
- `CACHE_TTL` — default 120s; overrides the framework default for
|
|
311
|
+
resources that don't declare an explicit `cache_ttl`.
|
|
312
|
+
- `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
|
|
313
|
+
kill switch (every `cache=True` resource becomes `cache=False` at
|
|
314
|
+
runtime, no Redis read or write).
|
|
315
|
+
|
|
316
|
+
Every easyapi setting lives inside the `EASYAPI = {...}` dict
|
|
317
|
+
(DRF/Celery-style namespace):
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
# settings.py
|
|
321
|
+
EASYAPI = {
|
|
322
|
+
'CACHE_TTL': 300,
|
|
323
|
+
'CACHE_TTL_ENABLE': True,
|
|
324
|
+
'ENFORCE_TOKEN': True,
|
|
325
|
+
'COOKIE_ID': 'sessionid',
|
|
326
|
+
'RATE_LIMITS': {...},
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Inside the bag the historical `EASYAPI_` prefix is redundant —
|
|
331
|
+
`EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
|
|
332
|
+
same setting.
|
|
333
|
+
|
|
334
|
+
`CACHE_TTL` only sets the default — resources that declare
|
|
335
|
+
`cache_ttl = N` keep that explicit value.
|
|
336
|
+
`CACHE_TTL_ENABLE = False` is a kill switch that forces
|
|
337
|
+
`self.cache = False` for every request, useful for incident response
|
|
338
|
+
without code edits.
|
|
339
|
+
|
|
266
340
|
**Per-scope caching.** When the response varies on a user/account
|
|
267
341
|
dimension *inside* the same tenant — role, space, plan, country —
|
|
268
342
|
declare it with `cache_scope_fields` so users sharing the same scope
|
|
@@ -482,11 +556,13 @@ pip install -r requirements-dev.txt
|
|
|
482
556
|
pytest
|
|
483
557
|
```
|
|
484
558
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
559
|
+
301 tests covering util, redis, cache (incl. per-account auto-fold and
|
|
560
|
+
per-scope keys), filters, filter validation, init, auth tokens (incl.
|
|
561
|
+
nonce replay), schemas, openapi, helpers, serializer (incl. per-call
|
|
562
|
+
timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
|
|
563
|
+
dispatch error handling, tenant connection and registry, MCP middleware
|
|
564
|
+
chain, route gating, WS subscription hardening, public exports, and
|
|
565
|
+
WebSocket optional import.
|
|
490
566
|
|
|
491
567
|
## Author
|
|
492
568
|
|
|
@@ -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,48 @@ 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
|
|
133
|
+
| `?segment_id=N` | Apply a saved segment (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 segments
|
|
138
|
+
|
|
139
|
+
A segment is a saved JSON filter expression — the same boolean tree
|
|
140
|
+
`?filter=<json>` accepts, stored under a stable id. Useful for CRM-style
|
|
141
|
+
"saved views", marketing audiences, dashboard filters.
|
|
142
|
+
|
|
143
|
+
Wire it up by pointing `EASYAPI.SEGMENT_MODEL` at a model that exposes a
|
|
144
|
+
`.conditions` attribute returning the Layer-2 dict:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# settings.py
|
|
148
|
+
EASYAPI = {
|
|
149
|
+
'SEGMENT_MODEL': 'modules.segment.models.Segment',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# modules/segment/models.py
|
|
153
|
+
class Segment(models.Model):
|
|
154
|
+
name = models.CharField(max_length=120)
|
|
155
|
+
conditions = models.JSONField() # the Layer-2 boolean tree
|
|
156
|
+
|
|
157
|
+
Segment.objects.create(
|
|
158
|
+
name='Active demo accounts',
|
|
159
|
+
conditions={
|
|
160
|
+
'logical_operator': 'AND',
|
|
161
|
+
'rules': [
|
|
162
|
+
{'field': 'active', 'operator': 'exact', 'value': True},
|
|
163
|
+
{'field': 'name', 'operator': 'icontains', 'value': 'demo'},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Any resource then accepts `GET /clients?segment_id=42`. Conditions are
|
|
170
|
+
validated against the resource's `filter_fields` whitelist; missing rows
|
|
171
|
+
return 404. When `SEGMENT_MODEL` is unset, `?segment_id=` is a no-op. A
|
|
172
|
+
bad path raises `ImportError` at first use — typos fail loudly instead of
|
|
173
|
+
silently disabling segments.
|
|
174
|
+
|
|
133
175
|
## Pydantic schemas (optional)
|
|
134
176
|
|
|
135
177
|
Set any of `create_schema`, `update_schema`, `list_schema` and easyapi
|
|
@@ -229,13 +271,45 @@ configuration needed; it just works for any project that uses
|
|
|
229
271
|
`aset_tenant`. The auto-fold is keyed by `account_id is not None`, so an
|
|
230
272
|
explicit `account_id = 0` still produces a per-tenant key (real value,
|
|
231
273
|
not absence). Disable globally via
|
|
232
|
-
`
|
|
233
|
-
deployment and want the legacy key shape.
|
|
274
|
+
`EASYAPI = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` if you have a
|
|
275
|
+
single-tenant deployment and want the legacy key shape.
|
|
234
276
|
|
|
235
277
|
If you override `_build_cache_key` in a project, call
|
|
236
278
|
`self._account_cache_segment()` and append the result so the override
|
|
237
279
|
inherits the tenant isolation.
|
|
238
280
|
|
|
281
|
+
**TTL settings.** Two project-level knobs in the `EASYAPI` bag:
|
|
282
|
+
|
|
283
|
+
- `CACHE_TTL` — default 120s; overrides the framework default for
|
|
284
|
+
resources that don't declare an explicit `cache_ttl`.
|
|
285
|
+
- `CACHE_TTL_ENABLE` — default `True`; flip to `False` for a global
|
|
286
|
+
kill switch (every `cache=True` resource becomes `cache=False` at
|
|
287
|
+
runtime, no Redis read or write).
|
|
288
|
+
|
|
289
|
+
Every easyapi setting lives inside the `EASYAPI = {...}` dict
|
|
290
|
+
(DRF/Celery-style namespace):
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
# settings.py
|
|
294
|
+
EASYAPI = {
|
|
295
|
+
'CACHE_TTL': 300,
|
|
296
|
+
'CACHE_TTL_ENABLE': True,
|
|
297
|
+
'ENFORCE_TOKEN': True,
|
|
298
|
+
'COOKIE_ID': 'sessionid',
|
|
299
|
+
'RATE_LIMITS': {...},
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Inside the bag the historical `EASYAPI_` prefix is redundant —
|
|
304
|
+
`EASYAPI_API_KEY_RESOLVER` and `API_KEY_RESOLVER` resolve to the
|
|
305
|
+
same setting.
|
|
306
|
+
|
|
307
|
+
`CACHE_TTL` only sets the default — resources that declare
|
|
308
|
+
`cache_ttl = N` keep that explicit value.
|
|
309
|
+
`CACHE_TTL_ENABLE = False` is a kill switch that forces
|
|
310
|
+
`self.cache = False` for every request, useful for incident response
|
|
311
|
+
without code edits.
|
|
312
|
+
|
|
239
313
|
**Per-scope caching.** When the response varies on a user/account
|
|
240
314
|
dimension *inside* the same tenant — role, space, plan, country —
|
|
241
315
|
declare it with `cache_scope_fields` so users sharing the same scope
|
|
@@ -455,11 +529,13 @@ pip install -r requirements-dev.txt
|
|
|
455
529
|
pytest
|
|
456
530
|
```
|
|
457
531
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
532
|
+
301 tests covering util, redis, cache (incl. per-account auto-fold and
|
|
533
|
+
per-scope keys), filters, filter validation, init, auth tokens (incl.
|
|
534
|
+
nonce replay), schemas, openapi, helpers, serializer (incl. per-call
|
|
535
|
+
timezone subclass), client_ip, allowed-domain checks, SecurityMiddleware,
|
|
536
|
+
dispatch error handling, tenant connection and registry, MCP middleware
|
|
537
|
+
chain, route gating, WS subscription hardening, public exports, and
|
|
538
|
+
WebSocket optional import.
|
|
463
539
|
|
|
464
540
|
## Author
|
|
465
541
|
|
|
@@ -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)
|
|
@@ -41,52 +41,56 @@ from .util import validate_session_key
|
|
|
41
41
|
from .rate_limit import RateLimiter
|
|
42
42
|
from .tenant.tenant import aset_tenant, get_api_session
|
|
43
43
|
from .redis_config import KEY_PREFIX, get_redis
|
|
44
|
-
from
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'login': [
|
|
70
|
-
{'interval': 5000, 'limit': 3}, # Janela curta
|
|
71
|
-
{'interval': 3600000, 'limit': 50} # Janela longa
|
|
72
|
-
],
|
|
73
|
-
'abuse': [
|
|
74
|
-
{'interval': 5000, 'limit': 20},
|
|
75
|
-
{'interval': 3600000, 'limit': 200}
|
|
76
|
-
]
|
|
77
|
-
}
|
|
44
|
+
from .settings_helper import get_cookie_id, get_setting, get_token_max_drift_ms
|
|
45
|
+
|
|
46
|
+
COOKIE_ID = get_cookie_id()
|
|
47
|
+
TOKEN_MAX_DRIFT_MS = get_token_max_drift_ms()
|
|
48
|
+
ALLOWED_ORIGINS = get_setting('ALLOWED_ORIGINS', default=[])
|
|
49
|
+
ENFORCE_TOKEN = get_setting('ENFORCE_TOKEN', default=False)
|
|
50
|
+
AUTO_SCOPE_CACHE_BY_ACCOUNT = get_setting(
|
|
51
|
+
'AUTO_SCOPE_CACHE_BY_ACCOUNT', default=True,
|
|
52
|
+
)
|
|
53
|
+
CACHE_TTL = get_setting('CACHE_TTL')
|
|
54
|
+
CACHE_TTL_ENABLE = get_setting('CACHE_TTL_ENABLE', default=True)
|
|
55
|
+
RATE_LIMITS = get_setting('RATE_LIMITS', default={
|
|
56
|
+
'api': [
|
|
57
|
+
{'interval': 1000, 'limit': 4},
|
|
58
|
+
{'interval': 5000, 'limit': 20},
|
|
59
|
+
],
|
|
60
|
+
'login': [
|
|
61
|
+
{'interval': 5000, 'limit': 3},
|
|
62
|
+
{'interval': 3600000, 'limit': 50},
|
|
63
|
+
],
|
|
64
|
+
'abuse': [
|
|
65
|
+
{'interval': 5000, 'limit': 20},
|
|
66
|
+
{'interval': 3600000, 'limit': 200},
|
|
67
|
+
],
|
|
68
|
+
})
|
|
78
69
|
|
|
79
70
|
|
|
80
71
|
logger = logging.getLogger(__name__)
|
|
81
72
|
|
|
82
73
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
74
|
+
_SEGMENT_MODEL_PATH = get_setting('SEGMENT_MODEL')
|
|
75
|
+
_segment_model_cache = False # sentinel; resolved lazily on first use
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_segment_model():
|
|
79
|
+
"""Lazy-resolve the project's Segment model.
|
|
80
|
+
|
|
81
|
+
Reads the dotted path from ``EASYAPI = {'SEGMENT_MODEL': ...}``.
|
|
82
|
+
When unset, ``?segment_id=`` is a no-op. Bad path raises so a typo
|
|
83
|
+
fails loudly instead of silently disabling segments.
|
|
84
|
+
"""
|
|
85
|
+
global _segment_model_cache
|
|
86
|
+
if _segment_model_cache is not False:
|
|
87
|
+
return _segment_model_cache
|
|
88
|
+
if not _SEGMENT_MODEL_PATH:
|
|
89
|
+
_segment_model_cache = None
|
|
90
|
+
return None
|
|
91
|
+
module_path, attr = _SEGMENT_MODEL_PATH.rsplit('.', 1)
|
|
92
|
+
_segment_model_cache = getattr(import_module(module_path), attr)
|
|
93
|
+
return _segment_model_cache
|
|
90
94
|
|
|
91
95
|
|
|
92
96
|
class BaseResource(View):
|
|
@@ -106,7 +110,9 @@ class BaseResource(View):
|
|
|
106
110
|
|
|
107
111
|
account_db = 'default'
|
|
108
112
|
cache = False
|
|
109
|
-
|
|
113
|
+
# Default TTL: settings.CACHE_TTL when set, else 120s.
|
|
114
|
+
# Subclasses can still override via ``cache_ttl = N`` and that wins.
|
|
115
|
+
cache_ttl = CACHE_TTL if CACHE_TTL is not None else 120
|
|
110
116
|
cache_namespace = None
|
|
111
117
|
route_cache = False
|
|
112
118
|
session_cache = False
|
|
@@ -218,15 +224,6 @@ class BaseResource(View):
|
|
|
218
224
|
self.m2m_fields.append(field.name)
|
|
219
225
|
continue
|
|
220
226
|
|
|
221
|
-
# if field.concrete and field.many_to_one:
|
|
222
|
-
# self.fk_fields.append(field.name)
|
|
223
|
-
# fields.append(f'{field.name}_id')
|
|
224
|
-
# continue
|
|
225
|
-
|
|
226
|
-
# if not field.concrete and field.one_to_many:
|
|
227
|
-
# self.related_fields.append(field.name)
|
|
228
|
-
# continue
|
|
229
|
-
|
|
230
227
|
all_fields = [
|
|
231
228
|
field.name for field in self.model._meta.local_fields
|
|
232
229
|
]
|
|
@@ -252,25 +249,21 @@ class BaseResource(View):
|
|
|
252
249
|
return None, None, None
|
|
253
250
|
|
|
254
251
|
def get_allowed_domain(self, request):
|
|
255
|
-
if ALLOWED_ORIGINS:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
host = request.headers.get('Host')
|
|
259
|
-
match = LOCAL_HOST.match(host)
|
|
260
|
-
|
|
261
|
-
if match:
|
|
262
|
-
allowed = True
|
|
252
|
+
if not ALLOWED_ORIGINS:
|
|
253
|
+
return
|
|
263
254
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
255
|
+
allowed = False
|
|
256
|
+
host = request.headers.get('Host') or ''
|
|
257
|
+
if LOCAL_HOST.match(host):
|
|
258
|
+
allowed = True
|
|
259
|
+
else:
|
|
260
|
+
referer = request.headers.get('Referer')
|
|
261
|
+
if referer:
|
|
262
|
+
domain = urlparse(referer).netloc.split(':')[0]
|
|
263
|
+
for origin in ALLOWED_ORIGINS:
|
|
264
|
+
if domain.endswith(origin):
|
|
265
|
+
allowed = True
|
|
266
|
+
break
|
|
274
267
|
|
|
275
268
|
if not allowed:
|
|
276
269
|
raise HTTPException(403, 'Not allowed')
|
|
@@ -290,12 +283,12 @@ class BaseResource(View):
|
|
|
290
283
|
await self.check_is_blocked(self.identifier)
|
|
291
284
|
|
|
292
285
|
if request.path.startswith('/login'):
|
|
293
|
-
result = RateLimiter.login_limited(self.identifier, RATE_LIMITS)
|
|
286
|
+
result = await RateLimiter.login_limited(self.identifier, RATE_LIMITS)
|
|
294
287
|
if result['rate_limited']:
|
|
295
288
|
await self.block(self.identifier)
|
|
296
289
|
return
|
|
297
290
|
|
|
298
|
-
result = RateLimiter.api_limited(self.identifier, RATE_LIMITS)
|
|
291
|
+
result = await RateLimiter.api_limited(self.identifier, RATE_LIMITS)
|
|
299
292
|
if result['abuse']:
|
|
300
293
|
await self.block(self.identifier)
|
|
301
294
|
if result['rate_limited']:
|
|
@@ -338,6 +331,7 @@ class BaseResource(View):
|
|
|
338
331
|
await validate_token_async(
|
|
339
332
|
request.headers.get('X-Token'),
|
|
340
333
|
self.user.get('token') if self.user else None,
|
|
334
|
+
max_drift_ms=TOKEN_MAX_DRIFT_MS,
|
|
341
335
|
)
|
|
342
336
|
|
|
343
337
|
_CACHE_SCOPE_SOURCES = ('user', 'account')
|
|
@@ -593,7 +587,15 @@ class BaseResource(View):
|
|
|
593
587
|
handler = getattr(self, self.method, method_not_allowed) if not func else None
|
|
594
588
|
|
|
595
589
|
is_custom_route = func is not None
|
|
596
|
-
|
|
590
|
+
# ``CACHE_TTL_ENABLE = False`` is a global kill switch — treats
|
|
591
|
+
# every ``cache=True`` resource as off without code edits.
|
|
592
|
+
# Useful for incident response (cache poisoning, stale data).
|
|
593
|
+
self.cache = (
|
|
594
|
+
self.cache
|
|
595
|
+
and CACHE_TTL_ENABLE
|
|
596
|
+
and self.method == 'get'
|
|
597
|
+
and (not is_custom_route or self.route_cache)
|
|
598
|
+
)
|
|
597
599
|
if self.cache:
|
|
598
600
|
cached = await self._serve_cache(request, session_key, is_custom_route)
|
|
599
601
|
if cached is not None:
|
|
@@ -661,10 +663,18 @@ class BaseResource(View):
|
|
|
661
663
|
|
|
662
664
|
if field in self.filter_fields:
|
|
663
665
|
param = params[key][0]
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
666
|
+
is_bool_field = False
|
|
667
|
+
if self.model:
|
|
668
|
+
try:
|
|
669
|
+
model_field = self.model._meta.get_field(field)
|
|
670
|
+
is_bool_field = isinstance(model_field, models.BooleanField)
|
|
671
|
+
except Exception:
|
|
672
|
+
is_bool_field = False
|
|
673
|
+
if is_bool_field:
|
|
674
|
+
if param.lower() == 'false':
|
|
675
|
+
param = False
|
|
676
|
+
elif param.lower() == 'true':
|
|
677
|
+
param = True
|
|
668
678
|
|
|
669
679
|
filter[key] = param
|
|
670
680
|
|
|
@@ -704,9 +714,6 @@ class BaseResource(View):
|
|
|
704
714
|
except Exception:
|
|
705
715
|
pass
|
|
706
716
|
|
|
707
|
-
#########################################################
|
|
708
|
-
# Funçoes dentro do Resource
|
|
709
|
-
#########################################################
|
|
710
717
|
async def serialize(self, result, **kwargs):
|
|
711
718
|
|
|
712
719
|
if type(result) is JsonResponse:
|
|
@@ -826,7 +833,6 @@ class BaseResource(View):
|
|
|
826
833
|
# GET
|
|
827
834
|
#########################################################
|
|
828
835
|
|
|
829
|
-
# count só se aplica a listagens
|
|
830
836
|
@sync_to_async
|
|
831
837
|
def count(self):
|
|
832
838
|
# InnoDB count(*) and Django's queryset.count() are slow on large tables
|
|
@@ -878,7 +884,6 @@ class BaseResource(View):
|
|
|
878
884
|
|
|
879
885
|
check(conditions)
|
|
880
886
|
|
|
881
|
-
# filtro por segmento/filter só se aplica a listagens
|
|
882
887
|
async def get_filters(self, request):
|
|
883
888
|
if self.filters:
|
|
884
889
|
self.queryset = self.queryset.filter(self.filters)
|
|
@@ -894,6 +899,7 @@ class BaseResource(View):
|
|
|
894
899
|
if segment_id is None and filter_ is None:
|
|
895
900
|
return
|
|
896
901
|
|
|
902
|
+
Segment = _get_segment_model()
|
|
897
903
|
if Segment and segment_id:
|
|
898
904
|
segment = await Segment.objects.filter(id=segment_id).afirst()
|
|
899
905
|
if not segment:
|
|
@@ -1178,8 +1184,12 @@ class BaseResource(View):
|
|
|
1178
1184
|
|
|
1179
1185
|
try:
|
|
1180
1186
|
await qs.adelete()
|
|
1181
|
-
except Exception
|
|
1182
|
-
|
|
1187
|
+
except Exception:
|
|
1188
|
+
logger.exception(
|
|
1189
|
+
'delete failed for %s id=%s',
|
|
1190
|
+
self.model._meta.label if self.model else '<unknown>', id,
|
|
1191
|
+
)
|
|
1192
|
+
raise HTTPException(400, 'Could not delete item')
|
|
1183
1193
|
|
|
1184
1194
|
return {'success': True, 'id': id, 'message': 'Deleted'}
|
|
1185
1195
|
|
|
@@ -1238,7 +1248,14 @@ class BaseResource(View):
|
|
|
1238
1248
|
field = getattr(self.model, key)
|
|
1239
1249
|
if field.field.primary_key:
|
|
1240
1250
|
key += '_id'
|
|
1241
|
-
|
|
1251
|
+
target = getattr(field.field, 'target_field', field.field)
|
|
1252
|
+
if target.get_internal_type() in (
|
|
1253
|
+
'AutoField', 'BigAutoField', 'SmallAutoField',
|
|
1254
|
+
'IntegerField', 'BigIntegerField',
|
|
1255
|
+
'PositiveIntegerField', 'PositiveBigIntegerField',
|
|
1256
|
+
'PositiveSmallIntegerField', 'SmallIntegerField',
|
|
1257
|
+
):
|
|
1258
|
+
value = int(value)
|
|
1242
1259
|
|
|
1243
1260
|
if isinstance(field.field, models.ForeignKey):
|
|
1244
1261
|
if not key.endswith('_id'):
|
|
@@ -1342,10 +1359,15 @@ class BaseResource(View):
|
|
|
1342
1359
|
except IntegrityError as error:
|
|
1343
1360
|
error_message = str(error)
|
|
1344
1361
|
if "Duplicate entry" in error_message:
|
|
1345
|
-
|
|
1346
|
-
|
|
1362
|
+
parts = error_message.split("'")
|
|
1363
|
+
duplicate_value = parts[1] if len(parts) > 1 else 'value'
|
|
1364
|
+
raise HTTPException(409, f'{duplicate_value} already exist')
|
|
1347
1365
|
|
|
1348
|
-
|
|
1366
|
+
logger.exception(
|
|
1367
|
+
'integrity error on create for %s',
|
|
1368
|
+
self.model._meta.label if self.model else '<unknown>',
|
|
1369
|
+
)
|
|
1370
|
+
raise HTTPException(409, 'Conflict creating item')
|
|
1349
1371
|
|
|
1350
1372
|
for key in custom.keys():
|
|
1351
1373
|
try:
|
|
@@ -519,7 +519,6 @@ async def get_results(timezone, data):
|
|
|
519
519
|
|
|
520
520
|
extra = data.get('extra')
|
|
521
521
|
|
|
522
|
-
# Filtro por fields específicos
|
|
523
522
|
filter_by = data.get('filter_by', {})
|
|
524
523
|
filtered_by_fields = filter_by.get('fields', {})
|
|
525
524
|
|
|
@@ -533,12 +532,10 @@ async def get_results(timezone, data):
|
|
|
533
532
|
else:
|
|
534
533
|
filter_by_fields[key] = value
|
|
535
534
|
|
|
536
|
-
# Aplicar filtros
|
|
537
535
|
queryset = queryset.filter(**filter_by_fields)
|
|
538
536
|
if or_conditions:
|
|
539
537
|
queryset = queryset.filter(or_conditions)
|
|
540
538
|
|
|
541
|
-
# Filtro por um período específico
|
|
542
539
|
filter_by_period = filter_by.get('period')
|
|
543
540
|
start_date = end_date = None
|
|
544
541
|
|