django-api-usage 0.1.0__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.
- django_api_usage-0.1.0/LICENSE +21 -0
- django_api_usage-0.1.0/PKG-INFO +155 -0
- django_api_usage-0.1.0/README.md +116 -0
- django_api_usage-0.1.0/pyproject.toml +51 -0
- django_api_usage-0.1.0/setup.cfg +4 -0
- django_api_usage-0.1.0/src/django_api_usage/__init__.py +3 -0
- django_api_usage-0.1.0/src/django_api_usage/admin.py +56 -0
- django_api_usage-0.1.0/src/django_api_usage/apps.py +11 -0
- django_api_usage-0.1.0/src/django_api_usage/buffers.py +140 -0
- django_api_usage-0.1.0/src/django_api_usage/checks.py +80 -0
- django_api_usage-0.1.0/src/django_api_usage/conf.py +81 -0
- django_api_usage-0.1.0/src/django_api_usage/deprecation.py +34 -0
- django_api_usage-0.1.0/src/django_api_usage/drf.py +71 -0
- django_api_usage-0.1.0/src/django_api_usage/maintenance.py +21 -0
- django_api_usage-0.1.0/src/django_api_usage/management/__init__.py +0 -0
- django_api_usage-0.1.0/src/django_api_usage/management/commands/__init__.py +0 -0
- django_api_usage-0.1.0/src/django_api_usage/management/commands/api_usage_flush.py +27 -0
- django_api_usage-0.1.0/src/django_api_usage/management/commands/api_usage_report.py +60 -0
- django_api_usage-0.1.0/src/django_api_usage/middleware.py +60 -0
- django_api_usage-0.1.0/src/django_api_usage/migrations/0001_initial.py +118 -0
- django_api_usage-0.1.0/src/django_api_usage/migrations/__init__.py +0 -0
- django_api_usage-0.1.0/src/django_api_usage/models.py +95 -0
- django_api_usage-0.1.0/src/django_api_usage/resolvers.py +104 -0
- django_api_usage-0.1.0/src/django_api_usage/tasks.py +22 -0
- django_api_usage-0.1.0/src/django_api_usage.egg-info/PKG-INFO +155 -0
- django_api_usage-0.1.0/src/django_api_usage.egg-info/SOURCES.txt +32 -0
- django_api_usage-0.1.0/src/django_api_usage.egg-info/dependency_links.txt +1 -0
- django_api_usage-0.1.0/src/django_api_usage.egg-info/requires.txt +17 -0
- django_api_usage-0.1.0/src/django_api_usage.egg-info/top_level.txt +1 -0
- django_api_usage-0.1.0/tests/test_checks.py +29 -0
- django_api_usage-0.1.0/tests/test_commands.py +51 -0
- django_api_usage-0.1.0/tests/test_drf.py +60 -0
- django_api_usage-0.1.0/tests/test_middleware.py +45 -0
- django_api_usage-0.1.0/tests/test_models.py +35 -0
|
@@ -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,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,116 @@
|
|
|
1
|
+

|
|
2
|
+

