django-ninja-aio-crud 2.1.0__py3-none-any.whl → 2.3.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.1.0.dist-info → django_ninja_aio_crud-2.3.0.dist-info}/METADATA +21 -16
- django_ninja_aio_crud-2.3.0.dist-info/RECORD +27 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/api.py +24 -0
- ninja_aio/auth.py +3 -3
- ninja_aio/decorators/__init__.py +23 -0
- ninja_aio/decorators/operations.py +9 -0
- ninja_aio/{decorators.py → decorators/views.py} +4 -3
- ninja_aio/exceptions.py +23 -1
- ninja_aio/factory/__init__.py +3 -0
- ninja_aio/factory/operations.py +296 -0
- ninja_aio/helpers/api.py +16 -2
- ninja_aio/helpers/query.py +6 -1
- ninja_aio/schemas/helpers.py +98 -18
- ninja_aio/types.py +1 -1
- ninja_aio/views/api.py +114 -34
- django_ninja_aio_crud-2.1.0.dist-info/RECORD +0 -23
- {django_ninja_aio_crud-2.1.0.dist-info → django_ninja_aio_crud-2.3.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-2.1.0.dist-info → django_ninja_aio_crud-2.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-ninja-aio-crud
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Django Ninja AIO CRUD - Rest Framework
|
|
5
5
|
Author: Giuseppe Casillo
|
|
6
|
-
Requires-Python: >=3.10
|
|
6
|
+
Requires-Python: >=3.10, <=3.14
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Topic :: Internet
|
|
@@ -100,11 +100,10 @@ from .models import Book
|
|
|
100
100
|
|
|
101
101
|
api = NinjaAIO()
|
|
102
102
|
|
|
103
|
+
@api.viewset(Book)
|
|
103
104
|
class BookViewSet(APIViewSet):
|
|
104
|
-
|
|
105
|
-
api = api
|
|
105
|
+
pass
|
|
106
106
|
|
|
107
|
-
BookViewSet().add_views_to_route()
|
|
108
107
|
```
|
|
109
108
|
|
|
110
109
|
Visit `/docs` → CRUD endpoints ready.
|
|
@@ -114,9 +113,8 @@ Visit `/docs` → CRUD endpoints ready.
|
|
|
114
113
|
## 🔄 Query Filtering
|
|
115
114
|
|
|
116
115
|
```python
|
|
116
|
+
@api.viewset(Book)
|
|
117
117
|
class BookViewSet(APIViewSet):
|
|
118
|
-
model = Book
|
|
119
|
-
api = api
|
|
120
118
|
query_params = {"published": (bool, None), "title": (str, None)}
|
|
121
119
|
|
|
122
120
|
async def query_params_handler(self, queryset, filters):
|
|
@@ -151,9 +149,8 @@ class Article(ModelSerializer):
|
|
|
151
149
|
class ReadSerializer:
|
|
152
150
|
fields = ["id", "title", "tags"]
|
|
153
151
|
|
|
152
|
+
@api.viewset(Article)
|
|
154
153
|
class ArticleViewSet(APIViewSet):
|
|
155
|
-
model = Article
|
|
156
|
-
api = api
|
|
157
154
|
m2m_relations = [
|
|
158
155
|
M2MRelationSchema(
|
|
159
156
|
model=Tag,
|
|
@@ -168,7 +165,6 @@ class ArticleViewSet(APIViewSet):
|
|
|
168
165
|
queryset = queryset.filter(name__icontains=n)
|
|
169
166
|
return queryset
|
|
170
167
|
|
|
171
|
-
ArticleViewSet().add_views_to_route()
|
|
172
168
|
```
|
|
173
169
|
|
|
174
170
|
Endpoints:
|
|
@@ -195,9 +191,8 @@ class JWTAuth(AsyncJwtBearer):
|
|
|
195
191
|
book_id = self.dcd.claims.get("sub")
|
|
196
192
|
return await Book.objects.aget(id=book_id)
|
|
197
193
|
|
|
194
|
+
@api.viewset(Book)
|
|
198
195
|
class SecureBookViewSet(APIViewSet):
|
|
199
|
-
model = Book
|
|
200
|
-
api = api
|
|
201
196
|
auth = [JWTAuth()]
|
|
202
197
|
get_auth = None # list/retrieve public
|
|
203
198
|
```
|
|
@@ -220,10 +215,21 @@ Available on every save/delete:
|
|
|
220
215
|
## 🧩 Adding Custom Endpoints
|
|
221
216
|
|
|
222
217
|
```python
|
|
218
|
+
from ninja_aio.decorators import api_get
|
|
219
|
+
|
|
220
|
+
@api.viewset(Book)
|
|
223
221
|
class BookViewSet(APIViewSet):
|
|
224
|
-
|
|
225
|
-
|
|
222
|
+
@api_get("/stats/")
|
|
223
|
+
async def stats(self, request):
|
|
224
|
+
total = await Book.objects.acount()
|
|
225
|
+
return {"total": total}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Or
|
|
226
229
|
|
|
230
|
+
```python
|
|
231
|
+
@api.viewset(Book)
|
|
232
|
+
class BookViewSet(APIViewSet):
|
|
227
233
|
def views(self):
|
|
228
234
|
@self.router.get("/stats/")
|
|
229
235
|
async def stats(request):
|
|
@@ -288,9 +294,8 @@ count = await Book.objects.acount()
|
|
|
288
294
|
## 🚫 Disable Operations
|
|
289
295
|
|
|
290
296
|
```python
|
|
297
|
+
@api.viewset(Book)
|
|
291
298
|
class ReadOnlyBookViewSet(APIViewSet):
|
|
292
|
-
model = Book
|
|
293
|
-
api = api
|
|
294
299
|
disable = ["update", "delete"]
|
|
295
300
|
```
|
|
296
301
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
ninja_aio/__init__.py,sha256=E3q86wZJvkl9LIkWh-fNxr_uH_7pT-x4KJC-WMIjMY0,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/models.py,sha256=aJlo5a64O4o-fB8QESLMUJpoA5kcjRJxPBiAIMxg46k,47652
|
|
6
|
+
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
7
|
+
ninja_aio/renders.py,sha256=VtmSliRJyZ6gjyoib8AXMVUBYF1jPNsiceCHujI_mAs,1699
|
|
8
|
+
ninja_aio/types.py,sha256=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
|
|
9
|
+
ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
|
|
10
|
+
ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
|
|
11
|
+
ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
|
|
12
|
+
ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
|
|
13
|
+
ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
|
|
14
|
+
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
ninja_aio/helpers/api.py,sha256=YMzuZ4-ZpUrJBQIabE26gb_GYwsH2rVosWRE95YfdPQ,20775
|
|
16
|
+
ninja_aio/helpers/query.py,sha256=YJMdEonCuqx1XjmszCK74mg5hcUPh84ynXrsuoSQdNA,4519
|
|
17
|
+
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
18
|
+
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
19
|
+
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
20
|
+
ninja_aio/schemas/helpers.py,sha256=W6IeHi5Tmbjh3FXwDYqjqlLBTVj5uTYq3_JVkNUWayo,7355
|
|
21
|
+
ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
|
|
22
|
+
ninja_aio/views/api.py,sha256=jJ0Awl9ynuvM6rWetgp9KHTKlnwREyjl2cxCobY4I_4,20158
|
|
23
|
+
ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
|
|
24
|
+
django_ninja_aio_crud-2.3.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
25
|
+
django_ninja_aio_crud-2.3.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
26
|
+
django_ninja_aio_crud-2.3.0.dist-info/METADATA,sha256=6NxeKxjsDeOO3Zp7E-keTspm6tEzzSvXlcpthS0OVYI,8790
|
|
27
|
+
django_ninja_aio_crud-2.3.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):
|
|
@@ -49,3 +52,24 @@ class NinjaAIO(NinjaAPI):
|
|
|
49
52
|
def set_default_exception_handlers(self):
|
|
50
53
|
set_api_exception_handlers(self)
|
|
51
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
|
@@ -124,7 +124,7 @@ def validate_key(key: Optional[JwtKeys], setting_name: str) -> JwtKeys:
|
|
|
124
124
|
key = getattr(settings, setting_name, None)
|
|
125
125
|
if key is None:
|
|
126
126
|
raise ValueError(f"{setting_name} is required")
|
|
127
|
-
if not isinstance(key, (jwk.RSAKey, jwk.ECKey)):
|
|
127
|
+
if not isinstance(key, (jwk.RSAKey, jwk.ECKey, jwk.OctKey)):
|
|
128
128
|
raise ValueError(
|
|
129
129
|
f"{setting_name} must be an instance of jwk.RSAKey or jwk.ECKey"
|
|
130
130
|
)
|
|
@@ -143,7 +143,7 @@ def validate_mandatory_claims(claims: dict) -> dict:
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def encode_jwt(
|
|
146
|
-
claims: dict, duration: int, private_key:
|
|
146
|
+
claims: dict, duration: int, private_key: JwtKeys = None, algorithm: str = None
|
|
147
147
|
) -> str:
|
|
148
148
|
"""
|
|
149
149
|
Encode and sign a JWT.
|
|
@@ -192,7 +192,7 @@ def encode_jwt(
|
|
|
192
192
|
|
|
193
193
|
def decode_jwt(
|
|
194
194
|
token: str,
|
|
195
|
-
public_key:
|
|
195
|
+
public_key: JwtKeys = None,
|
|
196
196
|
algorithms: list[str] = None,
|
|
197
197
|
) -> jwt.Token:
|
|
198
198
|
"""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .views import decorate_view, aatomic, unique_view
|
|
2
|
+
from .operations import (
|
|
3
|
+
api_get,
|
|
4
|
+
api_post,
|
|
5
|
+
api_put,
|
|
6
|
+
api_delete,
|
|
7
|
+
api_patch,
|
|
8
|
+
api_options,
|
|
9
|
+
api_head,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"decorate_view",
|
|
14
|
+
"aatomic",
|
|
15
|
+
"unique_view",
|
|
16
|
+
"api_get",
|
|
17
|
+
"api_post",
|
|
18
|
+
"api_put",
|
|
19
|
+
"api_delete",
|
|
20
|
+
"api_patch",
|
|
21
|
+
"api_options",
|
|
22
|
+
"api_head",
|
|
23
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from ninja_aio.factory import ApiMethodFactory
|
|
2
|
+
|
|
3
|
+
api_get = ApiMethodFactory.make("get")
|
|
4
|
+
api_post = ApiMethodFactory.make("post")
|
|
5
|
+
api_put = ApiMethodFactory.make("put")
|
|
6
|
+
api_patch = ApiMethodFactory.make("patch")
|
|
7
|
+
api_delete = ApiMethodFactory.make("delete")
|
|
8
|
+
api_options = ApiMethodFactory.make("options")
|
|
9
|
+
api_head = ApiMethodFactory.make("head")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
from django.db.transaction import Atomic
|
|
2
1
|
from functools import wraps
|
|
2
|
+
|
|
3
|
+
from django.db.transaction import Atomic
|
|
3
4
|
from asgiref.sync import sync_to_async
|
|
4
5
|
|
|
5
6
|
|
|
@@ -189,7 +190,7 @@ def decorate_view(*decorators):
|
|
|
189
190
|
@decorate_view(authenticate, log_request)
|
|
190
191
|
async def some_view(request):
|
|
191
192
|
...
|
|
192
|
-
|
|
193
|
+
|
|
193
194
|
Conditional decoration (skips None):
|
|
194
195
|
class MyAPIViewSet(APIViewSet):
|
|
195
196
|
api = api
|
|
@@ -215,4 +216,4 @@ def decorate_view(*decorators):
|
|
|
215
216
|
wrapped = dec(wrapped)
|
|
216
217
|
return wrapped
|
|
217
218
|
|
|
218
|
-
return _decorator
|
|
219
|
+
return _decorator
|
ninja_aio/exceptions.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from functools import partial
|
|
2
|
-
|
|
3
2
|
from joserfc.errors import JoseError
|
|
4
3
|
from ninja import NinjaAPI
|
|
5
4
|
from django.http import HttpRequest, HttpResponse
|
|
@@ -8,6 +7,8 @@ from django.db.models import Model
|
|
|
8
7
|
|
|
9
8
|
|
|
10
9
|
class BaseException(Exception):
|
|
10
|
+
"""Base application exception carrying a serializable error payload and status code."""
|
|
11
|
+
|
|
11
12
|
error: str | dict = ""
|
|
12
13
|
status_code: int = 400
|
|
13
14
|
|
|
@@ -17,6 +18,11 @@ class BaseException(Exception):
|
|
|
17
18
|
status_code: int | None = None,
|
|
18
19
|
details: str | None = None,
|
|
19
20
|
) -> None:
|
|
21
|
+
"""Initialize the exception with error content, optional HTTP status, and details.
|
|
22
|
+
|
|
23
|
+
If `error` is a string, it is wrapped into a dict under the `error` key.
|
|
24
|
+
If `error` is a dict, it is used directly. Optional `details` are merged.
|
|
25
|
+
"""
|
|
20
26
|
if isinstance(error, str):
|
|
21
27
|
self.error = {"error": error}
|
|
22
28
|
if isinstance(error, dict):
|
|
@@ -25,22 +31,30 @@ class BaseException(Exception):
|
|
|
25
31
|
self.status_code = status_code or self.status_code
|
|
26
32
|
|
|
27
33
|
def get_error(self):
|
|
34
|
+
"""Return the error body and HTTP status code tuple for response creation."""
|
|
28
35
|
return self.error, self.status_code
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
class SerializeError(BaseException):
|
|
39
|
+
"""Raised when serialization to or from request/response payloads fails."""
|
|
40
|
+
|
|
32
41
|
pass
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
class AuthError(BaseException):
|
|
45
|
+
"""Raised when authentication or authorization fails."""
|
|
46
|
+
|
|
36
47
|
pass
|
|
37
48
|
|
|
38
49
|
|
|
39
50
|
class NotFoundError(BaseException):
|
|
51
|
+
"""Raised when a requested model instance cannot be found."""
|
|
52
|
+
|
|
40
53
|
status_code = 404
|
|
41
54
|
error = "not found"
|
|
42
55
|
|
|
43
56
|
def __init__(self, model: Model, details=None):
|
|
57
|
+
"""Build a not-found error referencing the model's verbose name."""
|
|
44
58
|
super().__init__(
|
|
45
59
|
error={model._meta.verbose_name.replace(" ", "_"): self.error},
|
|
46
60
|
status_code=self.status_code,
|
|
@@ -49,19 +63,24 @@ class NotFoundError(BaseException):
|
|
|
49
63
|
|
|
50
64
|
|
|
51
65
|
class PydanticValidationError(BaseException):
|
|
66
|
+
"""Wrapper for pydantic ValidationError to normalize the API error response."""
|
|
67
|
+
|
|
52
68
|
def __init__(self, details=None):
|
|
69
|
+
"""Create a validation error with 400 status and provided details list."""
|
|
53
70
|
super().__init__("Validation Error", 400, details)
|
|
54
71
|
|
|
55
72
|
|
|
56
73
|
def _default_error(
|
|
57
74
|
request: HttpRequest, exc: BaseException, api: type[NinjaAPI]
|
|
58
75
|
) -> HttpResponse:
|
|
76
|
+
"""Default handler: convert BaseException to an API response."""
|
|
59
77
|
return api.create_response(request, exc.error, status=exc.status_code)
|
|
60
78
|
|
|
61
79
|
|
|
62
80
|
def _pydantic_validation_error(
|
|
63
81
|
request: HttpRequest, exc: ValidationError, api: type[NinjaAPI]
|
|
64
82
|
) -> HttpResponse:
|
|
83
|
+
"""Translate a pydantic ValidationError into a normalized API error response."""
|
|
65
84
|
error = PydanticValidationError(exc.errors(include_input=False))
|
|
66
85
|
return api.create_response(request, error.error, status=error.status_code)
|
|
67
86
|
|
|
@@ -69,11 +88,13 @@ def _pydantic_validation_error(
|
|
|
69
88
|
def _jose_error(
|
|
70
89
|
request: HttpRequest, exc: JoseError, api: type[NinjaAPI]
|
|
71
90
|
) -> HttpResponse:
|
|
91
|
+
"""Translate a JOSE library error into an unauthorized API response."""
|
|
72
92
|
error = BaseException(**parse_jose_error(exc), status_code=401)
|
|
73
93
|
return api.create_response(request, error.error, status=error.status_code)
|
|
74
94
|
|
|
75
95
|
|
|
76
96
|
def set_api_exception_handlers(api: type[NinjaAPI]) -> None:
|
|
97
|
+
"""Register exception handlers for common error types on the NinjaAPI instance."""
|
|
77
98
|
api.add_exception_handler(BaseException, partial(_default_error, api=api))
|
|
78
99
|
api.add_exception_handler(JoseError, partial(_jose_error, api=api))
|
|
79
100
|
api.add_exception_handler(
|
|
@@ -82,6 +103,7 @@ def set_api_exception_handlers(api: type[NinjaAPI]) -> None:
|
|
|
82
103
|
|
|
83
104
|
|
|
84
105
|
def parse_jose_error(jose_exc: JoseError) -> dict:
|
|
106
|
+
"""Extract error and optional description from a JoseError into a dict."""
|
|
85
107
|
error_msg = {"error": jose_exc.error}
|
|
86
108
|
return (
|
|
87
109
|
error_msg | {"details": jose_exc.description}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import (
|
|
3
|
+
Callable,
|
|
4
|
+
Dict,
|
|
5
|
+
List,
|
|
6
|
+
Optional,
|
|
7
|
+
Union,
|
|
8
|
+
Any,
|
|
9
|
+
)
|
|
10
|
+
import inspect
|
|
11
|
+
|
|
12
|
+
from ninja.constants import NOT_SET, NOT_SET_TYPE
|
|
13
|
+
from ninja.throttling import BaseThrottle
|
|
14
|
+
from ninja import Router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiMethodFactory:
|
|
18
|
+
"""
|
|
19
|
+
Factory for creating class-bound API method decorators that register endpoints
|
|
20
|
+
on a Ninja Router from instance methods.
|
|
21
|
+
|
|
22
|
+
This class enables defining API handlers as instance methods while ensuring
|
|
23
|
+
the resulting callables exposed to Ninja are free of `self`/`cls` in their
|
|
24
|
+
OpenAPI signatures, preventing them from being interpreted as query params.
|
|
25
|
+
|
|
26
|
+
Typical usage:
|
|
27
|
+
- Use ApiMethodFactory.make("get" | "post" | "put" | "delete" | ...) to produce
|
|
28
|
+
a decorator that can be applied to an instance method on a view class.
|
|
29
|
+
- When the owning instance (e.g., a subclass of ninja_aio.views.api.API) is
|
|
30
|
+
created, the method is lazily registered on its `router` with the provided
|
|
31
|
+
configuration (path, auth, response, tags, etc.).
|
|
32
|
+
|
|
33
|
+
The factory supports both sync and async methods. It wraps the original method
|
|
34
|
+
with a handler whose first argument is `request` (as expected by Ninja),
|
|
35
|
+
internally binding `self` from the instance so you can still write methods
|
|
36
|
+
naturally.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
- method_name: The HTTP method name used to select the corresponding Router
|
|
40
|
+
adder (e.g., "get", "post", etc.).
|
|
41
|
+
|
|
42
|
+
__init__(method_name: str)
|
|
43
|
+
Initialize the factory for a specific HTTP method.
|
|
44
|
+
|
|
45
|
+
Parameters:
|
|
46
|
+
- method_name: The name of the Router method to call (e.g., "get", "post").
|
|
47
|
+
This determines which endpoint registration function is used on the router.
|
|
48
|
+
|
|
49
|
+
_build_handler(view_instance, original)
|
|
50
|
+
Build a callable that Ninja can use as the endpoint handler, correctly
|
|
51
|
+
binding `self` and presenting a `request`-first signature.
|
|
52
|
+
|
|
53
|
+
Behavior:
|
|
54
|
+
- If the original method is async, return an async wrapper that awaits it.
|
|
55
|
+
- If the original method is sync, return a sync wrapper that calls it.
|
|
56
|
+
- The wrapper passes (view_instance, request, *args, **kwargs) to the
|
|
57
|
+
original method, ensuring instance binding while exposing a clean handler
|
|
58
|
+
to Ninja.
|
|
59
|
+
|
|
60
|
+
Parameters:
|
|
61
|
+
- view_instance: The object instance that owns the router and the method.
|
|
62
|
+
- original: The original instance method to be wrapped.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
- A callable suitable for Ninja route registration (sync or async).
|
|
66
|
+
|
|
67
|
+
_apply_metadata(clean_handler, original)
|
|
68
|
+
Copy relevant metadata from the original method to the wrapped handler to
|
|
69
|
+
improve OpenAPI generation and introspection.
|
|
70
|
+
|
|
71
|
+
Behavior:
|
|
72
|
+
- Preserve the function name where possible.
|
|
73
|
+
- Replace the __signature__ to exclude the first parameter if it is
|
|
74
|
+
`self` or `cls`, ensuring Ninja does not treat them as parameters.
|
|
75
|
+
- Copy annotations while removing `self` to avoid unwanted schema entries.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
- clean_handler: The wrapped function produced by _build_handler.
|
|
79
|
+
- original: The original method from which metadata will be copied.
|
|
80
|
+
|
|
81
|
+
build_decorator(
|
|
82
|
+
auth=NOT_SET,
|
|
83
|
+
throttle=NOT_SET,
|
|
84
|
+
response=NOT_SET,
|
|
85
|
+
Create and return a decorator that can be applied to an instance method to
|
|
86
|
+
lazily register it as an endpoint when the instance is initialized.
|
|
87
|
+
|
|
88
|
+
How it works:
|
|
89
|
+
- The decorator attaches an `_api_register` callable to the method.
|
|
90
|
+
- When invoked with an API view instance, `_api_register` resolves the
|
|
91
|
+
instance’s `router`, wraps the method via _build_handler, applies metadata
|
|
92
|
+
via _apply_metadata, and registers the handler using the router’s method
|
|
93
|
+
corresponding to `method_name` (e.g., router.get).
|
|
94
|
+
|
|
95
|
+
Parameters mirror Ninja Router endpoint registration and control OpenAPI
|
|
96
|
+
generation and request handling:
|
|
97
|
+
- path: Route path for the endpoint.
|
|
98
|
+
- auth: Authentication configuration or NOT_SET.
|
|
99
|
+
- throttle: Throttle configuration(s) or NOT_SET.
|
|
100
|
+
- response: Response schema/model or NOT_SET.
|
|
101
|
+
- operation_id: Optional OpenAPI operation identifier.
|
|
102
|
+
- summary: Short summary for OpenAPI.
|
|
103
|
+
- description: Detailed description for OpenAPI.
|
|
104
|
+
- tags: Grouping tags for OpenAPI.
|
|
105
|
+
- deprecated: Mark endpoint as deprecated in OpenAPI.
|
|
106
|
+
- by_alias, exclude_unset, exclude_defaults, exclude_none: Pydantic-related
|
|
107
|
+
serialization options for response models.
|
|
108
|
+
- url_name: Optional Django URL name.
|
|
109
|
+
- include_in_schema: Whether to include this endpoint in OpenAPI schema.
|
|
110
|
+
- openapi_extra: Additional raw OpenAPI metadata.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
- A decorator to apply to sync/async instance methods.
|
|
114
|
+
|
|
115
|
+
make(method_name: str)
|
|
116
|
+
Class method that returns a ready-to-use decorator function for the given
|
|
117
|
+
HTTP method, suitable for direct use on instance methods.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
api_get = ApiMethodFactory.make("get")
|
|
121
|
+
|
|
122
|
+
class MyView(API):
|
|
123
|
+
router = Router()
|
|
124
|
+
|
|
125
|
+
@api_get("/items")
|
|
126
|
+
async def list_items(self, request):
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
Parameters:
|
|
130
|
+
- method_name: The HTTP method name to bind (e.g., "get", "post", "put").
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
- A function that mirrors build_decorator’s signature, named
|
|
134
|
+
"api_{method_name}", with a docstring indicating it registers the
|
|
135
|
+
corresponding HTTP endpoint on the instance router.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, method_name: str):
|
|
139
|
+
self.method_name = method_name
|
|
140
|
+
|
|
141
|
+
def _build_handler(self, view_instance, original):
|
|
142
|
+
is_async = asyncio.iscoroutinefunction(original)
|
|
143
|
+
|
|
144
|
+
if is_async:
|
|
145
|
+
|
|
146
|
+
async def clean_handler(request, *args, **kwargs):
|
|
147
|
+
return await original(view_instance, request, *args, **kwargs)
|
|
148
|
+
else:
|
|
149
|
+
|
|
150
|
+
def clean_handler(request, *args, **kwargs):
|
|
151
|
+
return original(view_instance, request, *args, **kwargs)
|
|
152
|
+
|
|
153
|
+
return clean_handler
|
|
154
|
+
|
|
155
|
+
def _apply_metadata(self, clean_handler, original):
|
|
156
|
+
# name
|
|
157
|
+
try:
|
|
158
|
+
clean_handler.__name__ = getattr(
|
|
159
|
+
original, "__name__", clean_handler.__name__
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# signature and annotations without self/cls
|
|
165
|
+
try:
|
|
166
|
+
sig = inspect.signature(original)
|
|
167
|
+
params = sig.parameters
|
|
168
|
+
params_list = list(params.values())
|
|
169
|
+
if params_list and params_list[0].name in {"self", "cls"}:
|
|
170
|
+
params_list = params_list[1:]
|
|
171
|
+
clean_handler.__signature__ = sig.replace(parameters=params_list) # type: ignore[attr-defined]
|
|
172
|
+
|
|
173
|
+
anns = dict(getattr(original, "__annotations__", {}))
|
|
174
|
+
anns.pop("self", None)
|
|
175
|
+
clean_handler.__annotations__ = anns
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def build_decorator(
|
|
180
|
+
self,
|
|
181
|
+
path: str,
|
|
182
|
+
*,
|
|
183
|
+
auth: Any = NOT_SET,
|
|
184
|
+
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
|
|
185
|
+
response: Any = NOT_SET,
|
|
186
|
+
operation_id: Optional[str] = None,
|
|
187
|
+
summary: Optional[str] = None,
|
|
188
|
+
description: Optional[str] = None,
|
|
189
|
+
tags: Optional[List[str]] = None,
|
|
190
|
+
deprecated: Optional[bool] = None,
|
|
191
|
+
by_alias: Optional[bool] = None,
|
|
192
|
+
exclude_unset: Optional[bool] = None,
|
|
193
|
+
exclude_defaults: Optional[bool] = None,
|
|
194
|
+
exclude_none: Optional[bool] = None,
|
|
195
|
+
url_name: Optional[str] = None,
|
|
196
|
+
include_in_schema: bool = True,
|
|
197
|
+
openapi_extra: Optional[Dict[str, Any]] = None,
|
|
198
|
+
decorators: Optional[List[Callable]] = None, # es. [paginate(...)]
|
|
199
|
+
):
|
|
200
|
+
"""
|
|
201
|
+
Returns a decorator that can be applied to an async or sync instance method.
|
|
202
|
+
When the instance is created and owns a `router`, the wrapped method is
|
|
203
|
+
registered on that router using the provided configuration.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def decorator(func):
|
|
207
|
+
from ninja_aio.views.api import API
|
|
208
|
+
|
|
209
|
+
def register_on_instance(view_instance: API):
|
|
210
|
+
router: Router = getattr(view_instance, "router", None)
|
|
211
|
+
if router is None:
|
|
212
|
+
raise RuntimeError("The view instance does not have a router")
|
|
213
|
+
|
|
214
|
+
clean_handler = self._build_handler(view_instance, func)
|
|
215
|
+
self._apply_metadata(clean_handler, func)
|
|
216
|
+
|
|
217
|
+
# Apply additional decorators if any
|
|
218
|
+
if decorators:
|
|
219
|
+
for dec in reversed(decorators):
|
|
220
|
+
clean_handler = dec(clean_handler)
|
|
221
|
+
|
|
222
|
+
route_adder = getattr(router, self.method_name)
|
|
223
|
+
route_adder(
|
|
224
|
+
path=path,
|
|
225
|
+
auth=auth,
|
|
226
|
+
throttle=throttle,
|
|
227
|
+
response=response,
|
|
228
|
+
operation_id=operation_id,
|
|
229
|
+
summary=summary,
|
|
230
|
+
description=description,
|
|
231
|
+
tags=tags,
|
|
232
|
+
deprecated=deprecated,
|
|
233
|
+
by_alias=by_alias,
|
|
234
|
+
exclude_unset=exclude_unset,
|
|
235
|
+
exclude_defaults=exclude_defaults,
|
|
236
|
+
exclude_none=exclude_none,
|
|
237
|
+
url_name=url_name,
|
|
238
|
+
include_in_schema=include_in_schema,
|
|
239
|
+
openapi_extra=openapi_extra,
|
|
240
|
+
)(clean_handler)
|
|
241
|
+
|
|
242
|
+
setattr(func, "_api_register", register_on_instance)
|
|
243
|
+
return func
|
|
244
|
+
|
|
245
|
+
return decorator
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def make(cls, method_name: str):
|
|
249
|
+
"""Factory returning a decorator function for the given HTTP method."""
|
|
250
|
+
|
|
251
|
+
def wrapper(
|
|
252
|
+
path: str,
|
|
253
|
+
*,
|
|
254
|
+
auth: Any = NOT_SET,
|
|
255
|
+
throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
|
|
256
|
+
response: Any = NOT_SET,
|
|
257
|
+
operation_id: Optional[str] = None,
|
|
258
|
+
summary: Optional[str] = None,
|
|
259
|
+
description: Optional[str] = None,
|
|
260
|
+
tags: Optional[List[str]] = None,
|
|
261
|
+
deprecated: Optional[bool] = None,
|
|
262
|
+
by_alias: Optional[bool] = None,
|
|
263
|
+
exclude_unset: Optional[bool] = None,
|
|
264
|
+
exclude_defaults: Optional[bool] = None,
|
|
265
|
+
exclude_none: Optional[bool] = None,
|
|
266
|
+
url_name: Optional[str] = None,
|
|
267
|
+
include_in_schema: bool = True,
|
|
268
|
+
openapi_extra: Optional[Dict[str, Any]] = None,
|
|
269
|
+
decorators: Optional[List[Callable]] = None, # es. [paginate(...)]
|
|
270
|
+
):
|
|
271
|
+
return cls(method_name).build_decorator(
|
|
272
|
+
path,
|
|
273
|
+
auth=auth,
|
|
274
|
+
throttle=throttle,
|
|
275
|
+
response=response,
|
|
276
|
+
operation_id=operation_id,
|
|
277
|
+
summary=summary,
|
|
278
|
+
description=description,
|
|
279
|
+
tags=tags,
|
|
280
|
+
deprecated=deprecated,
|
|
281
|
+
by_alias=by_alias,
|
|
282
|
+
exclude_unset=exclude_unset,
|
|
283
|
+
exclude_defaults=exclude_defaults,
|
|
284
|
+
exclude_none=exclude_none,
|
|
285
|
+
url_name=url_name,
|
|
286
|
+
include_in_schema=include_in_schema,
|
|
287
|
+
openapi_extra=openapi_extra,
|
|
288
|
+
decorators=decorators,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
wrapper.__name__ = f"api_{method_name}"
|
|
292
|
+
wrapper.__doc__ = (
|
|
293
|
+
f"Class method decorator that lazily registers a {method_name.upper()} endpoint on the instance router.\n\n"
|
|
294
|
+
f"Parameters mirror api_get."
|
|
295
|
+
)
|
|
296
|
+
return wrapper
|
ninja_aio/helpers/api.py
CHANGED
|
@@ -337,6 +337,17 @@ class ManyToManyAPI:
|
|
|
337
337
|
remove=remove,
|
|
338
338
|
)
|
|
339
339
|
|
|
340
|
+
def _get_api_path(self, rel_path: str, append_slash: bool = None) -> str:
|
|
341
|
+
append_slash = append_slash if append_slash is not None else True
|
|
342
|
+
path = (
|
|
343
|
+
f"{self.view_set.path_retrieve}{rel_path}/"
|
|
344
|
+
if rel_path.startswith("/")
|
|
345
|
+
else f"{self.view_set.path_retrieve}/{rel_path}/"
|
|
346
|
+
)
|
|
347
|
+
if not append_slash:
|
|
348
|
+
path = path.rstrip("/")
|
|
349
|
+
return path
|
|
350
|
+
|
|
340
351
|
def _register_get_relation_view(
|
|
341
352
|
self,
|
|
342
353
|
*,
|
|
@@ -346,9 +357,10 @@ class ManyToManyAPI:
|
|
|
346
357
|
rel_path: str,
|
|
347
358
|
related_schema,
|
|
348
359
|
filters_schema,
|
|
360
|
+
append_slash: bool,
|
|
349
361
|
):
|
|
350
362
|
@self.router.get(
|
|
351
|
-
|
|
363
|
+
self._get_api_path(rel_path, append_slash=append_slash),
|
|
352
364
|
response={
|
|
353
365
|
200: list[related_schema],
|
|
354
366
|
self.view_set.error_codes: GenericMessageSchema,
|
|
@@ -400,7 +412,7 @@ class ManyToManyAPI:
|
|
|
400
412
|
summary = f"{action} {plural}"
|
|
401
413
|
|
|
402
414
|
@self.router.post(
|
|
403
|
-
|
|
415
|
+
self._get_api_path(rel_path),
|
|
404
416
|
response={
|
|
405
417
|
200: M2MSchemaOut,
|
|
406
418
|
self.view_set.error_codes: GenericMessageSchema,
|
|
@@ -465,6 +477,7 @@ class ManyToManyAPI:
|
|
|
465
477
|
related_schema = relation.related_schema
|
|
466
478
|
m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
|
|
467
479
|
filters_schema = self.relations_filters_schemas.get(related_name)
|
|
480
|
+
append_slash = relation.append_slash
|
|
468
481
|
|
|
469
482
|
if m2m_get:
|
|
470
483
|
self._register_get_relation_view(
|
|
@@ -474,6 +487,7 @@ class ManyToManyAPI:
|
|
|
474
487
|
rel_path=rel_path,
|
|
475
488
|
related_schema=related_schema,
|
|
476
489
|
filters_schema=filters_schema,
|
|
490
|
+
append_slash=append_slash,
|
|
477
491
|
)
|
|
478
492
|
|
|
479
493
|
if m2m_add or m2m_remove:
|
ninja_aio/helpers/query.py
CHANGED
|
@@ -8,10 +8,12 @@ from ninja_aio.schemas.helpers import (
|
|
|
8
8
|
|
|
9
9
|
class ScopeNamespace:
|
|
10
10
|
def __init__(self, **scopes):
|
|
11
|
+
"""Create a simple namespace where each provided scope becomes an attribute."""
|
|
11
12
|
for key, value in scopes.items():
|
|
12
13
|
setattr(self, key, value)
|
|
13
14
|
|
|
14
15
|
def __iter__(self):
|
|
16
|
+
"""Iterate over the stored scope values."""
|
|
15
17
|
return iter(self.__dict__.values())
|
|
16
18
|
|
|
17
19
|
|
|
@@ -51,6 +53,7 @@ class QueryUtil:
|
|
|
51
53
|
SCOPES: QueryUtilBaseScopesSchema
|
|
52
54
|
|
|
53
55
|
def __init__(self, model: ModelSerializerMeta):
|
|
56
|
+
"""Initialize QueryUtil, resolving base and extra scope configurations for a model."""
|
|
54
57
|
self.model = model
|
|
55
58
|
self._configuration = getattr(self.model, "QuerySet", None)
|
|
56
59
|
self._extra_configuration: list[ModelQuerySetExtraSchema] = getattr(
|
|
@@ -66,7 +69,9 @@ class QueryUtil:
|
|
|
66
69
|
**{scope: self._get_config(scope) for scope in self._BASE_SCOPES.values()},
|
|
67
70
|
**self.extra_configs,
|
|
68
71
|
}
|
|
69
|
-
self.read_config: ModelQuerySetSchema = self._configs.get(
|
|
72
|
+
self.read_config: ModelQuerySetSchema = self._configs.get(
|
|
73
|
+
self.SCOPES.READ, ModelQuerySetSchema()
|
|
74
|
+
)
|
|
70
75
|
self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
|
|
71
76
|
self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
|
|
72
77
|
)
|
ninja_aio/schemas/helpers.py
CHANGED
|
@@ -8,24 +8,50 @@ from pydantic import BaseModel, ConfigDict, model_validator
|
|
|
8
8
|
|
|
9
9
|
class M2MRelationSchema(BaseModel):
|
|
10
10
|
"""
|
|
11
|
-
Configuration schema for declaring a Many-to-Many relation in the API.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
11
|
+
Configuration schema for declaring and controlling a Many-to-Many (M2M) relation in the API.
|
|
12
|
+
|
|
13
|
+
This schema is used to describe an M2M relationship between a primary resource and its related
|
|
14
|
+
objects, and to automatically provision CRUD-like endpoints for managing that relation
|
|
15
|
+
(add, remove, and get). It supports both direct Django model classes and model serializers,
|
|
16
|
+
and can optionally expose a custom schema for the related output.
|
|
17
|
+
|
|
18
|
+
model (ModelSerializerMeta | Type[Model]):
|
|
19
|
+
The target related entity, provided either as a ModelSerializer (preferred) or a Django model.
|
|
20
|
+
If a plain model is supplied, you must also provide `related_schema`.
|
|
21
|
+
related_name (str):
|
|
22
|
+
The name of the M2M field on the Django model that links to the related objects.
|
|
23
|
+
add (bool):
|
|
24
|
+
Whether to enable an endpoint for adding related objects. Defaults to True.
|
|
25
|
+
remove (bool):
|
|
26
|
+
Whether to enable an endpoint for removing related objects. Defaults to True.
|
|
27
|
+
get (bool):
|
|
28
|
+
Whether to enable an endpoint for listing/retrieving related objects. Defaults to True.
|
|
29
|
+
path (str | None):
|
|
30
|
+
Optional custom URL path segment for the relation endpoints. If empty or None, a path
|
|
31
|
+
is auto-generated based on `related_name`.
|
|
32
|
+
auth (list | None):
|
|
33
|
+
Optional list of authentication backends to protect the relation endpoints.
|
|
34
|
+
filters (dict[str, tuple] | None):
|
|
35
|
+
Optional mapping of queryable filter fields for the GET endpoint, defined as:
|
|
36
|
+
field_name -> (type, default). Example: {"country": ("str", "")}.
|
|
37
|
+
related_schema (Type[Schema] | None):
|
|
38
|
+
Optional explicit schema to represent related objects in responses.
|
|
39
|
+
If `model` is a ModelSerializerMeta, this is auto-derived via `model.generate_related_s()`.
|
|
40
|
+
If `model` is a plain Django model, this must be provided.
|
|
41
|
+
append_slash (bool):
|
|
42
|
+
Whether to append a trailing slash to the generated GET endpoint path. Defaults to False for backward compatibility.
|
|
43
|
+
|
|
44
|
+
Validation:
|
|
45
|
+
- If `model` is not a ModelSerializerMeta, `related_schema` is required.
|
|
46
|
+
- When `model` is a ModelSerializerMeta and `related_schema` is not provided, it will be
|
|
47
|
+
automatically generated.
|
|
48
|
+
|
|
49
|
+
Usage example:
|
|
50
|
+
filters={"country": ("str", "")},
|
|
51
|
+
auth=[AuthBackend],
|
|
52
|
+
add=True,
|
|
53
|
+
remove=True,
|
|
54
|
+
get=True,
|
|
29
55
|
"""
|
|
30
56
|
|
|
31
57
|
model: ModelSerializerMeta | Type[Model]
|
|
@@ -37,6 +63,7 @@ class M2MRelationSchema(BaseModel):
|
|
|
37
63
|
auth: Optional[list] = None
|
|
38
64
|
filters: Optional[dict[str, tuple]] = None
|
|
39
65
|
related_schema: Optional[Type[Schema]] = None
|
|
66
|
+
append_slash: bool = False
|
|
40
67
|
|
|
41
68
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
42
69
|
|
|
@@ -61,28 +88,81 @@ class ModelQuerySetSchema(BaseModel):
|
|
|
61
88
|
|
|
62
89
|
|
|
63
90
|
class ModelQuerySetExtraSchema(ModelQuerySetSchema):
|
|
91
|
+
"""
|
|
92
|
+
Schema defining extra query parameters for model queryset operations in API endpoints.
|
|
93
|
+
Attributes:
|
|
94
|
+
scope (str): The scope defining the level of access for the queryset operation.
|
|
95
|
+
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
96
|
+
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
97
|
+
"""
|
|
64
98
|
scope: str
|
|
65
99
|
|
|
66
100
|
|
|
67
101
|
class ObjectQuerySchema(ModelQuerySetSchema):
|
|
102
|
+
"""
|
|
103
|
+
Schema defining query parameters for single object retrieval in API endpoints.
|
|
104
|
+
Attributes:
|
|
105
|
+
getters (Optional[dict]): A dictionary of getters to apply to the query.
|
|
106
|
+
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
107
|
+
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
108
|
+
"""
|
|
68
109
|
getters: Optional[dict] = {}
|
|
69
110
|
|
|
70
111
|
|
|
71
112
|
class ObjectsQuerySchema(ModelQuerySetSchema):
|
|
113
|
+
"""
|
|
114
|
+
Schema defining query parameters for multiple object retrieval in API endpoints.
|
|
115
|
+
Attributes:
|
|
116
|
+
filters (Optional[dict]): A dictionary of filters to apply to the query.
|
|
117
|
+
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
118
|
+
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
119
|
+
"""
|
|
72
120
|
filters: Optional[dict] = {}
|
|
73
121
|
|
|
74
122
|
|
|
75
123
|
class QuerySchema(ModelQuerySetSchema):
|
|
124
|
+
"""
|
|
125
|
+
Schema defining query parameters for API endpoints.
|
|
126
|
+
Attributes:
|
|
127
|
+
filters (Optional[dict]): A dictionary of filters to apply to the query.
|
|
128
|
+
getters (Optional[dict]): A dictionary of getters to apply to the query.
|
|
129
|
+
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
130
|
+
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
131
|
+
"""
|
|
76
132
|
filters: Optional[dict] = {}
|
|
77
133
|
getters: Optional[dict] = {}
|
|
78
134
|
|
|
79
135
|
|
|
80
136
|
class QueryUtilBaseScopesSchema(BaseModel):
|
|
137
|
+
"""
|
|
138
|
+
Schema defining base scopes for query utilities.
|
|
139
|
+
Attributes:
|
|
140
|
+
READ (str): Scope for read operations.
|
|
141
|
+
QUERYSET_REQUEST (str): Scope for queryset request operations.
|
|
142
|
+
"""
|
|
81
143
|
READ: str = "read"
|
|
82
144
|
QUERYSET_REQUEST: str = "queryset_request"
|
|
83
145
|
|
|
84
146
|
|
|
85
147
|
class DecoratorsSchema(Schema):
|
|
148
|
+
"""
|
|
149
|
+
Schema defining optional decorator lists for CRUD operations.
|
|
150
|
+
|
|
151
|
+
Attributes:
|
|
152
|
+
list (Optional[List]): Decorators applied to the list endpoint.
|
|
153
|
+
retrieve (Optional[List]): Decorators applied to the retrieve endpoint.
|
|
154
|
+
create (Optional[List]): Decorators applied to the create endpoint.
|
|
155
|
+
update (Optional[List]): Decorators applied to the update endpoint.
|
|
156
|
+
delete (Optional[List]): Decorators applied to the delete endpoint.
|
|
157
|
+
|
|
158
|
+
Notes:
|
|
159
|
+
- Each attribute holds an ordered collection of decorators (callables or decorator references)
|
|
160
|
+
to be applied to the corresponding endpoint.
|
|
161
|
+
- Defaults are empty lists, meaning no decorators are applied unless explicitly provided.
|
|
162
|
+
- Using mutable defaults (empty lists) at the class level may lead to shared state between instances.
|
|
163
|
+
Consider initializing these in __init__ or using default_factory (if using pydantic/dataclasses)
|
|
164
|
+
to avoid unintended side effects.
|
|
165
|
+
"""
|
|
86
166
|
list: Optional[List] = []
|
|
87
167
|
retrieve: Optional[List] = []
|
|
88
168
|
create: Optional[List] = []
|
ninja_aio/types.py
CHANGED
|
@@ -8,7 +8,7 @@ S_TYPES = Literal["read", "create", "update"]
|
|
|
8
8
|
F_TYPES = Literal["fields", "customs", "optionals", "excludes"]
|
|
9
9
|
SCHEMA_TYPES = Literal["In", "Out", "Patch", "Related"]
|
|
10
10
|
VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
|
|
11
|
-
JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey
|
|
11
|
+
JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey | jwk.OctKey
|
|
12
12
|
|
|
13
13
|
class ModelSerializerType(type):
|
|
14
14
|
def __repr__(self):
|
ninja_aio/views/api.py
CHANGED
|
@@ -5,6 +5,7 @@ from ninja.constants import NOT_SET
|
|
|
5
5
|
from ninja.pagination import paginate, AsyncPaginationBase, PageNumberPagination
|
|
6
6
|
from django.http import HttpRequest
|
|
7
7
|
from django.db.models import Model, QuerySet
|
|
8
|
+
from django.conf import settings
|
|
8
9
|
from pydantic import create_model
|
|
9
10
|
|
|
10
11
|
from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
|
|
@@ -18,18 +19,16 @@ from ninja_aio.helpers.api import ManyToManyAPI
|
|
|
18
19
|
from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
|
|
19
20
|
from ninja_aio.decorators import unique_view, decorate_view
|
|
20
21
|
|
|
21
|
-
ERROR_CODES = frozenset({400, 401, 404
|
|
22
|
+
ERROR_CODES = frozenset({400, 401, 404})
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
class
|
|
25
|
-
api: NinjaAPI
|
|
26
|
-
router_tag: str
|
|
27
|
-
|
|
25
|
+
class API:
|
|
26
|
+
api: NinjaAPI = None
|
|
27
|
+
router_tag: str = ""
|
|
28
|
+
router_tags: list[str] = []
|
|
29
|
+
api_route_path: str = ""
|
|
28
30
|
auth: list | None = NOT_SET
|
|
29
|
-
|
|
30
|
-
def __init__(self) -> None:
|
|
31
|
-
self.router = Router(tags=[self.router_tag])
|
|
32
|
-
self.error_codes = ERROR_CODES
|
|
31
|
+
router: Router = None
|
|
33
32
|
|
|
34
33
|
def views(self):
|
|
35
34
|
"""
|
|
@@ -38,7 +37,7 @@ class APIView:
|
|
|
38
37
|
async def some_method(request, *args, **kwargs):
|
|
39
38
|
pass
|
|
40
39
|
|
|
41
|
-
You can add
|
|
40
|
+
You can add views just doing:
|
|
42
41
|
|
|
43
42
|
@self.router.get(some_path, response=some_schema)
|
|
44
43
|
async def some_method(request, *args, **kwargs):
|
|
@@ -63,20 +62,85 @@ class APIView:
|
|
|
63
62
|
async def some_method(request, *args, **kwargs):
|
|
64
63
|
pass
|
|
65
64
|
"""
|
|
65
|
+
pass
|
|
66
66
|
|
|
67
67
|
def _add_views(self):
|
|
68
|
-
self.
|
|
69
|
-
|
|
68
|
+
for name in dir(self.__class__):
|
|
69
|
+
method = getattr(self.__class__, name)
|
|
70
|
+
if hasattr(method, "_api_register"):
|
|
71
|
+
method._api_register(self)
|
|
70
72
|
|
|
71
73
|
def add_views_to_route(self):
|
|
72
74
|
return self.api.add_router(f"{self.api_route_path}", self._add_views())
|
|
73
75
|
|
|
74
76
|
|
|
75
|
-
class
|
|
77
|
+
class APIView(API):
|
|
78
|
+
"""
|
|
79
|
+
Base class to register custom, non-CRUD endpoints on a Ninja Router.
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
from ninja_aio.decorations import api_get
|
|
83
|
+
|
|
84
|
+
@api.view(prefix="/custom", tags=["Custom"])
|
|
85
|
+
class CustomAPIView(APIView):
|
|
86
|
+
@api_get("/hello", response=SomeSchema)
|
|
87
|
+
async def hello(request):
|
|
88
|
+
return SomeSchema(...)
|
|
89
|
+
|
|
90
|
+
or
|
|
91
|
+
|
|
92
|
+
class CustomAPIView(APIView):
|
|
93
|
+
api = api
|
|
94
|
+
api_route_path = "/custom"
|
|
95
|
+
router_tags = ["Custom"]
|
|
96
|
+
|
|
97
|
+
def views(self):
|
|
98
|
+
@self.router.get("/hello", response=SomeSchema)
|
|
99
|
+
async def hello(request):
|
|
100
|
+
return SomeSchema(...)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
CustomAPIView().add_views_to_route()
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
api: NinjaAPI instance used to mount the router.
|
|
107
|
+
router_tag: Single tag used if router_tags is not provided.
|
|
108
|
+
router_tags: List of tags assigned to the router.
|
|
109
|
+
api_route_path: Base path where the router is mounted.
|
|
110
|
+
auth: Default auth list or NOT_SET for unauthenticated endpoints.
|
|
111
|
+
router: Router instance where views are registered.
|
|
112
|
+
error_codes: Common error codes returned by endpoints.
|
|
113
|
+
|
|
114
|
+
Overridable methods:
|
|
115
|
+
views(): Register your endpoints using self.router.get/post/patch/delete.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self, api: NinjaAPI = None, prefix: str = None, tags: list[str] = None
|
|
120
|
+
) -> None:
|
|
121
|
+
self.api = api or self.api
|
|
122
|
+
self.api_route_path = prefix or self.api_route_path
|
|
123
|
+
self.router_tags = tags or self.router_tags or [self.router_tag]
|
|
124
|
+
self.router = Router(tags=self.router_tags)
|
|
125
|
+
self.error_codes = ERROR_CODES
|
|
126
|
+
|
|
127
|
+
def _add_views(self):
|
|
128
|
+
super()._add_views()
|
|
129
|
+
self.views()
|
|
130
|
+
return self.router
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class APIViewSet(API):
|
|
76
134
|
"""
|
|
77
135
|
Base viewset generating async CRUD + optional M2M endpoints for a Django model.
|
|
78
136
|
|
|
79
137
|
Usage:
|
|
138
|
+
@api.viewset(model=MyModel)
|
|
139
|
+
class MyModelViewSet(APIViewSet):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
or
|
|
143
|
+
|
|
80
144
|
class MyModelViewSet(APIViewSet):
|
|
81
145
|
model = MyModel
|
|
82
146
|
api = api
|
|
@@ -109,8 +173,8 @@ class APIViewSet:
|
|
|
109
173
|
dict, and must return the (optionally) filtered queryset.
|
|
110
174
|
|
|
111
175
|
Example:
|
|
176
|
+
@api.viewset(model=models.User)
|
|
112
177
|
class UserViewSet(APIViewSet):
|
|
113
|
-
model = models.User
|
|
114
178
|
m2m_relations = [
|
|
115
179
|
M2MRelationSchema(
|
|
116
180
|
model=models.Tag,
|
|
@@ -149,7 +213,7 @@ class APIViewSet:
|
|
|
149
213
|
<related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
|
|
150
214
|
|
|
151
215
|
Error responses:
|
|
152
|
-
All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404
|
|
216
|
+
All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404).
|
|
153
217
|
|
|
154
218
|
Internal:
|
|
155
219
|
Dynamic path/filter schemas built with pydantic.create_model.
|
|
@@ -157,12 +221,9 @@ class APIViewSet:
|
|
|
157
221
|
"""
|
|
158
222
|
|
|
159
223
|
model: ModelSerializer | Model
|
|
160
|
-
api: NinjaAPI
|
|
161
|
-
router_tag: str = ""
|
|
162
224
|
schema_in: Schema | None = None
|
|
163
225
|
schema_out: Schema | None = None
|
|
164
226
|
schema_update: Schema | None = None
|
|
165
|
-
auth: list | None = NOT_SET
|
|
166
227
|
get_auth: list | None = NOT_SET
|
|
167
228
|
post_auth: list | None = NOT_SET
|
|
168
229
|
patch_auth: list | None = NOT_SET
|
|
@@ -170,7 +231,6 @@ class APIViewSet:
|
|
|
170
231
|
pagination_class: type[AsyncPaginationBase] = PageNumberPagination
|
|
171
232
|
query_params: dict[str, tuple[type, ...]] = {}
|
|
172
233
|
disable: list[type[VIEW_TYPES]] = []
|
|
173
|
-
api_route_path: str = ""
|
|
174
234
|
list_docs = "List all objects."
|
|
175
235
|
create_docs = "Create a new object."
|
|
176
236
|
retrieve_docs = "Retrieve a specific object by its primary key."
|
|
@@ -180,8 +240,16 @@ class APIViewSet:
|
|
|
180
240
|
m2m_auth: list | None = NOT_SET
|
|
181
241
|
extra_decorators: DecoratorsSchema = DecoratorsSchema()
|
|
182
242
|
|
|
183
|
-
def __init__(
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
api: NinjaAPI = None,
|
|
246
|
+
model: Model | ModelSerializer = None,
|
|
247
|
+
prefix: str = None,
|
|
248
|
+
tags: list[str] = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
self.api = api or self.api
|
|
184
251
|
self.error_codes = ERROR_CODES
|
|
252
|
+
self.model = model or self.model
|
|
185
253
|
self.model_util = (
|
|
186
254
|
ModelUtil(self.model)
|
|
187
255
|
if not isinstance(self.model, ModelSerializerMeta)
|
|
@@ -191,16 +259,22 @@ class APIViewSet:
|
|
|
191
259
|
self.path_schema = self._generate_path_schema()
|
|
192
260
|
self.filters_schema = self._generate_filters_schema()
|
|
193
261
|
self.model_verbose_name = self.model._meta.verbose_name.capitalize()
|
|
194
|
-
self.router_tag =
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
self.
|
|
198
|
-
self.path = "/"
|
|
262
|
+
self.router_tag = self.router_tag or self.model_verbose_name
|
|
263
|
+
self.router_tags = self.router_tags or tags or [self.router_tag]
|
|
264
|
+
self.router = Router(tags=self.router_tags)
|
|
265
|
+
self.append_slash = getattr(settings, "NINJA_AIO_APPEND_SLASH", True)
|
|
266
|
+
self.path = "/" if self.append_slash else ""
|
|
199
267
|
self.get_path = ""
|
|
200
|
-
self.path_retrieve = f"{{{self.model_util.model_pk_name}}}/"
|
|
201
268
|
self.get_path_retrieve = f"{{{self.model_util.model_pk_name}}}"
|
|
269
|
+
self.path_retrieve = (
|
|
270
|
+
f"{self.get_path_retrieve}/"
|
|
271
|
+
if self.append_slash
|
|
272
|
+
else self.get_path_retrieve
|
|
273
|
+
)
|
|
202
274
|
self.api_route_path = (
|
|
203
|
-
self.api_route_path
|
|
275
|
+
self.api_route_path
|
|
276
|
+
or prefix
|
|
277
|
+
or self.model_util.verbose_name_path_resolver()
|
|
204
278
|
)
|
|
205
279
|
self.m2m_api = (
|
|
206
280
|
None
|
|
@@ -439,6 +513,7 @@ class APIViewSet:
|
|
|
439
513
|
Register CRUD (unless disabled), custom views, and M2M endpoints.
|
|
440
514
|
If 'all' in disable only CRUD is skipped; M2M + custom still added.
|
|
441
515
|
"""
|
|
516
|
+
super()._add_views()
|
|
442
517
|
if "all" in self.disable:
|
|
443
518
|
return self._set_additional_views()
|
|
444
519
|
|
|
@@ -450,18 +525,18 @@ class APIViewSet:
|
|
|
450
525
|
|
|
451
526
|
return self._set_additional_views()
|
|
452
527
|
|
|
453
|
-
def add_views_to_route(self):
|
|
454
|
-
"""
|
|
455
|
-
Attach router with registered endpoints to the NinjaAPI instance.
|
|
456
|
-
"""
|
|
457
|
-
return self.api.add_router(f"{self.api_route_path}", self._add_views())
|
|
458
|
-
|
|
459
528
|
|
|
460
529
|
class ReadOnlyViewSet(APIViewSet):
|
|
461
530
|
"""
|
|
462
531
|
ReadOnly viewset generating async List + Retrieve endpoints for a Django model.
|
|
463
532
|
|
|
464
533
|
Usage:
|
|
534
|
+
@api.viewset(model=MyModel)
|
|
535
|
+
class MyModelReadOnlyViewSet(ReadOnlyViewSet):
|
|
536
|
+
pass
|
|
537
|
+
|
|
538
|
+
or
|
|
539
|
+
|
|
465
540
|
class MyModelReadOnlyViewSet(ReadOnlyViewSet):
|
|
466
541
|
model = MyModel
|
|
467
542
|
api = api
|
|
@@ -476,10 +551,15 @@ class WriteOnlyViewSet(APIViewSet):
|
|
|
476
551
|
WriteOnly viewset generating async Create + Update + Delete endpoints for a Django model.
|
|
477
552
|
|
|
478
553
|
Usage:
|
|
554
|
+
@api.viewset(model=MyModel)
|
|
555
|
+
class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
or
|
|
559
|
+
|
|
479
560
|
class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
|
|
480
561
|
model = MyModel
|
|
481
562
|
api = api
|
|
482
|
-
MyModelWriteOnlyViewSet().add_views_to_route()
|
|
483
563
|
"""
|
|
484
564
|
|
|
485
565
|
disable = ["list", "retrieve"]
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
ninja_aio/__init__.py,sha256=YGWehZQ4d5yVFCd1ax-s61BSkND60Yh383YmUJq-LtY,119
|
|
2
|
-
ninja_aio/api.py,sha256=SS1TYUiFkdYjfJLVy6GI90GOzvIHzPEeL-UcqWFRHkM,1684
|
|
3
|
-
ninja_aio/auth.py,sha256=w9TXDQwJSZzCbncsSwemINTUe1IPZGHnLKiqWTeOoFA,9163
|
|
4
|
-
ninja_aio/decorators.py,sha256=BHoFIiqdIVMFqSxGh-F6WeZFo1xZK4ieDw3dzKfxZIM,8147
|
|
5
|
-
ninja_aio/exceptions.py,sha256=1-iRbrloIyi0CR6Tcrn5YR4_LloA7PPohKIBaxXJ0-8,2596
|
|
6
|
-
ninja_aio/models.py,sha256=aJlo5a64O4o-fB8QESLMUJpoA5kcjRJxPBiAIMxg46k,47652
|
|
7
|
-
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
8
|
-
ninja_aio/renders.py,sha256=VtmSliRJyZ6gjyoib8AXMVUBYF1jPNsiceCHujI_mAs,1699
|
|
9
|
-
ninja_aio/types.py,sha256=_QV0DySZhCV1otuh1zhrlHgCaFnr5fd64GQrcA-qKoc,564
|
|
10
|
-
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
ninja_aio/helpers/api.py,sha256=BTe7OL-X7YgWYeXmka8TmN4-gA43FVZhtH7q0dRjYX0,20238
|
|
12
|
-
ninja_aio/helpers/query.py,sha256=tE8RjXvSig-WB_0LRQ0LqoE4G_HMHsu0Na5QzTNIm6U,4262
|
|
13
|
-
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
14
|
-
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
15
|
-
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
16
|
-
ninja_aio/schemas/helpers.py,sha256=rmE0D15lJg95Unv8PU44Hbf0VDTcErMCZZFG3D_znTo,2823
|
|
17
|
-
ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
|
|
18
|
-
ninja_aio/views/api.py,sha256=ispbr5w6WCUdbXmn2LY94zDQ0qb2nDNeTLLNAUBFoDc,17736
|
|
19
|
-
ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
|
|
20
|
-
django_ninja_aio_crud-2.1.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
21
|
-
django_ninja_aio_crud-2.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
22
|
-
django_ninja_aio_crud-2.1.0.dist-info/METADATA,sha256=91kIanSvlN_aeRmhZuXZR2pIBXEzUUOuYLJkijzN7QI,8672
|
|
23
|
-
django_ninja_aio_crud-2.1.0.dist-info/RECORD,,
|
|
File without changes
|
{django_ninja_aio_crud-2.1.0.dist-info → django_ninja_aio_crud-2.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|