django-ninja-problems 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jansem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ninja-problems
3
+ Version: 0.1.0
4
+ Summary: RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs
5
+ Author: Jansem
6
+ License-Expression: MIT
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Internet :: WWW/HTTP
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: django>=4.2
14
+ Requires-Dist: django-ninja>=1.0.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
18
+ Requires-Dist: pytest-django>=4.8.0; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # django-ninja-problems
22
+
23
+ RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install django-ninja-problems
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from ninja import NinjaAPI
35
+ from django_ninja_problems import register_problem_handlers
36
+
37
+ api = NinjaAPI()
38
+ register_problem_handlers(api)
39
+ ```
40
+
41
+ ## Supported Exceptions
42
+
43
+ - `ninja.errors.HttpError`
44
+ - `django.http.Http404` and `ObjectDoesNotExist`
45
+ - `ninja.errors.ValidationError` and `pydantic.ValidationError` (HTTP 422)
46
+ - `django.core.exceptions.ValidationError` (HTTP 400 or 409)
47
+ - `django.db.IntegrityError` (HTTP 409)
48
+ - Custom authentication exceptions (HTTP 401)
49
+ - Unexpected `Exception` (HTTP 500 masked in production)
@@ -0,0 +1,29 @@
1
+ # django-ninja-problems
2
+
3
+ RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install django-ninja-problems
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from ninja import NinjaAPI
15
+ from django_ninja_problems import register_problem_handlers
16
+
17
+ api = NinjaAPI()
18
+ register_problem_handlers(api)
19
+ ```
20
+
21
+ ## Supported Exceptions
22
+
23
+ - `ninja.errors.HttpError`
24
+ - `django.http.Http404` and `ObjectDoesNotExist`
25
+ - `ninja.errors.ValidationError` and `pydantic.ValidationError` (HTTP 422)
26
+ - `django.core.exceptions.ValidationError` (HTTP 400 or 409)
27
+ - `django.db.IntegrityError` (HTTP 409)
28
+ - Custom authentication exceptions (HTTP 401)
29
+ - Unexpected `Exception` (HTTP 500 masked in production)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "django-ninja-problems"
7
+ version = "0.1.0"
8
+ description = "RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Jansem" }]
13
+ classifiers = [
14
+ "Framework :: Django",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Internet :: WWW/HTTP",
17
+ ]
18
+ dependencies = [
19
+ "django>=4.2",
20
+ "django-ninja>=1.0.0",
21
+ "pydantic>=2.0.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0.0",
27
+ "pytest-django>=4.8.0",
28
+ ]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ django_ninja_problems = ["py.typed"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from .exceptions import (
2
+ BadRequestProblem,
3
+ ConflictProblem,
4
+ ForbiddenProblem,
5
+ NotFoundProblem,
6
+ ProblemException,
7
+ UnauthorizedProblem,
8
+ UnprocessableEntityProblem,
9
+ )
10
+ from .handlers import create_problem_response
11
+ from .registrar import register_problem_handlers
12
+ from .schemas import ErrorItem, ProblemDetails
13
+
14
+ __all__ = [
15
+ "BadRequestProblem",
16
+ "ConflictProblem",
17
+ "ErrorItem",
18
+ "ForbiddenProblem",
19
+ "NotFoundProblem",
20
+ "ProblemDetails",
21
+ "ProblemException",
22
+ "UnauthorizedProblem",
23
+ "UnprocessableEntityProblem",
24
+ "create_problem_response",
25
+ "register_problem_handlers",
26
+ ]
@@ -0,0 +1,113 @@
1
+ from typing import Any
2
+ from .schemas import ErrorItem
3
+
4
+
5
+ class ProblemException(Exception):
6
+ def __init__(
7
+ self,
8
+ status: int = 500,
9
+ title: str = "Internal Server Error",
10
+ detail: str | None = None,
11
+ type: str = "about:blank",
12
+ instance: str | None = None,
13
+ errors: list[ErrorItem] | None = None,
14
+ **extra: Any,
15
+ ) -> None:
16
+ self.status = status
17
+ self.title = title
18
+ self.detail = detail
19
+ self.type = type
20
+ self.instance = instance
21
+ self.errors = errors
22
+ self.extra = extra
23
+ super().__init__(detail or title)
24
+
25
+
26
+ class BadRequestProblem(ProblemException):
27
+ def __init__(
28
+ self,
29
+ detail: str | None = None,
30
+ errors: list[ErrorItem] | None = None,
31
+ **extra: Any,
32
+ ) -> None:
33
+ super().__init__(
34
+ status=400,
35
+ title="Bad Request",
36
+ detail=detail,
37
+ errors=errors,
38
+ **extra,
39
+ )
40
+
41
+
42
+ class UnauthorizedProblem(ProblemException):
43
+ def __init__(
44
+ self,
45
+ detail: str | None = "Authentication credentials were not provided or are invalid.",
46
+ **extra: Any,
47
+ ) -> None:
48
+ super().__init__(
49
+ status=401,
50
+ title="Unauthorized",
51
+ detail=detail,
52
+ **extra,
53
+ )
54
+
55
+
56
+ class ForbiddenProblem(ProblemException):
57
+ def __init__(
58
+ self,
59
+ detail: str | None = "You do not have permission to perform this action.",
60
+ **extra: Any,
61
+ ) -> None:
62
+ super().__init__(
63
+ status=403,
64
+ title="Forbidden",
65
+ detail=detail,
66
+ **extra,
67
+ )
68
+
69
+
70
+ class NotFoundProblem(ProblemException):
71
+ def __init__(
72
+ self,
73
+ detail: str | None = "The requested resource was not found.",
74
+ **extra: Any,
75
+ ) -> None:
76
+ super().__init__(
77
+ status=404,
78
+ title="Not Found",
79
+ detail=detail,
80
+ **extra,
81
+ )
82
+
83
+
84
+ class ConflictProblem(ProblemException):
85
+ def __init__(
86
+ self,
87
+ detail: str | None = "A conflict occurred with the current state of the resource.",
88
+ errors: list[ErrorItem] | None = None,
89
+ **extra: Any,
90
+ ) -> None:
91
+ super().__init__(
92
+ status=409,
93
+ title="Conflict",
94
+ detail=detail,
95
+ errors=errors,
96
+ **extra,
97
+ )
98
+
99
+
100
+ class UnprocessableEntityProblem(ProblemException):
101
+ def __init__(
102
+ self,
103
+ detail: str | None = "Unable to process the contained instructions.",
104
+ errors: list[ErrorItem] | None = None,
105
+ **extra: Any,
106
+ ) -> None:
107
+ super().__init__(
108
+ status=422,
109
+ title="Unprocessable Entity",
110
+ detail=detail,
111
+ errors=errors,
112
+ **extra,
113
+ )
@@ -0,0 +1,94 @@
1
+ import re
2
+ from typing import Any
3
+ from django.core.exceptions import ValidationError as DjangoValidationError
4
+ from django.db import IntegrityError
5
+ from .schemas import ErrorItem
6
+
7
+
8
+ def format_pydantic_location(location: tuple[Any, ...]) -> str:
9
+ parts = [str(item) for item in location if str(item) not in {"body", "query", "path", "header", "cookie"}]
10
+ return ".".join(parts) if parts else ".".join(str(item) for item in location)
11
+
12
+
13
+ def format_pydantic_errors(errors: list[dict[str, Any]]) -> list[ErrorItem]:
14
+ formatted: list[ErrorItem] = []
15
+ for error in errors:
16
+ loc = error.get("loc", ())
17
+ field = format_pydantic_location(loc)
18
+ message = str(error.get("msg", "Invalid value"))
19
+ code = str(error.get("type", "value_error"))
20
+ formatted.append(ErrorItem(field=field, message=message, code=code))
21
+ return formatted
22
+
23
+
24
+ def format_django_validation_error(exc: DjangoValidationError) -> list[ErrorItem]:
25
+ formatted: list[ErrorItem] = []
26
+ if hasattr(exc, "error_dict"):
27
+ for field, error_list in exc.error_dict.items():
28
+ for item in error_list:
29
+ formatted.append(
30
+ ErrorItem(
31
+ field=field,
32
+ message=str(item.message if hasattr(item, "message") else item),
33
+ code=getattr(item, "code", "invalid"),
34
+ )
35
+ )
36
+ return formatted
37
+
38
+ if hasattr(exc, "error_list"):
39
+ for item in exc.error_list:
40
+ formatted.append(
41
+ ErrorItem(
42
+ field=None,
43
+ message=str(item.message if hasattr(item, "message") else item),
44
+ code=getattr(item, "code", "invalid"),
45
+ )
46
+ )
47
+ return formatted
48
+
49
+ for message in exc.messages:
50
+ formatted.append(ErrorItem(field=None, message=str(message), code="invalid"))
51
+ return formatted
52
+
53
+
54
+ def is_unique_constraint_violation(exc: Exception) -> bool:
55
+ if isinstance(exc, DjangoValidationError):
56
+ if hasattr(exc, "error_dict"):
57
+ for errors in exc.error_dict.values():
58
+ for error in errors:
59
+ if getattr(error, "code", None) == "unique":
60
+ return True
61
+ return False
62
+
63
+ if isinstance(exc, IntegrityError):
64
+ message = str(exc).lower()
65
+ unique_indicators = [
66
+ "unique constraint",
67
+ "duplicate key",
68
+ "already exists",
69
+ "unique_violation",
70
+ ]
71
+ return any(indicator in message for indicator in unique_indicators)
72
+
73
+ return False
74
+
75
+
76
+ def format_integrity_error_detail(exc: IntegrityError) -> str:
77
+ message = str(exc)
78
+ lower_message = message.lower()
79
+
80
+ if "unique constraint" in lower_message or "duplicate key" in lower_message:
81
+ match = re.search(r"Key \((.*?)\)=\((.*?)\) already exists", message)
82
+ if match:
83
+ field_name, field_value = match.groups()
84
+ return f"A record with {field_name} '{field_value}' already exists."
85
+ match = re.search(r"UNIQUE constraint failed: (.*)", message)
86
+ if match:
87
+ column = match.group(1).split(".")[-1]
88
+ return f"A record with this {column} already exists."
89
+ return "A record with identical unique fields already exists."
90
+
91
+ if "foreign key constraint" in lower_message or "foreign_key_violation" in lower_message:
92
+ return "Referenced resource does not exist or cannot be modified due to dependent records."
93
+
94
+ return "A database integrity constraint was violated."
@@ -0,0 +1,186 @@
1
+ from http import HTTPStatus
2
+ from typing import Any
3
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError
4
+ from django.db import IntegrityError
5
+ from django.http import Http404, HttpRequest, HttpResponse
6
+ from ninja import NinjaAPI
7
+ from ninja.errors import HttpError, ValidationError as NinjaValidationError
8
+ from pydantic import ValidationError as PydanticValidationError
9
+
10
+ from .exceptions import ProblemException
11
+ from .formatters import (
12
+ format_django_validation_error,
13
+ format_integrity_error_detail,
14
+ format_pydantic_errors,
15
+ is_unique_constraint_violation,
16
+ )
17
+ from .schemas import ProblemDetails
18
+
19
+
20
+ def resolve_http_title(status_code: int) -> str:
21
+ try:
22
+ return HTTPStatus(status_code).phrase
23
+ except ValueError:
24
+ return "HTTP Error"
25
+
26
+
27
+ def create_problem_response(
28
+ api: NinjaAPI,
29
+ request: HttpRequest,
30
+ problem: ProblemDetails,
31
+ ) -> HttpResponse:
32
+ payload = problem.model_dump(exclude_none=True)
33
+ response = api.create_response(request, payload, status=problem.status)
34
+ response["Content-Type"] = "application/problem+json"
35
+ return response
36
+
37
+
38
+ def handle_problem_exception(
39
+ request: HttpRequest,
40
+ exc: ProblemException,
41
+ api: NinjaAPI,
42
+ ) -> HttpResponse:
43
+ problem = ProblemDetails(
44
+ type=exc.type,
45
+ title=exc.title,
46
+ status=exc.status,
47
+ detail=exc.detail,
48
+ instance=exc.instance or request.path,
49
+ errors=exc.errors,
50
+ **exc.extra,
51
+ )
52
+ return create_problem_response(api, request, problem)
53
+
54
+
55
+ def handle_http_error(
56
+ request: HttpRequest,
57
+ exc: HttpError,
58
+ api: NinjaAPI,
59
+ ) -> HttpResponse:
60
+ status_code = exc.status_code
61
+ detail = None
62
+ title = resolve_http_title(status_code)
63
+
64
+ if isinstance(exc.message, str):
65
+ detail = exc.message
66
+ elif isinstance(exc.message, dict):
67
+ detail = exc.message.get("detail") or exc.message.get("texto") or str(exc.message)
68
+ title = exc.message.get("title") or exc.message.get("titulo") or title
69
+
70
+ problem = ProblemDetails(
71
+ title=title,
72
+ status=status_code,
73
+ detail=detail,
74
+ instance=request.path,
75
+ )
76
+ return create_problem_response(api, request, problem)
77
+
78
+
79
+ def handle_not_found(
80
+ request: HttpRequest,
81
+ exc: Http404 | ObjectDoesNotExist,
82
+ api: NinjaAPI,
83
+ ) -> HttpResponse:
84
+ detail_message = str(exc)
85
+ detail = detail_message if detail_message and not detail_message.startswith("<") else "The requested resource was not found."
86
+ problem = ProblemDetails(
87
+ title="Not Found",
88
+ status=404,
89
+ detail=detail,
90
+ instance=request.path,
91
+ )
92
+ return create_problem_response(api, request, problem)
93
+
94
+
95
+ def handle_ninja_validation_error(
96
+ request: HttpRequest,
97
+ exc: NinjaValidationError | PydanticValidationError,
98
+ api: NinjaAPI,
99
+ ) -> HttpResponse:
100
+ raw_errors = exc.errors() if callable(getattr(exc, "errors", None)) else getattr(exc, "errors", [])
101
+ errors = format_pydantic_errors(raw_errors if isinstance(raw_errors, list) else [])
102
+ problem = ProblemDetails(
103
+ title="Unprocessable Entity",
104
+ status=422,
105
+ detail="The request body or parameters failed validation.",
106
+ instance=request.path,
107
+ errors=errors,
108
+ )
109
+ return create_problem_response(api, request, problem)
110
+
111
+
112
+ def handle_django_validation_error(
113
+ request: HttpRequest,
114
+ exc: DjangoValidationError,
115
+ api: NinjaAPI,
116
+ ) -> HttpResponse:
117
+ errors = format_django_validation_error(exc)
118
+ if is_unique_constraint_violation(exc):
119
+ problem = ProblemDetails(
120
+ title="Conflict",
121
+ status=409,
122
+ detail="A record with conflicting unique values already exists.",
123
+ instance=request.path,
124
+ errors=errors,
125
+ )
126
+ return create_problem_response(api, request, problem)
127
+
128
+ problem = ProblemDetails(
129
+ title="Bad Request",
130
+ status=400,
131
+ detail="The submitted data failed validation.",
132
+ instance=request.path,
133
+ errors=errors,
134
+ )
135
+ return create_problem_response(api, request, problem)
136
+
137
+
138
+ def handle_integrity_error(
139
+ request: HttpRequest,
140
+ exc: IntegrityError,
141
+ api: NinjaAPI,
142
+ ) -> HttpResponse:
143
+ detail = format_integrity_error_detail(exc)
144
+ problem = ProblemDetails(
145
+ title="Conflict",
146
+ status=409,
147
+ detail=detail,
148
+ instance=request.path,
149
+ )
150
+ return create_problem_response(api, request, problem)
151
+
152
+
153
+ def handle_authentication_error(
154
+ request: HttpRequest,
155
+ exc: Exception,
156
+ api: NinjaAPI,
157
+ ) -> HttpResponse:
158
+ if isinstance(exc, HttpError) and exc.message:
159
+ detail = exc.message if isinstance(exc.message, str) else str(exc.message)
160
+ elif str(exc):
161
+ detail = str(exc)
162
+ else:
163
+ detail = "Authentication credentials were not provided or are invalid."
164
+
165
+ problem = ProblemDetails(
166
+ title="Unauthorized",
167
+ status=401,
168
+ detail=detail,
169
+ instance=request.path,
170
+ )
171
+ return create_problem_response(api, request, problem)
172
+
173
+
174
+ def handle_unexpected_exception(
175
+ request: HttpRequest,
176
+ exc: Exception,
177
+ api: NinjaAPI,
178
+ debug: bool,
179
+ ) -> HttpResponse:
180
+ problem = ProblemDetails(
181
+ title="Internal Server Error",
182
+ status=500,
183
+ detail=f"{type(exc).__name__}: {exc}" if debug else "An unexpected error occurred while processing the request.",
184
+ instance=request.path,
185
+ )
186
+ return create_problem_response(api, request, problem)
@@ -0,0 +1,83 @@
1
+ from typing import Any, Callable
2
+ from django.conf import settings
3
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError
4
+ from django.db import IntegrityError
5
+ from django.http import Http404, HttpRequest, HttpResponse
6
+ from ninja import NinjaAPI
7
+ from ninja.errors import AuthenticationError, HttpError, ValidationError as NinjaValidationError
8
+ from pydantic import ValidationError as PydanticValidationError
9
+
10
+ from .exceptions import ProblemException
11
+ from .handlers import (
12
+ handle_authentication_error,
13
+ handle_django_validation_error,
14
+ handle_http_error,
15
+ handle_integrity_error,
16
+ handle_ninja_validation_error,
17
+ handle_not_found,
18
+ handle_problem_exception,
19
+ handle_unexpected_exception,
20
+ )
21
+
22
+
23
+ def register_problem_handlers(
24
+ api: NinjaAPI,
25
+ *,
26
+ debug: bool | None = None,
27
+ auth_exceptions: tuple[type[Exception], ...] | None = None,
28
+ custom_handlers: dict[type[Exception], Callable[[HttpRequest, Any], HttpResponse]] | None = None,
29
+ ) -> None:
30
+ is_debug = getattr(settings, "DEBUG", False) if debug is None else debug
31
+
32
+ api.add_exception_handler(
33
+ ProblemException,
34
+ lambda request, exc: handle_problem_exception(request, exc, api),
35
+ )
36
+ api.add_exception_handler(
37
+ HttpError,
38
+ lambda request, exc: handle_http_error(request, exc, api),
39
+ )
40
+ api.add_exception_handler(
41
+ Http404,
42
+ lambda request, exc: handle_not_found(request, exc, api),
43
+ )
44
+ api.add_exception_handler(
45
+ ObjectDoesNotExist,
46
+ lambda request, exc: handle_not_found(request, exc, api),
47
+ )
48
+ api.add_exception_handler(
49
+ NinjaValidationError,
50
+ lambda request, exc: handle_ninja_validation_error(request, exc, api),
51
+ )
52
+ api.add_exception_handler(
53
+ PydanticValidationError,
54
+ lambda request, exc: handle_ninja_validation_error(request, exc, api),
55
+ )
56
+ api.add_exception_handler(
57
+ DjangoValidationError,
58
+ lambda request, exc: handle_django_validation_error(request, exc, api),
59
+ )
60
+ api.add_exception_handler(
61
+ IntegrityError,
62
+ lambda request, exc: handle_integrity_error(request, exc, api),
63
+ )
64
+ api.add_exception_handler(
65
+ AuthenticationError,
66
+ lambda request, exc: handle_authentication_error(request, exc, api),
67
+ )
68
+
69
+ if auth_exceptions:
70
+ for auth_exc in auth_exceptions:
71
+ api.add_exception_handler(
72
+ auth_exc,
73
+ lambda request, exc: handle_authentication_error(request, exc, api),
74
+ )
75
+
76
+ if custom_handlers:
77
+ for exc_class, handler in custom_handlers.items():
78
+ api.add_exception_handler(exc_class, handler)
79
+
80
+ api.add_exception_handler(
81
+ Exception,
82
+ lambda request, exc: handle_unexpected_exception(request, exc, api, is_debug),
83
+ )
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+ from pydantic import BaseModel, ConfigDict, Field
3
+
4
+
5
+ class ErrorItem(BaseModel):
6
+ field: str | None = None
7
+ message: str
8
+ code: str | None = None
9
+
10
+
11
+ class ProblemDetails(BaseModel):
12
+ model_config = ConfigDict(extra="allow")
13
+
14
+ type: str = "about:blank"
15
+ title: str
16
+ status: int
17
+ detail: str | None = None
18
+ instance: str | None = None
19
+ errors: list[ErrorItem] | None = None
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ninja-problems
3
+ Version: 0.1.0
4
+ Summary: RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs
5
+ Author: Jansem
6
+ License-Expression: MIT
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Internet :: WWW/HTTP
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: django>=4.2
14
+ Requires-Dist: django-ninja>=1.0.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
18
+ Requires-Dist: pytest-django>=4.8.0; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # django-ninja-problems
22
+
23
+ RFC 7807 and RFC 9457 Problem Details for Django Ninja APIs.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install django-ninja-problems
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from ninja import NinjaAPI
35
+ from django_ninja_problems import register_problem_handlers
36
+
37
+ api = NinjaAPI()
38
+ register_problem_handlers(api)
39
+ ```
40
+
41
+ ## Supported Exceptions
42
+
43
+ - `ninja.errors.HttpError`
44
+ - `django.http.Http404` and `ObjectDoesNotExist`
45
+ - `ninja.errors.ValidationError` and `pydantic.ValidationError` (HTTP 422)
46
+ - `django.core.exceptions.ValidationError` (HTTP 400 or 409)
47
+ - `django.db.IntegrityError` (HTTP 409)
48
+ - Custom authentication exceptions (HTTP 401)
49
+ - Unexpected `Exception` (HTTP 500 masked in production)
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/django_ninja_problems/__init__.py
5
+ src/django_ninja_problems/exceptions.py
6
+ src/django_ninja_problems/formatters.py
7
+ src/django_ninja_problems/handlers.py
8
+ src/django_ninja_problems/py.typed
9
+ src/django_ninja_problems/registrar.py
10
+ src/django_ninja_problems/schemas.py
11
+ src/django_ninja_problems.egg-info/PKG-INFO
12
+ src/django_ninja_problems.egg-info/SOURCES.txt
13
+ src/django_ninja_problems.egg-info/dependency_links.txt
14
+ src/django_ninja_problems.egg-info/requires.txt
15
+ src/django_ninja_problems.egg-info/top_level.txt
16
+ tests/test_problems.py
@@ -0,0 +1,7 @@
1
+ django>=4.2
2
+ django-ninja>=1.0.0
3
+ pydantic>=2.0.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
7
+ pytest-django>=4.8.0
@@ -0,0 +1,231 @@
1
+ import pytest
2
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError as DjangoValidationError
3
+ from django.db import IntegrityError
4
+ from django.http import Http404
5
+ from ninja import NinjaAPI, Schema
6
+ from ninja.errors import AuthenticationError, HttpError
7
+ from ninja.testing import TestClient
8
+
9
+ from django_ninja_problems import (
10
+ BadRequestProblem,
11
+ ConflictProblem,
12
+ ForbiddenProblem,
13
+ NotFoundProblem,
14
+ ProblemException,
15
+ UnauthorizedProblem,
16
+ UnprocessableEntityProblem,
17
+ register_problem_handlers,
18
+ )
19
+
20
+
21
+ class CustomAuthException(Exception):
22
+ pass
23
+
24
+
25
+ class SampleInput(Schema):
26
+ name: str
27
+ age: int
28
+
29
+
30
+ def build_test_api(debug: bool = False) -> NinjaAPI:
31
+ api = NinjaAPI()
32
+ register_problem_handlers(
33
+ api,
34
+ debug=debug,
35
+ auth_exceptions=(CustomAuthException,),
36
+ )
37
+
38
+ @api.get("/problem-custom")
39
+ def get_custom_problem(request):
40
+ raise NotFoundProblem(detail="Custom item not found.")
41
+
42
+ @api.get("/problem-base")
43
+ def get_base_problem(request):
44
+ raise ProblemException(status=418, title="I'm a teapot", detail="Teapot exception")
45
+
46
+ @api.get("/http-error-str")
47
+ def get_http_error_str(request):
48
+ raise HttpError(403, "Access is denied.")
49
+
50
+ @api.get("/http-error-dict")
51
+ def get_http_error_dict(request):
52
+ raise HttpError(400, {"titulo": "Dado inválido", "texto": "Valor fora da faixa permitida."})
53
+
54
+ @api.get("/not-found-django")
55
+ def get_not_found_django(request):
56
+ raise Http404("Item not found.")
57
+
58
+ @api.get("/does-not-exist")
59
+ def get_does_not_exist(request):
60
+ raise ObjectDoesNotExist("Object was deleted.")
61
+
62
+ @api.post("/validation")
63
+ def post_validation(request, data: SampleInput):
64
+ return {"status": "ok"}
65
+
66
+ @api.get("/django-val-error")
67
+ def get_django_val_error(request):
68
+ raise DjangoValidationError({"name": ["Name is mandatory."]})
69
+
70
+ @api.get("/django-unique-error")
71
+ def get_django_unique_error(request):
72
+ raise DjangoValidationError({"email": [DjangoValidationError("Email already registered.", code="unique")]})
73
+
74
+ @api.get("/integrity-error")
75
+ def get_integrity_error(request):
76
+ raise IntegrityError("UNIQUE constraint failed: auth_user.username")
77
+
78
+ @api.get("/auth-error")
79
+ def get_auth_error(request):
80
+ raise AuthenticationError(401, "Token expired")
81
+
82
+ @api.get("/custom-auth-error")
83
+ def get_custom_auth_error(request):
84
+ raise CustomAuthException("Invalid OIDC Token")
85
+
86
+ @api.get("/server-error")
87
+ def get_server_error(request):
88
+ raise ZeroDivisionError("division by zero")
89
+
90
+ return api
91
+
92
+
93
+ def test_problem_exception_subclass():
94
+ client = TestClient(build_test_api())
95
+ response = client.get("/problem-custom")
96
+ assert response.status_code == 404
97
+ assert response.headers["Content-Type"] == "application/problem+json"
98
+ data = response.json()
99
+ assert data["type"] == "about:blank"
100
+ assert data["title"] == "Not Found"
101
+ assert data["status"] == 404
102
+ assert data["detail"] == "Custom item not found."
103
+ assert data["instance"] == "/problem-custom"
104
+
105
+
106
+ def test_problem_exception_base():
107
+ client = TestClient(build_test_api())
108
+ response = client.get("/problem-base")
109
+ assert response.status_code == 418
110
+ assert response.headers["Content-Type"] == "application/problem+json"
111
+ data = response.json()
112
+ assert data["title"] == "I'm a teapot"
113
+ assert data["status"] == 418
114
+ assert data["detail"] == "Teapot exception"
115
+
116
+
117
+ def test_http_error_string():
118
+ client = TestClient(build_test_api())
119
+ response = client.get("/http-error-str")
120
+ assert response.status_code == 403
121
+ assert response.headers["Content-Type"] == "application/problem+json"
122
+ data = response.json()
123
+ assert data["title"] == "Forbidden"
124
+ assert data["status"] == 403
125
+ assert data["detail"] == "Access is denied."
126
+
127
+
128
+ def test_http_error_dict():
129
+ client = TestClient(build_test_api())
130
+ response = client.get("/http-error-dict")
131
+ assert response.status_code == 400
132
+ data = response.json()
133
+ assert data["title"] == "Dado inválido"
134
+ assert data["detail"] == "Valor fora da faixa permitida."
135
+ assert data["status"] == 400
136
+
137
+
138
+ def test_http_404_and_does_not_exist():
139
+ client = TestClient(build_test_api())
140
+ res_404 = client.get("/not-found-django")
141
+ assert res_404.status_code == 404
142
+ assert res_404.json()["title"] == "Not Found"
143
+ assert res_404.json()["detail"] == "Item not found."
144
+
145
+ res_dne = client.get("/does-not-exist")
146
+ assert res_dne.status_code == 404
147
+ assert res_dne.json()["title"] == "Not Found"
148
+ assert res_dne.json()["detail"] == "Object was deleted."
149
+
150
+
151
+ def test_validation_error_422():
152
+ client = TestClient(build_test_api())
153
+ response = client.post("/validation", json={"name": "Alice", "age": "not-a-number"})
154
+ assert response.status_code == 422
155
+ assert response.headers["Content-Type"] == "application/problem+json"
156
+ data = response.json()
157
+ assert data["title"] == "Unprocessable Entity"
158
+ assert data["status"] == 422
159
+ assert "errors" in data
160
+ assert len(data["errors"]) > 0
161
+ assert any(err["field"].endswith("age") for err in data["errors"])
162
+
163
+
164
+ def test_django_validation_error():
165
+ client = TestClient(build_test_api())
166
+ response = client.get("/django-val-error")
167
+ assert response.status_code == 400
168
+ data = response.json()
169
+ assert data["title"] == "Bad Request"
170
+ assert data["status"] == 400
171
+ assert any(err["field"] == "name" for err in data["errors"])
172
+
173
+
174
+ def test_django_validation_unique_to_conflict():
175
+ client = TestClient(build_test_api())
176
+ response = client.get("/django-unique-error")
177
+ assert response.status_code == 409
178
+ data = response.json()
179
+ assert data["title"] == "Conflict"
180
+ assert data["status"] == 409
181
+ assert any(err["field"] == "email" for err in data["errors"])
182
+
183
+
184
+ def test_integrity_error():
185
+ client = TestClient(build_test_api())
186
+ response = client.get("/integrity-error")
187
+ assert response.status_code == 409
188
+ data = response.json()
189
+ assert data["title"] == "Conflict"
190
+ assert data["status"] == 409
191
+ assert "already exists" in data["detail"]
192
+
193
+
194
+ def test_authentication_error():
195
+ client = TestClient(build_test_api())
196
+ response = client.get("/auth-error")
197
+ assert response.status_code == 401
198
+ data = response.json()
199
+ assert data["title"] == "Unauthorized"
200
+ assert data["status"] == 401
201
+ assert data["detail"] == "Token expired"
202
+
203
+
204
+ def test_custom_auth_exception():
205
+ client = TestClient(build_test_api())
206
+ response = client.get("/custom-auth-error")
207
+ assert response.status_code == 401
208
+ data = response.json()
209
+ assert data["title"] == "Unauthorized"
210
+ assert data["status"] == 401
211
+ assert data["detail"] == "Invalid OIDC Token"
212
+
213
+
214
+ def test_unexpected_error_debug_false():
215
+ client = TestClient(build_test_api(debug=False))
216
+ response = client.get("/server-error")
217
+ assert response.status_code == 500
218
+ data = response.json()
219
+ assert data["title"] == "Internal Server Error"
220
+ assert data["status"] == 500
221
+ assert data["detail"] == "An unexpected error occurred while processing the request."
222
+
223
+
224
+ def test_unexpected_error_debug_true():
225
+ client = TestClient(build_test_api(debug=True))
226
+ response = client.get("/server-error")
227
+ assert response.status_code == 500
228
+ data = response.json()
229
+ assert data["title"] == "Internal Server Error"
230
+ assert data["status"] == 500
231
+ assert "ZeroDivisionError" in data["detail"]