diff --git a/docs/qppe-server.yaml b/docs/qppe-server.yaml index d4bebfa..894a59b 100644 --- a/docs/qppe-server.yaml +++ b/docs/qppe-server.yaml @@ -106,7 +106,9 @@ paths: application/json: schema: $ref: "#/components/schemas/QuestionCreated" - 400: + 404: + description: Package not found. + 422: description: Validation error headers: Content-Language: @@ -114,9 +116,7 @@ paths: content: application/json: schema: - type: object - 404: - description: Package not found. + $ref: "#/components/schemas/OptionsFormValidationError" 500: description: Error occurred. content: @@ -1067,6 +1067,14 @@ components: description: Optional human-readable reason for the error. required: [ error_code, temporary ] + OptionsFormValidationError: + allOf: + - $ref: "#/components/schemas/RequestError" + - type: object + properties: + errors: + type: object + QuestionStateMigrationError: type: object properties: diff --git a/questionpy_server/models.py b/questionpy_server/models.py index f594833..58f8b7f 100644 --- a/questionpy_server/models.py +++ b/questionpy_server/models.py @@ -99,6 +99,7 @@ class RequestErrorCode(Enum): INVALID_QUESTION_STATE = "INVALID_QUESTION_STATE" INVALID_PACKAGE = "INVALID_PACKAGE" INVALID_REQUEST = "INVALID_REQUEST" + INVALID_OPTIONS_FORM = "INVALID_OPTIONS_FORM" PACKAGE_ERROR = "PACKAGE_ERROR" PACKAGE_NOT_FOUND = "PACKAGE_NOT_FOUND" CALLBACK_API_ERROR = "CALLBACK_API_ERROR" @@ -113,6 +114,10 @@ class RequestError(BaseModel): reason: str | None = None +class OptionsFormValidationError(RequestError): + errors: dict[str, str] + + class QuestionStateMigrationErrorCode(Enum): NOT_IMPLEMENTED = "NOT_IMPLEMENTED" DOWNGRADE_NOT_POSSIBLE = "DOWNGRADE_NOT_POSSIBLE" diff --git a/questionpy_server/web/_middlewares.py b/questionpy_server/web/_middlewares.py index 155fce0..610259c 100644 --- a/questionpy_server/web/_middlewares.py +++ b/questionpy_server/web/_middlewares.py @@ -10,7 +10,7 @@ from aiohttp.web_response import StreamResponse import questionpy_server.web.errors as web_error -from questionpy_common.api.qtype import InvalidAttemptStateError, InvalidQuestionStateError +from questionpy_common.api.qtype import InvalidAttemptStateError, InvalidQuestionStateError, OptionsFormValidationError from questionpy_common.error import QPyBaseError from questionpy_server.worker.exception import ( StaticFileSizeMismatchError, @@ -50,6 +50,8 @@ async def error_middleware(request: Request, handler: Handler) -> StreamResponse except tuple(exception_map.keys()) as e: exception = exception_map[type(e)] return exception(reason=e.reason, temporary=e.temporary) + except OptionsFormValidationError as e: + return web_error.InvalidOptionsFormError(reason=e.reason, errors=e.errors) except Exception: # noqa: BLE001 web_logger.exception("There was an unexpected error while processing the request.") return web_error.ServerError(reason="unknown", temporary=True) diff --git a/questionpy_server/web/errors.py b/questionpy_server/web/errors.py index d9a2d82..484c9b6 100644 --- a/questionpy_server/web/errors.py +++ b/questionpy_server/web/errors.py @@ -6,7 +6,7 @@ from aiohttp import web from aiohttp.log import web_logger -from questionpy_server.models import RequestError, RequestErrorCode +from questionpy_server.models import OptionsFormValidationError, RequestError, RequestErrorCode class _ExceptionMixin(web.HTTPException): @@ -95,6 +95,19 @@ def __init__(self, *, reason: str | None, **_: Any) -> None: ) +class InvalidOptionsFormError(web.HTTPUnprocessableEntity, _ExceptionMixin): + def __init__(self, *, reason: str | None, errors: dict[str, str], **_: Any) -> None: + super().__init__( + "Invalid form data was provided", + OptionsFormValidationError( + error_code=RequestErrorCode.INVALID_OPTIONS_FORM, + reason=reason, + temporary=False, + errors=errors, + ), + ) + + class PackageError(web.HTTPInternalServerError, _ExceptionMixin): def __init__(self, *, reason: str | None, temporary: bool) -> None: super().__init__( diff --git a/tests/questionpy_server/web/test_error_middleware.py b/tests/questionpy_server/web/test_error_middleware.py index 2290714..342eeb6 100644 --- a/tests/questionpy_server/web/test_error_middleware.py +++ b/tests/questionpy_server/web/test_error_middleware.py @@ -5,8 +5,9 @@ from typing import Any, NoReturn import pytest -from aiohttp import web +from aiohttp import MultipartWriter, web from aiohttp.pytest_plugin import AiohttpClient +from aiohttp.test_utils import TestClient from aiohttp.web_exceptions import HTTPBadRequest, HTTPException, HTTPMethodNotAllowed, HTTPNotFound from questionpy_common.api.qtype import InvalidQuestionStateError @@ -30,6 +31,7 @@ WorkerStartError, ) from questionpy_server.worker.runtime.messages import WorkerMemoryLimitExceededError, WorkerUnknownError +from tests.conftest import PACKAGE def error_server(error: Exception) -> web.Application: @@ -156,3 +158,30 @@ async def test_unexpected_exception_should_return_server_error( assert logger_name == "aiohttp.web" assert "unexpected error" in message assert log_level == logging.ERROR + + +async def test_invalid_options_form_data_error(client: TestClient) -> None: + with PACKAGE.path.open("rb") as package_fd, MultipartWriter("form-data") as writer: + part = writer.append(package_fd) + part.set_content_disposition("form-data", name="package") + + part = writer.append_json({"form_data": {}}) + part.set_content_disposition("form-data", name="main") + + res = await client.post( + f"/packages/{PACKAGE.hash}/question", + data=writer, + ) + + assert res.status == 422 + data = await res.json() + assert ( + data.items() + >= { + "error_code": RequestErrorCode.INVALID_OPTIONS_FORM.value, + "temporary": False, + "reason": None, + }.items() + ) + + assert data["errors"].items() == {"my_hidden": "Field required", "my_repetition": "Field required"}.items()