django-api-usage 0.1.0__py3-none-any.whl
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.
- django_api_usage/__init__.py +3 -0
- django_api_usage/admin.py +56 -0
- django_api_usage/apps.py +11 -0
- django_api_usage/buffers.py +140 -0
- django_api_usage/checks.py +80 -0
- django_api_usage/conf.py +81 -0
- django_api_usage/deprecation.py +34 -0
- django_api_usage/drf.py +71 -0
- django_api_usage/maintenance.py +21 -0
- django_api_usage/management/__init__.py +0 -0
- django_api_usage/management/commands/__init__.py +0 -0
- django_api_usage/management/commands/api_usage_flush.py +27 -0
- django_api_usage/management/commands/api_usage_report.py +60 -0
- django_api_usage/middleware.py +60 -0
- django_api_usage/migrations/0001_initial.py +118 -0
- django_api_usage/migrations/__init__.py +0 -0
- django_api_usage/models.py +95 -0
- django_api_usage/resolvers.py +104 -0
- django_api_usage/tasks.py +22 -0
- django_api_usage-0.1.0.dist-info/METADATA +155 -0
- django_api_usage-0.1.0.dist-info/RECORD +24 -0
- django_api_usage-0.1.0.dist-info/WHEEL +5 -0
- django_api_usage-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_api_usage-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Read-only admin for usage counters, plus editable deprecation metadata."""
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
|
|
5
|
+
from .models import Consumer, Endpoint, EndpointStat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin.register(Endpoint)
|
|
9
|
+
class EndpointAdmin(admin.ModelAdmin):
|
|
10
|
+
list_display = (
|
|
11
|
+
"app_label",
|
|
12
|
+
"route_name",
|
|
13
|
+
"method",
|
|
14
|
+
"site_id",
|
|
15
|
+
"deprecated",
|
|
16
|
+
"sunset_date",
|
|
17
|
+
"replacement",
|
|
18
|
+
"owner",
|
|
19
|
+
)
|
|
20
|
+
list_filter = ("deprecated", "app_label", "method", "site_id")
|
|
21
|
+
search_fields = ("route_name", "replacement", "owner", "notes")
|
|
22
|
+
list_editable = ("deprecated", "sunset_date", "replacement", "owner")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@admin.register(EndpointStat)
|
|
26
|
+
class EndpointStatAdmin(admin.ModelAdmin):
|
|
27
|
+
list_display = ("date", "endpoint", "client_type", "status_class", "count")
|
|
28
|
+
list_filter = ("date", "client_type", "status_class")
|
|
29
|
+
list_select_related = ("endpoint",)
|
|
30
|
+
date_hierarchy = "date"
|
|
31
|
+
|
|
32
|
+
def has_add_permission(self, request):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def has_change_permission(self, request, obj=None):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@admin.register(Consumer)
|
|
40
|
+
class ConsumerAdmin(admin.ModelAdmin):
|
|
41
|
+
list_display = (
|
|
42
|
+
"kind",
|
|
43
|
+
"ref_short",
|
|
44
|
+
"user_agent_family",
|
|
45
|
+
"request_count",
|
|
46
|
+
"first_seen",
|
|
47
|
+
"last_seen",
|
|
48
|
+
"contact",
|
|
49
|
+
)
|
|
50
|
+
list_filter = ("kind",)
|
|
51
|
+
search_fields = ("contact", "notes")
|
|
52
|
+
list_editable = ("contact",)
|
|
53
|
+
|
|
54
|
+
@admin.display(description="Reference")
|
|
55
|
+
def ref_short(self, obj):
|
|
56
|
+
return obj.ref_hash[:12]
|
django_api_usage/apps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoApiUsageConfig(AppConfig):
|
|
5
|
+
name = "django_api_usage"
|
|
6
|
+
verbose_name = "Django API usage"
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Register system checks (middleware installed, consumer salt, ...).
|
|
11
|
+
from . import checks # noqa: F401
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Recording backends.
|
|
2
|
+
|
|
3
|
+
The default backend accumulates counters in a single cache key and flushes them
|
|
4
|
+
to the database in batches (``api_usage_flush``), so a request never pays a
|
|
5
|
+
database write. With ``FAIL_OPEN`` metering can never break a request.
|
|
6
|
+
|
|
7
|
+
Note: the cache backend trades perfect accuracy under high concurrency for
|
|
8
|
+
throughput. If you need per-request exactness, set ``BUFFER_BACKEND = "db"``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from django.core.cache import caches
|
|
15
|
+
from django.db import transaction
|
|
16
|
+
from django.db.models import F
|
|
17
|
+
from django.utils import timezone
|
|
18
|
+
|
|
19
|
+
from .conf import api_settings
|
|
20
|
+
from .models import Consumer, Endpoint, EndpointStat
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("django_api_usage")
|
|
23
|
+
|
|
24
|
+
# Order matters: it is how a buffered bucket is encoded into a cache key.
|
|
25
|
+
_FIELDS = ("site_id", "app_label", "route_name", "method", "client_type")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cache():
|
|
29
|
+
return caches[api_settings.CACHE_ALIAS]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _buffer_key():
|
|
33
|
+
return f"{api_settings.CACHE_PREFIX}:buffer"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def status_class(status_code):
|
|
37
|
+
"""``204`` -> ``2xx``."""
|
|
38
|
+
return f"{int(status_code) // 100}xx"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record_hit(dimensions, status_code, consumer=None):
|
|
42
|
+
"""Record a single hit.
|
|
43
|
+
|
|
44
|
+
``dimensions`` is a mapping with the keys in :data:`_FIELDS`.
|
|
45
|
+
"""
|
|
46
|
+
if api_settings.BUFFER_BACKEND == "db":
|
|
47
|
+
_write_to_db(dimensions, status_class(status_code), 1)
|
|
48
|
+
else:
|
|
49
|
+
_buffer(dimensions, status_class(status_code))
|
|
50
|
+
if consumer and api_settings.TRACK_CONSUMERS:
|
|
51
|
+
_touch_consumer(consumer)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _buffer(dimensions, status_code_class):
|
|
55
|
+
cache = _cache()
|
|
56
|
+
key = _buffer_key()
|
|
57
|
+
raw = cache.get(key)
|
|
58
|
+
counters = json.loads(raw) if raw else {}
|
|
59
|
+
bucket = "{}|{}".format(
|
|
60
|
+
"|".join(str(dimensions.get(field, "")) for field in _FIELDS),
|
|
61
|
+
status_code_class,
|
|
62
|
+
)
|
|
63
|
+
counters[bucket] = counters.get(bucket, 0) + 1
|
|
64
|
+
cache.set(key, json.dumps(counters), None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def flush():
|
|
68
|
+
"""Move buffered counters from the cache into the database.
|
|
69
|
+
|
|
70
|
+
Returns the number of counter buckets written.
|
|
71
|
+
"""
|
|
72
|
+
cache = _cache()
|
|
73
|
+
key = _buffer_key()
|
|
74
|
+
raw = cache.get(key)
|
|
75
|
+
if not raw:
|
|
76
|
+
return 0
|
|
77
|
+
cache.delete(key)
|
|
78
|
+
counters = json.loads(raw) if isinstance(raw, str) else raw
|
|
79
|
+
written = 0
|
|
80
|
+
for bucket, count in counters.items():
|
|
81
|
+
dimension_key, _, status_code_class = bucket.rpartition("|")
|
|
82
|
+
values = dimension_key.split("|")
|
|
83
|
+
if len(values) != len(_FIELDS):
|
|
84
|
+
logger.warning("django-api-usage: skipping malformed bucket %r", bucket)
|
|
85
|
+
continue
|
|
86
|
+
dimensions = dict(zip(_FIELDS, values))
|
|
87
|
+
dimensions["site_id"] = _as_int(dimensions.get("site_id"))
|
|
88
|
+
_write_to_db(dimensions, status_code_class, count)
|
|
89
|
+
written += 1
|
|
90
|
+
return written
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _as_int(value):
|
|
94
|
+
if value in (None, "", "None"):
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return int(value)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_to_db(dimensions, status_code_class, count):
|
|
103
|
+
with transaction.atomic():
|
|
104
|
+
endpoint, _ = Endpoint.objects.get_or_create(
|
|
105
|
+
site_id=dimensions.get("site_id"),
|
|
106
|
+
app_label=dimensions.get("app_label") or "unknown",
|
|
107
|
+
route_name=dimensions.get("route_name") or "unknown",
|
|
108
|
+
method=dimensions.get("method") or "GET",
|
|
109
|
+
)
|
|
110
|
+
stat, created = EndpointStat.objects.get_or_create(
|
|
111
|
+
endpoint=endpoint,
|
|
112
|
+
date=timezone.localdate(),
|
|
113
|
+
client_type=dimensions.get("client_type") or "anon",
|
|
114
|
+
status_class=status_code_class,
|
|
115
|
+
defaults={"count": count},
|
|
116
|
+
)
|
|
117
|
+
if not created:
|
|
118
|
+
EndpointStat.objects.filter(pk=stat.pk).update(count=F("count") + count)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _touch_consumer(consumer):
|
|
122
|
+
kind, ref_hash, user_agent_family = consumer
|
|
123
|
+
now = timezone.now()
|
|
124
|
+
obj, created = Consumer.objects.get_or_create(
|
|
125
|
+
kind=kind,
|
|
126
|
+
ref_hash=ref_hash,
|
|
127
|
+
defaults={
|
|
128
|
+
"user_agent_family": user_agent_family,
|
|
129
|
+
"first_seen": now,
|
|
130
|
+
"last_seen": now,
|
|
131
|
+
"request_count": 1,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
if created:
|
|
135
|
+
return
|
|
136
|
+
Consumer.objects.filter(pk=obj.pk).update(
|
|
137
|
+
last_seen=now,
|
|
138
|
+
request_count=F("request_count") + 1,
|
|
139
|
+
user_agent_family=user_agent_family,
|
|
140
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Django system checks: catch misconfiguration early."""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.checks import Warning, register
|
|
5
|
+
from django.utils.module_loading import import_string
|
|
6
|
+
|
|
7
|
+
from .conf import api_settings
|
|
8
|
+
|
|
9
|
+
W001 = Warning(
|
|
10
|
+
"django-api-usage is enabled but no subclass of "
|
|
11
|
+
"'django_api_usage.middleware.ApiUsageMiddleware' is in MIDDLEWARE; no usage "
|
|
12
|
+
"will be recorded.",
|
|
13
|
+
id="api_usage.W001",
|
|
14
|
+
)
|
|
15
|
+
W002 = Warning(
|
|
16
|
+
"API_USAGE['TRACK_CONSUMERS'] is enabled but API_USAGE['CONSUMER_SALT'] is empty; "
|
|
17
|
+
"SECRET_KEY will be used as the salt. Set an explicit, rotatable salt.",
|
|
18
|
+
id="api_usage.W002",
|
|
19
|
+
)
|
|
20
|
+
W003 = Warning(
|
|
21
|
+
"API_USAGE['BUFFER_BACKEND'] is 'cache' but the configured cache backend is "
|
|
22
|
+
"DummyCache, which stores nothing: no usage will be recorded. Configure a real "
|
|
23
|
+
"cache or set BUFFER_BACKEND='db'.",
|
|
24
|
+
id="api_usage.W003",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DUMMY_CACHE_BACKEND = "django.core.cache.backends.dummy.DummyCache"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _middleware_installed():
|
|
31
|
+
"""True when some MIDDLEWARE entry is ApiUsageMiddleware or a subclass.
|
|
32
|
+
|
|
33
|
+
Subclasses are accepted on purpose: projects are encouraged to subclass the
|
|
34
|
+
middleware to narrow what gets metered (for example, only ``/api/``).
|
|
35
|
+
"""
|
|
36
|
+
from .middleware import ApiUsageMiddleware
|
|
37
|
+
|
|
38
|
+
for entry in getattr(settings, "MIDDLEWARE", []) or []:
|
|
39
|
+
if not isinstance(entry, str):
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
obj = import_string(entry)
|
|
43
|
+
except Exception: # pragma: no cover - unresolvable entry
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(obj, type) and issubclass(obj, ApiUsageMiddleware):
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cache_backend_path():
|
|
51
|
+
"""Dotted path of the configured cache backend, or '' if unavailable."""
|
|
52
|
+
from django.core.cache import caches
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
backend = caches[api_settings.CACHE_ALIAS]
|
|
56
|
+
except Exception: # pragma: no cover - misconfigured CACHES
|
|
57
|
+
return ""
|
|
58
|
+
backend_class = type(backend)
|
|
59
|
+
return f"{backend_class.__module__}.{backend_class.__name__}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@register()
|
|
63
|
+
def api_usage_checks(app_configs, **kwargs):
|
|
64
|
+
errors = []
|
|
65
|
+
if not api_settings.ENABLED:
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
if not _middleware_installed():
|
|
69
|
+
errors.append(W001)
|
|
70
|
+
|
|
71
|
+
if api_settings.TRACK_CONSUMERS and not api_settings.CONSUMER_SALT:
|
|
72
|
+
errors.append(W002)
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
api_settings.BUFFER_BACKEND == "cache"
|
|
76
|
+
and _cache_backend_path() == DUMMY_CACHE_BACKEND
|
|
77
|
+
):
|
|
78
|
+
errors.append(W003)
|
|
79
|
+
|
|
80
|
+
return errors
|
django_api_usage/conf.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Configuration for django-api-usage.
|
|
2
|
+
|
|
3
|
+
Every option is read from the ``API_USAGE`` dictionary in Django settings::
|
|
4
|
+
|
|
5
|
+
API_USAGE = {
|
|
6
|
+
"ENABLED": True,
|
|
7
|
+
"TRACK_CONSUMERS": False,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
Resolver options accept either a dotted path to a callable or the callable
|
|
11
|
+
itself. Nothing in this module depends on the host project.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
# Master switch: when False the middleware becomes a no-op.
|
|
18
|
+
"ENABLED": True,
|
|
19
|
+
# Metering must never break a request.
|
|
20
|
+
"FAIL_OPEN": True,
|
|
21
|
+
# "cache" buffers counters in the cache and writes them in batches;
|
|
22
|
+
# "db" writes one aggregate update per request (simpler, slower).
|
|
23
|
+
"BUFFER_BACKEND": "cache",
|
|
24
|
+
"CACHE_ALIAS": "default",
|
|
25
|
+
"CACHE_PREFIX": "api_usage",
|
|
26
|
+
# Raw per-day aggregates older than this are pruned. 0 keeps them forever.
|
|
27
|
+
"RETENTION_DAYS": 90,
|
|
28
|
+
# Opt-in consumer attribution. Only salted hashes are stored; see the docs.
|
|
29
|
+
"TRACK_CONSUMERS": False,
|
|
30
|
+
# Salt used to hash consumer identifiers. Keep it secret and rotatable.
|
|
31
|
+
"CONSUMER_SALT": "",
|
|
32
|
+
# Resolvers (dotted path or callable).
|
|
33
|
+
"APP_LABEL_RESOLVER": "django_api_usage.resolvers.default_app_label",
|
|
34
|
+
"ROUTE_NAME_RESOLVER": "django_api_usage.resolvers.default_route_name",
|
|
35
|
+
"CLIENT_TYPE_RESOLVER": "django_api_usage.resolvers.default_client_type",
|
|
36
|
+
"SITE_RESOLVER": "django_api_usage.resolvers.default_site_id",
|
|
37
|
+
"CONSUMER_RESOLVER": "django_api_usage.resolvers.default_consumer",
|
|
38
|
+
"ROLE_SCOPES_RESOLVER": "django_api_usage.drf.default_role_scopes",
|
|
39
|
+
# Regexes matched against request.path; matching requests are not recorded.
|
|
40
|
+
"IGNORE_PATHS": (
|
|
41
|
+
r"^/static/",
|
|
42
|
+
r"^/media/",
|
|
43
|
+
r"^/favicon\.ico$",
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApiUsageSettings:
|
|
49
|
+
"""Lazy accessor over ``settings.API_USAGE`` with sane defaults."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, user_settings=None):
|
|
52
|
+
self._user = user_settings
|
|
53
|
+
self._resolved = {}
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def user_settings(self):
|
|
57
|
+
if self._user is None:
|
|
58
|
+
from django.conf import settings as django_settings
|
|
59
|
+
|
|
60
|
+
self._user = getattr(django_settings, "API_USAGE", {}) or {}
|
|
61
|
+
return self._user
|
|
62
|
+
|
|
63
|
+
def __getattr__(self, attr):
|
|
64
|
+
if attr not in DEFAULTS:
|
|
65
|
+
raise AttributeError(f"Invalid API_USAGE setting: {attr!r}")
|
|
66
|
+
if attr in self._resolved:
|
|
67
|
+
return self._resolved[attr]
|
|
68
|
+
value = self.user_settings.get(attr, DEFAULTS[attr])
|
|
69
|
+
if attr.endswith("_RESOLVER"):
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
value = import_string(value)
|
|
72
|
+
self._resolved[attr] = value
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
def reload(self):
|
|
76
|
+
"""Forget cached settings and resolved callables (used in tests)."""
|
|
77
|
+
self._user = None
|
|
78
|
+
self._resolved = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
api_settings = ApiUsageSettings()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Deprecation lifecycle helpers built on top of the usage counters."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.db.models import Sum
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from .models import Endpoint, EndpointStat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def usage(period_days=90, site_id=None):
|
|
12
|
+
"""Return ``[(endpoint, calls), ...]`` for every known endpoint."""
|
|
13
|
+
since = timezone.localdate() - timedelta(days=period_days)
|
|
14
|
+
endpoints = Endpoint.objects.all()
|
|
15
|
+
if site_id is not None:
|
|
16
|
+
endpoints = endpoints.filter(site_id=site_id)
|
|
17
|
+
totals = (
|
|
18
|
+
EndpointStat.objects.filter(date__gte=since)
|
|
19
|
+
.values("endpoint_id")
|
|
20
|
+
.annotate(total=Sum("count"))
|
|
21
|
+
)
|
|
22
|
+
by_id = {row["endpoint_id"]: row["total"] for row in totals}
|
|
23
|
+
return [(endpoint, by_id.get(endpoint.pk, 0)) for endpoint in endpoints]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def in_use(period_days=90, site_id=None):
|
|
27
|
+
"""Endpoints with traffic in the period, most used first."""
|
|
28
|
+
rows = [(ep, total) for ep, total in usage(period_days, site_id) if total > 0]
|
|
29
|
+
return sorted(rows, key=lambda row: row[1], reverse=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def candidates_for_deprecation(period_days=90, site_id=None):
|
|
33
|
+
"""Endpoints with no traffic in the period: the safe ones to deprecate."""
|
|
34
|
+
return [ep for ep, total in usage(period_days, site_id) if total == 0]
|
django_api_usage/drf.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Optional DRF integration: scope-based permissions with a shadow mode.
|
|
2
|
+
|
|
3
|
+
Importing this module requires ``djangorestframework`` to be installed.
|
|
4
|
+
|
|
5
|
+
The point of the shadow mode is to let a new permission be **deployed and
|
|
6
|
+
measured before it is enforced**: with ``API_ENFORCEMENT = "report"`` nothing is
|
|
7
|
+
blocked, and the middleware adds an ``X-Api-Would-Deny`` header so you can see
|
|
8
|
+
what the permission would have rejected.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
from rest_framework.permissions import BasePermission
|
|
13
|
+
|
|
14
|
+
from .conf import api_settings
|
|
15
|
+
|
|
16
|
+
REPORT = "report"
|
|
17
|
+
WARN = "warn"
|
|
18
|
+
ENFORCE = "enforce"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def enforcement_mode(view=None):
|
|
22
|
+
"""Resolve the enforcement mode, honouring per-view/per-endpoint overrides."""
|
|
23
|
+
overrides = getattr(settings, "API_ENFORCEMENT_OVERRIDES", {}) or {}
|
|
24
|
+
if view is not None:
|
|
25
|
+
key = getattr(view, "enforcement_key", None) or getattr(view, "basename", None)
|
|
26
|
+
if key and key in overrides:
|
|
27
|
+
return overrides[key]
|
|
28
|
+
return getattr(settings, "API_ENFORCEMENT", REPORT)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_role_scopes(user):
|
|
32
|
+
"""Scopes granted to a user.
|
|
33
|
+
|
|
34
|
+
Uses ``user.get_api_scopes()`` when the project exposes it, otherwise maps
|
|
35
|
+
Django groups named ``api:<scope>`` (e.g. ``api:content:write``).
|
|
36
|
+
"""
|
|
37
|
+
getter = getattr(user, "get_api_scopes", None)
|
|
38
|
+
if callable(getter):
|
|
39
|
+
return set(getter())
|
|
40
|
+
return {
|
|
41
|
+
group.name[4:] for group in user.groups.all() if group.name.startswith("api:")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def granted_scopes(request):
|
|
46
|
+
"""Scopes granted to the caller: client key scopes, or role-derived scopes."""
|
|
47
|
+
scopes = set()
|
|
48
|
+
api_key = getattr(request, "api_key", None)
|
|
49
|
+
if api_key is not None:
|
|
50
|
+
scopes.update(api_key.scopes.values_list("code", flat=True))
|
|
51
|
+
return scopes
|
|
52
|
+
user = getattr(request, "user", None)
|
|
53
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
54
|
+
scopes.update(api_settings.ROLE_SCOPES_RESOLVER(user))
|
|
55
|
+
return scopes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HasAPIScope(BasePermission):
|
|
59
|
+
"""Require the scopes declared in ``view.required_scopes``.
|
|
60
|
+
|
|
61
|
+
Views that declare no scopes are denied (fail closed) once enforcing.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def has_permission(self, request, view):
|
|
65
|
+
required = set(getattr(view, "required_scopes", ()) or ())
|
|
66
|
+
if required and required <= granted_scopes(request):
|
|
67
|
+
return True
|
|
68
|
+
if enforcement_mode(view) in (REPORT, WARN):
|
|
69
|
+
request._api_usage_would_deny = sorted(required)
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Maintenance helpers (retention/pruning)."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
|
|
7
|
+
from .conf import api_settings
|
|
8
|
+
from .models import EndpointStat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prune(retention_days=None):
|
|
12
|
+
"""Delete raw counters older than the retention window.
|
|
13
|
+
|
|
14
|
+
Returns the number of deleted rows. ``RETENTION_DAYS = 0`` disables pruning.
|
|
15
|
+
"""
|
|
16
|
+
days = api_settings.RETENTION_DAYS if retention_days is None else retention_days
|
|
17
|
+
if not days:
|
|
18
|
+
return 0
|
|
19
|
+
cutoff = timezone.localdate() - timedelta(days=days)
|
|
20
|
+
deleted, _ = EndpointStat.objects.filter(date__lt=cutoff).delete()
|
|
21
|
+
return deleted
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Flush buffered counters and apply the retention window.
|
|
2
|
+
|
|
3
|
+
Run from cron or a Celery beat schedule:
|
|
4
|
+
|
|
5
|
+
python manage.py api_usage_flush
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.core.management.base import BaseCommand
|
|
9
|
+
|
|
10
|
+
from django_api_usage.buffers import flush
|
|
11
|
+
from django_api_usage.maintenance import prune
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Command(BaseCommand):
|
|
15
|
+
help = "Move buffered counters to the database and prune old rows."
|
|
16
|
+
|
|
17
|
+
def add_arguments(self, parser):
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--no-prune", action="store_true", help="Skip the retention/pruning step."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def handle(self, *args, **options):
|
|
23
|
+
written = flush()
|
|
24
|
+
self.stdout.write(f"Flushed {written} counter bucket(s).")
|
|
25
|
+
if not options["no_prune"]:
|
|
26
|
+
deleted = prune()
|
|
27
|
+
self.stdout.write(f"Pruned {deleted} old stat row(s).")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Report API usage per endpoint and per application.
|
|
2
|
+
|
|
3
|
+
This is the command that feeds a deprecation decision:
|
|
4
|
+
|
|
5
|
+
python manage.py api_usage_report --days 90
|
|
6
|
+
python manage.py api_usage_report --days 90 --sunset-candidates
|
|
7
|
+
python manage.py api_usage_report --app gida
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from django.core.management.base import BaseCommand
|
|
11
|
+
|
|
12
|
+
from django_api_usage.deprecation import candidates_for_deprecation, in_use
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(BaseCommand):
|
|
16
|
+
help = "Report API usage per endpoint and app, to support deprecation decisions."
|
|
17
|
+
|
|
18
|
+
def add_arguments(self, parser):
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--days",
|
|
21
|
+
type=int,
|
|
22
|
+
default=90,
|
|
23
|
+
help="Look-back window in days (default: 90).",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--app", dest="app_label", default=None, help="Filter by app."
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--site", type=int, default=None, help="Filter by site id.")
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--sunset-candidates",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="List endpoints with no traffic in the window instead of usage.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def handle(self, *args, **options):
|
|
36
|
+
days = options["days"]
|
|
37
|
+
site_id = options["site"]
|
|
38
|
+
app_label = options["app_label"]
|
|
39
|
+
|
|
40
|
+
if options["sunset_candidates"]:
|
|
41
|
+
endpoints = candidates_for_deprecation(days, site_id)
|
|
42
|
+
if app_label:
|
|
43
|
+
endpoints = [ep for ep in endpoints if ep.app_label == app_label]
|
|
44
|
+
self.stdout.write(f"Endpoints without traffic in the last {days} day(s):")
|
|
45
|
+
for endpoint in endpoints:
|
|
46
|
+
self.stdout.write(
|
|
47
|
+
f" {endpoint.method} {endpoint.route_name} ({endpoint.app_label})"
|
|
48
|
+
)
|
|
49
|
+
self.stdout.write(f"Total: {len(endpoints)}")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
rows = in_use(days, site_id)
|
|
53
|
+
if app_label:
|
|
54
|
+
rows = [(ep, total) for ep, total in rows if ep.app_label == app_label]
|
|
55
|
+
self.stdout.write("{:<12} {:<40} {:>10}".format("APP", "ENDPOINT", "CALLS"))
|
|
56
|
+
for endpoint, total in rows:
|
|
57
|
+
self.stdout.write(
|
|
58
|
+
f"{endpoint.app_label:<12} {endpoint.route_name:<40} {total:>10}"
|
|
59
|
+
)
|
|
60
|
+
self.stdout.write(f"Endpoints with traffic: {len(rows)}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Fail-open middleware that meters every Django view, DRF or not."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .buffers import record_hit
|
|
7
|
+
from .conf import api_settings
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("django_api_usage")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiUsageMiddleware:
|
|
13
|
+
"""Record one aggregated hit per request and expose the shadow-mode header.
|
|
14
|
+
|
|
15
|
+
Placed anywhere in ``MIDDLEWARE``; it only needs the final response.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, get_response):
|
|
19
|
+
self.get_response = get_response
|
|
20
|
+
|
|
21
|
+
def __call__(self, request):
|
|
22
|
+
response = self.get_response(request)
|
|
23
|
+
if api_settings.ENABLED:
|
|
24
|
+
self._record(request, response)
|
|
25
|
+
self._add_shadow_header(request, response)
|
|
26
|
+
return response
|
|
27
|
+
|
|
28
|
+
def _record(self, request, response):
|
|
29
|
+
try:
|
|
30
|
+
if self._is_ignored(request):
|
|
31
|
+
return
|
|
32
|
+
dimensions = {
|
|
33
|
+
"site_id": api_settings.SITE_RESOLVER(request),
|
|
34
|
+
"app_label": api_settings.APP_LABEL_RESOLVER(request),
|
|
35
|
+
"route_name": api_settings.ROUTE_NAME_RESOLVER(request),
|
|
36
|
+
"method": request.method,
|
|
37
|
+
"client_type": api_settings.CLIENT_TYPE_RESOLVER(request),
|
|
38
|
+
}
|
|
39
|
+
consumer = None
|
|
40
|
+
if api_settings.TRACK_CONSUMERS:
|
|
41
|
+
consumer = api_settings.CONSUMER_RESOLVER(request)
|
|
42
|
+
record_hit(dimensions, response.status_code, consumer)
|
|
43
|
+
except Exception:
|
|
44
|
+
logger.exception("django-api-usage: could not record request")
|
|
45
|
+
if not api_settings.FAIL_OPEN:
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _add_shadow_header(request, response):
|
|
50
|
+
would_deny = getattr(request, "_api_usage_would_deny", None)
|
|
51
|
+
if would_deny:
|
|
52
|
+
response["X-Api-Would-Deny"] = ",".join(sorted(would_deny))
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _is_ignored(request):
|
|
56
|
+
path = request.path or ""
|
|
57
|
+
for pattern in api_settings.IGNORE_PATHS:
|
|
58
|
+
if re.search(pattern, path):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Generated by Django 4.2.30 on 2026-10-07 15:10
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import django.db.models.deletion
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name="Endpoint",
|
|
17
|
+
fields=[
|
|
18
|
+
(
|
|
19
|
+
"id",
|
|
20
|
+
models.BigAutoField(
|
|
21
|
+
auto_created=True,
|
|
22
|
+
primary_key=True,
|
|
23
|
+
serialize=False,
|
|
24
|
+
verbose_name="ID",
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
"site_id",
|
|
29
|
+
models.PositiveIntegerField(blank=True, db_index=True, null=True),
|
|
30
|
+
),
|
|
31
|
+
("app_label", models.CharField(db_index=True, max_length=64)),
|
|
32
|
+
("route_name", models.CharField(max_length=160)),
|
|
33
|
+
("method", models.CharField(max_length=8)),
|
|
34
|
+
("deprecated", models.BooleanField(default=False)),
|
|
35
|
+
("sunset_date", models.DateField(blank=True, null=True)),
|
|
36
|
+
("replacement", models.CharField(blank=True, max_length=160)),
|
|
37
|
+
("owner", models.CharField(blank=True, max_length=160)),
|
|
38
|
+
("notes", models.TextField(blank=True)),
|
|
39
|
+
],
|
|
40
|
+
options={
|
|
41
|
+
"ordering": ("app_label", "route_name", "method"),
|
|
42
|
+
"unique_together": {("site_id", "app_label", "route_name", "method")},
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
migrations.CreateModel(
|
|
46
|
+
name="Consumer",
|
|
47
|
+
fields=[
|
|
48
|
+
(
|
|
49
|
+
"id",
|
|
50
|
+
models.BigAutoField(
|
|
51
|
+
auto_created=True,
|
|
52
|
+
primary_key=True,
|
|
53
|
+
serialize=False,
|
|
54
|
+
verbose_name="ID",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
"kind",
|
|
59
|
+
models.CharField(
|
|
60
|
+
choices=[
|
|
61
|
+
("api_key", "API key"),
|
|
62
|
+
("user", "User"),
|
|
63
|
+
("ip_hash", "IP hash"),
|
|
64
|
+
],
|
|
65
|
+
max_length=16,
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
("ref_hash", models.CharField(db_index=True, max_length=64)),
|
|
69
|
+
("user_agent_family", models.CharField(blank=True, max_length=64)),
|
|
70
|
+
("first_seen", models.DateTimeField(default=django.utils.timezone.now)),
|
|
71
|
+
("last_seen", models.DateTimeField(default=django.utils.timezone.now)),
|
|
72
|
+
("request_count", models.PositiveIntegerField(default=0)),
|
|
73
|
+
("contact", models.CharField(blank=True, max_length=200)),
|
|
74
|
+
("notes", models.TextField(blank=True)),
|
|
75
|
+
],
|
|
76
|
+
options={
|
|
77
|
+
"ordering": ("-last_seen",),
|
|
78
|
+
"unique_together": {("kind", "ref_hash")},
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
migrations.CreateModel(
|
|
82
|
+
name="EndpointStat",
|
|
83
|
+
fields=[
|
|
84
|
+
(
|
|
85
|
+
"id",
|
|
86
|
+
models.BigAutoField(
|
|
87
|
+
auto_created=True,
|
|
88
|
+
primary_key=True,
|
|
89
|
+
serialize=False,
|
|
90
|
+
verbose_name="ID",
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
("date", models.DateField(db_index=True)),
|
|
94
|
+
("client_type", models.CharField(default="anon", max_length=16)),
|
|
95
|
+
("status_class", models.CharField(default="2xx", max_length=4)),
|
|
96
|
+
("count", models.PositiveIntegerField(default=0)),
|
|
97
|
+
(
|
|
98
|
+
"endpoint",
|
|
99
|
+
models.ForeignKey(
|
|
100
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
101
|
+
related_name="stats",
|
|
102
|
+
to="django_api_usage.endpoint",
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
],
|
|
106
|
+
options={
|
|
107
|
+
"ordering": ("-date",),
|
|
108
|
+
"indexes": [
|
|
109
|
+
models.Index(
|
|
110
|
+
fields=["date", "endpoint"], name="django_api__date_fd01a8_idx"
|
|
111
|
+
)
|
|
112
|
+
],
|
|
113
|
+
"unique_together": {
|
|
114
|
+
("endpoint", "date", "client_type", "status_class")
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Data model.
|
|
2
|
+
|
|
3
|
+
Two layers live here on purpose:
|
|
4
|
+
|
|
5
|
+
* **Usage metering** (:class:`Endpoint`, :class:`EndpointStat`) is the neutral
|
|
6
|
+
core: how much is each endpoint of each app used.
|
|
7
|
+
* **Deprecation lifecycle** (the extra fields on :class:`Endpoint`) is the
|
|
8
|
+
optional layer built on top of the same data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from django.db import models
|
|
12
|
+
from django.utils import timezone
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Endpoint(models.Model):
|
|
16
|
+
"""One distinct (site, app, route, method) combination that was called."""
|
|
17
|
+
|
|
18
|
+
# Plain integer instead of a FK to sites.Site: keeps the package usable in
|
|
19
|
+
# projects without django.contrib.sites.
|
|
20
|
+
site_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
|
|
21
|
+
app_label = models.CharField(max_length=64, db_index=True)
|
|
22
|
+
route_name = models.CharField(max_length=160)
|
|
23
|
+
method = models.CharField(max_length=8)
|
|
24
|
+
|
|
25
|
+
# Deprecation lifecycle (optional layer).
|
|
26
|
+
deprecated = models.BooleanField(default=False)
|
|
27
|
+
sunset_date = models.DateField(null=True, blank=True)
|
|
28
|
+
replacement = models.CharField(max_length=160, blank=True)
|
|
29
|
+
owner = models.CharField(max_length=160, blank=True)
|
|
30
|
+
notes = models.TextField(blank=True)
|
|
31
|
+
|
|
32
|
+
class Meta:
|
|
33
|
+
unique_together = ("site_id", "app_label", "route_name", "method")
|
|
34
|
+
ordering = ("app_label", "route_name", "method")
|
|
35
|
+
|
|
36
|
+
def __str__(self):
|
|
37
|
+
return f"{self.method} {self.route_name} ({self.app_label})"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_sunset(self):
|
|
41
|
+
"""True when the sunset date has been reached."""
|
|
42
|
+
return bool(self.sunset_date and self.sunset_date <= timezone.localdate())
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def days_until_sunset(self):
|
|
46
|
+
if not self.sunset_date:
|
|
47
|
+
return None
|
|
48
|
+
return (self.sunset_date - timezone.localdate()).days
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EndpointStat(models.Model):
|
|
52
|
+
"""Aggregated counter per endpoint, day, client type and status class."""
|
|
53
|
+
|
|
54
|
+
endpoint = models.ForeignKey(
|
|
55
|
+
Endpoint, on_delete=models.CASCADE, related_name="stats"
|
|
56
|
+
)
|
|
57
|
+
date = models.DateField(db_index=True)
|
|
58
|
+
client_type = models.CharField(max_length=16, default="anon")
|
|
59
|
+
status_class = models.CharField(max_length=4, default="2xx")
|
|
60
|
+
count = models.PositiveIntegerField(default=0)
|
|
61
|
+
|
|
62
|
+
class Meta:
|
|
63
|
+
unique_together = ("endpoint", "date", "client_type", "status_class")
|
|
64
|
+
ordering = ("-date",)
|
|
65
|
+
indexes = [models.Index(fields=["date", "endpoint"])]
|
|
66
|
+
|
|
67
|
+
def __str__(self):
|
|
68
|
+
return f"{self.date} {self.endpoint} {self.client_type} {self.count}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Consumer(models.Model):
|
|
72
|
+
"""An observed caller, identified only by a salted hash. Opt-in."""
|
|
73
|
+
|
|
74
|
+
KIND_CHOICES = (
|
|
75
|
+
("api_key", "API key"),
|
|
76
|
+
("user", "User"),
|
|
77
|
+
("ip_hash", "IP hash"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
kind = models.CharField(max_length=16, choices=KIND_CHOICES)
|
|
81
|
+
ref_hash = models.CharField(max_length=64, db_index=True)
|
|
82
|
+
user_agent_family = models.CharField(max_length=64, blank=True)
|
|
83
|
+
first_seen = models.DateTimeField(default=timezone.now)
|
|
84
|
+
last_seen = models.DateTimeField(default=timezone.now)
|
|
85
|
+
request_count = models.PositiveIntegerField(default=0)
|
|
86
|
+
# Manually maintained: who to contact before deprecating something.
|
|
87
|
+
contact = models.CharField(max_length=200, blank=True)
|
|
88
|
+
notes = models.TextField(blank=True)
|
|
89
|
+
|
|
90
|
+
class Meta:
|
|
91
|
+
unique_together = ("kind", "ref_hash")
|
|
92
|
+
ordering = ("-last_seen",)
|
|
93
|
+
|
|
94
|
+
def __str__(self):
|
|
95
|
+
return f"{self.kind} {self.ref_hash[:12]}"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Default resolvers describing a request.
|
|
2
|
+
|
|
3
|
+
Everything here is overridable from ``API_USAGE`` so the package stays free of
|
|
4
|
+
project-specific knowledge. Resolvers must be cheap: they run on every request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
|
|
9
|
+
from django.conf import settings as django_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _view_module(match):
|
|
13
|
+
"""Best-effort module name of the callable that handled the request."""
|
|
14
|
+
func = getattr(match, "func", None)
|
|
15
|
+
view_class = getattr(func, "cls", None) # class-based views and DRF viewsets
|
|
16
|
+
if view_class is not None:
|
|
17
|
+
return getattr(view_class, "__module__", "") or ""
|
|
18
|
+
return getattr(func, "__module__", "") or ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_app_label(request):
|
|
22
|
+
"""Return a short application label for the resolved view.
|
|
23
|
+
|
|
24
|
+
``tokikom.gida.views`` -> ``gida``, ``myproject.api.rest_views`` -> ``api``.
|
|
25
|
+
Override with ``API_USAGE["APP_LABEL_RESOLVER"]`` when your layout differs.
|
|
26
|
+
"""
|
|
27
|
+
match = getattr(request, "resolver_match", None)
|
|
28
|
+
if match is None:
|
|
29
|
+
return "unknown"
|
|
30
|
+
if getattr(match, "app_name", None):
|
|
31
|
+
return match.app_name
|
|
32
|
+
parts = [part for part in _view_module(match).split(".") if part]
|
|
33
|
+
if not parts:
|
|
34
|
+
return "unknown"
|
|
35
|
+
suffixes = {"views", "viewsets", "rest_views", "api"}
|
|
36
|
+
if len(parts) >= 2 and parts[-1] in suffixes:
|
|
37
|
+
return parts[-2]
|
|
38
|
+
return parts[-1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_route_name(request):
|
|
42
|
+
"""Return the named URL pattern, never the raw path (cardinality!)."""
|
|
43
|
+
match = getattr(request, "resolver_match", None)
|
|
44
|
+
if match is None:
|
|
45
|
+
return "unknown"
|
|
46
|
+
return (
|
|
47
|
+
getattr(match, "url_name", None) or getattr(match, "route", None) or "unknown"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_client_type(request):
|
|
52
|
+
"""Classify the caller as ``client``, ``user`` or ``anon``."""
|
|
53
|
+
if getattr(request, "api_client", None) is not None:
|
|
54
|
+
return "client"
|
|
55
|
+
user = getattr(request, "user", None)
|
|
56
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
57
|
+
return "user"
|
|
58
|
+
return "anon"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_site_id(request):
|
|
62
|
+
"""Return ``settings.SITE_ID`` as an int, or ``None`` when not configured."""
|
|
63
|
+
site_id = getattr(django_settings, "SITE_ID", None)
|
|
64
|
+
if site_id is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return int(site_id)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hash(value, salt):
|
|
73
|
+
return hashlib.sha256(f"{salt}:{value}".encode()).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _user_agent_family(request):
|
|
77
|
+
user_agent = request.META.get("HTTP_USER_AGENT", "") or ""
|
|
78
|
+
return user_agent.split(" ")[0][:64]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_consumer(request):
|
|
82
|
+
"""Return ``(kind, ref_hash, user_agent_family)`` or ``None``.
|
|
83
|
+
|
|
84
|
+
Raw identifiers are never stored: only a salted SHA-256 digest. Set
|
|
85
|
+
``API_USAGE["CONSUMER_SALT"]`` to a secret, rotatable value.
|
|
86
|
+
"""
|
|
87
|
+
from .conf import api_settings
|
|
88
|
+
|
|
89
|
+
salt = api_settings.CONSUMER_SALT or django_settings.SECRET_KEY
|
|
90
|
+
user_agent_family = _user_agent_family(request)
|
|
91
|
+
|
|
92
|
+
api_key = getattr(request, "api_key", None)
|
|
93
|
+
if api_key is not None:
|
|
94
|
+
return ("api_key", _hash(str(api_key.pk), salt), user_agent_family)
|
|
95
|
+
|
|
96
|
+
user = getattr(request, "user", None)
|
|
97
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
98
|
+
return ("user", _hash(str(user.pk), salt), user_agent_family)
|
|
99
|
+
|
|
100
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
101
|
+
ip = forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
|
|
102
|
+
if not ip:
|
|
103
|
+
return None
|
|
104
|
+
return ("ip_hash", _hash(ip, salt), user_agent_family)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Optional Celery tasks. Requires the ``celery`` extra."""
|
|
2
|
+
|
|
3
|
+
from .buffers import flush
|
|
4
|
+
from .maintenance import prune
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from celery import shared_task
|
|
8
|
+
except ImportError: # pragma: no cover - celery is an optional dependency
|
|
9
|
+
shared_task = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if shared_task is not None:
|
|
13
|
+
|
|
14
|
+
@shared_task
|
|
15
|
+
def flush_api_usage():
|
|
16
|
+
"""Move buffered counters to the database."""
|
|
17
|
+
return flush()
|
|
18
|
+
|
|
19
|
+
@shared_task
|
|
20
|
+
def prune_api_usage():
|
|
21
|
+
"""Apply the retention window to raw counters."""
|
|
22
|
+
return prune()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-api-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer.
|
|
5
|
+
Author-email: CodeSyntax <teknika@codesyntax.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/codesyntax/django-api-usage
|
|
8
|
+
Project-URL: Source, https://github.com/codesyntax/django-api-usage
|
|
9
|
+
Project-URL: Issues, https://github.com/codesyntax/django-api-usage/issues
|
|
10
|
+
Keywords: django,api,usage,metrics,deprecation,drf
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.0
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: Django>=4.2
|
|
26
|
+
Provides-Extra: drf
|
|
27
|
+
Requires-Dist: djangorestframework>=3.14; extra == "drf"
|
|
28
|
+
Provides-Extra: celery
|
|
29
|
+
Requires-Dist: celery>=5.2; extra == "celery"
|
|
30
|
+
Provides-Extra: redis
|
|
31
|
+
Requires-Dist: redis>=4.0; extra == "redis"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-django; extra == "dev"
|
|
35
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
36
|
+
Requires-Dist: black==26.5.1; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff==0.16.10; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+

|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
# django-api-usage
|
|
47
|
+
|
|
48
|
+
Lightweight, privacy-first **usage metering for Django APIs**, with an optional
|
|
49
|
+
**deprecation lifecycle** layer on top.
|
|
50
|
+
|
|
51
|
+
It answers two questions that decide whether an endpoint can be changed or removed:
|
|
52
|
+
|
|
53
|
+
1. **How much is this endpoint (or this whole Django app) used?**
|
|
54
|
+
2. **Who is calling it, so they can be contacted before it changes?**
|
|
55
|
+
|
|
56
|
+
The package is deliberately generic: the core is plain Django middleware (works
|
|
57
|
+
with or without Django REST Framework), it never breaks a request because
|
|
58
|
+
metering failed, and it stores no raw personal data by default.
|
|
59
|
+
|
|
60
|
+
## Why not just `drf-api-tracking`?
|
|
61
|
+
|
|
62
|
+
`drf-api-tracking` stores one database row per request (including bodies), which
|
|
63
|
+
is heavy and privacy-sensitive. `django-api-usage` accumulates **counters** and
|
|
64
|
+
keeps consumer attribution **hashed and opt-in**. That makes it suitable both for
|
|
65
|
+
continuous usage analytics and for the concrete decision of deprecating an
|
|
66
|
+
endpoint.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install django-api-usage
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
INSTALLED_APPS = [
|
|
76
|
+
# ...
|
|
77
|
+
"django_api_usage",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
MIDDLEWARE = [
|
|
81
|
+
# ...
|
|
82
|
+
"django_api_usage.middleware.ApiUsageMiddleware",
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Metering starts immediately. Counters are buffered in the cache and written to
|
|
87
|
+
the database in batches:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python manage.py api_usage_flush # from cron, or a Celery beat task
|
|
91
|
+
python manage.py api_usage_report --days 30
|
|
92
|
+
python manage.py api_usage_report --days 90 --sunset-candidates
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With Celery (and the `celery` extra) two tasks are provided:
|
|
96
|
+
`django_api_usage.tasks.flush_api_usage` and
|
|
97
|
+
`django_api_usage.tasks.prune_api_usage`.
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
API_USAGE = {
|
|
103
|
+
"ENABLED": True,
|
|
104
|
+
"FAIL_OPEN": True,
|
|
105
|
+
"BUFFER_BACKEND": "cache", # "cache" (batched) or "db" (write per request)
|
|
106
|
+
"RETENTION_DAYS": 90,
|
|
107
|
+
"TRACK_CONSUMERS": False, # opt-in; stores hashed callers only
|
|
108
|
+
"CONSUMER_SALT": "a-rotatable-secret",
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
All resolvers are overridable, so no project-specific knowledge leaks into the
|
|
113
|
+
package:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
API_USAGE = {
|
|
117
|
+
"APP_LABEL_RESOLVER": "myproject.api.resolvers.app_label",
|
|
118
|
+
"SITE_RESOLVER": "myproject.api.resolvers.site_id",
|
|
119
|
+
"ROLE_SCOPES_RESOLVER": "myproject.api.resolvers.role_scopes",
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Deprecation lifecycle
|
|
124
|
+
|
|
125
|
+
`Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
|
|
126
|
+
same data that measures usage also drives a deprecation plan. See the consuming
|
|
127
|
+
project's migration guide for the full workflow (measure → notify → enforce).
|
|
128
|
+
|
|
129
|
+
## DRF shadow mode
|
|
130
|
+
|
|
131
|
+
`django_api_usage.drf.HasAPIScope` enforces scopes declared on a view:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
class AllUserViewSet(viewsets.ReadOnlyModelViewSet):
|
|
135
|
+
permission_classes = [HasAPIScope]
|
|
136
|
+
required_scopes = ["user:read_all"]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
With `API_ENFORCEMENT = "report"` (the default) nothing is blocked yet: a
|
|
140
|
+
`X-Api-Would-Deny` header is returned instead, so you can deploy a permission and
|
|
141
|
+
measure what it *would* have denied before enforcing it.
|
|
142
|
+
|
|
143
|
+
## Status
|
|
144
|
+
|
|
145
|
+
**Alpha.** The core is implemented: metering middleware, models and migrations,
|
|
146
|
+
management commands, the deprecation layer and a DRF shadow-mode permission.
|
|
147
|
+
Tested on Python 3.10-3.12 and Django 4.2/5.2. MIT licensed.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
python -m django test tests --settings=tests.settings
|
|
153
|
+
ruff check .
|
|
154
|
+
black --check .
|
|
155
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
django_api_usage/__init__.py,sha256=Rnl9TTnoJUF0gwudzH71y7EEq2lqCnSLy0AoqP53S7M,105
|
|
2
|
+
django_api_usage/admin.py,sha256=Gs8bTO_4GJCtoRM6Jp94oGkC1Xivt_buz4wmEMxfqY0,1515
|
|
3
|
+
django_api_usage/apps.py,sha256=IP6SBrHpuyHuVvLwIjvgRhV8LAQxp82WkyLO2SRGYZI,342
|
|
4
|
+
django_api_usage/buffers.py,sha256=QInMzFZZHgstuVmSYklH38uLrsiIvD5IrOqM2qjNXQA,4292
|
|
5
|
+
django_api_usage/checks.py,sha256=HT4xVAXMx89faygH7WflfmCfLOMaxEFuPsh8tmriCbA,2509
|
|
6
|
+
django_api_usage/conf.py,sha256=yGBigS_pGqg-N2Ud-trHQWqXfVuAUcZlc5ZXJk3yja0,2872
|
|
7
|
+
django_api_usage/deprecation.py,sha256=e_QSzCL_jHMBcu4XIH4rrjUPqB8APsNhka_pKhZgC_g,1272
|
|
8
|
+
django_api_usage/drf.py,sha256=VucHwwQqA9ZA66XKWcIhfea8qy0q37aDyu2Z_HVI8RA,2499
|
|
9
|
+
django_api_usage/maintenance.py,sha256=z3--j65dWyNDJN5THkSDe3g5u5LcCptjHjfoEvsv8Mc,629
|
|
10
|
+
django_api_usage/middleware.py,sha256=pCunQ5dj8VIAc2PJSm2Ab5WhCuVKH4e7cRyjFUj26bI,2027
|
|
11
|
+
django_api_usage/models.py,sha256=skV4tumzZ2m1AC70TZzlR3t54TOB1-uVohDDg2kUESk,3429
|
|
12
|
+
django_api_usage/resolvers.py,sha256=euoj73k_fP0ypFc3a1OImdUX8c9Cds1TsVZ5X8rbqLI,3509
|
|
13
|
+
django_api_usage/tasks.py,sha256=gGUfvNvv_qvsKGcpER1Q7cGZ8vFodTxItT00w5-JVOY,536
|
|
14
|
+
django_api_usage/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
django_api_usage/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
django_api_usage/management/commands/api_usage_flush.py,sha256=51TVn5wiVdM3Vr1Li5pRcEiofpTFRAqrglpgTKxngaA,821
|
|
17
|
+
django_api_usage/management/commands/api_usage_report.py,sha256=fbiXCK2NlEhlE_8bdy7a_zfnTPqlddb885i7WZ2l4GI,2290
|
|
18
|
+
django_api_usage/migrations/0001_initial.py,sha256=0mt6BMYDmHedbQpdd_nChFWb7BxhdH6J1iaKmM6oniw,4432
|
|
19
|
+
django_api_usage/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
django_api_usage-0.1.0.dist-info/licenses/LICENSE,sha256=wtaPGB5yvwDIUqTgIMOnQDpMc4xjG7qnhvAtTPlEggM,1067
|
|
21
|
+
django_api_usage-0.1.0.dist-info/METADATA,sha256=gf2phpPt2fgiwjAQOnptStsOGpby32Ki3F_DcrnERj0,5291
|
|
22
|
+
django_api_usage-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
23
|
+
django_api_usage-0.1.0.dist-info/top_level.txt,sha256=M0Lfi57fatFaVtRXWLvyuHRbYp3UsnHy-f1Xqa-0z8A,17
|
|
24
|
+
django_api_usage-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodeSyntax
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_api_usage
|