django-ninja-expose 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_ninja_expose-0.1.0/PKG-INFO +65 -0
- django_ninja_expose-0.1.0/README.md +54 -0
- django_ninja_expose-0.1.0/pyproject.toml +29 -0
- django_ninja_expose-0.1.0/pyproject.toml.orig +28 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/__init__.py +20 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/api.py +18 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/config.py +17 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/decorator.py +88 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/exceptions.py +21 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/introspection.py +28 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/operations.py +179 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/py.typed +0 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/queries.py +75 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/registry.py +21 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/router.py +27 -0
- django_ninja_expose-0.1.0/src/django_ninja_expose/schemas.py +69 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-ninja-expose
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add @expose() to a Django model. Get a REST API, powered by Django Ninja.
|
|
5
|
+
Author: Benjamin Chau
|
|
6
|
+
Author-email: Benjamin Chau <68836494+swarfte@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: django>=6.1.3
|
|
8
|
+
Requires-Dist: django-ninja>=1.7.3
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# django-ninja-expose
|
|
13
|
+
|
|
14
|
+
Add `@expose()` to a Django model. Get a REST API.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from django.db import models
|
|
18
|
+
from django_ninja_expose import expose
|
|
19
|
+
|
|
20
|
+
@expose()
|
|
21
|
+
class Task(models.Model):
|
|
22
|
+
title = models.CharField(max_length=200)
|
|
23
|
+
completed = models.BooleanField(default=False)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# urls.py
|
|
28
|
+
from django.urls import path
|
|
29
|
+
from ninja import NinjaAPI
|
|
30
|
+
from django_ninja_expose import mount
|
|
31
|
+
|
|
32
|
+
api = NinjaAPI()
|
|
33
|
+
mount(api) # or api.add_router("", get_router())
|
|
34
|
+
|
|
35
|
+
urlpatterns = [path("api/", api.urls)]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You get `GET/POST /api/Task`, `GET/PUT/PATCH/DELETE/HEAD /api/Task/{pk}`, Django-native
|
|
39
|
+
filtering (`?title__icontains=x&completed=false`), Ninja pagination and OpenAPI docs.
|
|
40
|
+
|
|
41
|
+
## `@expose(path=None, methods=None, fields=None, query=None)`
|
|
42
|
+
|
|
43
|
+
`None` = automatic, `[]` = none, list = explicit restriction.
|
|
44
|
+
|
|
45
|
+
| arg | `None` | `[]` | list |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `path` | class name | n/a | URL path |
|
|
48
|
+
| `methods` | all six (`HEAD` is never implied) | no operations | only these |
|
|
49
|
+
| `fields` | all concrete fields + M2M | no fields | only these |
|
|
50
|
+
| `query` | all exposed fields | filtering disabled | only these (subset of `fields`) |
|
|
51
|
+
|
|
52
|
+
Invalid configuration fails at import time (`ExposeConfigurationError` and subclasses);
|
|
53
|
+
duplicate paths fail when the router is built.
|
|
54
|
+
|
|
55
|
+
## Security warning
|
|
56
|
+
|
|
57
|
+
`@expose()` exposes model data and CRUD operations by default, with no authentication.
|
|
58
|
+
Explicitly restrict `methods`, `fields` and `query` for production APIs, and add auth
|
|
59
|
+
on your `NinjaAPI`.
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- Foreign keys and many-to-many fields are represented by primary keys (no nesting).
|
|
64
|
+
- Filters accept Django lookups/transforms on whitelisted fields only; relationship traversal is rejected (HTTP 400).
|
|
65
|
+
- Writes run Django's `full_clean()`; non-editable fields (e.g. `auto_now_add`) are never writable.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# django-ninja-expose
|
|
2
|
+
|
|
3
|
+
Add `@expose()` to a Django model. Get a REST API.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django_ninja_expose import expose
|
|
8
|
+
|
|
9
|
+
@expose()
|
|
10
|
+
class Task(models.Model):
|
|
11
|
+
title = models.CharField(max_length=200)
|
|
12
|
+
completed = models.BooleanField(default=False)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# urls.py
|
|
17
|
+
from django.urls import path
|
|
18
|
+
from ninja import NinjaAPI
|
|
19
|
+
from django_ninja_expose import mount
|
|
20
|
+
|
|
21
|
+
api = NinjaAPI()
|
|
22
|
+
mount(api) # or api.add_router("", get_router())
|
|
23
|
+
|
|
24
|
+
urlpatterns = [path("api/", api.urls)]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You get `GET/POST /api/Task`, `GET/PUT/PATCH/DELETE/HEAD /api/Task/{pk}`, Django-native
|
|
28
|
+
filtering (`?title__icontains=x&completed=false`), Ninja pagination and OpenAPI docs.
|
|
29
|
+
|
|
30
|
+
## `@expose(path=None, methods=None, fields=None, query=None)`
|
|
31
|
+
|
|
32
|
+
`None` = automatic, `[]` = none, list = explicit restriction.
|
|
33
|
+
|
|
34
|
+
| arg | `None` | `[]` | list |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| `path` | class name | n/a | URL path |
|
|
37
|
+
| `methods` | all six (`HEAD` is never implied) | no operations | only these |
|
|
38
|
+
| `fields` | all concrete fields + M2M | no fields | only these |
|
|
39
|
+
| `query` | all exposed fields | filtering disabled | only these (subset of `fields`) |
|
|
40
|
+
|
|
41
|
+
Invalid configuration fails at import time (`ExposeConfigurationError` and subclasses);
|
|
42
|
+
duplicate paths fail when the router is built.
|
|
43
|
+
|
|
44
|
+
## Security warning
|
|
45
|
+
|
|
46
|
+
`@expose()` exposes model data and CRUD operations by default, with no authentication.
|
|
47
|
+
Explicitly restrict `methods`, `fields` and `query` for production APIs, and add auth
|
|
48
|
+
on your `NinjaAPI`.
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
|
|
52
|
+
- Foreign keys and many-to-many fields are represented by primary keys (no nesting).
|
|
53
|
+
- Filters accept Django lookups/transforms on whitelisted fields only; relationship traversal is rejected (HTTP 400).
|
|
54
|
+
- Writes run Django's `full_clean()`; non-editable fields (e.g. `auto_now_add`) are never writable.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-ninja-expose"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add @expose() to a Django model. Get a REST API, powered by Django Ninja."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"django>=6.1.3",
|
|
9
|
+
"django-ninja>=1.7.3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[[project.authors]]
|
|
13
|
+
name = "Benjamin Chau"
|
|
14
|
+
email = "68836494+swarfte@users.noreply.github.com"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=9.2.0",
|
|
23
|
+
"pytest-django>=4.14.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
28
|
+
pythonpath = ["."]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-ninja-expose"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add @expose() to a Django model. Get a REST API, powered by Django Ninja."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Benjamin Chau", email = "68836494+swarfte@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"django>=6.1.3",
|
|
12
|
+
"django-ninja>=1.7.3",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.2.0",
|
|
22
|
+
"pytest-django>=4.14.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
27
|
+
pythonpath = ["."]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .api import get_router, mount
|
|
2
|
+
from .decorator import expose
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
ExposeConfigurationError,
|
|
5
|
+
InvalidFieldError,
|
|
6
|
+
InvalidLookupError,
|
|
7
|
+
InvalidQueryFieldError,
|
|
8
|
+
UnsupportedMethodError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"expose",
|
|
13
|
+
"get_router",
|
|
14
|
+
"mount",
|
|
15
|
+
"ExposeConfigurationError",
|
|
16
|
+
"UnsupportedMethodError",
|
|
17
|
+
"InvalidFieldError",
|
|
18
|
+
"InvalidQueryFieldError",
|
|
19
|
+
"InvalidLookupError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Convenience entry points for wiring exposed models into a NinjaAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ninja import NinjaAPI, Router
|
|
6
|
+
|
|
7
|
+
from .router import build_router
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_router() -> Router:
|
|
11
|
+
"""Router containing every ``@expose()``d model. Call after apps are loaded."""
|
|
12
|
+
return build_router()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def mount(api: NinjaAPI, prefix: str = "") -> NinjaAPI:
|
|
16
|
+
"""Attach all exposed models to ``api``."""
|
|
17
|
+
api.add_router(prefix, get_router())
|
|
18
|
+
return api
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exposure configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
SUPPORTED_METHODS: tuple[str, ...] = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ExposureConfig:
|
|
12
|
+
"""Fully resolved configuration (no ``None`` values) for one model."""
|
|
13
|
+
|
|
14
|
+
path: str
|
|
15
|
+
methods: tuple[str, ...]
|
|
16
|
+
fields: tuple[str, ...]
|
|
17
|
+
query: tuple[str, ...]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The public ``@expose()`` decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from django.db.models import Model
|
|
9
|
+
|
|
10
|
+
from . import registry
|
|
11
|
+
from .config import SUPPORTED_METHODS, ExposureConfig
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
ExposeConfigurationError,
|
|
14
|
+
InvalidFieldError,
|
|
15
|
+
InvalidQueryFieldError,
|
|
16
|
+
UnsupportedMethodError,
|
|
17
|
+
)
|
|
18
|
+
from .introspection import get_eligible_fields
|
|
19
|
+
|
|
20
|
+
M = TypeVar("M", bound=type[Model])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve(
|
|
24
|
+
model: type[Model],
|
|
25
|
+
path: str | None,
|
|
26
|
+
methods: Iterable[str] | None,
|
|
27
|
+
fields: Iterable[str] | None,
|
|
28
|
+
query: Iterable[str] | None,
|
|
29
|
+
) -> ExposureConfig:
|
|
30
|
+
if path is not None and (not isinstance(path, str) or not path.strip("/")):
|
|
31
|
+
raise ExposeConfigurationError(f"Invalid path {path!r} for {model.__name__}.")
|
|
32
|
+
resolved_path = path.strip("/") if path is not None else model.__name__
|
|
33
|
+
|
|
34
|
+
if methods is None:
|
|
35
|
+
resolved_methods = SUPPORTED_METHODS
|
|
36
|
+
else:
|
|
37
|
+
seen: list[str] = []
|
|
38
|
+
for m in methods:
|
|
39
|
+
upper = str(m).upper()
|
|
40
|
+
if upper not in SUPPORTED_METHODS:
|
|
41
|
+
raise UnsupportedMethodError(f"{m} is not a supported HTTP method.")
|
|
42
|
+
if upper not in seen:
|
|
43
|
+
seen.append(upper)
|
|
44
|
+
resolved_methods = tuple(seen)
|
|
45
|
+
|
|
46
|
+
eligible = get_eligible_fields(model)
|
|
47
|
+
if fields is None:
|
|
48
|
+
resolved_fields = tuple(eligible)
|
|
49
|
+
else:
|
|
50
|
+
resolved_fields = tuple(dict.fromkeys(fields))
|
|
51
|
+
for name in resolved_fields:
|
|
52
|
+
if name not in eligible:
|
|
53
|
+
raise InvalidFieldError(f"{model.__name__} has no field named '{name}'.")
|
|
54
|
+
|
|
55
|
+
if query is None:
|
|
56
|
+
resolved_query = resolved_fields
|
|
57
|
+
else:
|
|
58
|
+
resolved_query = tuple(dict.fromkeys(query))
|
|
59
|
+
for name in resolved_query:
|
|
60
|
+
if name not in eligible:
|
|
61
|
+
raise InvalidQueryFieldError(
|
|
62
|
+
f"{model.__name__} has no field named '{name}' (used in query)."
|
|
63
|
+
)
|
|
64
|
+
if name not in resolved_fields:
|
|
65
|
+
raise InvalidQueryFieldError(
|
|
66
|
+
f"Query field '{name}' of {model.__name__} is not in the exposed fields."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return ExposureConfig(resolved_path, resolved_methods, resolved_fields, resolved_query)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def expose(
|
|
73
|
+
path: str | None = None,
|
|
74
|
+
methods: Iterable[str] | None = None,
|
|
75
|
+
fields: Iterable[str] | None = None,
|
|
76
|
+
query: Iterable[str] | None = None,
|
|
77
|
+
):
|
|
78
|
+
"""Expose a Django model as a REST API.
|
|
79
|
+
|
|
80
|
+
``None`` means automatic, ``[]`` means none, a list means explicit restriction.
|
|
81
|
+
The model class is returned unchanged.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def decorator(model: M) -> M:
|
|
85
|
+
registry.register(model, _resolve(model, path, methods, fields, query))
|
|
86
|
+
return model
|
|
87
|
+
|
|
88
|
+
return decorator
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions raised by django-ninja-expose."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExposeConfigurationError(Exception):
|
|
5
|
+
"""Raised at startup when an ``@expose()`` configuration is invalid."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedMethodError(ExposeConfigurationError):
|
|
9
|
+
"""An unsupported HTTP method was requested."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidFieldError(ExposeConfigurationError):
|
|
13
|
+
"""``fields`` references a field the model does not have."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidQueryFieldError(ExposeConfigurationError):
|
|
17
|
+
"""``query`` references an unknown field or a field that is not exposed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidLookupError(Exception):
|
|
21
|
+
"""A request used an invalid filter field or lookup (mapped to HTTP 400)."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Thin wrappers around ``Model._meta``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.db.models import Model
|
|
7
|
+
from django.db.models.fields import AutoFieldMixin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_eligible_fields(model: type[Model]) -> dict[str, models.Field]:
|
|
11
|
+
"""Concrete fields plus forward many-to-many fields, keyed by name."""
|
|
12
|
+
meta = model._meta
|
|
13
|
+
result: dict[str, models.Field] = {f.name: f for f in meta.concrete_fields}
|
|
14
|
+
result.update({f.name: f for f in meta.many_to_many})
|
|
15
|
+
return result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_pk(model: type[Model]) -> models.Field:
|
|
19
|
+
return model._meta.pk
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_auto_pk(field: models.Field) -> bool:
|
|
23
|
+
return isinstance(field, AutoFieldMixin)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_writable(field: models.Field) -> bool:
|
|
27
|
+
"""Whether a field may appear in create/update payloads."""
|
|
28
|
+
return bool(field.editable) and not is_auto_pk(field)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Operation factory: CRUD views registered on a Ninja router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.core.exceptions import ValidationError
|
|
8
|
+
from django.db import models, transaction
|
|
9
|
+
from django.db.models import Model
|
|
10
|
+
from django.http import HttpRequest, HttpResponse
|
|
11
|
+
from django.shortcuts import get_object_or_404
|
|
12
|
+
from django.utils.module_loading import import_string
|
|
13
|
+
from ninja import Router, Status
|
|
14
|
+
from ninja.conf import settings as ninja_settings
|
|
15
|
+
from ninja.errors import HttpError
|
|
16
|
+
from ninja.errors import ValidationError as NinjaValidationError
|
|
17
|
+
from ninja.pagination import paginate
|
|
18
|
+
|
|
19
|
+
from .config import ExposureConfig
|
|
20
|
+
from .exceptions import InvalidLookupError
|
|
21
|
+
from .introspection import get_eligible_fields
|
|
22
|
+
from .queries import apply_filters, build_filters
|
|
23
|
+
from .schemas import ModelSchemas
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reserved_params() -> set[str]:
|
|
27
|
+
"""Query params owned by the Ninja pagination class (limit/offset/page...)."""
|
|
28
|
+
paginator = import_string(ninja_settings.PAGINATION_CLASS)
|
|
29
|
+
return set(paginator.Input.model_fields)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validation_error(exc: ValidationError) -> NinjaValidationError:
|
|
33
|
+
if hasattr(exc, "error_dict"):
|
|
34
|
+
errors = [
|
|
35
|
+
{"loc": ["body", k], "msg": "; ".join(v), "type": "value_error"}
|
|
36
|
+
for k, v in exc.message_dict.items()
|
|
37
|
+
]
|
|
38
|
+
else:
|
|
39
|
+
errors = [{"loc": ["body"], "msg": "; ".join(exc.messages), "type": "value_error"}]
|
|
40
|
+
return NinjaValidationError(errors)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_field(model: type[Model], key: str) -> models.Field:
|
|
44
|
+
try:
|
|
45
|
+
return model._meta.get_field(key)
|
|
46
|
+
except Exception: # alias such as `project_id`
|
|
47
|
+
return model._meta.get_field(key.removesuffix("_id"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply(model: type[Model], instance: Model, data: dict[str, Any]) -> dict[str, list]:
|
|
51
|
+
"""Assign payload data to instance; return m2m values to set after save."""
|
|
52
|
+
m2m: dict[str, list] = {}
|
|
53
|
+
for key, value in data.items():
|
|
54
|
+
field = _resolve_field(model, key)
|
|
55
|
+
if field.many_to_many:
|
|
56
|
+
if value is None: # omitted optional m2m: leave unchanged
|
|
57
|
+
continue
|
|
58
|
+
m2m[field.name] = list(value or [])
|
|
59
|
+
elif field.is_relation:
|
|
60
|
+
setattr(instance, field.attname, value)
|
|
61
|
+
else:
|
|
62
|
+
setattr(instance, field.name, value)
|
|
63
|
+
return m2m
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _save(model: type[Model], instance: Model, data: dict[str, Any]) -> Model:
|
|
67
|
+
try:
|
|
68
|
+
with transaction.atomic():
|
|
69
|
+
m2m = _apply(model, instance, data)
|
|
70
|
+
# Validate only what the payload touched (relevant for PATCH).
|
|
71
|
+
touched = {_resolve_field(model, k).name for k in data}
|
|
72
|
+
exclude = [f.name for f in model._meta.fields if f.name not in touched]
|
|
73
|
+
instance.full_clean(exclude=exclude)
|
|
74
|
+
for name, ids in m2m.items():
|
|
75
|
+
related = model._meta.get_field(name).remote_field.model
|
|
76
|
+
if related._default_manager.filter(pk__in=ids).count() != len(set(ids)):
|
|
77
|
+
raise ValidationError({name: ["Some related objects do not exist."]})
|
|
78
|
+
instance.save()
|
|
79
|
+
for name, ids in m2m.items():
|
|
80
|
+
getattr(instance, name).set(ids)
|
|
81
|
+
except ValidationError as exc:
|
|
82
|
+
raise _validation_error(exc) from exc
|
|
83
|
+
return instance
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_operations(
|
|
87
|
+
router: Router, model: type[Model], config: ExposureConfig, schemas: ModelSchemas
|
|
88
|
+
) -> None:
|
|
89
|
+
collection = f"/{config.path}"
|
|
90
|
+
detail = f"/{config.path}/{{pk}}"
|
|
91
|
+
prefix = model._meta.label_lower.replace(".", "_")
|
|
92
|
+
methods = config.methods
|
|
93
|
+
reserved = _reserved_params()
|
|
94
|
+
Read, pk_type = schemas.read, schemas.pk_type
|
|
95
|
+
|
|
96
|
+
def queryset() -> models.QuerySet:
|
|
97
|
+
return model._default_manager.all()
|
|
98
|
+
|
|
99
|
+
def filtered(request: HttpRequest) -> models.QuerySet:
|
|
100
|
+
params = {k: request.GET[k] for k in request.GET}
|
|
101
|
+
try:
|
|
102
|
+
filters = build_filters(model, config.query, params, reserved)
|
|
103
|
+
return apply_filters(queryset(), filters)
|
|
104
|
+
except InvalidLookupError as exc:
|
|
105
|
+
raise HttpError(400, str(exc)) from exc
|
|
106
|
+
|
|
107
|
+
def add(url: str, http_method: str, func, name: str, **kwargs) -> None:
|
|
108
|
+
func.__name__ = name
|
|
109
|
+
router.add_api_operation(
|
|
110
|
+
url,
|
|
111
|
+
[http_method],
|
|
112
|
+
func,
|
|
113
|
+
operation_id=f"{prefix}_{name}",
|
|
114
|
+
tags=[model.__name__],
|
|
115
|
+
**kwargs,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if "GET" in methods:
|
|
119
|
+
|
|
120
|
+
def list_view(request: HttpRequest, **kwargs):
|
|
121
|
+
return filtered(request)
|
|
122
|
+
|
|
123
|
+
add(collection, "GET", paginate()(list_view), "list", response=list[Read])
|
|
124
|
+
|
|
125
|
+
def get_view(request: HttpRequest, pk):
|
|
126
|
+
return get_object_or_404(queryset(), pk=pk)
|
|
127
|
+
|
|
128
|
+
get_view.__annotations__ = {"pk": pk_type}
|
|
129
|
+
add(detail, "GET", get_view, "get", response=Read)
|
|
130
|
+
|
|
131
|
+
if "POST" in methods:
|
|
132
|
+
|
|
133
|
+
def create_view(request: HttpRequest, payload):
|
|
134
|
+
return Status(201, _save(model, model(), payload.model_dump()))
|
|
135
|
+
|
|
136
|
+
create_view.__annotations__ = {"payload": schemas.create}
|
|
137
|
+
add(collection, "POST", create_view, "create", response={201: Read})
|
|
138
|
+
|
|
139
|
+
if "PUT" in methods:
|
|
140
|
+
|
|
141
|
+
def put_view(request: HttpRequest, pk, payload):
|
|
142
|
+
obj = get_object_or_404(queryset(), pk=pk)
|
|
143
|
+
return _save(model, obj, payload.model_dump())
|
|
144
|
+
|
|
145
|
+
put_view.__annotations__ = {"pk": pk_type, "payload": schemas.update}
|
|
146
|
+
add(detail, "PUT", put_view, "update", response=Read)
|
|
147
|
+
|
|
148
|
+
if "PATCH" in methods:
|
|
149
|
+
|
|
150
|
+
def patch_view(request: HttpRequest, pk, payload):
|
|
151
|
+
obj = get_object_or_404(queryset(), pk=pk)
|
|
152
|
+
return _save(model, obj, payload.model_dump(exclude_unset=True))
|
|
153
|
+
|
|
154
|
+
patch_view.__annotations__ = {"pk": pk_type, "payload": schemas.patch}
|
|
155
|
+
add(detail, "PATCH", patch_view, "patch", response=Read)
|
|
156
|
+
|
|
157
|
+
if "DELETE" in methods:
|
|
158
|
+
|
|
159
|
+
def delete_view(request: HttpRequest, pk):
|
|
160
|
+
get_object_or_404(queryset(), pk=pk).delete()
|
|
161
|
+
return Status(204, None)
|
|
162
|
+
|
|
163
|
+
delete_view.__annotations__ = {"pk": pk_type}
|
|
164
|
+
add(detail, "DELETE", delete_view, "delete", response={204: None})
|
|
165
|
+
|
|
166
|
+
if "HEAD" in methods:
|
|
167
|
+
|
|
168
|
+
def head_list_view(request: HttpRequest):
|
|
169
|
+
filtered(request)
|
|
170
|
+
return HttpResponse(status=200)
|
|
171
|
+
|
|
172
|
+
add(collection, "HEAD", head_list_view, "head_list")
|
|
173
|
+
|
|
174
|
+
def head_detail_view(request: HttpRequest, pk):
|
|
175
|
+
get_object_or_404(queryset(), pk=pk)
|
|
176
|
+
return HttpResponse(status=200)
|
|
177
|
+
|
|
178
|
+
head_detail_view.__annotations__ = {"pk": pk_type}
|
|
179
|
+
add(detail, "HEAD", head_detail_view, "head_detail")
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Query engine: safely turn query parameters into ORM filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.exceptions import FieldError, ValidationError
|
|
9
|
+
from django.db import models
|
|
10
|
+
from django.db.models import Model, QuerySet
|
|
11
|
+
|
|
12
|
+
from .exceptions import InvalidLookupError
|
|
13
|
+
from .introspection import get_eligible_fields
|
|
14
|
+
|
|
15
|
+
LOOKUP_SEP = "__"
|
|
16
|
+
_BOOL = {"true": True, "false": False}
|
|
17
|
+
_MULTI = {"in", "range"}
|
|
18
|
+
_ORM_ERRORS = (FieldError, ValueError, TypeError, ValidationError)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _convert(raw: str, last: str, is_bool_field: bool) -> Any:
|
|
22
|
+
lowered = raw.lower()
|
|
23
|
+
if last == "isnull":
|
|
24
|
+
if lowered not in _BOOL:
|
|
25
|
+
raise InvalidLookupError("isnull expects 'true' or 'false'.")
|
|
26
|
+
return _BOOL[lowered]
|
|
27
|
+
if last in _MULTI:
|
|
28
|
+
return raw.split(",")
|
|
29
|
+
if is_bool_field and lowered in _BOOL:
|
|
30
|
+
return _BOOL[lowered]
|
|
31
|
+
return raw
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_filters(
|
|
35
|
+
model: type[Model],
|
|
36
|
+
allowed: Iterable[str],
|
|
37
|
+
params: Mapping[str, str],
|
|
38
|
+
reserved: Iterable[str] = (),
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Validate params against the whitelist and return ``filter(**kwargs)``."""
|
|
41
|
+
allowed = set(allowed)
|
|
42
|
+
reserved = set(reserved)
|
|
43
|
+
eligible = get_eligible_fields(model)
|
|
44
|
+
filters: dict[str, Any] = {}
|
|
45
|
+
|
|
46
|
+
for key, raw in params.items():
|
|
47
|
+
if key in reserved:
|
|
48
|
+
continue
|
|
49
|
+
base, *rest = key.split(LOOKUP_SEP)
|
|
50
|
+
if base not in allowed or base not in eligible:
|
|
51
|
+
raise InvalidLookupError(f"Filtering by '{base}' is not allowed.")
|
|
52
|
+
field = eligible[base]
|
|
53
|
+
|
|
54
|
+
if rest:
|
|
55
|
+
# The part directly after the field must be a lookup or transform of
|
|
56
|
+
# the field itself; this blocks relationship traversal (project__name).
|
|
57
|
+
first = rest[0]
|
|
58
|
+
if not (field.get_lookup(first) or field.get_transform(first)):
|
|
59
|
+
raise InvalidLookupError(f"Unsupported lookup '{first}' for field '{base}'.")
|
|
60
|
+
is_bool = isinstance(field, models.BooleanField) and not rest
|
|
61
|
+
filters[key] = _convert(raw, rest[-1] if rest else "exact", is_bool)
|
|
62
|
+
|
|
63
|
+
# Let Django validate the full expression (transform chains, value coercion).
|
|
64
|
+
try:
|
|
65
|
+
model._default_manager.none().filter(**filters)
|
|
66
|
+
except _ORM_ERRORS as exc:
|
|
67
|
+
raise InvalidLookupError(f"Invalid filter: {exc}") from exc
|
|
68
|
+
return filters
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply_filters(qs: QuerySet, filters: dict[str, Any]) -> QuerySet:
|
|
72
|
+
try:
|
|
73
|
+
return qs.filter(**filters)
|
|
74
|
+
except _ORM_ERRORS as exc:
|
|
75
|
+
raise InvalidLookupError(f"Invalid filter: {exc}") from exc
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lightweight Model -> ExposureConfig registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.db.models import Model
|
|
6
|
+
|
|
7
|
+
from .config import ExposureConfig
|
|
8
|
+
|
|
9
|
+
_registry: dict[type[Model], ExposureConfig] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(model: type[Model], config: ExposureConfig) -> None:
|
|
13
|
+
_registry[model] = config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_registry() -> dict[type[Model], ExposureConfig]:
|
|
17
|
+
return dict(_registry)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clear() -> None:
|
|
21
|
+
_registry.clear()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Build a Ninja Router from the exposure registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.db.models import Model
|
|
6
|
+
from ninja import Router
|
|
7
|
+
|
|
8
|
+
from .exceptions import ExposeConfigurationError
|
|
9
|
+
from .operations import register_operations
|
|
10
|
+
from .registry import get_registry
|
|
11
|
+
from .schemas import build_schemas
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_router(models: list[type[Model]] | None = None) -> Router:
|
|
15
|
+
"""Create a Router with endpoints for every exposed model (or the given ones)."""
|
|
16
|
+
registry = get_registry()
|
|
17
|
+
if models is not None:
|
|
18
|
+
registry = {m: registry[m] for m in models}
|
|
19
|
+
|
|
20
|
+
seen: dict[str, type[Model]] = {}
|
|
21
|
+
router = Router()
|
|
22
|
+
for model, config in registry.items():
|
|
23
|
+
if config.path in seen:
|
|
24
|
+
raise ExposeConfigurationError(f"Duplicate expose path: '{config.path}'.")
|
|
25
|
+
seen[config.path] = model
|
|
26
|
+
register_operations(router, model, config, build_schemas(model, config))
|
|
27
|
+
return router
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Schema factory: Read / Create / Update / Patch schemas built on Ninja."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.db.models import Model
|
|
9
|
+
from ninja import Schema
|
|
10
|
+
from ninja.orm import create_schema
|
|
11
|
+
from ninja.orm.fields import get_schema_field
|
|
12
|
+
from pydantic import ConfigDict, create_model
|
|
13
|
+
|
|
14
|
+
from .config import ExposureConfig
|
|
15
|
+
from .introspection import get_eligible_fields, get_pk, is_writable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _InputSchema(Schema):
|
|
19
|
+
"""Accept both `project` and the alias `project_id` for foreign keys."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ModelSchemas:
|
|
26
|
+
read: type[Schema]
|
|
27
|
+
create: type[Schema]
|
|
28
|
+
update: type[Schema]
|
|
29
|
+
patch: type[Schema]
|
|
30
|
+
pk_type: Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build(
|
|
34
|
+
model: type[Model],
|
|
35
|
+
name: str,
|
|
36
|
+
fields: list[str],
|
|
37
|
+
optional: list[str] | str | None = None,
|
|
38
|
+
base=Schema,
|
|
39
|
+
):
|
|
40
|
+
if not fields: # ninja treats an empty list as "all fields"
|
|
41
|
+
return create_model(name, __base__=base)
|
|
42
|
+
return create_schema(
|
|
43
|
+
model,
|
|
44
|
+
name=name,
|
|
45
|
+
fields=fields,
|
|
46
|
+
optional_fields=optional,
|
|
47
|
+
base_class=base,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_schemas(model: type[Model], config: ExposureConfig) -> ModelSchemas:
|
|
52
|
+
eligible = get_eligible_fields(model)
|
|
53
|
+
pk = get_pk(model)
|
|
54
|
+
base = f"{model._meta.app_label.title()}{model.__name__}"
|
|
55
|
+
|
|
56
|
+
read_fields = list(config.fields)
|
|
57
|
+
|
|
58
|
+
create_fields = [n for n in read_fields if is_writable(eligible[n])]
|
|
59
|
+
update_fields = [n for n in create_fields if n != pk.name]
|
|
60
|
+
# blank many-to-many fields may be omitted (Ninja would otherwise require them)
|
|
61
|
+
blank_m2m = [n for n in create_fields if eligible[n].many_to_many and eligible[n].blank]
|
|
62
|
+
|
|
63
|
+
return ModelSchemas(
|
|
64
|
+
read=_build(model, f"{base}Read", read_fields),
|
|
65
|
+
create=_build(model, f"{base}Create", create_fields, optional=blank_m2m, base=_InputSchema),
|
|
66
|
+
update=_build(model, f"{base}Update", update_fields, optional=blank_m2m, base=_InputSchema),
|
|
67
|
+
patch=_build(model, f"{base}Patch", update_fields, optional="__all__", base=_InputSchema),
|
|
68
|
+
pk_type=get_schema_field(pk, optional=False)[0],
|
|
69
|
+
)
|