From 67f31187a07b394beb10b6273f80b55a601ada0e Mon Sep 17 00:00:00 2001 From: PGijsbers Date: Mon, 13 Apr 2026 15:20:43 +0200 Subject: [PATCH 1/5] Wrap input validation error in RFC9457 response --- src/core/errors.py | 22 ++++++++++++++++++++++ src/main.py | 9 ++++++++- tests/routers/openml/dataset_tag_test.py | 7 ++++++- tests/routers/openml/task_list_test.py | 14 +++++++------- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/core/errors.py b/src/core/errors.py index dea1f50..055dc9b 100644 --- a/src/core/errors.py +++ b/src/core/errors.py @@ -7,6 +7,7 @@ from http import HTTPStatus from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse # ============================================================================= @@ -89,6 +90,27 @@ def problem_detail_exception_handler( ) +def validation_exception_handler( + request: Request, # noqa: ARG001 + exc: RequestValidationError, +) -> JSONResponse: + """FastAPI exception handler for RequestValidationError. + + Returns a RFC 9457 compliant response for input validation failures. + """ + return JSONResponse( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content={ + "type": "about:blank", + "title": "Validation Error", + "status": HTTPStatus.UNPROCESSABLE_ENTITY, + "detail": "Input validation failed.", + "errors": exc.errors(), + }, + media_type="application/problem+json", + ) + + # ============================================================================= # Dataset Errors # ============================================================================= diff --git a/src/main.py b/src/main.py index 3be2c5c..3af0e23 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,13 @@ from loguru import logger from config import load_configuration -from core.errors import ProblemDetailError, problem_detail_exception_handler +from fastapi.exceptions import RequestValidationError + +from core.errors import ( + ProblemDetailError, + problem_detail_exception_handler, + validation_exception_handler, +) from core.logging import ( add_request_context_to_log, log_request_duration, @@ -87,6 +93,7 @@ def create_api(configuration_file: Path | None = None) -> FastAPI: app.middleware("http")(add_request_context_to_log) app.add_exception_handler(ProblemDetailError, problem_detail_exception_handler) # type: ignore[arg-type] + app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore[arg-type] logger.info("Adding routers to app") app.include_router(datasets_router) diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py index cddd0d8..fbe1ffe 100644 --- a/tests/routers/openml/dataset_tag_test.py +++ b/tests/routers/openml/dataset_tag_test.py @@ -42,7 +42,12 @@ async def test_dataset_tag_invalid_tag_is_rejected( ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - assert response.json()["detail"][0]["loc"] == ["body", "tag"] + assert response.headers["content-type"] == "application/problem+json" + body = response.json() + assert body["type"] == "about:blank" + assert body["title"] == "Validation Error" + assert body["status"] == HTTPStatus.UNPROCESSABLE_ENTITY + assert body["errors"][0]["loc"] == ["body", "tag"] # ── Direct call tests: tag_dataset ── diff --git a/tests/routers/openml/task_list_test.py b/tests/routers/openml/task_list_test.py index 4667967..67d8539 100644 --- a/tests/routers/openml/task_list_test.py +++ b/tests/routers/openml/task_list_test.py @@ -135,9 +135,9 @@ async def test_list_tasks_invalid_pagination_type( ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY # Verify that the error points to the correct field - detail = response.json()["detail"][0] - assert detail["loc"][-2:] == ["pagination", expected_field] - assert detail["type"] in {"type_error.integer", "int_parsing", "int_type"} + error = response.json()["errors"][0] + assert error["loc"][-2:] == ["pagination", expected_field] + assert error["type"] in {"type_error.integer", "int_parsing", "int_type"} @pytest.mark.parametrize( @@ -150,8 +150,8 @@ async def test_list_tasks_invalid_range(value: str, py_api: httpx.AsyncClient) - response = await py_api.post("/tasks/list", json={"number_instances": value}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY # Verify the error is for the correct field - detail = response.json()["detail"][0] - assert detail["loc"][-1] == "number_instances" + error = response.json()["errors"][0] + assert error["loc"][-1] == "number_instances" @pytest.mark.parametrize( @@ -171,9 +171,9 @@ async def test_list_tasks_invalid_inputs( response = await py_api.post("/tasks/list", json=payload) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY # Ensure we are failing for the field we provided - detail = response.json()["detail"][0] + error = response.json()["errors"][0] expected_field = next(iter(payload)) - assert detail["loc"][-1] == expected_field + assert error["loc"][-1] == expected_field async def test_list_tasks_no_results_api_mapping(py_api: httpx.AsyncClient) -> None: From f42d6628b8f4cb3f4d88dc028689e7c4a25d484f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:22:22 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 3af0e23..46cd79c 100644 --- a/src/main.py +++ b/src/main.py @@ -7,11 +7,10 @@ import uvicorn from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError from loguru import logger from config import load_configuration -from fastapi.exceptions import RequestValidationError - from core.errors import ( ProblemDetailError, problem_detail_exception_handler, From fca6c1dbf637364023f1aa23aa0fa0b05def361b Mon Sep 17 00:00:00 2001 From: PGijsbers Date: Mon, 13 Apr 2026 15:27:12 +0200 Subject: [PATCH 3/5] Test only relevent field content is rejected --- tests/routers/openml/dataset_tag_test.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py index fbe1ffe..08d78de 100644 --- a/tests/routers/openml/dataset_tag_test.py +++ b/tests/routers/openml/dataset_tag_test.py @@ -42,12 +42,7 @@ async def test_dataset_tag_invalid_tag_is_rejected( ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - assert response.headers["content-type"] == "application/problem+json" - body = response.json() - assert body["type"] == "about:blank" - assert body["title"] == "Validation Error" - assert body["status"] == HTTPStatus.UNPROCESSABLE_ENTITY - assert body["errors"][0]["loc"] == ["body", "tag"] + assert response.json()[0]["loc"] == ["body", "tag"] # ── Direct call tests: tag_dataset ── From e5f09bfc612dbc08444e8b5c6aba998c64e4800e Mon Sep 17 00:00:00 2001 From: PGijsbers Date: Mon, 13 Apr 2026 15:33:55 +0200 Subject: [PATCH 4/5] Add a type --- src/core/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/errors.py b/src/core/errors.py index 055dc9b..69d7e0c 100644 --- a/src/core/errors.py +++ b/src/core/errors.py @@ -101,7 +101,7 @@ def validation_exception_handler( return JSONResponse( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, content={ - "type": "about:blank", + "type": "https://openml.org/problems/validation-error", "title": "Validation Error", "status": HTTPStatus.UNPROCESSABLE_ENTITY, "detail": "Input validation failed.", From 2814c6aa379ffe5fa640983c857e91ff638cfd79 Mon Sep 17 00:00:00 2001 From: PGijsbers Date: Mon, 13 Apr 2026 15:35:44 +0200 Subject: [PATCH 5/5] Fetch error from response --- tests/routers/openml/dataset_tag_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/routers/openml/dataset_tag_test.py b/tests/routers/openml/dataset_tag_test.py index 08d78de..d11fc96 100644 --- a/tests/routers/openml/dataset_tag_test.py +++ b/tests/routers/openml/dataset_tag_test.py @@ -42,7 +42,7 @@ async def test_dataset_tag_invalid_tag_is_rejected( ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - assert response.json()[0]["loc"] == ["body", "tag"] + assert response.json()["errors"][0]["loc"] == ["body", "tag"] # ── Direct call tests: tag_dataset ──