django-ninja-problems 0.1.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.
@@ -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)
File without changes
@@ -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,12 @@
1
+ django_ninja_problems/__init__.py,sha256=8aAVvbieG1HfGTn-3c_5_7l22VvX7E5c3-pONn6b32s,634
2
+ django_ninja_problems/exceptions.py,sha256=UpqnY8Aoj0j_Xwn2bzmAkdrDqhuNFHYd4dYh9q2Kn8E,2840
3
+ django_ninja_problems/formatters.py,sha256=GKWo0bFsIANcOg946woGQjFDITwyTor7on9TYzvp0bY,3595
4
+ django_ninja_problems/handlers.py,sha256=bGq5FDY4Xw4PrFj_LqAiiSvWIg3MZc7wyKGAXfi2dVs,5509
5
+ django_ninja_problems/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ django_ninja_problems/registrar.py,sha256=rJCQJx82s45JMcset0729Z35XEQkc9V-8lRZ8TOp__Q,2845
7
+ django_ninja_problems/schemas.py,sha256=mahDc3JH6GGRi5xK7znhIa-1-pf67cKmpZiNquv81wA,423
8
+ django_ninja_problems-0.1.0.dist-info/licenses/LICENSE,sha256=BmH9uq40Gg695-P1cfM64VP7cbaTEUuXFvyHa6esZpA,1063
9
+ django_ninja_problems-0.1.0.dist-info/METADATA,sha256=q3x_drtKAjgHv4Dxeas3tXNW2OqMupFTGEqm4xz0_bg,1312
10
+ django_ninja_problems-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ django_ninja_problems-0.1.0.dist-info/top_level.txt,sha256=pzrLek-hSNGIEIHUHLVVnGwMMToO-hwcIwYUaCsFx50,22
12
+ django_ninja_problems-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ django_ninja_problems