easyapi-django 0.34__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.34/easyapi_django.egg-info → easyapi_django-0.36}/PKG-INFO +88 -12
- {easyapi_django-0.34 → easyapi_django-0.36}/README.md +86 -10
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/auth.py +15 -3
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/base.py +118 -89
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/calc.py +0 -3
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/client_ip.py +3 -4
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/filters.py +0 -1
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/middleware.py +5 -3
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/openapi.py +3 -4
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/rate_limit.py +49 -43
- easyapi_django-0.36/easyapi/redis_config.py +162 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/routes.py +2 -4
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/security.py +12 -29
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/serializer.py +1 -2
- easyapi_django-0.36/easyapi/settings_helper.py +49 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/db_router.py +0 -2
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/tenant.py +6 -10
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/ws.py +2 -5
- {easyapi_django-0.34 → easyapi_django-0.36/easyapi_django.egg-info}/PKG-INFO +88 -12
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/SOURCES.txt +3 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/pyproject.toml +8 -3
- easyapi_django-0.36/tests/test_allowed_domain.py +50 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_api_key_resolver.py +4 -2
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_auth.py +17 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_block.py +24 -1
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache.py +89 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_openapi.py +2 -2
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_redis_config.py +48 -3
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_serializer.py +11 -0
- easyapi_django-0.36/tests/test_settings_helper.py +80 -0
- easyapi_django-0.34/easyapi/redis_config.py +0 -106
- {easyapi_django-0.34 → easyapi_django-0.36}/LICENSE +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/blocking.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/constants.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/dates.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/exception.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/helpers.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/schemas.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi/util.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/setup.cfg +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_base_init.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_exports.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_filters.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_helpers.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_index_route.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_mcp.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_schemas.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_security.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_util.py +0 -0
- {easyapi_django-0.34 → easyapi_django-0.36}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.34 → 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')
|
|
@@ -482,8 +476,15 @@ class BaseResource(View):
|
|
|
482
476
|
|
|
483
477
|
label = self.model._meta.label_lower if self.model else 'unknown'
|
|
484
478
|
outcome = 'hits' if response else 'misses'
|
|
485
|
-
|
|
486
|
-
|
|
479
|
+
# Per-model counters live in a single hash so ``get_cache_stats``
|
|
480
|
+
# can read them with one HGETALL instead of SCAN over the whole
|
|
481
|
+
# keyspace (the original ``cache_stats:hits:<label>`` layout
|
|
482
|
+
# cost 10+ seconds on production-sized Redis).
|
|
483
|
+
from .redis_config import _BY_MODEL_KEY, cache_stats_field
|
|
484
|
+
pipe = redis.pipeline()
|
|
485
|
+
pipe.incr(f'{KEY_PREFIX}cache_stats:{outcome}')
|
|
486
|
+
pipe.hincrby(_BY_MODEL_KEY, cache_stats_field(outcome, label), 1)
|
|
487
|
+
await pipe.execute()
|
|
487
488
|
|
|
488
489
|
if response:
|
|
489
490
|
return HttpResponse(response, content_type='application/json')
|
|
@@ -586,7 +587,15 @@ class BaseResource(View):
|
|
|
586
587
|
handler = getattr(self, self.method, method_not_allowed) if not func else None
|
|
587
588
|
|
|
588
589
|
is_custom_route = func is not None
|
|
589
|
-
|
|
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
|
+
)
|
|
590
599
|
if self.cache:
|
|
591
600
|
cached = await self._serve_cache(request, session_key, is_custom_route)
|
|
592
601
|
if cached is not None:
|
|
@@ -654,10 +663,18 @@ class BaseResource(View):
|
|
|
654
663
|
|
|
655
664
|
if field in self.filter_fields:
|
|
656
665
|
param = params[key][0]
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
661
678
|
|
|
662
679
|
filter[key] = param
|
|
663
680
|
|
|
@@ -697,9 +714,6 @@ class BaseResource(View):
|
|
|
697
714
|
except Exception:
|
|
698
715
|
pass
|
|
699
716
|
|
|
700
|
-
#########################################################
|
|
701
|
-
# Funçoes dentro do Resource
|
|
702
|
-
#########################################################
|
|
703
717
|
async def serialize(self, result, **kwargs):
|
|
704
718
|
|
|
705
719
|
if type(result) is JsonResponse:
|
|
@@ -819,7 +833,6 @@ class BaseResource(View):
|
|
|
819
833
|
# GET
|
|
820
834
|
#########################################################
|
|
821
835
|
|
|
822
|
-
# count só se aplica a listagens
|
|
823
836
|
@sync_to_async
|
|
824
837
|
def count(self):
|
|
825
838
|
# InnoDB count(*) and Django's queryset.count() are slow on large tables
|
|
@@ -871,7 +884,6 @@ class BaseResource(View):
|
|
|
871
884
|
|
|
872
885
|
check(conditions)
|
|
873
886
|
|
|
874
|
-
# filtro por segmento/filter só se aplica a listagens
|
|
875
887
|
async def get_filters(self, request):
|
|
876
888
|
if self.filters:
|
|
877
889
|
self.queryset = self.queryset.filter(self.filters)
|
|
@@ -887,6 +899,7 @@ class BaseResource(View):
|
|
|
887
899
|
if segment_id is None and filter_ is None:
|
|
888
900
|
return
|
|
889
901
|
|
|
902
|
+
Segment = _get_segment_model()
|
|
890
903
|
if Segment and segment_id:
|
|
891
904
|
segment = await Segment.objects.filter(id=segment_id).afirst()
|
|
892
905
|
if not segment:
|
|
@@ -1171,8 +1184,12 @@ class BaseResource(View):
|
|
|
1171
1184
|
|
|
1172
1185
|
try:
|
|
1173
1186
|
await qs.adelete()
|
|
1174
|
-
except Exception
|
|
1175
|
-
|
|
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')
|
|
1176
1193
|
|
|
1177
1194
|
return {'success': True, 'id': id, 'message': 'Deleted'}
|
|
1178
1195
|
|
|
@@ -1231,7 +1248,14 @@ class BaseResource(View):
|
|
|
1231
1248
|
field = getattr(self.model, key)
|
|
1232
1249
|
if field.field.primary_key:
|
|
1233
1250
|
key += '_id'
|
|
1234
|
-
|
|
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)
|
|
1235
1259
|
|
|
1236
1260
|
if isinstance(field.field, models.ForeignKey):
|
|
1237
1261
|
if not key.endswith('_id'):
|
|
@@ -1335,10 +1359,15 @@ class BaseResource(View):
|
|
|
1335
1359
|
except IntegrityError as error:
|
|
1336
1360
|
error_message = str(error)
|
|
1337
1361
|
if "Duplicate entry" in error_message:
|
|
1338
|
-
|
|
1339
|
-
|
|
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')
|
|
1340
1365
|
|
|
1341
|
-
|
|
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')
|
|
1342
1371
|
|
|
1343
1372
|
for key in custom.keys():
|
|
1344
1373
|
try:
|