django-ninja-aio-crud 0.10.2__py3-none-any.whl → 2.4.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.
Potentially problematic release.
This version of django-ninja-aio-crud might be problematic. Click here for more details.
- django_ninja_aio_crud-2.4.0.dist-info/METADATA +382 -0
- django_ninja_aio_crud-2.4.0.dist-info/RECORD +29 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/api.py +24 -2
- ninja_aio/auth.py +186 -4
- ninja_aio/decorators/__init__.py +23 -0
- ninja_aio/decorators/operations.py +9 -0
- ninja_aio/decorators/views.py +219 -0
- ninja_aio/exceptions.py +36 -1
- ninja_aio/factory/__init__.py +3 -0
- ninja_aio/factory/operations.py +296 -0
- ninja_aio/helpers/__init__.py +0 -0
- ninja_aio/helpers/api.py +506 -0
- ninja_aio/helpers/query.py +108 -0
- ninja_aio/models/__init__.py +4 -0
- ninja_aio/models/serializers.py +738 -0
- ninja_aio/models/utils.py +894 -0
- ninja_aio/renders.py +26 -26
- ninja_aio/schemas/__init__.py +23 -0
- ninja_aio/{schemas.py → schemas/api.py} +0 -5
- ninja_aio/schemas/generics.py +5 -0
- ninja_aio/schemas/helpers.py +170 -0
- ninja_aio/types.py +3 -1
- ninja_aio/views/__init__.py +3 -0
- ninja_aio/views/api.py +582 -0
- ninja_aio/views/mixins.py +275 -0
- django_ninja_aio_crud-0.10.2.dist-info/METADATA +0 -526
- django_ninja_aio_crud-0.10.2.dist-info/RECORD +0 -14
- ninja_aio/models.py +0 -549
- ninja_aio/views.py +0 -522
- {django_ninja_aio_crud-0.10.2.dist-info → django_ninja_aio_crud-2.4.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-0.10.2.dist-info → django_ninja_aio_crud-2.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-ninja-aio-crud
|
|
3
|
+
Version: 2.4.0
|
|
4
|
+
Summary: Django Ninja AIO CRUD - Rest Framework
|
|
5
|
+
Author: Giuseppe Casillo
|
|
6
|
+
Requires-Python: >=3.10, <3.15
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Internet
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Topic :: Software Development
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Framework :: Django
|
|
24
|
+
Classifier: Framework :: AsyncIO
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: django-ninja >=1.3.0, <1.6
|
|
29
|
+
Requires-Dist: joserfc >=1.0.0, <= 1.4.1
|
|
30
|
+
Requires-Dist: orjson >= 3.10.7, <= 3.11.5
|
|
31
|
+
Requires-Dist: coverage ; extra == "test"
|
|
32
|
+
Project-URL: Documentation, https://django-ninja-aio.com
|
|
33
|
+
Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
|
|
36
|
+
# 🥷 django-ninja-aio-crud
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
[](https://sonarcloud.io/summary/new_code?id=caspel26_django-ninja-aio-crud)
|
|
40
|
+
[](https://codecov.io/gh/caspel26/django-ninja-aio-crud/)
|
|
41
|
+
[](https://pypi.org/project/django-ninja-aio-crud/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](https://github.com/astral-sh/ruff)
|
|
44
|
+
|
|
45
|
+
> Lightweight async CRUD layer on top of **[Django Ninja](https://django-ninja.dev/)** with automatic schema generation, filtering, pagination, auth & Many‑to‑Many management.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## ✨ Features
|
|
50
|
+
|
|
51
|
+
- Async CRUD ViewSets (create, list, retrieve, update, delete)
|
|
52
|
+
- Automatic Pydantic schemas from `ModelSerializer` (read/create/update)
|
|
53
|
+
- Dynamic query params (runtime schema via `pydantic.create_model`)
|
|
54
|
+
- Per-method authentication (`auth`, `get_auth`, `post_auth`, etc.)
|
|
55
|
+
- Async pagination (customizable)
|
|
56
|
+
- M2M relation endpoints via `M2MRelationSchema` (add/remove/get + filters)
|
|
57
|
+
- Reverse relation serialization
|
|
58
|
+
- Hook methods (`query_params_handler`, `<related>_query_params_handler`, `custom_actions`, lifecycle hooks)
|
|
59
|
+
- ORJSON renderer through `NinjaAIO`
|
|
60
|
+
- Clean, minimal integration
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 📦 Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install django-ninja-aio-crud
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add to your project’s dependencies and ensure Django Ninja is installed.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 🚀 Quick Start
|
|
75
|
+
|
|
76
|
+
models.py
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from django.db import models
|
|
80
|
+
from ninja_aio.models import ModelSerializer
|
|
81
|
+
|
|
82
|
+
class Book(ModelSerializer):
|
|
83
|
+
title = models.CharField(max_length=120)
|
|
84
|
+
published = models.BooleanField(default=True)
|
|
85
|
+
|
|
86
|
+
class ReadSerializer:
|
|
87
|
+
fields = ["id", "title", "published"]
|
|
88
|
+
|
|
89
|
+
class CreateSerializer:
|
|
90
|
+
fields = ["title", "published"]
|
|
91
|
+
|
|
92
|
+
class UpdateSerializer:
|
|
93
|
+
optionals = [("title", str), ("published", bool)]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
views.py
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from ninja_aio import NinjaAIO
|
|
100
|
+
from ninja_aio.views import APIViewSet
|
|
101
|
+
from .models import Book
|
|
102
|
+
|
|
103
|
+
api = NinjaAIO()
|
|
104
|
+
|
|
105
|
+
@api.viewset(Book)
|
|
106
|
+
class BookViewSet(APIViewSet):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Visit `/docs` → CRUD endpoints ready.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 🔄 Query Filtering
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
@api.viewset(Book)
|
|
119
|
+
class BookViewSet(APIViewSet):
|
|
120
|
+
query_params = {"published": (bool, None), "title": (str, None)}
|
|
121
|
+
|
|
122
|
+
async def query_params_handler(self, queryset, filters):
|
|
123
|
+
if filters.get("published") is not None:
|
|
124
|
+
queryset = queryset.filter(published=filters["published"])
|
|
125
|
+
if filters.get("title"):
|
|
126
|
+
queryset = queryset.filter(title__icontains=filters["title"])
|
|
127
|
+
return queryset
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Request:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
GET /book/?published=true&title=python
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 🤝 Many-to-Many Example (with filters)
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from ninja_aio.schemas import M2MRelationSchema
|
|
142
|
+
|
|
143
|
+
class Tag(ModelSerializer):
|
|
144
|
+
name = models.CharField(max_length=50)
|
|
145
|
+
class ReadSerializer:
|
|
146
|
+
fields = ["id", "name"]
|
|
147
|
+
|
|
148
|
+
class Article(ModelSerializer):
|
|
149
|
+
title = models.CharField(max_length=120)
|
|
150
|
+
tags = models.ManyToManyField(Tag, related_name="articles")
|
|
151
|
+
|
|
152
|
+
class ReadSerializer:
|
|
153
|
+
fields = ["id", "title", "tags"]
|
|
154
|
+
|
|
155
|
+
@api.viewset(Article)
|
|
156
|
+
class ArticleViewSet(APIViewSet):
|
|
157
|
+
m2m_relations = [
|
|
158
|
+
M2MRelationSchema(
|
|
159
|
+
model=Tag,
|
|
160
|
+
related_name="tags",
|
|
161
|
+
filters={"name": (str, "")}
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
async def tags_query_params_handler(self, queryset, filters):
|
|
166
|
+
n = filters.get("name")
|
|
167
|
+
if n:
|
|
168
|
+
queryset = queryset.filter(name__icontains=n)
|
|
169
|
+
return queryset
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Endpoints:
|
|
174
|
+
|
|
175
|
+
- `GET /article/{pk}/tag?name=dev`
|
|
176
|
+
- `POST /article/{pk}/tag/` body: `{"add":[1,2],"remove":[3]}`
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 🔐 Authentication (JWT example)
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from ninja_aio.auth import AsyncJwtBearer
|
|
184
|
+
from joserfc import jwk
|
|
185
|
+
from .models import Book
|
|
186
|
+
|
|
187
|
+
PUBLIC_KEY = "-----BEGIN PUBLIC KEY----- ..."
|
|
188
|
+
|
|
189
|
+
class JWTAuth(AsyncJwtBearer):
|
|
190
|
+
jwt_public = jwk.RSAKey.import_key(PUBLIC_KEY)
|
|
191
|
+
jwt_alg = "RS256"
|
|
192
|
+
claims = {"sub": {"essential": True}}
|
|
193
|
+
|
|
194
|
+
async def auth_handler(self, request):
|
|
195
|
+
book_id = self.dcd.claims.get("sub")
|
|
196
|
+
return await Book.objects.aget(id=book_id)
|
|
197
|
+
|
|
198
|
+
@api.viewset(Book)
|
|
199
|
+
class SecureBookViewSet(APIViewSet):
|
|
200
|
+
auth = [JWTAuth()]
|
|
201
|
+
get_auth = None # list/retrieve public
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 📑 Lifecycle Hooks (ModelSerializer)
|
|
207
|
+
|
|
208
|
+
Available on every save/delete:
|
|
209
|
+
|
|
210
|
+
- `on_create_before_save`
|
|
211
|
+
- `on_create_after_save`
|
|
212
|
+
- `before_save`
|
|
213
|
+
- `after_save`
|
|
214
|
+
- `on_delete`
|
|
215
|
+
- `custom_actions(payload)` (create/update custom field logic)
|
|
216
|
+
- `post_create()` (after create commit)
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 🧩 Adding Custom Endpoints
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from ninja_aio.decorators import api_get
|
|
224
|
+
|
|
225
|
+
@api.viewset(Book)
|
|
226
|
+
class BookViewSet(APIViewSet):
|
|
227
|
+
@api_get("/stats/")
|
|
228
|
+
async def stats(self, request):
|
|
229
|
+
total = await Book.objects.acount()
|
|
230
|
+
return {"total": total}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Or
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
@api.viewset(Book)
|
|
237
|
+
class BookViewSet(APIViewSet):
|
|
238
|
+
def views(self):
|
|
239
|
+
@self.router.get("/stats/")
|
|
240
|
+
async def stats(request):
|
|
241
|
+
total = await Book.objects.acount()
|
|
242
|
+
return {"total": total}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 📄 Pagination
|
|
248
|
+
|
|
249
|
+
Default: `PageNumberPagination`. Override:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from ninja.pagination import PageNumberPagination
|
|
253
|
+
|
|
254
|
+
class LargePagination(PageNumberPagination):
|
|
255
|
+
page_size = 50
|
|
256
|
+
max_page_size = 200
|
|
257
|
+
|
|
258
|
+
@api.viewset(Book)
|
|
259
|
+
class BookViewSet(APIViewSet):
|
|
260
|
+
pagination_class = LargePagination
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Meta-driven Serializer (for vanilla Django models)
|
|
266
|
+
|
|
267
|
+
If you already have Django models and don't want to inherit from ModelSerializer, use the Meta-driven Serializer to generate dynamic schemas and integrate with APIViewSet.
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
from ninja_aio.models import serializers
|
|
273
|
+
from . import models
|
|
274
|
+
|
|
275
|
+
class BookSerializer(serializers.Serializer):
|
|
276
|
+
class Meta:
|
|
277
|
+
model = models.Book
|
|
278
|
+
schema_in = serializers.SchemaModelConfig(fields=["title", "published"])
|
|
279
|
+
schema_out = serializers.SchemaModelConfig(fields=["id", "title", "published"])
|
|
280
|
+
schema_update = serializers.SchemaModelConfig(optionals=[("title", str), ("published", bool)])
|
|
281
|
+
|
|
282
|
+
@api.viewset(models.Book)
|
|
283
|
+
class BookViewSet(APIViewSet):
|
|
284
|
+
serializer_class = BookSerializer
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- Works without modifying existing models
|
|
288
|
+
- Supports nested relations via relations_serializers
|
|
289
|
+
- APIViewSet will auto-generate missing schemas from the serializer
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## 🛠 Project Structure & Docs
|
|
294
|
+
|
|
295
|
+
Documentation (MkDocs + Material):
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
docs/
|
|
299
|
+
getting_started/
|
|
300
|
+
tutorial/
|
|
301
|
+
api/
|
|
302
|
+
views/
|
|
303
|
+
models/
|
|
304
|
+
authentication.md
|
|
305
|
+
pagination.md
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Browse full reference:
|
|
309
|
+
|
|
310
|
+
- APIViewSet: `docs/api/views/api_view_set.md`
|
|
311
|
+
- APIView: `docs/api/views/api_view.md`
|
|
312
|
+
- ModelSerializer: `docs/api/models/model_serializer.md`
|
|
313
|
+
- Authentication: `docs/api/authentication.md`
|
|
314
|
+
- Example repository: https://github.com/caspel26/ninja-aio-blog-example
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 🧪 Tests
|
|
319
|
+
|
|
320
|
+
Use Django test runner + async ORM patterns. Example async pattern:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
obj = await Book.objects.acreate(title="T1", published=True)
|
|
324
|
+
count = await Book.objects.acount()
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 🚫 Disable Operations
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
@api.viewset(Book)
|
|
333
|
+
class ReadOnlyBookViewSet(APIViewSet):
|
|
334
|
+
disable = ["update", "delete"]
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## 📌 Performance Tips
|
|
340
|
+
|
|
341
|
+
- Use `queryset_request` classmethod to prefetch
|
|
342
|
+
- Index frequently filtered fields
|
|
343
|
+
- Keep pagination enabled
|
|
344
|
+
- Limit slices (`queryset = queryset[:1000]`) for heavy searches
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## 🤲 Contributing
|
|
349
|
+
|
|
350
|
+
1. Fork
|
|
351
|
+
2. Create branch
|
|
352
|
+
3. Add tests
|
|
353
|
+
4. Run lint (`ruff check .`)
|
|
354
|
+
5. Open PR
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## ⭐ Support
|
|
359
|
+
|
|
360
|
+
Star the repo or donate:
|
|
361
|
+
|
|
362
|
+
- [Buy me a coffee](https://buymeacoffee.com/caspel26)
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## 📜 License
|
|
367
|
+
|
|
368
|
+
MIT License. See [LICENSE](LICENSE).
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 🔗 Quick Links
|
|
373
|
+
|
|
374
|
+
| Item | Link |
|
|
375
|
+
| ------- | -------------------------------------------------------- |
|
|
376
|
+
| PyPI | https://pypi.org/project/django-ninja-aio-crud/ |
|
|
377
|
+
| Docs | https://django-ninja-aio.com |
|
|
378
|
+
| Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
|
|
379
|
+
| Example | https://github.com/caspel26/ninja-aio-blog-example |
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
ninja_aio/__init__.py,sha256=Ms2GNzuORIvMqPlxF2wzxTHgoQBL_OpaeqByF4LtwhI,119
|
|
2
|
+
ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
|
|
3
|
+
ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
|
|
4
|
+
ninja_aio/exceptions.py,sha256=_3xFqfFCOfrrMhSA0xbMqgXy8R0UQjhXaExrFvaDAjY,3891
|
|
5
|
+
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
6
|
+
ninja_aio/renders.py,sha256=89g46NWUT8nmDG-rG0nxUYbAQWhuXcYKrPh7e1r_Fc4,1735
|
|
7
|
+
ninja_aio/types.py,sha256=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
|
|
8
|
+
ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
|
|
9
|
+
ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
|
|
10
|
+
ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
|
|
11
|
+
ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
|
|
12
|
+
ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
|
|
13
|
+
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
ninja_aio/helpers/api.py,sha256=YMzuZ4-ZpUrJBQIabE26gb_GYwsH2rVosWRE95YfdPQ,20775
|
|
15
|
+
ninja_aio/helpers/query.py,sha256=YJMdEonCuqx1XjmszCK74mg5hcUPh84ynXrsuoSQdNA,4519
|
|
16
|
+
ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
|
|
17
|
+
ninja_aio/models/serializers.py,sha256=wFEG6QrOPN4TiDEIRkOxExIKkjAQ0I432czaELZZxdI,25110
|
|
18
|
+
ninja_aio/models/utils.py,sha256=PkvdByNuZKnTzQhdkbqZuG6CEIMIZTCO4QkCUgvqBjs,28776
|
|
19
|
+
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
20
|
+
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
21
|
+
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
22
|
+
ninja_aio/schemas/helpers.py,sha256=W6IeHi5Tmbjh3FXwDYqjqlLBTVj5uTYq3_JVkNUWayo,7355
|
|
23
|
+
ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
|
|
24
|
+
ninja_aio/views/api.py,sha256=AQWtu8oI9R0blk-PW8BpwXLuejj1Z8vqpoNqlU_WrGs,20953
|
|
25
|
+
ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
|
|
26
|
+
django_ninja_aio_crud-2.4.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
27
|
+
django_ninja_aio_crud-2.4.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
28
|
+
django_ninja_aio_crud-2.4.0.dist-info/METADATA,sha256=qRTfA6me4WsclgqfdyVKL7nPOfv3rRasiLNQdVUeZpM,9988
|
|
29
|
+
django_ninja_aio_crud-2.4.0.dist-info/RECORD,,
|
ninja_aio/__init__.py
CHANGED
ninja_aio/api.py
CHANGED
|
@@ -5,10 +5,13 @@ from ninja.throttling import BaseThrottle
|
|
|
5
5
|
from ninja import NinjaAPI
|
|
6
6
|
from ninja.openapi.docs import DocsBase, Swagger
|
|
7
7
|
from ninja.constants import NOT_SET, NOT_SET_TYPE
|
|
8
|
+
from django.db import models
|
|
8
9
|
|
|
9
10
|
from .parsers import ORJSONParser
|
|
10
11
|
from .renders import ORJSONRenderer
|
|
11
12
|
from .exceptions import set_api_exception_handlers
|
|
13
|
+
from .views import APIView, APIViewSet
|
|
14
|
+
from .models import ModelSerializer
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class NinjaAIO(NinjaAPI):
|
|
@@ -23,7 +26,6 @@ class NinjaAIO(NinjaAPI):
|
|
|
23
26
|
docs_decorator=None,
|
|
24
27
|
servers: list[dict[str, Any]] | None = None,
|
|
25
28
|
urls_namespace: str | None = None,
|
|
26
|
-
csrf: bool = False,
|
|
27
29
|
auth: Sequence[Any] | NOT_SET_TYPE = NOT_SET,
|
|
28
30
|
throttle: BaseThrottle | list[BaseThrottle] | NOT_SET_TYPE = NOT_SET,
|
|
29
31
|
default_router: Router | None = None,
|
|
@@ -39,7 +41,6 @@ class NinjaAIO(NinjaAPI):
|
|
|
39
41
|
docs_decorator=docs_decorator,
|
|
40
42
|
servers=servers,
|
|
41
43
|
urls_namespace=urls_namespace,
|
|
42
|
-
csrf=csrf,
|
|
43
44
|
auth=auth,
|
|
44
45
|
throttle=throttle,
|
|
45
46
|
default_router=default_router,
|
|
@@ -51,3 +52,24 @@ class NinjaAIO(NinjaAPI):
|
|
|
51
52
|
def set_default_exception_handlers(self):
|
|
52
53
|
set_api_exception_handlers(self)
|
|
53
54
|
super().set_default_exception_handlers()
|
|
55
|
+
|
|
56
|
+
def view(self, prefix: str, tags: list[str] = None) -> Any:
|
|
57
|
+
def wrapper(view: type[APIView]):
|
|
58
|
+
instance = view(api=self, prefix=prefix, tags=tags)
|
|
59
|
+
instance.add_views_to_route()
|
|
60
|
+
return instance
|
|
61
|
+
|
|
62
|
+
return wrapper
|
|
63
|
+
|
|
64
|
+
def viewset(
|
|
65
|
+
self,
|
|
66
|
+
model: models.Model | ModelSerializer,
|
|
67
|
+
prefix: str = None,
|
|
68
|
+
tags: list[str] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
def wrapper(viewset: type[APIViewSet]):
|
|
71
|
+
instance = viewset(api=self, model=model, prefix=prefix, tags=tags)
|
|
72
|
+
instance.add_views_to_route()
|
|
73
|
+
return instance
|
|
74
|
+
|
|
75
|
+
return wrapper
|
ninja_aio/auth.py
CHANGED
|
@@ -1,12 +1,88 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
1
4
|
from joserfc import jwt, jwk, errors
|
|
2
5
|
from django.http.request import HttpRequest
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
from django.conf import settings
|
|
3
8
|
from ninja.security.http import HttpBearer
|
|
4
9
|
|
|
5
|
-
from .
|
|
10
|
+
from ninja_aio.types import JwtKeys
|
|
11
|
+
|
|
12
|
+
JWT_MANDATORY_CLAIMS = [
|
|
13
|
+
("iss", "JWT_ISSUER"),
|
|
14
|
+
("aud", "JWT_AUDIENCE"),
|
|
15
|
+
]
|
|
6
16
|
|
|
7
17
|
|
|
8
18
|
class AsyncJwtBearer(HttpBearer):
|
|
9
|
-
|
|
19
|
+
"""
|
|
20
|
+
AsyncJwtBearer provides asynchronous JWT-based authentication for Django Ninja endpoints
|
|
21
|
+
using HTTP Bearer tokens. It decodes and validates JWTs against a configured public key
|
|
22
|
+
and claim registry, then delegates user retrieval to an overridable async handler.
|
|
23
|
+
Attributes:
|
|
24
|
+
jwt_public (jwk.RSAKey | jwk.ECKey):
|
|
25
|
+
The public key (JWK format) used to verify the JWT signature.
|
|
26
|
+
Must be set externally before authentication occurs.
|
|
27
|
+
claims (dict[str, dict]):
|
|
28
|
+
A mapping defining expected JWT claims passed to jwt.JWTClaimsRegistry.
|
|
29
|
+
Each key corresponds to a claim name; values configure validation rules
|
|
30
|
+
(e.g., {'iss': {'value': 'https://issuer.example'}}).
|
|
31
|
+
algorithms (list[str]):
|
|
32
|
+
List of permitted JWT algorithms for signature verification. Defaults to ["RS256"].
|
|
33
|
+
dcd (jwt.Token | None):
|
|
34
|
+
Set after successful decode; holds the decoded token object (assigned dynamically).
|
|
35
|
+
Class Methods:
|
|
36
|
+
get_claims() -> jwt.JWTClaimsRegistry:
|
|
37
|
+
Constructs and returns a claims registry from the class-level claims definition.
|
|
38
|
+
Instance Methods:
|
|
39
|
+
validate_claims(claims: jwt.Claims) -> None:
|
|
40
|
+
Validates the provided claims object against the registry. Raises jose.errors.JoseError
|
|
41
|
+
or ValueError-derived exceptions if validation fails.
|
|
42
|
+
auth_handler(request: HttpRequest) -> Any:
|
|
43
|
+
Asynchronous hook to be overridden by subclasses to implement application-specific
|
|
44
|
+
user resolution (e.g., fetching a user model instance). Must return a user-like object
|
|
45
|
+
on success or raise / return False on failure.
|
|
46
|
+
authenticate(request: HttpRequest, token: str) -> Any | bool:
|
|
47
|
+
Orchestrates authentication:
|
|
48
|
+
1. Attempts to decode the JWT using the configured public key and algorithms.
|
|
49
|
+
2. Validates claims via validate_claims.
|
|
50
|
+
3. Delegates to auth_handler for domain-specific user retrieval.
|
|
51
|
+
Returns the user object on success; returns False if decoding or claim validation fails.
|
|
52
|
+
Usage Notes:
|
|
53
|
+
- You must assign jwt_public (jwk.RSAKey) and populate claims before calling authenticate.
|
|
54
|
+
- Override auth_handler to integrate with your user persistence layer.
|
|
55
|
+
- Token decoding failures (e.g., signature mismatch, malformed token) result in False.
|
|
56
|
+
- Claim validation errors (e.g., expired token, issuer mismatch) result in False.
|
|
57
|
+
- This class does not itself raise HTTP errors; caller may translate False into an HTTP response.
|
|
58
|
+
Example Extension:
|
|
59
|
+
class MyBearer(AsyncJwtBearer):
|
|
60
|
+
jwt_public = jwk.RSAKey.import_key(open("pub.pem").read())
|
|
61
|
+
claims = {
|
|
62
|
+
"iss": {"value": "https://auth.example"},
|
|
63
|
+
"aud": {"value": "my-api"},
|
|
64
|
+
}
|
|
65
|
+
async def auth_handler(self, request):
|
|
66
|
+
sub = self.dcd.claims.get("sub")
|
|
67
|
+
return await get_user_by_id(sub)
|
|
68
|
+
Thread Safety:
|
|
69
|
+
- Instances are not inherently thread-safe if mutable shared state is attached.
|
|
70
|
+
- Prefer per-request instantiation or ensure read-only shared configuration.
|
|
71
|
+
Security Considerations:
|
|
72
|
+
- Ensure jwt_public key rotation strategy is in place.
|
|
73
|
+
- Validate critical claims (exp, nbf, iss, aud) via the claims registry configuration.
|
|
74
|
+
- Avoid logging raw tokens or sensitive claim contents.
|
|
75
|
+
Raises:
|
|
76
|
+
jose.errors.JoseError:
|
|
77
|
+
Propagated from validate_claims if claim checks fail.
|
|
78
|
+
ValueError:
|
|
79
|
+
May occur during token decoding (e.g., invalid structure) but is internally caught
|
|
80
|
+
and converted to a False return value.
|
|
81
|
+
Return Semantics:
|
|
82
|
+
- authenticate -> user object (success) | False (failure)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
jwt_public: JwtKeys
|
|
10
86
|
claims: dict[str, dict]
|
|
11
87
|
algorithms: list[str] = ["RS256"]
|
|
12
88
|
|
|
@@ -31,13 +107,119 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
31
107
|
"""
|
|
32
108
|
try:
|
|
33
109
|
self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
|
|
34
|
-
except ValueError
|
|
110
|
+
except ValueError:
|
|
35
111
|
# raise AuthError(", ".join(exc.args), 401)
|
|
36
112
|
return False
|
|
37
113
|
|
|
38
114
|
try:
|
|
39
115
|
self.validate_claims(self.dcd.claims)
|
|
40
|
-
except errors.JoseError
|
|
116
|
+
except errors.JoseError:
|
|
41
117
|
return False
|
|
42
118
|
|
|
43
119
|
return await self.auth_handler(request)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_key(key: Optional[JwtKeys], setting_name: str) -> JwtKeys:
|
|
123
|
+
if key is None:
|
|
124
|
+
key = getattr(settings, setting_name, None)
|
|
125
|
+
if key is None:
|
|
126
|
+
raise ValueError(f"{setting_name} is required")
|
|
127
|
+
if not isinstance(key, (jwk.RSAKey, jwk.ECKey, jwk.OctKey)):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{setting_name} must be an instance of jwk.RSAKey or jwk.ECKey"
|
|
130
|
+
)
|
|
131
|
+
return key
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_mandatory_claims(claims: dict) -> dict:
|
|
135
|
+
for claim_key, setting_name in JWT_MANDATORY_CLAIMS:
|
|
136
|
+
if claims.get(claim_key) is not None:
|
|
137
|
+
continue
|
|
138
|
+
value = getattr(settings, setting_name, None)
|
|
139
|
+
if value is None:
|
|
140
|
+
raise ValueError(f"jwt {claim_key} is required")
|
|
141
|
+
claims[claim_key] = value
|
|
142
|
+
return claims
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def encode_jwt(
|
|
146
|
+
claims: dict, duration: int, private_key: JwtKeys = None, algorithm: str = None
|
|
147
|
+
) -> str:
|
|
148
|
+
"""
|
|
149
|
+
Encode and sign a JWT.
|
|
150
|
+
|
|
151
|
+
Adds time-based claims and ensures mandatory issuer/audience:
|
|
152
|
+
- iat: current time (timezone-aware)
|
|
153
|
+
- nbf: current time
|
|
154
|
+
- exp: current time + duration (seconds)
|
|
155
|
+
- iss/aud: from claims if provided; otherwise from settings.JWT_ISSUER and settings.JWT_AUDIENCE
|
|
156
|
+
|
|
157
|
+
Parameters:
|
|
158
|
+
- claims (dict): additional claims to merge into the payload (can override defaults)
|
|
159
|
+
- duration (int): token lifetime in seconds
|
|
160
|
+
- private_key (jwk.RSAKey): RSA/EC JWK for signing; defaults to settings.JWT_PRIVATE_KEY
|
|
161
|
+
- algorithm (str): JWS algorithm (default "RS256")
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
- str: JWT compact string
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
- ValueError: if private_key is missing or not jwk.RSAKey/jwk.ECKey
|
|
168
|
+
- ValueError: if mandatory claims (iss, aud) are missing and not in settings
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
- Header includes alg, typ=JWT, and kid from the private key (if available).
|
|
172
|
+
- Uses timezone-aware timestamps from django.utils.timezone.
|
|
173
|
+
"""
|
|
174
|
+
now = timezone.now()
|
|
175
|
+
nbf = now
|
|
176
|
+
pkey = validate_key(private_key, "JWT_PRIVATE_KEY")
|
|
177
|
+
algorithm = algorithm or "RS256"
|
|
178
|
+
claims = validate_mandatory_claims(claims)
|
|
179
|
+
kid_h = {"kid": pkey.kid} if pkey.kid else {}
|
|
180
|
+
return jwt.encode(
|
|
181
|
+
header={"alg": algorithm, "typ": "JWT"} | kid_h,
|
|
182
|
+
claims={
|
|
183
|
+
"iat": now,
|
|
184
|
+
"nbf": nbf,
|
|
185
|
+
"exp": now + datetime.timedelta(seconds=duration),
|
|
186
|
+
}
|
|
187
|
+
| claims,
|
|
188
|
+
key=pkey,
|
|
189
|
+
algorithms=[algorithm],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def decode_jwt(
|
|
194
|
+
token: str,
|
|
195
|
+
public_key: JwtKeys = None,
|
|
196
|
+
algorithms: list[str] = None,
|
|
197
|
+
) -> jwt.Token:
|
|
198
|
+
"""
|
|
199
|
+
Decode and verify a JSON Web Token (JWT) using the provided RSA public key.
|
|
200
|
+
This function decodes the JWT, verifies its signature, and returns the decoded token object.
|
|
201
|
+
Parameters:
|
|
202
|
+
- token (str): The JWT string to decode.
|
|
203
|
+
- public_key (jwk.RSAKey, optional): RSA public key used to verify the token's signature.
|
|
204
|
+
If not provided, settings.JWT_PUBLIC_KEY will be used. Must be an instance of jwk.RSAKey.
|
|
205
|
+
- algorithms (list[str], optional): List of permitted algorithms for signature verification.
|
|
206
|
+
Defaults to ["RS256"] if not provided.
|
|
207
|
+
Returns:
|
|
208
|
+
- jwt.Token: The decoded JWT token object containing header and claims.
|
|
209
|
+
Raises:
|
|
210
|
+
- ValueError: If no public key is provided or if the provided key is not an instance of jwk.RSAKey.
|
|
211
|
+
- jose.errors.JoseError: If the token is invalid or fails verification.
|
|
212
|
+
Notes:
|
|
213
|
+
- The function uses the specified algorithms to restrict acceptable signing methods.
|
|
214
|
+
Example:
|
|
215
|
+
decoded_token = decode_jwt(
|
|
216
|
+
token=my_jwt,
|
|
217
|
+
public_key=my_rsa_jwk,
|
|
218
|
+
algorithms=["RS256"],
|
|
219
|
+
)
|
|
220
|
+
"""
|
|
221
|
+
return jwt.decode(
|
|
222
|
+
token,
|
|
223
|
+
validate_key(public_key, "JWT_PUBLIC_KEY"),
|
|
224
|
+
algorithms=algorithms or ["RS256"],
|
|
225
|
+
)
|