Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: problem details plugin #3323

Merged
merged 24 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e2fbbba
feat: Added precedence of CLI parameters over envs (#3190)
kedod Mar 17, 2024
26719a7
wip: problem details plugin
guacs Mar 26, 2024
0d0aba9
refactor: respect HTTPException arguments, and extract more informati…
guacs Apr 1, 2024
27abe7a
test: add tests for problem details plugin
guacs Apr 1, 2024
bed2574
fix: rename variable for consistency
guacs Apr 8, 2024
d04c87e
fix: pass status code to response
guacs Apr 8, 2024
2708295
docs: add note regarding overriding HTTPException handler
guacs Apr 8, 2024
70fefca
fix: fix rebase shenanigans
guacs Apr 8, 2024
d06c85d
fix: ignore mypy error
guacs Apr 8, 2024
aa740f6
fix: stringify type alias
guacs Apr 8, 2024
2dff68d
fix: correct the docstring
guacs Apr 9, 2024
a9ba152
feat: merge `extra` into problem details
guacs Apr 9, 2024
02d7719
fix: specify generic types
guacs Apr 9, 2024
d3041a7
Update litestar/plugins/problem_details.py
JacobCoffee Apr 18, 2024
1d92778
feat: handle mapping and list for extra
guacs Apr 19, 2024
2427d20
docs: add versionadded tag
guacs Apr 19, 2024
a966a91
docs: add usage docs for problem details plugin
guacs Apr 21, 2024
51f2896
docs: add problem details API reference
guacs Apr 21, 2024
4762266
docs: remove use of problem details as example for json suffix
guacs Apr 21, 2024
d31a768
Fix some issues in problem details
provinzkraut Aug 25, 2024
7d78e83
Some more fixes
provinzkraut Aug 25, 2024
a81176f
Mention in docs
provinzkraut Aug 25, 2024
985e238
Fix docs more
provinzkraut Aug 25, 2024
a9d9387
Formatting
provinzkraut Aug 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ app = Litestar(route_handlers=[hello_world])
- Support for `dataclasses`, `TypedDict`, [pydantic version 1 and version 2](https://docs.pydantic.dev/latest/),
[msgspec](https://github.com/jcrist/msgspec) and [attrs](https://www.attrs.org/en/stable/)
- Layered parameter declaration
- Support for [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) standardized "Problem Detail" error responses
- [Automatic API documentation with](#redoc-swagger-ui-and-stoplight-elements-api-documentation):
- [Scalar](https://github.com/scalar/scalar/)
- [RapiDoc](https://github.com/rapi-doc/RapiDoc)
Expand Down
1 change: 1 addition & 0 deletions docs/PYPI_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ app = Litestar(route_handlers=[hello_world])
- Support for `dataclasses`, `TypedDict`, [pydantic version 1 and version 2](https://docs.pydantic.dev/latest/),
[msgspec](https://github.com/jcrist/msgspec) and [attrs](https://www.attrs.org/en/stable/)
- Layered parameter declaration
- Support for [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) standardized "Problem Detail" error responses
- [Automatic API documentation with](#redoc-swagger-ui-and-stoplight-elements-api-documentation):
- [Scalar](https://github.com/scalar/scalar/)
- [RapiDoc](https://github.com/rapi-doc/RapiDoc)
Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
(PY_RE, r"advanced_alchemy\.config.common\.SessionT"),
(PY_RE, r".*R"),
(PY_OBJ, r"litestar.security.jwt.auth.TokenT"),
(PY_CLASS, "ExceptionToProblemDetailMapType"),
]

# Warnings about missing references to those targets in the specified location will be ignored.
Expand Down
30 changes: 30 additions & 0 deletions docs/examples/plugins/problem_details/basic_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from dataclasses import dataclass

from litestar import Litestar, post
from litestar.plugins.problem_details import ProblemDetailsConfig, ProblemDetailsException, ProblemDetailsPlugin


@dataclass
class PurchaseItem:
item_id: int
quantity: int


@post("/purchase")
async def purchase(data: PurchaseItem) -> None:
# Logic to check if the user has enough credit to buy the item.
# We assume the user does not have enough credit.

raise ProblemDetailsException(
type_="https://example.com/probs/out-of-credit",
title="You do not have enough credit.",
detail="Your current balance is 30, but that costs 50.",
instance="/account/12345/msgs/abc",
extra={"balance": 30},
)


problem_details_plugin = ProblemDetailsPlugin(ProblemDetailsConfig())
app = Litestar(route_handlers=[purchase], plugins=[problem_details_plugin])

# run: /purchase --header "Content-Type: application/json" --request POST --data '{"item_id": 1234, "quantity": 2}'
49 changes: 49 additions & 0 deletions docs/examples/plugins/problem_details/convert_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from dataclasses import dataclass

from litestar import Litestar, post
from litestar.plugins.problem_details import ProblemDetailsConfig, ProblemDetailsException, ProblemDetailsPlugin


@dataclass
class PurchaseItem:
item_id: int
quantity: int


class PurchaseNotAllowedError(Exception):
def __init__(self, account_id: int, balance: int, detail: str) -> None:
self.account_id = account_id
self.balance = balance
self.detail = detail


@post("/purchase")
async def purchase(data: PurchaseItem) -> None:
raise PurchaseNotAllowedError(
account_id=12345,
balance=30,
detail="Your current balance is 30, but that costs 50.",
)


def convert_purchase_not_allowed_to_problem_details(exc: PurchaseNotAllowedError) -> ProblemDetailsException:
return ProblemDetailsException(
type_="https://example.com/probs/out-of-credit",
title="You do not have enough credit.",
detail=exc.detail,
instance=f"/account/{exc.account_id}/msgs/abc",
extra={"balance": exc.balance},
)


problem_details_plugin = ProblemDetailsPlugin(
ProblemDetailsConfig(
enable_for_all_http_exceptions=True,
exception_to_problem_detail_map={PurchaseNotAllowedError: convert_purchase_not_allowed_to_problem_details},
)
)
app = Litestar(route_handlers=[purchase], plugins=[problem_details_plugin])

# run: /purchase --header "Content-Type: application/json" --request POST --data '{"item_id": 1234, "quantity": 2}'
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from dataclasses import dataclass

from litestar import Litestar, post
from litestar.exceptions.http_exceptions import NotFoundException
from litestar.plugins.problem_details import ProblemDetailsConfig, ProblemDetailsPlugin


@dataclass
class PurchaseItem:
item_id: int
quantity: int


@post("/purchase")
async def purchase(data: PurchaseItem) -> None:
# Logic to check if the user has enough credit to buy the item.
# We assume the user does not have enough credit.

raise NotFoundException(detail="No item with the given ID was found", extra={"item_id": data.item_id})


problem_details_plugin = ProblemDetailsPlugin(ProblemDetailsConfig(enable_for_all_http_exceptions=True))
app = Litestar(route_handlers=[purchase], plugins=[problem_details_plugin])

# run: /purchase --header "Content-Type: application/json" --request POST --data '{"item_id": 1234, "quantity": 2}'
6 changes: 5 additions & 1 deletion docs/examples/responses/json_suffix_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from litestar import Litestar, get


@get("/resources", status_code=litestar.status_codes.HTTP_418_IM_A_TEAPOT, media_type="application/problem+json")
@get(
"/resources",
status_code=litestar.status_codes.HTTP_418_IM_A_TEAPOT,
media_type="application/vnd.example.resource+json",
)
async def retrieve_resource() -> Dict[str, Any]:
return {
"title": "Server thinks it is a teapot",
Expand Down
1 change: 1 addition & 0 deletions docs/reference/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ plugins
:hidden:

flash_messages
problem_details
structlog
sqlalchemy
7 changes: 7 additions & 0 deletions docs/reference/plugins/problem_details.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
===============
problem details
===============


.. automodule:: litestar.plugins.problem_details
:members:
1 change: 1 addition & 0 deletions docs/usage/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,4 @@ signature (their :func:`__init__` method).
:titlesonly:

flash_messages
problem_details
40 changes: 40 additions & 0 deletions docs/usage/plugins/problem_details.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
===============
Problem Details
===============

.. versionadded:: 2.9.0

Problem details are a standardized way of providing machine-readable details of errors in HTTP
responses as specified in `RFC 9457`_, the latest RFC at the time of writing.

.. _RFC 9457: https://datatracker.ietf.org/doc/html/rfc9457

Usage
-----

To send a problem details response, the ``ProblemDetailsPlugin`` should be registered and then
a ``ProblemDetailsException`` can be raised anywhere which will automatically be converted
into a problem details response.

.. literalinclude:: /examples/plugins/problem_details/basic_usage.py
:language: python
:caption: Basic usage of the problem details plugin.

You can convert all ``HTTPExceptions`` into problem details response by enabling the flag in the ``ProblemDetailsConfig.``

.. literalinclude:: /examples/plugins/problem_details/convert_http_exceptions.py
:language: python
:caption: Converting ``HTTPException`` into problem details response.


You can also convert any exception that is not a ``HTTPException`` into a problem details response
by providing a mapping of the exception type to a callable that converts the exception into a
``ProblemDetailsException.``

.. tip:: This can used to override how the ``HTTPException`` is converted into a problem details response as well.

.. literalinclude:: /examples/plugins/problem_details/convert_exceptions.py
:language: python
:caption: Converting custom exceptions into problem details response.

.. warning:: If the ``extra`` field is a ``Mapping``, then it's merged into the problem details response, otherwise it's included in the response with the key ``extra.``
4 changes: 2 additions & 2 deletions docs/usage/responses.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ this :ref:`custom responses <usage/responses:Custom Responses>`.
You can also set an application media type string with the ``+json`` suffix
defined in `RFC 6839 <https://datatracker.ietf.org/doc/html/rfc6839#section-3.1>`_
as the ``media_type`` and it will be recognized and serialized as json.
For example, you can use ``application/problem+json``
(see `RFC 7807 <https://datatracker.ietf.org/doc/html/rfc7807#section-6.1>`_)

For example, you can use ``application/vnd.example.resource+json``
and it will work just like json but have the appropriate content-type header
and show up in the generated OpenAPI schema.

Expand Down
154 changes: 154 additions & 0 deletions litestar/plugins/problem_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Plugin for converting exceptions into a problem details response."""

from __future__ import annotations
guacs marked this conversation as resolved.
Show resolved Hide resolved

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Mapping, TypeVar

from typing_extensions import TypeAlias

from litestar.exceptions.http_exceptions import HTTPException
from litestar.plugins.base import InitPluginProtocol
from litestar.response.base import Response

if TYPE_CHECKING:
from litestar.config.app import AppConfig
from litestar.connection.request import Request
from litestar.types.callable_types import ExceptionHandler, ExceptionT

ProblemDetailsExceptionT = TypeVar("ProblemDetailsExceptionT", bound="ProblemDetailsException")
ProblemDetailsExceptionHandlerType: TypeAlias = "Callable[[Request, ProblemDetailsExceptionT], Response]"
ExceptionToProblemDetailMapType: TypeAlias = (
"Mapping[type[ExceptionT], Callable[[ExceptionT], ProblemDetailsExceptionT]]"
)


def _problem_details_exception_handler(request: Request[Any, Any, Any], exc: ProblemDetailsException) -> Response[Any]:
return exc.to_response(request)


def _create_exception_handler(
exc_to_problem_details_exc_fn: Callable[[ExceptionT], ProblemDetailsException], exc_type: type[ExceptionT]
) -> ExceptionHandler[ExceptionT]:
def _exception_handler(req: Request, exc: exc_type) -> Response: # type: ignore[valid-type]
problem_details_exc = exc_to_problem_details_exc_fn(exc)

return problem_details_exc.to_response(req)

return _exception_handler


def _http_exception_to_problem_detail_exception(exc: HTTPException) -> ProblemDetailsException:
return ProblemDetailsException(
status_code=exc.status_code,
title=exc.detail,
extra=exc.extra,
headers=exc.headers,
)


class ProblemDetailsException(HTTPException):
"""A problem details exception as per RFC 9457."""

_PROBLEM_DETAILS_MEDIA_TYPE = "application/problem+json"

def __init__(
self,
*args: Any,
detail: str = "",
status_code: int | None = None,
headers: dict[str, str] | None = None,
extra: dict[str, Any] | list[Any] | None = None,
type_: str | None = None,
title: str | None = None,
instance: str | None = None,
) -> None:
"""Initialize ``ProblemDetailsException``.

Args:
*args: if ``detail`` kwarg not provided, first arg should be error detail.
detail: Exception details or message. Will default to args[0] if not provided.
status_code: Exception HTTP status code.
headers: Headers to set on the response.
extra: An extra mapping to attach to the exception.
type_: The type field in the problem details.
title: The title field in the problem details.
instance: The instance field in the problem details.
"""

super().__init__(
*args,
detail=detail,
status_code=status_code,
headers=headers,
extra=extra,
)

self.type_ = type_
self.title = title
self.instance = instance

def to_response(self, request: Request[Any, Any, Any]) -> Response[dict[str, Any]]:
"""Convert the problem details exception into a ``Response.``"""

problem_details: dict[str, Any] = {"status": self.status_code}
if self.type_ is not None:
problem_details["type"] = self.type_
if self.title is not None:
problem_details["title"] = self.title
if self.instance is not None:
problem_details["instance"] = self.instance
if self.detail is not None:
problem_details["detail"] = self.detail

if extra := self.extra:
if isinstance(extra, Mapping):
problem_details.update(extra)
else:
problem_details["extra"] = extra

provinzkraut marked this conversation as resolved.
Show resolved Hide resolved
return Response(
problem_details,
headers=self.headers,
media_type=self._PROBLEM_DETAILS_MEDIA_TYPE,
status_code=self.status_code,
)


@dataclass
class ProblemDetailsConfig:
"""The configuration object for ``ProblemDetailsPlugin.``"""

exception_handler: ProblemDetailsExceptionHandlerType = _problem_details_exception_handler
"""The exception handler used for ``ProblemdetailsException.``"""

enable_for_all_http_exceptions: bool = False
"""Flag indicating whether to convert all :exc:`HTTPException` into ``ProblemDetailsException.``"""

exception_to_problem_detail_map: ExceptionToProblemDetailMapType = field(default_factory=dict)
"""A mapping to convert exceptions into ``ProblemDetailsException.``

All exceptions provided in this will get a custom exception handler where these exceptions
are converted into ``ProblemDetailException`` before handling them. This can be used to override
the handler for ``HTTPException`` as well.
"""


class ProblemDetailsPlugin(InitPluginProtocol):
"""A plugin to convert exceptions into problem details as per RFC 9457."""

def __init__(self, config: ProblemDetailsConfig | None = None):
self.config = config or ProblemDetailsConfig()

def on_app_init(self, app_config: AppConfig) -> AppConfig:
app_config.exception_handlers[ProblemDetailsException] = self.config.exception_handler

if self.config.enable_for_all_http_exceptions:
app_config.exception_handlers[HTTPException] = _create_exception_handler(
_http_exception_to_problem_detail_exception, HTTPException
)

for exc_type, conversion_fn in self.config.exception_to_problem_detail_map.items():
app_config.exception_handlers[exc_type] = _create_exception_handler(conversion_fn, exc_type)

return app_config
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ def test_json_suffix_responses() -> None:
"type": "Server delusion",
"status": 418,
}
assert res.headers["content-type"] == "application/problem+json"
assert res.headers["content-type"] == "application/vnd.example.resource+json"
Loading
Loading