From ad39a84466e443914a060afa2de1e551f80b0c3e Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 19:39:10 +0200 Subject: [PATCH 1/8] feat(api): introduced facility exceptions to reduce boilerplate code --- src/eoap_problems_registry/fastapi.py | 229 ++++++++++++++++++++++---- 1 file changed, 193 insertions(+), 36 deletions(-) diff --git a/src/eoap_problems_registry/fastapi.py b/src/eoap_problems_registry/fastapi.py index 9a6d26c..c788b89 100644 --- a/src/eoap_problems_registry/fastapi.py +++ b/src/eoap_problems_registry/fastapi.py @@ -14,44 +14,42 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from fastapi import HTTPException -if TYPE_CHECKING: - from . import ( - AlreadyExists, - BadRequest, - BusinessRuleViolation, - Conflict, - FailedDependency, - Forbidden, - Gone, - InsufficientStorage, - InvalidBodyPropertyFormat, - InvalidBodyPropertyValue, - InvalidParameters, - InvalidRequestHeaderFormat, - InvalidRequestParameterFormat, - InvalidRequestParameterValue, - InvalidStateTransition, - LicenseCancelled, - LicenseExpired, - MethodNotAllowed, - MissingBodyProperty, - MissingRequestHeader, - MissingRequestParameter, - NotAcceptable, - NotFound, - NotImplemented, - RequestTimeout, - ServerError, - ServiceUnavailable, - Unauthorized, - UnavailableForLegalReasons, - UnprocessableContent, - ValidationError, - ) +from . import ( + AlreadyExists, + BadRequest, + BusinessRuleViolation, + Conflict, + ErrorDetail, + FailedDependency, + Forbidden, + Gone, + InsufficientStorage, + InvalidBodyPropertyFormat, + InvalidBodyPropertyValue, + InvalidParameters, + InvalidRequestHeaderFormat, + InvalidRequestParameterFormat, + InvalidRequestParameterValue, + InvalidStateTransition, + LicenseCancelled, + LicenseExpired, + MethodNotAllowed, + MissingBodyProperty, + MissingRequestHeader, + MissingRequestParameter, + NotAcceptable, + NotFound, + NotImplemented, + RequestTimeout, + ServerError, + ServiceUnavailable, + Unauthorized, + UnavailableForLegalReasons, + UnprocessableContent, + ValidationError, +) class ProblemRegistryException(HTTPException): @@ -89,7 +87,166 @@ def __init__( | UnavailableForLegalReasons | UnprocessableContent | ValidationError, + errors: list[ErrorDetail] | None = None, ) -> None: self.status_code = problem.status + + problem.errors = errors self.detail = problem.model_dump_json(exclude_none=True) + self.headers = {"Content-Type": "application/problem+json"} + + +class AlreadyExistsException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=AlreadyExists(), errors=errors) + + +class BadRequestException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=BadRequest(), errors=errors) + + +class BusinessRuleViolationException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=BusinessRuleViolation(), errors=errors) + + +class ConflictException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=Conflict(), errors=errors) + + +class FailedDependencyException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=FailedDependency(), errors=errors) + + +class ForbiddenException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=Forbidden(), errors=errors) + + +class GoneException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=Gone(), errors=errors) + + +class InsufficientStorageException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InsufficientStorage(), errors=errors) + + +class InvalidBodyPropertyFormatException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidBodyPropertyFormat(), errors=errors) + + +class InvalidBodyPropertyValueException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidBodyPropertyValue(), errors=errors) + + +class InvalidParametersException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidParameters(), errors=errors) + + +class InvalidRequestHeaderFormatException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidRequestHeaderFormat(), errors=errors) + + +class InvalidRequestParameterFormatException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidRequestParameterFormat(), errors=errors) + + +class InvalidRequestParameterValueException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidRequestParameterValue(), errors=errors) + + +class InvalidStateTransitionException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=InvalidStateTransition(), errors=errors) + + +class LicenseCancelledException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=LicenseCancelled(), errors=errors) + + +class LicenseExpiredException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=LicenseExpired(), errors=errors) + + +class MissingBodyPropertyException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=MissingBodyProperty(), errors=errors) + + +class MissingRequestHeaderException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=MissingRequestHeader(), errors=errors) + + +class MissingRequestParameterException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=MissingRequestParameter(), errors=errors) + + +class MethodNotAllowedException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=MethodNotAllowed(), errors=errors) + + +class NotAcceptableException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=NotAcceptable(), errors=errors) + + +class NotFoundException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=NotFound(), errors=errors) + + +class NotImplementedException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=NotImplemented(), errors=errors) + + +class RequestTimeoutException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=RequestTimeout(), errors=errors) + + +class ServerErrorException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=ServerError(), errors=errors) + + +class ServiceUnavailableException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=ServiceUnavailable(), errors=errors) + + +class UnauthorizedException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=Unauthorized(), errors=errors) + + +class UnavailableForLegalReasonsException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=UnavailableForLegalReasons(), errors=errors) + + +class UnprocessableContentException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=UnprocessableContent(), errors=errors) + + +class ValidationErrorException(ProblemRegistryException): + def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + super().__init__(problem=ValidationError(), errors=errors) From 670353c0d780a2b402a3cc1ba7f887127325ce4a Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 22:49:55 +0200 Subject: [PATCH 2/8] docs(changelog): reformatted according to specification --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae307c..49dc1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,19 +19,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -[1.4.0] - 2026-08-05 +[## 1.4.0] - 2026-08-05 ### Fixed - `FastAPI` integration did not support the `InvalidStateTransition`. -[1.3.0] - 2026-07-30 +## [1.3.0] - 2026-07-30 ### Changed - Stronger code chekers with Ruff+McCabe & Bandit -[1.2.0] - 2026-07-26 +## [1.2.0] - 2026-07-26 ### Added @@ -47,19 +47,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `507` Insufficient - [FastAPI](https://fastapi.tiangolo.com/) integration. -[1.1.0] - 2026-07-23 +## [1.1.0] - 2026-07-23 ### Added - `Gone` as new problem detail and reponse. -[1.0.1] - 2026-07-20 +## [1.0.1] - 2026-07-20 ### Fixed - Generated models solve Pydantic/Pylance [issue](https://github.com/pydantic/pydantic/discussions/7379) -[1.0.0] - 2026-07-17 +## [1.0.0] - 2026-07-17 ### Added From 2bb2edd7a9b3ef2cd0311af4e35dfe303de3ec3d Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 22:59:21 +0200 Subject: [PATCH 3/8] feat(api): reducing boilerplate code even more with union types --- src/eoap_problems_registry/fastapi.py | 70 ++++++++++++++------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/src/eoap_problems_registry/fastapi.py b/src/eoap_problems_registry/fastapi.py index c788b89..515a449 100644 --- a/src/eoap_problems_registry/fastapi.py +++ b/src/eoap_problems_registry/fastapi.py @@ -87,166 +87,170 @@ def __init__( | UnavailableForLegalReasons | UnprocessableContent | ValidationError, - errors: list[ErrorDetail] | None = None, + errors: ErrorDetail | list[ErrorDetail] | None = None, ) -> None: self.status_code = problem.status - problem.errors = errors + if errors: + if isinstance(errors, list): + problem.errors = errors + else: + problem.errors = [errors] self.detail = problem.model_dump_json(exclude_none=True) self.headers = {"Content-Type": "application/problem+json"} class AlreadyExistsException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=AlreadyExists(), errors=errors) class BadRequestException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=BadRequest(), errors=errors) class BusinessRuleViolationException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=BusinessRuleViolation(), errors=errors) class ConflictException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=Conflict(), errors=errors) class FailedDependencyException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=FailedDependency(), errors=errors) class ForbiddenException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=Forbidden(), errors=errors) class GoneException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=Gone(), errors=errors) class InsufficientStorageException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InsufficientStorage(), errors=errors) class InvalidBodyPropertyFormatException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidBodyPropertyFormat(), errors=errors) class InvalidBodyPropertyValueException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidBodyPropertyValue(), errors=errors) class InvalidParametersException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidParameters(), errors=errors) class InvalidRequestHeaderFormatException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidRequestHeaderFormat(), errors=errors) class InvalidRequestParameterFormatException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidRequestParameterFormat(), errors=errors) class InvalidRequestParameterValueException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidRequestParameterValue(), errors=errors) class InvalidStateTransitionException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=InvalidStateTransition(), errors=errors) class LicenseCancelledException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=LicenseCancelled(), errors=errors) class LicenseExpiredException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=LicenseExpired(), errors=errors) class MissingBodyPropertyException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=MissingBodyProperty(), errors=errors) class MissingRequestHeaderException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=MissingRequestHeader(), errors=errors) class MissingRequestParameterException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=MissingRequestParameter(), errors=errors) class MethodNotAllowedException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=MethodNotAllowed(), errors=errors) class NotAcceptableException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=NotAcceptable(), errors=errors) class NotFoundException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=NotFound(), errors=errors) class NotImplementedException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=NotImplemented(), errors=errors) class RequestTimeoutException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=RequestTimeout(), errors=errors) class ServerErrorException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=ServerError(), errors=errors) class ServiceUnavailableException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=ServiceUnavailable(), errors=errors) class UnauthorizedException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=Unauthorized(), errors=errors) class UnavailableForLegalReasonsException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=UnavailableForLegalReasons(), errors=errors) class UnprocessableContentException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=UnprocessableContent(), errors=errors) class ValidationErrorException(ProblemRegistryException): - def __init__(self, errors: list[ErrorDetail] | None = None) -> None: + def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: super().__init__(problem=ValidationError(), errors=errors) From ac32c9fcf6b42f2020661eab2b71abc94653ced7 Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 23:00:11 +0200 Subject: [PATCH 4/8] docs(changelog): added 1.5.0 release --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dc1fe..720172a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -[## 1.4.0] - 2026-08-05 +## [1.5.0] - 2026-08-05 + +### Added + +- Enhanced `FastAPI` integration with introducing facility exceptions to reduce boilerplate code. + +## [1.4.0] - 2026-08-05 ### Fixed @@ -65,7 +71,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial version -[Unreleased]: https://github.com/eoap/problems-registry/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/eoap/problems-registry/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/eoap/problems-registry/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/eoap/problems-registry/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/eoap/problems-registry/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/eoap/problems-registry/compare/v1.1.0...v1.2.0 From 58211b3508a34c468d712fd634af188e9c277b59 Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 23:13:46 +0200 Subject: [PATCH 5/8] test(api): added exaustive facility exceptions test Coverage: 100% --- tests/test_fastapi.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 3cbfeb3..099ee40 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import inspect import json import unittest from fastapi import HTTPException import eoap_problems_registry as registry +import eoap_problems_registry.fastapi as fastapi_registry from eoap_problems_registry.fastapi import ProblemRegistryException @@ -54,5 +56,79 @@ def test_uses_the_problem_json_content_type(self): ) +class DeclaredProblemRegistryExceptionsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.exception_types = [ + exception_type + for _, exception_type in inspect.getmembers( + fastapi_registry, + inspect.isclass, + ) + if exception_type.__module__ == fastapi_registry.__name__ + and exception_type is not ProblemRegistryException + and issubclass(exception_type, ProblemRegistryException) + and exception_type.__name__.endswith("Exception") + ] + + def test_each_declared_exception_reflects_its_related_problem_model(self): + self.assertGreater(len(self.exception_types), 0) + + for exception_type in self.exception_types: + with self.subTest(exception=exception_type.__name__): + problem_model_name = exception_type.__name__.removesuffix("Exception") + problem_model_type = getattr(registry, problem_model_name) + + problem = problem_model_type() + exception = exception_type() + + self.assertIsInstance(exception, HTTPException) + self.assertEqual(exception.status_code, problem.status) + self.assertEqual( + json.loads(exception.detail), + problem.model_dump(mode="json", exclude_none=True), + ) + self.assertEqual( + exception.headers, + {"Content-Type": "application/problem+json"}, + ) + + def test_each_declared_exception_sets_a_single_error(self): + error = registry.ErrorDetail( + detail="The supplied value is invalid.", + parameter="limit", + ) + expected_errors = [error.model_dump(mode="json", exclude_none=True)] + + for exception_type in self.exception_types: + with self.subTest(exception=exception_type.__name__): + exception = exception_type(errors=error) + detail = json.loads(exception.detail) + + self.assertEqual(detail["errors"], expected_errors) + + def test_each_declared_exception_sets_multiple_errors(self): + errors = [ + registry.ErrorDetail( + detail="The supplied value is invalid.", + parameter="limit", + ), + registry.ErrorDetail( + detail="The supplied header is invalid.", + header="X-Request-ID", + ), + ] + expected_errors = [ + error.model_dump(mode="json", exclude_none=True) for error in errors + ] + + for exception_type in self.exception_types: + with self.subTest(exception=exception_type.__name__): + exception = exception_type(errors=errors) + detail = json.loads(exception.detail) + + self.assertEqual(detail["errors"], expected_errors) + + if __name__ == "__main__": unittest.main() From f55921d9fe1c22b91fd951eb24f18aa4feae5df8 Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 23:43:49 +0200 Subject: [PATCH 6/8] feat(api): added the chance to provide auxiliary headers to exceptions --- src/eoap_problems_registry/fastapi.py | 271 ++++++++++++++++++++------ tests/test_fastapi.py | 25 +++ 2 files changed, 233 insertions(+), 63 deletions(-) diff --git a/src/eoap_problems_registry/fastapi.py b/src/eoap_problems_registry/fastapi.py index 515a449..82d1eb1 100644 --- a/src/eoap_problems_registry/fastapi.py +++ b/src/eoap_problems_registry/fastapi.py @@ -88,6 +88,7 @@ def __init__( | UnprocessableContent | ValidationError, errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, ) -> None: self.status_code = problem.status @@ -98,159 +99,303 @@ def __init__( problem.errors = [errors] self.detail = problem.model_dump_json(exclude_none=True) - self.headers = {"Content-Type": "application/problem+json"} + headers = (headers or {}).copy() + headers["Content-Type"] = "application/problem+json" + self.headers = headers class AlreadyExistsException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=AlreadyExists(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=AlreadyExists(), errors=errors, headers=headers) class BadRequestException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=BadRequest(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=BadRequest(), errors=errors, headers=headers) class BusinessRuleViolationException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=BusinessRuleViolation(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=BusinessRuleViolation(), errors=errors, headers=headers + ) class ConflictException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=Conflict(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=Conflict(), errors=errors, headers=headers) class FailedDependencyException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=FailedDependency(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=FailedDependency(), errors=errors, headers=headers) class ForbiddenException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=Forbidden(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=Forbidden(), errors=errors, headers=headers) class GoneException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=Gone(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=Gone(), errors=errors, headers=headers) class InsufficientStorageException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InsufficientStorage(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=InsufficientStorage(), errors=errors, headers=headers) class InvalidBodyPropertyFormatException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidBodyPropertyFormat(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidBodyPropertyFormat(), errors=errors, headers=headers + ) class InvalidBodyPropertyValueException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidBodyPropertyValue(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidBodyPropertyValue(), errors=errors, headers=headers + ) class InvalidParametersException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidParameters(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=InvalidParameters(), errors=errors, headers=headers) class InvalidRequestHeaderFormatException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidRequestHeaderFormat(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidRequestHeaderFormat(), errors=errors, headers=headers + ) class InvalidRequestParameterFormatException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidRequestParameterFormat(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidRequestParameterFormat(), errors=errors, headers=headers + ) class InvalidRequestParameterValueException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidRequestParameterValue(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidRequestParameterValue(), errors=errors, headers=headers + ) class InvalidStateTransitionException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=InvalidStateTransition(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=InvalidStateTransition(), errors=errors, headers=headers + ) class LicenseCancelledException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=LicenseCancelled(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=LicenseCancelled(), errors=errors, headers=headers) class LicenseExpiredException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=LicenseExpired(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=LicenseExpired(), errors=errors, headers=headers) class MissingBodyPropertyException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=MissingBodyProperty(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=MissingBodyProperty(), errors=errors, headers=headers) class MissingRequestHeaderException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=MissingRequestHeader(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=MissingRequestHeader(), errors=errors, headers=headers) class MissingRequestParameterException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=MissingRequestParameter(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=MissingRequestParameter(), errors=errors, headers=headers + ) class MethodNotAllowedException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=MethodNotAllowed(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=MethodNotAllowed(), errors=errors, headers=headers) class NotAcceptableException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=NotAcceptable(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=NotAcceptable(), errors=errors, headers=headers) class NotFoundException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=NotFound(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=NotFound(), errors=errors, headers=headers) class NotImplementedException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=NotImplemented(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=NotImplemented(), errors=errors, headers=headers) class RequestTimeoutException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=RequestTimeout(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=RequestTimeout(), errors=errors, headers=headers) class ServerErrorException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=ServerError(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=ServerError(), errors=errors, headers=headers) class ServiceUnavailableException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=ServiceUnavailable(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=ServiceUnavailable(), errors=errors, headers=headers) class UnauthorizedException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=Unauthorized(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=Unauthorized(), errors=errors, headers=headers) class UnavailableForLegalReasonsException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=UnavailableForLegalReasons(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__( + problem=UnavailableForLegalReasons(), errors=errors, headers=headers + ) class UnprocessableContentException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=UnprocessableContent(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=UnprocessableContent(), errors=errors, headers=headers) class ValidationErrorException(ProblemRegistryException): - def __init__(self, errors: ErrorDetail | list[ErrorDetail] | None = None) -> None: - super().__init__(problem=ValidationError(), errors=errors) + def __init__( + self, + errors: ErrorDetail | list[ErrorDetail] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(problem=ValidationError(), errors=errors, headers=headers) diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 099ee40..45fb4d0 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -129,6 +129,31 @@ def test_each_declared_exception_sets_multiple_errors(self): self.assertEqual(detail["errors"], expected_errors) + def test_each_declared_exception_merges_custom_headers(self): + headers = { + "X-Request-ID": "abc123", + "Content-Type": "text/plain", + } + + for exception_type in self.exception_types: + with self.subTest(exception=exception_type.__name__): + exception = exception_type(headers=headers) + + self.assertEqual( + exception.headers, + { + "X-Request-ID": "abc123", + "Content-Type": "application/problem+json", + }, + ) + self.assertEqual( + headers, + { + "X-Request-ID": "abc123", + "Content-Type": "text/plain", + }, + ) + if __name__ == "__main__": unittest.main() From 101e34eca690bb90961edb1ff5068cae1228ade2 Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 23:48:43 +0200 Subject: [PATCH 7/8] docs(changelog-md): updating 1.5.0 release --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 720172a..f62f3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Enhanced `FastAPI` integration with introducing facility exceptions to reduce boilerplate code. +- Added `headers` support in `FastAPI` exceptions, when needing to send auxiliary HTTP headers i.e. `Retry-After` ## [1.4.0] - 2026-08-05 From 6bcb6e7f008dfbc3e938abbbe4498c7112382c05 Mon Sep 17 00:00:00 2001 From: stripodi Date: Wed, 5 Aug 2026 23:49:33 +0200 Subject: [PATCH 8/8] release(pip): preparing 1.5.0 release Build: hatch --- src/eoap_problems_registry/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eoap_problems_registry/__about__.py b/src/eoap_problems_registry/__about__.py index 7656507..1d73bcb 100644 --- a/src/eoap_problems_registry/__about__.py +++ b/src/eoap_problems_registry/__about__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "1.4.0" +__version__ = "1.5.0"