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.
@@ -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")
@@ -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
+ )