Source code for litestar_security.schema

"""Wire-schema conventions for generated route bodies.

Every request and response schema on the generated ``/auth`` route tree shares
one casing convention and one unknown-field policy through :class:`WireStruct`.
Applications defining their own schemas alongside the generated routes may
inherit the same base so a single convention holds across the whole tree.

:class:`RouteError` lives here rather than beside either route family's own
schemas because both need it and neither owns it: ``providers/oauth/`` does not
import from ``accounts/``, and the body it describes is Litestar's rather than
this library's.
"""

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, cast

import msgspec
from litestar.exceptions import ImproperlyConfiguredException

if TYPE_CHECKING:
    from litestar.dto.types import RenameStrategy

__all__ = ("ProblemDetail", "RouteError", "WirePolicy", "WireStruct")

RENAME_STRATEGIES: frozenset[str] = frozenset({"lower", "upper", "camel", "pascal", "kebab"})
"""The named casing strategies :class:`WirePolicy` accepts by name."""


[docs] @dataclass(frozen=True, slots=True) class WirePolicy: """How generated request and response bodies are spelled on the wire. One value carries both halves of the convention, and it is hashable, so the generated routers a feature configuration caches stay one router per policy rather than one router that a second application can find already built for a casing it did not ask for. """ rename: "RenameStrategy | None" = None """The casing strategy, or ``None`` for the field names as Python spells them.""" forbid_unknown_fields: bool = True """Whether an unrecognized member is a decoding error rather than ignored.""" def __post_init__(self) -> None: """Reject a strategy no schema could be built with. Raises: ImproperlyConfiguredException: If the strategy is neither one of the named strategies nor a callable, or the unknown-field policy is not boolean. """ rename: object = self.rename if rename is not None and not callable(rename) and rename not in RENAME_STRATEGIES: named = ", ".join(sorted(RENAME_STRATEGIES)) message = f"Wire rename strategy must be callable or one of: {named}" raise ImproperlyConfiguredException(detail=message) forbid_unknown_fields = cast("object", self.forbid_unknown_fields) if forbid_unknown_fields.__class__ is not bool: message = "Wire unknown-field policy must be boolean" raise ImproperlyConfiguredException(detail=message)
[docs] class WireStruct(msgspec.Struct, frozen=True, forbid_unknown_fields=True): """Base for every generated-route wire schema, and the default it is spelled in. Field names reach the wire exactly as they are spelled in Python, and an unrecognized member is a decoding error rather than a silently discarded key. Rejecting the unknown member is what keeps a stale or misspelled optional field from resolving to its default and producing a wrong but successful request. That is the default rather than a fixed policy. An application chooses the convention through ``SecurityConfig.wire_rename`` and ``wire_forbid_unknown_fields``, and the generated routes carry the choice into the request body, the response body, and the OpenAPI schema together. A schema declares what it is called here; the configuration decides how it is spelled. Subclasses restate ``frozen=True``:: class LocalCredentials(WireStruct, frozen=True): '''Password credentials accepted by generated login handlers.''' That keyword is redundant at runtime, because msgspec inherits the struct configuration, and required by the type checkers, which read immutability from the class keywords rather than from the base. Keeping the base frozen anyway means a subclass that omits the keyword is still immutable in fact, which is the safer direction for the mistake to fall. Strictness applies to decoding, so it constrains request schemas only; response schemas inherit it inertly. A schema that must tolerate members it does not model - a specification-defined body whose sender may legitimately add them - overrides the policy for itself and records why:: class BackchannelLogout(WireStruct, frozen=True, forbid_unknown_fields=False): '''The specification permits unrecognized members.''' Prefer that per-schema override to relaxing this base: it keeps the safe default intact and leaves the reason beside the schema that needs it. """ __wire_casing__: ClassVar[bool] = True """Whether the application's configured casing applies to this schema. A schema whose member names belong to a specification, or to a sender other than this library, sets this ``False`` and records why beside itself. Its members then reach the wire and the document exactly as they are spelled here whatever an application configures. """
[docs] class RouteError(WireStruct, frozen=True, forbid_unknown_fields=False): """The body a generated route sends when it *raises* rather than returns. A denial - 400, 401, 429, 503, and the OAuth 409 - reaches the wire through Litestar's exception handling, not through the handler's return value, so the body is ``ExceptionResponseContent``: the status repeated inside the payload, a human-readable ``detail``, and ``extra`` when the raised exception carries structured context. A request-validation failure always carries one, as a list of ``{message, key, source}`` entries, so ``extra`` is a member clients see in practice rather than a theoretical one. Distinguish this from :class:`~litestar_security.accounts.OperationMessage`, which is the body a handler *returns* - the 200 confirmations and the 409 conflict. The two are separate schemas because the distinction that decides the shape is raised-versus-returned, not error-versus-success. Unknown members are tolerated here, against the base policy, because the sender is Litestar: an application handler may add its own members and a future Litestar may too, and neither is a reason for a client of this library to fail decoding. """ __wire_casing__: ClassVar[bool] = False """Litestar's exception handling renders this body, so its members are its own.""" status_code: int """The HTTP status, repeated in the body by Litestar's exception handling.""" detail: str """A human-readable explanation that never names an account.""" extra: dict[str, Any] | list[Any] | None = None """Structured context the raised exception carried, when it carried any."""
[docs] class ProblemDetail(WireStruct, frozen=True, forbid_unknown_fields=False): """The body a denial takes when the application converts every HTTP exception. An application installing Litestar's problem-details plugin with ``enable_for_all_http_exceptions=True`` replaces :class:`RouteError` on every raised status, and the response is served as ``application/problem+json``. These are the members Litestar's conversion actually emits, which is not the RFC 9457 five-member shape: the raised ``detail`` is moved onto ``title`` and ``detail`` falls back to the HTTP reason phrase, while ``type`` and ``instance`` are never produced. Unknown members are tolerated both because RFC 9457 permits extension members and because the sender is Litestar rather than this library. """ __wire_casing__: ClassVar[bool] = False """Litestar's problem-details conversion renders this body, so its members are its own.""" status: int """The HTTP status, repeated in the body.""" title: str """The raised explanation, which the conversion moves here from ``detail``.""" detail: str """The HTTP reason phrase, which the conversion leaves as the default.""" extra: dict[str, Any] | list[Any] | None = None """Structured context the raised exception carried, carried through unchanged."""