easyapi-django 0.32__tar.gz → 0.34__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.32/easyapi_django.egg-info → easyapi_django-0.34}/PKG-INFO +9 -7
- {easyapi_django-0.32 → easyapi_django-0.34}/README.md +8 -6
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/base.py +46 -9
- easyapi_django-0.34/easyapi/redis_config.py +106 -0
- {easyapi_django-0.32 → easyapi_django-0.34/easyapi_django.egg-info}/PKG-INFO +9 -7
- {easyapi_django-0.32 → easyapi_django-0.34}/pyproject.toml +1 -1
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache.py +44 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache_scope.py +31 -8
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_redis_config.py +35 -0
- easyapi_django-0.32/easyapi/redis_config.py +0 -64
- {easyapi_django-0.32 → easyapi_django-0.34}/LICENSE +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/auth.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/blocking.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/calc.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/client_ip.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/constants.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/dates.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/exception.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/filters.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/helpers.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/middleware.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/openapi.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/rate_limit.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/routes.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/schemas.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/security.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/serializer.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/util.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi/ws.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/SOURCES.txt +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/setup.cfg +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_api_key_resolver.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_auth.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_base_init.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_block.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_exception_render.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_exports.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_filters.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_helpers.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_index_route.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_mcp.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_openapi.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_schemas.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_security.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_serializer.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_util.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.32 → easyapi_django-0.34}/tests/test_ws_optional.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi_django
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34
|
|
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
6
|
License: MIT
|
|
@@ -278,12 +278,14 @@ class TaskResource(BaseResource):
|
|
|
278
278
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
When a request has authenticated context but a configured scope field
|
|
282
|
+
is **missing** from the session payload, the framework logs a `WARNING`
|
|
283
|
+
(logger `easyapi.base`) and **disables cache for that request** — the
|
|
284
|
+
response is neither read from nor written to Redis. Sharing a key
|
|
285
|
+
across users when the scope can't be resolved would be a silent leak
|
|
286
|
+
across whatever dimension the operator was trying to protect.
|
|
287
|
+
Anonymous requests skip the fold cleanly (no warning, no leak).
|
|
288
|
+
`None`, `0` and `''` count as present (a real value).
|
|
287
289
|
|
|
288
290
|
Use `before_cache` for the rare case that needs context outside
|
|
289
291
|
`self.user` / `self.account`:
|
|
@@ -251,12 +251,14 @@ class TaskResource(BaseResource):
|
|
|
251
251
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
When a request has authenticated context but a configured scope field
|
|
255
|
+
is **missing** from the session payload, the framework logs a `WARNING`
|
|
256
|
+
(logger `easyapi.base`) and **disables cache for that request** — the
|
|
257
|
+
response is neither read from nor written to Redis. Sharing a key
|
|
258
|
+
across users when the scope can't be resolved would be a silent leak
|
|
259
|
+
across whatever dimension the operator was trying to protect.
|
|
260
|
+
Anonymous requests skip the fold cleanly (no warning, no leak).
|
|
261
|
+
`None`, `0` and `''` count as present (a real value).
|
|
260
262
|
|
|
261
263
|
Use `before_cache` for the rare case that needs context outside
|
|
262
264
|
`self.user` / `self.account`:
|
|
@@ -375,16 +375,24 @@ class BaseResource(View):
|
|
|
375
375
|
def _resolve_cache_scope(self):
|
|
376
376
|
"""Build the scope hash for the cache key.
|
|
377
377
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
``
|
|
381
|
-
|
|
378
|
+
Three outcomes:
|
|
379
|
+
|
|
380
|
+
- ``None`` *with cache enabled*: no scope fields configured, or
|
|
381
|
+
fully anonymous request. Cache continues normally; the key
|
|
382
|
+
simply has no scope segment.
|
|
383
|
+
- ``str`` segment (``'scope=...'``): fold succeeded.
|
|
384
|
+
- ``None`` *with cache disabled*: scope was configured but the
|
|
385
|
+
required field is missing from the session payload. The
|
|
386
|
+
framework logs a ``WARNING`` and **disables cache for this
|
|
387
|
+
request** (``self.cache = False``) so the response is neither
|
|
388
|
+
read from nor written to Redis. Sharing a cache key across
|
|
389
|
+
users when the scope can't be resolved would be a silent
|
|
390
|
+
leak across whatever dimension the operator was protecting.
|
|
382
391
|
"""
|
|
383
392
|
if not self.cache_scope_fields:
|
|
384
393
|
return None
|
|
385
394
|
|
|
386
395
|
# Fully anonymous request → nothing to scope by, skip silently.
|
|
387
|
-
# Strict mode applies the moment any auth context exists.
|
|
388
396
|
if self.user is None and self.account is None:
|
|
389
397
|
return None
|
|
390
398
|
|
|
@@ -395,18 +403,20 @@ class BaseResource(View):
|
|
|
395
403
|
if container is None:
|
|
396
404
|
logger.warning(
|
|
397
405
|
"cache scope source %r missing from session for %s; "
|
|
398
|
-
"
|
|
399
|
-
"
|
|
406
|
+
"disabling cache for this request (populate the "
|
|
407
|
+
"session payload to enable caching)",
|
|
400
408
|
source, self.__class__.__name__,
|
|
401
409
|
)
|
|
410
|
+
self.cache = False
|
|
402
411
|
return None
|
|
403
412
|
if field not in container:
|
|
404
413
|
logger.warning(
|
|
405
414
|
"cache scope field %r missing from %s session payload "
|
|
406
|
-
"for %s;
|
|
407
|
-
"
|
|
415
|
+
"for %s; disabling cache for this request (populate "
|
|
416
|
+
"the session payload to enable caching)",
|
|
408
417
|
field, source, self.__class__.__name__,
|
|
409
418
|
)
|
|
419
|
+
self.cache = False
|
|
410
420
|
return None
|
|
411
421
|
parts.append(f'{source}.{field}={container[field]!r}')
|
|
412
422
|
|
|
@@ -597,6 +607,12 @@ class BaseResource(View):
|
|
|
597
607
|
|
|
598
608
|
if type(response) in [dict, list]:
|
|
599
609
|
response = await self.serialize(response)
|
|
610
|
+
elif self.cache and isinstance(response, JsonResponse):
|
|
611
|
+
# Handler returned a built JsonResponse — serialize() would
|
|
612
|
+
# short-circuit before save_cache, leaving the cache cold
|
|
613
|
+
# forever. Persist the response body directly so the next
|
|
614
|
+
# hit on this key is served by _serve_cache.
|
|
615
|
+
await self._save_response_cache(response)
|
|
600
616
|
|
|
601
617
|
return response
|
|
602
618
|
|
|
@@ -734,6 +750,27 @@ class BaseResource(View):
|
|
|
734
750
|
await redis.sadd(ns_key, self.cache_key)
|
|
735
751
|
await redis.expire(ns_key, max(self.cache_ttl * 2, 86400))
|
|
736
752
|
|
|
753
|
+
async def _save_response_cache(self, response):
|
|
754
|
+
"""Cache a pre-built JsonResponse's body.
|
|
755
|
+
|
|
756
|
+
Mirrors :meth:`save_cache` but skips JSON re-encoding — the
|
|
757
|
+
``response.content`` is already JSON bytes. Used when a handler
|
|
758
|
+
returns ``JsonResponse(...)`` directly (a common pattern for
|
|
759
|
+
custom ``get_objs`` overrides) and ``serialize`` would otherwise
|
|
760
|
+
short-circuit before ``save_cache`` runs.
|
|
761
|
+
"""
|
|
762
|
+
if not self.cache:
|
|
763
|
+
return
|
|
764
|
+
body = response.content
|
|
765
|
+
if isinstance(body, bytes):
|
|
766
|
+
body = body.decode('utf-8')
|
|
767
|
+
redis = get_redis()
|
|
768
|
+
await redis.setex(self.cache_key, self.cache_ttl, body)
|
|
769
|
+
if self.cache_namespace:
|
|
770
|
+
ns_key = f'{KEY_PREFIX}cache_ns:{self.cache_namespace}'
|
|
771
|
+
await redis.sadd(ns_key, self.cache_key)
|
|
772
|
+
await redis.expire(ns_key, max(self.cache_ttl * 2, 86400))
|
|
773
|
+
|
|
737
774
|
async def invalidate_cache(self, namespaces):
|
|
738
775
|
if not namespaces:
|
|
739
776
|
return
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import weakref
|
|
4
|
+
|
|
5
|
+
import redis.asyncio as aioredis
|
|
6
|
+
|
|
7
|
+
REDIS_SERVER = os.environ['REDIS_SERVER']
|
|
8
|
+
REDIS_DB = int(os.environ['REDIS_DB'])
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from settings.env import REDIS_PREFIX
|
|
12
|
+
except ImportError:
|
|
13
|
+
REDIS_PREFIX = os.environ.get('REDIS_PREFIX', '')
|
|
14
|
+
|
|
15
|
+
KEY_PREFIX = f'{REDIS_PREFIX}:' if REDIS_PREFIX else ''
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# `redis.asyncio.Redis` ties its connection futures to the event loop
|
|
19
|
+
# it was created on. ASGI servers + ``async_to_sync`` bridges spin up
|
|
20
|
+
# multiple loops in the same process — a single process-global client
|
|
21
|
+
# crashes those calls with ``RuntimeError: got Future attached to a
|
|
22
|
+
# different loop``. We cache one client per loop in a WeakKeyDictionary
|
|
23
|
+
# so entries auto-clean when the loop is GC'd.
|
|
24
|
+
_clients = weakref.WeakKeyDictionary()
|
|
25
|
+
|
|
26
|
+
# Fallback for callers without a running loop (rare; sync contexts that
|
|
27
|
+
# manually drive the event loop). Created lazily.
|
|
28
|
+
_loopless_client = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Tests monkeypatch this attribute to inject a fake (e.g. fakeredis).
|
|
32
|
+
# When set, ``get_redis()`` returns it unconditionally. None in
|
|
33
|
+
# production, where per-loop caching takes over.
|
|
34
|
+
_redis_client = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_redis():
|
|
38
|
+
global _loopless_client
|
|
39
|
+
|
|
40
|
+
# Test injection hook
|
|
41
|
+
if _redis_client is not None:
|
|
42
|
+
return _redis_client
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
if _loopless_client is None:
|
|
48
|
+
_loopless_client = aioredis.Redis(
|
|
49
|
+
host=REDIS_SERVER,
|
|
50
|
+
db=REDIS_DB,
|
|
51
|
+
decode_responses=True,
|
|
52
|
+
)
|
|
53
|
+
return _loopless_client
|
|
54
|
+
|
|
55
|
+
client = _clients.get(loop)
|
|
56
|
+
if client is None:
|
|
57
|
+
client = aioredis.Redis(
|
|
58
|
+
host=REDIS_SERVER,
|
|
59
|
+
db=REDIS_DB,
|
|
60
|
+
decode_responses=True,
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
_clients[loop] = client
|
|
64
|
+
except TypeError:
|
|
65
|
+
# Custom loop without weakref support — fall back to
|
|
66
|
+
# returning a fresh client without caching.
|
|
67
|
+
pass
|
|
68
|
+
return client
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def get_cache_stats():
|
|
72
|
+
redis = get_redis()
|
|
73
|
+
hits = int(await redis.get(f'{KEY_PREFIX}cache_stats:hits') or 0)
|
|
74
|
+
misses = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses') or 0)
|
|
75
|
+
total = hits + misses
|
|
76
|
+
ratio = hits / total if total else 0.0
|
|
77
|
+
|
|
78
|
+
pattern = f'{KEY_PREFIX}cache_stats:hits:*'
|
|
79
|
+
by_model = {}
|
|
80
|
+
async for key in redis.scan_iter(match=pattern):
|
|
81
|
+
label = key.split(':hits:', 1)[1]
|
|
82
|
+
h = int(await redis.get(key) or 0)
|
|
83
|
+
m = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses:{label}') or 0)
|
|
84
|
+
t = h + m
|
|
85
|
+
by_model[label] = {
|
|
86
|
+
'hits': h,
|
|
87
|
+
'misses': m,
|
|
88
|
+
'total': t,
|
|
89
|
+
'ratio': h / t if t else 0.0,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
'hits': hits,
|
|
94
|
+
'misses': misses,
|
|
95
|
+
'total': total,
|
|
96
|
+
'ratio': ratio,
|
|
97
|
+
'by_model': by_model,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def reset_cache_stats():
|
|
102
|
+
redis = get_redis()
|
|
103
|
+
pattern = f'{KEY_PREFIX}cache_stats:*'
|
|
104
|
+
keys = [k async for k in redis.scan_iter(match=pattern)]
|
|
105
|
+
if keys:
|
|
106
|
+
await redis.delete(*keys)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi_django
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34
|
|
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
6
|
License: MIT
|
|
@@ -278,12 +278,14 @@ class TaskResource(BaseResource):
|
|
|
278
278
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
When a request has authenticated context but a configured scope field
|
|
282
|
+
is **missing** from the session payload, the framework logs a `WARNING`
|
|
283
|
+
(logger `easyapi.base`) and **disables cache for that request** — the
|
|
284
|
+
response is neither read from nor written to Redis. Sharing a key
|
|
285
|
+
across users when the scope can't be resolved would be a silent leak
|
|
286
|
+
across whatever dimension the operator was trying to protect.
|
|
287
|
+
Anonymous requests skip the fold cleanly (no warning, no leak).
|
|
288
|
+
`None`, `0` and `''` count as present (a real value).
|
|
287
289
|
|
|
288
290
|
Use `before_cache` for the rare case that needs context outside
|
|
289
291
|
`self.user` / `self.account`:
|
|
@@ -92,6 +92,50 @@ def test_cache_namespaces_no_model_returns_empty():
|
|
|
92
92
|
assert res._cache_namespaces() == []
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_save_response_cache_persists_jsonresponse_body(fake_redis):
|
|
97
|
+
"""Resources whose handler returns ``JsonResponse(...)`` directly
|
|
98
|
+
bypass ``serialize()``. Without ``_save_response_cache`` the body
|
|
99
|
+
was never written to Redis even with ``cache = True`` — cache miss
|
|
100
|
+
forever (the ``/api/me`` symptom)."""
|
|
101
|
+
from django.http import JsonResponse
|
|
102
|
+
|
|
103
|
+
res = CachedResource()
|
|
104
|
+
res.cache = True
|
|
105
|
+
res.cache_key = f'{KEY_PREFIX}cache:/me'
|
|
106
|
+
res.cache_namespace = 'list:testapp.space'
|
|
107
|
+
|
|
108
|
+
payload = {'id': 7, 'name': 'demo', 'email': 'a@b.com'}
|
|
109
|
+
response = JsonResponse(payload)
|
|
110
|
+
|
|
111
|
+
await res._save_response_cache(response)
|
|
112
|
+
|
|
113
|
+
redis = get_redis()
|
|
114
|
+
raw = await redis.get(res.cache_key)
|
|
115
|
+
assert raw is not None
|
|
116
|
+
|
|
117
|
+
import json as _json
|
|
118
|
+
assert _json.loads(raw) == payload
|
|
119
|
+
|
|
120
|
+
members = await redis.smembers(f'{KEY_PREFIX}cache_ns:list:testapp.space')
|
|
121
|
+
assert res.cache_key in members
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_save_response_cache_skipped_when_cache_off(fake_redis):
|
|
126
|
+
from django.http import JsonResponse
|
|
127
|
+
|
|
128
|
+
res = CachedResource()
|
|
129
|
+
res.cache = False
|
|
130
|
+
res.cache_key = f'{KEY_PREFIX}cache:/me'
|
|
131
|
+
res.cache_namespace = 'list:testapp.space'
|
|
132
|
+
|
|
133
|
+
await res._save_response_cache(JsonResponse({'id': 1}))
|
|
134
|
+
|
|
135
|
+
redis = get_redis()
|
|
136
|
+
assert await redis.get(res.cache_key) is None
|
|
137
|
+
|
|
138
|
+
|
|
95
139
|
@pytest.mark.asyncio
|
|
96
140
|
async def test_save_cache_handles_datetime(fake_redis):
|
|
97
141
|
from datetime import datetime
|
|
@@ -157,43 +157,66 @@ def test_multiple_dimensions_combine():
|
|
|
157
157
|
# ── lenient semantics (warn + skip fold, never 500) ────────────────
|
|
158
158
|
|
|
159
159
|
|
|
160
|
-
def
|
|
161
|
-
"""Missing scope field on authenticated request must
|
|
162
|
-
request —
|
|
163
|
-
|
|
160
|
+
def test_missing_field_disables_cache(caplog):
|
|
161
|
+
"""Missing scope field on authenticated request must disable cache
|
|
162
|
+
for that request — a shared key across users would silently leak
|
|
163
|
+
data across whatever dimension the operator was trying to protect.
|
|
164
|
+
The 500 strict mode was too aggressive (took the site down); just
|
|
165
|
+
skipping the fold and writing to a shared key was too permissive."""
|
|
164
166
|
import logging as _logging
|
|
165
167
|
|
|
166
168
|
class _R(_Resource):
|
|
167
169
|
cache_scope_fields = ['space_id']
|
|
168
170
|
res = _make(_R, user={'id': 1}) # space_id NOT in user
|
|
171
|
+
assert res.cache is True # configured
|
|
169
172
|
|
|
170
173
|
with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
|
|
171
174
|
key = res._build_cache_key(_request(), None)
|
|
172
175
|
|
|
173
|
-
|
|
176
|
+
# Fold skipped AND cache disabled for this request
|
|
177
|
+
assert 'scope=' not in key
|
|
178
|
+
assert res.cache is False, (
|
|
179
|
+
'cache must be disabled when scope is configured but unresolved'
|
|
180
|
+
)
|
|
181
|
+
|
|
174
182
|
records = [r for r in caplog.records if r.name == 'easyapi.base']
|
|
175
|
-
assert records
|
|
183
|
+
assert records
|
|
176
184
|
msg = records[0].getMessage()
|
|
177
185
|
assert 'space_id' in msg
|
|
178
|
-
assert '
|
|
186
|
+
assert 'disabling cache' in msg
|
|
179
187
|
|
|
180
188
|
|
|
181
|
-
def
|
|
189
|
+
def test_missing_account_source_disables_cache(caplog):
|
|
182
190
|
import logging as _logging
|
|
183
191
|
|
|
184
192
|
class _R(_Resource):
|
|
185
193
|
cache_scope_fields = [('account', 'plan_id')]
|
|
186
194
|
res = _make(_R, user={'id': 1}, account=None)
|
|
195
|
+
assert res.cache is True
|
|
187
196
|
|
|
188
197
|
with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
|
|
189
198
|
key = res._build_cache_key(_request(), None)
|
|
190
199
|
|
|
191
200
|
assert 'scope=' not in key
|
|
201
|
+
assert res.cache is False
|
|
192
202
|
records = [r for r in caplog.records if r.name == 'easyapi.base']
|
|
193
203
|
assert records
|
|
194
204
|
assert 'account' in records[0].getMessage()
|
|
195
205
|
|
|
196
206
|
|
|
207
|
+
def test_resolved_scope_keeps_cache_enabled():
|
|
208
|
+
"""Sanity: when scope resolves cleanly, cache stays enabled."""
|
|
209
|
+
class _R(_Resource):
|
|
210
|
+
cache_scope_fields = ['space_id']
|
|
211
|
+
res = _make(_R, user={'id': 1, 'space_id': 7})
|
|
212
|
+
assert res.cache is True
|
|
213
|
+
|
|
214
|
+
key = res._build_cache_key(_request(), None)
|
|
215
|
+
|
|
216
|
+
assert 'scope=' in key
|
|
217
|
+
assert res.cache is True
|
|
218
|
+
|
|
219
|
+
|
|
197
220
|
# ── coexistence with session_cache ─────────────────────────────────
|
|
198
221
|
|
|
199
222
|
|
|
@@ -21,6 +21,41 @@ def test_get_redis_returns_singleton(fake_redis):
|
|
|
21
21
|
assert a is b
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def test_get_redis_caches_per_loop(monkeypatch):
|
|
25
|
+
"""Without the test injection hook, get_redis must hand a separate
|
|
26
|
+
client to each event loop — otherwise asyncio raises 'Future
|
|
27
|
+
attached to a different loop' under ASGI + async_to_sync."""
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(redis_config, '_redis_client', None)
|
|
31
|
+
redis_config._clients.clear()
|
|
32
|
+
|
|
33
|
+
async def grab():
|
|
34
|
+
return get_redis()
|
|
35
|
+
|
|
36
|
+
loop_a = asyncio.new_event_loop()
|
|
37
|
+
loop_b = asyncio.new_event_loop()
|
|
38
|
+
try:
|
|
39
|
+
client_a = loop_a.run_until_complete(grab())
|
|
40
|
+
client_b = loop_b.run_until_complete(grab())
|
|
41
|
+
# Same loop reused → same client; different loops → different clients.
|
|
42
|
+
client_a_again = loop_a.run_until_complete(grab())
|
|
43
|
+
assert client_a is client_a_again
|
|
44
|
+
assert client_a is not client_b
|
|
45
|
+
finally:
|
|
46
|
+
loop_a.close()
|
|
47
|
+
loop_b.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_get_redis_test_hook_overrides_per_loop_cache(fake_redis):
|
|
51
|
+
"""The conftest fixture monkeypatches ``_redis_client``; that hook
|
|
52
|
+
must short-circuit the per-loop logic so tests get the fakeredis
|
|
53
|
+
instance regardless of which loop they run in."""
|
|
54
|
+
a = get_redis()
|
|
55
|
+
b = get_redis()
|
|
56
|
+
assert a is b is fake_redis
|
|
57
|
+
|
|
58
|
+
|
|
24
59
|
@pytest.mark.asyncio
|
|
25
60
|
async def test_cache_stats_empty(fake_redis):
|
|
26
61
|
await reset_cache_stats()
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
import redis.asyncio as aioredis
|
|
4
|
-
|
|
5
|
-
REDIS_SERVER = os.environ['REDIS_SERVER']
|
|
6
|
-
REDIS_DB = int(os.environ['REDIS_DB'])
|
|
7
|
-
|
|
8
|
-
try:
|
|
9
|
-
from settings.env import REDIS_PREFIX
|
|
10
|
-
except ImportError:
|
|
11
|
-
REDIS_PREFIX = os.environ.get('REDIS_PREFIX', '')
|
|
12
|
-
|
|
13
|
-
KEY_PREFIX = f'{REDIS_PREFIX}:' if REDIS_PREFIX else ''
|
|
14
|
-
|
|
15
|
-
_redis_client = None
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_redis():
|
|
19
|
-
global _redis_client
|
|
20
|
-
if _redis_client is None:
|
|
21
|
-
_redis_client = aioredis.Redis(
|
|
22
|
-
host=REDIS_SERVER,
|
|
23
|
-
db=REDIS_DB,
|
|
24
|
-
decode_responses=True,
|
|
25
|
-
)
|
|
26
|
-
return _redis_client
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
async def get_cache_stats():
|
|
30
|
-
redis = get_redis()
|
|
31
|
-
hits = int(await redis.get(f'{KEY_PREFIX}cache_stats:hits') or 0)
|
|
32
|
-
misses = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses') or 0)
|
|
33
|
-
total = hits + misses
|
|
34
|
-
ratio = hits / total if total else 0.0
|
|
35
|
-
|
|
36
|
-
pattern = f'{KEY_PREFIX}cache_stats:hits:*'
|
|
37
|
-
by_model = {}
|
|
38
|
-
async for key in redis.scan_iter(match=pattern):
|
|
39
|
-
label = key.split(':hits:', 1)[1]
|
|
40
|
-
h = int(await redis.get(key) or 0)
|
|
41
|
-
m = int(await redis.get(f'{KEY_PREFIX}cache_stats:misses:{label}') or 0)
|
|
42
|
-
t = h + m
|
|
43
|
-
by_model[label] = {
|
|
44
|
-
'hits': h,
|
|
45
|
-
'misses': m,
|
|
46
|
-
'total': t,
|
|
47
|
-
'ratio': h / t if t else 0.0,
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
'hits': hits,
|
|
52
|
-
'misses': misses,
|
|
53
|
-
'total': total,
|
|
54
|
-
'ratio': ratio,
|
|
55
|
-
'by_model': by_model,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
async def reset_cache_stats():
|
|
60
|
-
redis = get_redis()
|
|
61
|
-
pattern = f'{KEY_PREFIX}cache_stats:*'
|
|
62
|
-
keys = [k async for k in redis.scan_iter(match=pattern)]
|
|
63
|
-
if keys:
|
|
64
|
-
await redis.delete(*keys)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|