Skip to content

Commit

Permalink
feat: OptionsFormValidationError web error
Browse files Browse the repository at this point in the history
  • Loading branch information
janbritz committed Jan 27, 2025
1 parent 43a429c commit 70145d7
Show file tree
Hide file tree
Showing 5 changed files with 64 additions and 7 deletions.
16 changes: 12 additions & 4 deletions docs/qppe-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,17 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/QuestionCreated"
400:
404:
description: Package not found.
422:
description: Validation error
headers:
Content-Language:
$ref: '#/components/headers/ContentLanguage'
content:
application/json:
schema:
type: object
404:
description: Package not found.
$ref: "#/components/schemas/OptionsFormValidationError"
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion questionpy_server/web/_middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
raise exception(reason=e.reason, temporary=e.temporary) from e
except OptionsFormValidationError as e:
raise web_error.InvalidOptionsFormError(reason=e.reason, errors=e.errors) from e
except Exception as e:
web_logger.exception("There was an unexpected error while processing the request.")
raise web_error.ServerError(reason="unknown", temporary=True) from e
Expand Down
15 changes: 14 additions & 1 deletion questionpy_server/web/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]) -> 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__(
Expand Down
31 changes: 30 additions & 1 deletion tests/questionpy_server/web/test_error_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()

0 comments on commit 70145d7

Please sign in to comment.