|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
# django-api-usage
|
|
8
|
+
|
|
9
|
+
Lightweight, privacy-first **usage metering for Django APIs**, with an optional
|
|
10
|
+
**deprecation lifecycle** layer on top.
|
|
11
|
+
|
|
12
|
+
It answers two questions that decide whether an endpoint can be changed or removed:
|
|
13
|
+
|
|
14
|
+
1. **How much is this endpoint (or this whole Django app) used?**
|
|
15
|
+
2. **Who is calling it, so they can be contacted before it changes?**
|
|
16
|
+
|
|
17
|
+
The package is deliberately generic: the core is plain Django middleware (works
|
|
18
|
+
with or without Django REST Framework), it never breaks a request because
|
|
19
|
+
metering failed, and it stores no raw personal data by default.
|
|
20
|
+
|
|
21
|
+
## Why not just `drf-api-tracking`?
|
|
22
|
+
|
|
23
|
+
`drf-api-tracking` stores one database row per request (including bodies), which
|
|
24
|
+
is heavy and privacy-sensitive. `django-api-usage` accumulates **counters** and
|
|
25
|
+
keeps consumer attribution **hashed and opt-in**. That makes it suitable both for
|
|
26
|
+
continuous usage analytics and for the concrete decision of deprecating an
|
|
27
|
+
endpoint.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install django-api-usage
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
INSTALLED_APPS = [
|
|
37
|
+
# ...
|
|
38
|
+
"django_api_usage",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
MIDDLEWARE = [
|
|
42
|
+
# ...
|
|
43
|
+
"django_api_usage.middleware.ApiUsageMiddleware",
|
|
44
|
+
]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Metering starts immediately. Counters are buffered in the cache and written to
|
|
48
|
+
the database in batches:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python manage.py api_usage_flush # from cron, or a Celery beat task
|
|
52
|
+
python manage.py api_usage_report --days 30
|
|
53
|
+
python manage.py api_usage_report --days 90 --sunset-candidates
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
With Celery (and the `celery` extra) two tasks are provided:
|
|
57
|
+
`django_api_usage.tasks.flush_api_usage` and
|
|
58
|
+
`django_api_usage.tasks.prune_api_usage`.
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
API_USAGE = {
|
|
64
|
+
"ENABLED": True,
|
|
65
|
+
"FAIL_OPEN": True,
|
|
66
|
+
"BUFFER_BACKEND": "cache", # "cache" (batched) or "db" (write per request)
|
|
67
|
+
"RETENTION_DAYS": 90,
|
|
68
|
+
"TRACK_CONSUMERS": False, # opt-in; stores hashed callers only
|
|
69
|
+
"CONSUMER_SALT": "a-rotatable-secret",
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
All resolvers are overridable, so no project-specific knowledge leaks into the
|
|
74
|
+
package:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
API_USAGE = {
|
|
78
|
+
"APP_LABEL_RESOLVER": "myproject.api.resolvers.app_label",
|
|
79
|
+
"SITE_RESOLVER": "myproject.api.resolvers.site_id",
|
|
80
|
+
"ROLE_SCOPES_RESOLVER": "myproject.api.resolvers.role_scopes",
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Deprecation lifecycle
|
|
85
|
+
|
|
86
|
+
`Endpoint` carries `deprecated`, `sunset_date`, `replacement` and `owner`, so the
|
|
87
|
+
same data that measures usage also drives a deprecation plan. See the consuming
|
|
88
|
+
project's migration guide for the full workflow (measure → notify → enforce).
|
|
89
|
+
|
|
90
|
+
## DRF shadow mode
|
|
91
|
+
|
|
92
|
+
`django_api_usage.drf.HasAPIScope` enforces scopes declared on a view:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
class AllUserViewSet(viewsets.ReadOnlyModelViewSet):
|
|
96
|
+
permission_classes = [HasAPIScope]
|
|
97
|
+
required_scopes = ["user:read_all"]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
With `API_ENFORCEMENT = "report"` (the default) nothing is blocked yet: a
|
|
101
|
+
`X-Api-Would-Deny` header is returned instead, so you can deploy a permission and
|
|
102
|
+
measure what it *would* have denied before enforcing it.
|
|
103
|
+
|
|
104
|
+
## Status
|
|
105
|
+
|
|
106
|
+
**Alpha.** The core is implemented: metering middleware, models and migrations,
|
|
107
|
+
management commands, the deprecation layer and a DRF shadow-mode permission.
|
|
108
|
+
Tested on Python 3.10-3.12 and Django 4.2/5.2. MIT licensed.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m django test tests --settings=tests.settings
|
|
114
|
+
ruff check .
|
|
115
|
+
black --check .
|
|
116
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-api-usage"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight, privacy-first usage metering for Django APIs, with an optional deprecation lifecycle layer."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "CodeSyntax", email = "teknika@codesyntax.com" }]
|
|
14
|
+
keywords = ["django", "api", "usage", "metrics", "deprecation", "drf"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Django :: 4.2",
|
|
19
|
+
"Framework :: Django :: 5.0",
|
|
20
|
+
"Framework :: Django :: 5.1",
|
|
21
|
+
"Framework :: Django :: 5.2",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["Django>=4.2"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
drf = ["djangorestframework>=3.14"]
|
|
32
|
+
celery = ["celery>=5.2"]
|
|
33
|
+
redis = ["redis>=4.0"]
|
|
34
|
+
dev = ["pytest", "pytest-django", "djangorestframework>=3.14", "black==26.5.1", "ruff==0.16.10"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/codesyntax/django-api-usage"
|
|
38
|
+
Source = "https://github.com/codesyntax/django-api-usage"
|
|
39
|
+
Issues = "https://github.com/codesyntax/django-api-usage/issues"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.black]
|
|
45
|
+
line-length = 88
|
|
46
|
+
target-version = ["py310"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
50
|
+
pythonpath = ["src", "."]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -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]
|
|
@@ -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
|