diff --git a/fastapi/__init__.py b/fastapi/__init__.py index df55e7a6c..75c9a40ca 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -18,7 +18,9 @@ from .param_functions import Header as Header from .param_functions import Path as Path from .param_functions import Query as Query from .param_functions import Security as Security +from .problem_details import ProblemDetails as ProblemDetails from .requests import Request as Request +from .responses import ProblemDetailsResponse as ProblemDetailsResponse from .responses import Response as Response from .routing import APIRouter as APIRouter from .websockets import WebSocket as WebSocket diff --git a/fastapi/applications.py b/fastapi/applications.py index 56e1a3e60..be9b212b1 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -8,6 +8,8 @@ from fastapi import routing from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.exception_handlers import ( http_exception_handler, + problem_details_http_exception_handler, + problem_details_request_validation_exception_handler, request_validation_exception_handler, websocket_request_validation_exception_handler, ) @@ -860,6 +862,67 @@ class FastAPI(Starlette): """ ), ] = True, + problem_details: Annotated[ + bool, + Doc( + """ + Enable RFC 9457 Problem Details for HTTP API error responses. + + When `True`, the default exception handlers for `HTTPException` + and `RequestValidationError` return responses with + `Content-Type: application/problem+json` and the standard + Problem Details body (`type`, `title`, `status`, `detail`, + `instance`, and extension members). Read more about it in the + [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457). + + When `False` (default), the traditional `{"detail": ...}` format + is used. + """ + ), + ] = False, + problem_type_base_uri: Annotated[ + str | None, + Doc( + """ + A base URI for problem type identifiers when using RFC 9457 + Problem Details. + + When set, it is combined with a path segment derived from the + error to form the `type` URI. For example, + `"https://example.com/errors"` and a 404 error produces + `"https://example.com/errors/not-found"`. + + The resolution hierarchy is: + + 1. **Per-exception override**: if `type` is passed to + `HTTPException()`, it is used directly (absolute URIs as-is; + relative paths combined with this base). + 2. **`problem_types` mapping**: a status code lookup in + `problem_types` overrides auto-derivation. + 3. **Auto-derivation**: the HTTP reason phrase is slugified + (e.g., "Not Found" → `not-found`). + 4. If none of the above apply, `"about:blank"` is used. + + Only meaningful when `problem_details=True`. Ignored otherwise. + """ + ), + ] = None, + problem_types: Annotated[ + dict[int | type, str] | None, + Doc( + """ + A mapping of status codes to problem type path segments when + using RFC 9457 Problem Details. + + For example, `{404: "order-not-found", 422: "bad-input"}` + produces `type` URIs like + `"https://example.com/errors/order-not-found"` when combined + with a `problem_type_base_uri`. + + Only meaningful when `problem_details=True`. Ignored otherwise. + """ + ), + ] = None, **extra: Annotated[ Any, Doc( @@ -890,6 +953,9 @@ class FastAPI(Starlette): self.separate_input_output_schemas = separate_input_output_schemas self.openapi_external_docs = openapi_external_docs self.extra = extra + self.problem_details = problem_details + self.problem_type_base_uri = problem_type_base_uri + self.problem_types = problem_types self.openapi_version: Annotated[ str, Doc( @@ -1000,9 +1066,17 @@ class FastAPI(Starlette): self.exception_handlers: dict[ Any, Callable[[Request, Any], Response | Awaitable[Response]] ] = {} if exception_handlers is None else dict(exception_handlers) - self.exception_handlers.setdefault(HTTPException, http_exception_handler) self.exception_handlers.setdefault( - RequestValidationError, request_validation_exception_handler + HTTPException, + problem_details_http_exception_handler + if problem_details + else http_exception_handler, + ) + self.exception_handlers.setdefault( + RequestValidationError, + problem_details_request_validation_exception_handler + if problem_details + else request_validation_exception_handler, ) # Starlette still has incorrect type specification for the handlers @@ -1098,6 +1172,7 @@ class FastAPI(Starlette): servers=self.servers, separate_input_output_schemas=self.separate_input_output_schemas, external_docs=self.openapi_external_docs, + problem_details=self.problem_details, ) self._openapi_routes_version = routes_version return self.openapi_schema diff --git a/fastapi/exception_handlers.py b/fastapi/exception_handlers.py index 475dd7bdd..c5368980b 100644 --- a/fastapi/exception_handlers.py +++ b/fastapi/exception_handlers.py @@ -1,5 +1,12 @@ +from collections.abc import Mapping + from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.problem_details import ( + problem_details_from_http_exception, + problem_details_from_request_validation, +) +from fastapi.responses import ProblemDetailsResponse from fastapi.utils import is_body_allowed_for_status_code from fastapi.websockets import WebSocket from starlette.exceptions import HTTPException @@ -32,3 +39,52 @@ async def websocket_request_validation_exception_handler( await websocket.close( code=WS_1008_POLICY_VIOLATION, reason=jsonable_encoder(exc.errors()) ) + + +def _get_problem_details_config( + request: Request, +) -> tuple[str | None, Mapping[int | type, str] | None]: + try: + app = request.app + except (AttributeError, KeyError): + return None, None + return ( + getattr(app, "problem_type_base_uri", None), + getattr(app, "problem_types", None), + ) + + +async def problem_details_http_exception_handler( + request: Request, exc: HTTPException +) -> Response: + headers = getattr(exc, "headers", None) + if not is_body_allowed_for_status_code(exc.status_code): + return Response(status_code=exc.status_code, headers=headers) + type_base_uri, problem_types = _get_problem_details_config(request) + problem = problem_details_from_http_exception( + exc, + url=str(request.url), + type_base_uri=type_base_uri, + problem_types=problem_types, + ) + return ProblemDetailsResponse( + content=problem.model_dump(exclude_none=True), + status_code=exc.status_code, + headers=headers, + ) + + +async def problem_details_request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> ProblemDetailsResponse: + type_base_uri, problem_types = _get_problem_details_config(request) + problem = problem_details_from_request_validation( + exc.errors(), + url=str(request.url), + type_base_uri=type_base_uri, + problem_types=problem_types, + ) + return ProblemDetailsResponse( + content=problem.model_dump(exclude_none=True), + status_code=422, + ) diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index d7065c52f..a3d974c86 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -79,8 +79,25 @@ class HTTPException(StarletteHTTPException): """ ), ] = None, + type: Annotated[ + str | None, + Doc( + """ + An optional URI reference identifying the problem type, as defined in + RFC 9457. + + When set, this overrides the automatic problem type resolution in + the RFC 9457 error response (e.g., from `problem_type_base_uri` + or `problem_types`). + + Read more about it in the + [RFC 9457 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457). + """ + ), + ] = None, ) -> None: super().__init__(status_code=status_code, detail=detail, headers=headers) + self.type: str | None = type class WebSocketException(StarletteWebSocketException): diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 2e0aca118..0570911c8 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -68,6 +68,23 @@ validation_error_response_definition = { }, } +problem_details_definition = { + "title": "ProblemDetails", + "type": "object", + "properties": { + "type": { + "title": "Type", + "type": "string", + "default": "about:blank", + }, + "title": {"title": "Title", "type": "string"}, + "status": {"title": "Status", "type": "integer"}, + "detail": {"title": "Detail", "type": "string"}, + "instance": {"title": "Instance", "type": "string"}, + }, + "additionalProperties": True, +} + status_code_ranges: dict[str, str] = { "1XX": "Information", "2XX": "Success", @@ -266,6 +283,7 @@ def get_openapi_path( tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any] ], separate_input_output_schemas: bool = True, + problem_details: bool = False, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: path = {} security_schemes: dict[str, Any] = {} @@ -334,6 +352,7 @@ def get_openapi_path( model_name_map=model_name_map, field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, + problem_details=problem_details, ) callbacks[callback.name] = {callback.path: cb_path} operation["callbacks"] = callbacks @@ -457,21 +476,37 @@ def get_openapi_path( status in operation["responses"] for status in [http422, "4XX", "default"] ): - operation["responses"][http422] = { - "description": "Validation Error", - "content": { - "application/json": { - "schema": {"$ref": REF_PREFIX + "HTTPValidationError"} - } - }, - } - if "ValidationError" not in definitions: - definitions.update( - { - "ValidationError": validation_error_definition, - "HTTPValidationError": validation_error_response_definition, - } - ) + if problem_details: + operation["responses"][http422] = { + "description": "Validation Error", + "content": { + "application/problem+json": { + "schema": {"$ref": REF_PREFIX + "ProblemDetails"} + } + }, + } + if "ProblemDetails" not in definitions: + definitions.update( + { + "ProblemDetails": problem_details_definition, + } + ) + else: + operation["responses"][http422] = { + "description": "Validation Error", + "content": { + "application/json": { + "schema": {"$ref": REF_PREFIX + "HTTPValidationError"} + } + }, + } + if "ValidationError" not in definitions: + definitions.update( + { + "ValidationError": validation_error_definition, + "HTTPValidationError": validation_error_response_definition, + } + ) if route.openapi_extra: deep_dict_update(operation, route.openapi_extra) path[method.lower()] = operation @@ -536,6 +571,7 @@ def get_openapi( license_info: dict[str, str | Any] | None = None, separate_input_output_schemas: bool = True, external_docs: dict[str, Any] | None = None, + problem_details: bool = False, ) -> dict[str, Any]: info: dict[str, Any] = {"title": title, "version": version} if summary: @@ -572,6 +608,7 @@ def get_openapi( model_name_map=model_name_map, field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, + problem_details=problem_details, ) if result: path, security_schemes, path_definitions = result @@ -592,6 +629,7 @@ def get_openapi( model_name_map=model_name_map, field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, + problem_details=problem_details, ) if result: path, security_schemes, path_definitions = result diff --git a/fastapi/problem_details.py b/fastapi/problem_details.py new file mode 100644 index 000000000..dbf3d3136 --- /dev/null +++ b/fastapi/problem_details.py @@ -0,0 +1,113 @@ +"""RFC 9457 Problem Details for HTTP APIs.""" + +import http.client +from collections.abc import Mapping, Sequence +from typing import Any + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ConfigDict + + +class ProblemDetails(BaseModel): + type: str = "about:blank" + title: str | None = None + status: int | None = None + detail: str | None = None + instance: str | None = None + + model_config = ConfigDict(extra="allow") + + +def _slugify(text: str) -> str: + return text.lower().replace(" ", "-").replace("'", "") + + +def resolve_problem_type( + *, + explicit_type: str | None = None, + status_code: int | None = None, + error_kind: str | None = None, + type_base_uri: str | None = None, + problem_types: Mapping[int | type, str] | None = None, +) -> str: + segment: str | None = None + + if explicit_type: + segment = explicit_type + elif problem_types and status_code is not None: + segment = problem_types.get(status_code) + + if segment is not None: + if segment.startswith(("http://", "https://", "about:", "tag:", "urn:")): + return segment + if type_base_uri: + return f"{type_base_uri.rstrip('/')}/{segment.lstrip('/')}" + return segment + + if error_kind: + if type_base_uri: + return f"{type_base_uri.rstrip('/')}/{error_kind}" + return error_kind + + if type_base_uri and status_code is not None: + reason = http.client.responses.get(status_code) + if reason: + segment = _slugify(reason) + return f"{type_base_uri.rstrip('/')}/{segment}" + + return "about:blank" + + +def problem_details_from_http_exception( + exc: Any, + *, + url: str | None = None, + type_base_uri: str | None = None, + problem_types: Mapping[int | type, str] | None = None, +) -> ProblemDetails: + status_code: int = exc.status_code + detail: Any = getattr(exc, "detail", None) + detail_str = detail if isinstance(detail, str) else None + kwargs: dict[str, Any] = {} + if not isinstance(detail, str) and detail is not None: + kwargs["errors"] = jsonable_encoder(detail) + problem_type = resolve_problem_type( + explicit_type=getattr(exc, "type", None), + status_code=status_code, + type_base_uri=type_base_uri, + problem_types=problem_types, + ) + return ProblemDetails( + type=problem_type, + title=http.client.responses.get(status_code), + status=status_code, + detail=detail_str, + instance=url, + **kwargs, + ) + + +def problem_details_from_request_validation( + errors: Sequence[Any], + *, + url: str | None = None, + type_base_uri: str | None = None, + problem_types: Mapping[int | type, str] | None = None, +) -> ProblemDetails: + errors_list = list(errors) + num_errors = len(errors_list) + problem_type = resolve_problem_type( + status_code=422, + error_kind="validation-error", + type_base_uri=type_base_uri, + problem_types=problem_types, + ) + extra: dict[str, Any] = {"errors": jsonable_encoder(errors_list)} + return ProblemDetails( + type=problem_type, + title="Validation Error", + status=422, + detail=f"{num_errors} validation error{'s' if num_errors != 1 else ''}", + instance=url, + **extra, + ) diff --git a/fastapi/responses.py b/fastapi/responses.py index 29df4b7a6..cf31d1219 100644 --- a/fastapi/responses.py +++ b/fastapi/responses.py @@ -13,6 +13,10 @@ from starlette.responses import StreamingResponse as StreamingResponse # noqa from typing_extensions import deprecated +class ProblemDetailsResponse(JSONResponse): + media_type = "application/problem+json" + + class _UjsonModule(Protocol): def dumps(self, __obj: Any, *, ensure_ascii: bool = ...) -> str: ... diff --git a/tests/test_problem_details.py b/tests/test_problem_details.py new file mode 100644 index 000000000..d1413ce76 --- /dev/null +++ b/tests/test_problem_details.py @@ -0,0 +1,510 @@ +import http.client + +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.responses import ProblemDetailsResponse +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + + +def test_problem_details_response_content_type(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Item not found") + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 404 + assert response.headers["content-type"] == "application/problem+json" + + +def test_http_exception_string_detail(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Item not found") + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 404 + data = response.json() + assert data["type"] == "about:blank" + assert data["title"] == "Not Found" + assert data["status"] == 404 + assert data["detail"] == "Item not found" + assert "instance" in data + assert "errors" not in data + + +def test_http_exception_dict_detail(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException( + status_code=422, + detail={"code": "CUSTOM_ERROR", "message": "Something went wrong"}, + ) + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 422 + data = response.json() + assert data["title"] == http.client.responses.get(422) + assert data["status"] == 422 + assert "detail" not in data + assert "errors" in data + assert data["errors"] == {"code": "CUSTOM_ERROR", "message": "Something went wrong"} + + +def test_http_exception_list_detail(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=400, detail=["err1", "err2"]) + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 400 + data = response.json() + assert "detail" not in data + assert data["errors"] == ["err1", "err2"] + + +def test_http_exception_with_headers(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException( + status_code=404, + detail="Not found", + headers={"X-Error": "custom"}, + ) + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 404 + assert response.headers.get("x-error") == "custom" + assert response.json()["detail"] == "Not found" + + +def test_no_body_status_code_204(): + app = FastAPI(problem_details=True) + + @app.get("/no-body") + def no_body(): + raise HTTPException(status_code=204) + + client = TestClient(app) + response = client.get("/no-body") + assert response.status_code == 204 + assert not response.content + + +def test_no_body_status_code_304(): + app = FastAPI(problem_details=True) + + @app.get("/no-body") + def no_body(): + raise HTTPException(status_code=304) + + client = TestClient(app) + response = client.get("/no-body") + assert response.status_code == 304 + assert not response.content + + +def test_no_body_status_code_with_detail(): + app = FastAPI(problem_details=True) + + @app.get("/no-body") + def no_body(): + raise HTTPException(status_code=204, detail="should disappear") + + client = TestClient(app) + response = client.get("/no-body") + assert response.status_code == 204 + assert not response.content + + +def test_validation_error(): + app = FastAPI(problem_details=True) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + assert response.status_code == 422 + data = response.json() + assert data["type"] == "validation-error" + assert data["title"] == "Validation Error" + assert data["status"] == 422 + assert data["detail"] == "1 validation error" + assert "instance" in data + assert "errors" in data + assert len(data["errors"]) == 1 + assert data["errors"][0]["loc"] == ["path", "item_id"] + assert data["errors"][0]["type"] == "int_parsing" + + +def test_multiple_validation_errors(): + from pydantic import BaseModel + + class Item(BaseModel): + name: str + price: float + + app = FastAPI(problem_details=True) + + @app.post("/items") + def create_item(item: Item): + return item + + client = TestClient(app) + response = client.post("/items", json={}) + assert response.status_code == 422 + data = response.json() + assert data["detail"] == "2 validation errors" + assert len(data["errors"]) == 2 + + +def test_validation_error_content_type(): + app = FastAPI(problem_details=True) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + assert response.headers["content-type"] == "application/problem+json" + + +def test_validation_error_overridden_by_custom_handler(): + app = FastAPI(problem_details=True) + + @app.exception_handler(RequestValidationError) + async def custom_handler(request, exc): + from starlette.responses import JSONResponse + + return JSONResponse({"custom": "handler"}, status_code=422) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + assert response.json() == {"custom": "handler"} + + +def test_http_exception_overridden_by_custom_handler(): + app = FastAPI(problem_details=True) + + @app.exception_handler(HTTPException) + async def custom_handler(request, exc): + from starlette.responses import JSONResponse + + return JSONResponse({"custom": "http"}, status_code=exc.status_code) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404) + + client = TestClient(app) + response = client.get("/error") + assert response.json() == {"custom": "http"} + + +def test_default_false_uses_legacy_format(): + app = FastAPI(problem_details=False) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app) + response = client.get("/error") + assert response.status_code == 404 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"detail": "Not found"} + + +def test_default_false_validation_uses_legacy_format(): + app = FastAPI(problem_details=False) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + assert response.status_code == 422 + assert response.headers["content-type"] == "application/json" + data = response.json() + assert "detail" in data + assert isinstance(data["detail"], list) + + +def test_problem_details_not_inherited_by_default(): + app = FastAPI() + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app) + response = client.get("/error") + assert response.headers["content-type"] == "application/json" + + +def test_openapi_schema_with_problem_details(): + app = FastAPI(problem_details=True) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + schema = client.get("/openapi.json").json() + assert schema["paths"]["/items/{item_id}"]["get"]["responses"]["422"] == snapshot( + { + "description": "Validation Error", + "content": { + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/ProblemDetails"} + } + }, + } + ) + assert "ProblemDetails" in schema["components"]["schemas"] + assert ( + schema["components"]["schemas"]["ProblemDetails"]["title"] == "ProblemDetails" + ) + + +def test_openapi_schema_without_problem_details(): + app = FastAPI(problem_details=False) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + schema = client.get("/openapi.json").json() + assert ( + schema["paths"]["/items/{item_id}"]["get"]["responses"]["422"]["content"][ + "application/json" + ]["schema"]["$ref"] + == "#/components/schemas/HTTPValidationError" + ) + assert "HTTPValidationError" in schema["components"]["schemas"] + + +def test_openapi_schema_default(): + app = FastAPI() + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + schema = client.get("/openapi.json").json() + assert "HTTPValidationError" in schema["components"]["schemas"] + assert "ProblemDetails" not in schema["components"]["schemas"] + + +def test_problem_details_response_class(): + response = ProblemDetailsResponse( + content={"type": "about:blank", "title": "Error", "status": 400}, + status_code=400, + ) + assert response.media_type == "application/problem+json" + assert response.status_code == 400 + + +def test_problem_details_handler_request_without_app(): + from fastapi.exception_handlers import _get_problem_details_config + from starlette.requests import Request + + request = Request( + scope={"type": "http", "method": "GET", "path": "/", "headers": []} + ) + result = _get_problem_details_config(request) + assert result == (None, None) + + +def test_unicode_status_code(): + app = FastAPI(problem_details=True) + + @app.get("/teapot") + def teapot(): + raise HTTPException(status_code=418) + + client = TestClient(app) + response = client.get("/teapot") + assert response.status_code == 418 + data = response.json() + assert data["title"] == "I'm a Teapot" + assert data["status"] == 418 + + +def test_instance_url_reflects_request(): + app = FastAPI(problem_details=True) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app, base_url="http://testserver") + response = client.get("/items/abc") + data = response.json() + assert data["instance"] == "http://testserver/items/abc" + + +def test_explicit_type_on_http_exception(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException( + status_code=404, detail="Not found", type="my-custom-problem" + ) + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "my-custom-problem" + + +def test_explicit_absolute_type_uri(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException( + status_code=404, + detail="Not found", + type="https://example.com/errors/custom", + ) + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "https://example.com/errors/custom" + + +def test_type_base_uri_auto_derivation(): + app = FastAPI( + problem_details=True, + problem_type_base_uri="https://example.com/errors", + ) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "https://example.com/errors/not-found" + + +def test_type_base_uri_with_problem_types_map(): + app = FastAPI( + problem_details=True, + problem_type_base_uri="https://example.com/errors", + problem_types={404: "order-not-found"}, + ) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "https://example.com/errors/order-not-found" + + +def test_explicit_type_overrides_problem_types_map(): + app = FastAPI( + problem_details=True, + problem_type_base_uri="https://example.com/errors", + problem_types={404: "order-not-found"}, + ) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found", type="custom-override") + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "https://example.com/errors/custom-override" + + +def test_validation_error_type_with_base_uri(): + app = FastAPI( + problem_details=True, + problem_type_base_uri="https://example.com/errors", + ) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + data = response.json() + assert data["type"] == "https://example.com/errors/validation-error" + + +def test_validation_error_problem_types_map(): + app = FastAPI( + problem_details=True, + problem_type_base_uri="https://example.com/errors", + problem_types={422: "bad-input"}, + ) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + response = client.get("/items/foo") + data = response.json() + assert data["type"] == "https://example.com/errors/bad-input" + + +def test_no_base_uri_falls_back_to_segment(): + app = FastAPI(problem_details=True) + + @app.get("/error") + def raise_error(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app) + response = client.get("/error") + data = response.json() + assert data["type"] == "about:blank" + + +def test_openapi_schema_problem_details_has_additional_properties(): + app = FastAPI(problem_details=True) + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + schema = client.get("/openapi.json").json() + pd_schema = schema["components"]["schemas"]["ProblemDetails"] + assert pd_schema.get("additionalProperties") is True