Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: 🎨 Enh/error handler 500 #5487

Draft
wants to merge 48 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ce641af
always log exceptions
pcrespov Mar 13, 2024
7da7d49
error handler for unexpected error
pcrespov Mar 13, 2024
d1b5f0b
renaming and adding reason
pcrespov Mar 13, 2024
887a28d
refactoring into error handlers
pcrespov Mar 13, 2024
5bbe81a
ignore import in ruff since we have isort
pcrespov Mar 14, 2024
6567ee1
mapping errors
pcrespov Mar 14, 2024
50116ae
fixes map
pcrespov Mar 14, 2024
61437a2
draft composing errors
pcrespov Mar 14, 2024
5e62efd
handles unexpected errors
pcrespov Mar 14, 2024
cb32aaf
minor rename
pcrespov Mar 14, 2024
d129457
extending tests
pcrespov Mar 14, 2024
e9ac91f
adding more tests
pcrespov Mar 14, 2024
04f4cb5
linter
pcrespov Mar 14, 2024
4b68282
mpyp fix
pcrespov Mar 14, 2024
cdf8b6d
minor
pcrespov Mar 14, 2024
31d9742
cleanup and doc
pcrespov Mar 18, 2024
eb59ca5
minor
pcrespov Mar 19, 2024
a81b521
fixes linter
pcrespov Mar 22, 2024
f2d4140
fixes http successful and renaming
pcrespov Mar 25, 2024
488ddcc
fixes tests
pcrespov Mar 25, 2024
083a79e
enforces envelope responses
pcrespov Mar 25, 2024
f03a0ef
changes validation error response
pcrespov Mar 25, 2024
ff18f98
draft
pcrespov Mar 26, 2024
275cc5e
new models
pcrespov Mar 26, 2024
0bd3d9e
renames http errors
pcrespov Mar 26, 2024
9727e2f
examples with tests
pcrespov Mar 26, 2024
dff2d84
adds http successful and covered unexcpected exceptions
pcrespov Mar 26, 2024
1309ac3
adapts tests when unhandled exception
pcrespov Mar 27, 2024
b0bd600
adapts tests to changes
pcrespov Mar 27, 2024
9a2038a
fixes
pcrespov Mar 27, 2024
0b1badb
fixes raised HTTPSuccessfule and added more tests
pcrespov Mar 27, 2024
ca22ff6
fixes on handlers
pcrespov Mar 27, 2024
5d988f6
fixes linter
pcrespov Mar 27, 2024
37cf13a
enforces content tupe
pcrespov Mar 27, 2024
1542eea
adapts assert helper
pcrespov Mar 27, 2024
219c06d
fixes tests
pcrespov Mar 27, 2024
17b2559
adds mesage in validation error
pcrespov Mar 27, 2024
14e0988
fixes tests
pcrespov Apr 2, 2024
4d06846
WIP
pcrespov Apr 2, 2024
31eaa21
moves response factories
pcrespov Apr 2, 2024
d5e791f
Frontend: enh/error handler 500 (#80)
odeimaiz Apr 3, 2024
c0a7926
comment
pcrespov Apr 3, 2024
1a9db41
moves enveloped to models-library
pcrespov Apr 4, 2024
f3f2421
error models and converters
pcrespov Apr 4, 2024
ce96c75
rm old error modles
pcrespov Apr 4, 2024
c9e9734
responses payloads
pcrespov Apr 4, 2024
0af86ce
rename
pcrespov Apr 4, 2024
b97bcd0
rename
pcrespov Apr 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ select = [
]
ignore = [
"E501", # line too long, handled by black
"I001", # Minor incompatibilites with isort:https://docs.astral.sh/ruff/rules/unsorted-imports/#unsorted-imports-i001
"S101", # use of `assert` detected hanbled by pylance, does not support noseq
"TID252", # [*] Relative imports from parent modules are banned
"TRY300", # Checks for return statements in try blocks. SEE https://beta.ruff.rs/docs/rules/try-consider-else/
Expand Down
107 changes: 107 additions & 0 deletions packages/models-library/src/models_library/rest_payloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
""" Common models for RESPONSE payloads

- Enveloped response body
- Error model in Enveloped
- Flash message

NOTE: these are all Output models
"""

from typing import Any, ClassVar, TypeAlias

from pydantic import BaseModel, ValidationError


class OneError(BaseModel):
msg: str
# optional
kind: str | None = None
loc: str | None = None
ctx: dict[str, Any] | None = None

class Config:
schema_extra: ClassVar[dict[str, Any]] = {
"examples": [
# HTTP_422_UNPROCESSABLE_ENTITY
{
"loc": "path.project_uuid",
"msg": "value is not a valid uuid",
"kind": "type_error.uuid",
},
# HTTP_401_UNAUTHORIZED
{
"msg": "You have to activate your account via email, before you can login",
"kind": "activation_required",
"ctx": {"resend_email_url": "https://foo.io/resend?code=123456"},
},
]
}

@classmethod
def from_exception(cls, exc: Exception) -> "OneError":
return cls(
msg=f"{exc}", # str(exc) always exists
kind=exc.__class__.__name__, # exception class name always exists
)


class ManyErrors(BaseModel):
msg: str
details: list[OneError] = []

class Config:
schema_extra: ClassVar[dict[str, Any]] = {
"example": {
# Collects all errors in a body HTTP_422_UNPROCESSABLE_ENTITY
"msg": "Invalid field/s 'body.x, body.z' in request",
"details": [
{
"loc": "body.x",
"msg": "field required",
"kind": "value_error.missing",
},
{
"loc": "body.z",
"msg": "field required",
"kind": "value_error.missing",
},
],
}
}


OneOrManyErrors: TypeAlias = OneError | ManyErrors


def loc_to_jq_filter(parts: tuple[int | str, ...]) -> str:
"""Converts Loc into jq filter

SEE https://jqlang.github.io/jq/manual/#basic-filters
"""
return "".join(["." + _ if isinstance(_, str) else f"[{_}]" for _ in parts])


def create_error_model_from_validation_error(
validation_error: ValidationError, msg: str
) -> OneOrManyErrors:
details = [
OneError(
msg=e["msg"],
kind=e["type"],
loc=loc_to_jq_filter(e["loc"]),
ctx=e.get("ctx", None),
)
for e in validation_error.errors()
]

assert details # nosec

if len(details) == 1:
return details[0]
return ManyErrors(msg=msg, details=details)


class FlashMessage(BaseModel):
message: str
level: str = "INFO"
logger: str = "user"
139 changes: 139 additions & 0 deletions packages/models-library/tests/test_rest_payloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# pylint: disable=broad-exception-caught
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
# pylint: disable=unused-variable

import pytest
from faker import Faker
from models_library.rest_payloads import (
ManyErrors,
OneError,
create_error_model_from_validation_error,
loc_to_jq_filter,
)
from pydantic import BaseModel, ValidationError


class ActivationRequiredError(Exception):
...


@pytest.fixture
def generic_exc() -> Exception:
try:
msg = "Needs to confirm email firt"
raise ActivationRequiredError(msg) # noqa: TRY301
except Exception as exc:
return exc


def test_minimal_error_model(faker: Faker):
minimal_error = OneError(msg=faker.sentence())
assert minimal_error.msg
assert not minimal_error.kind
assert not minimal_error.ctx


def test_error_model_from_generic_exception(faker: Faker, generic_exc: Exception):
error_from_exception = OneError.from_exception(generic_exc)
assert error_from_exception.ctx is None


def test_error_model_with_context(faker: Faker, generic_exc: Exception):
# e.g. HTTP_401_UNAUTHORIZED
error_with_context = OneError.from_exception(
generic_exc,
ctx={"resend_confirmation_url": faker.url()},
)

assert error_with_context.kind == "ActivationRequiredError"
assert error_with_context.ctx


def test_to_jq_query():
#
# SEE https://jqlang.github.io/jq/manual/#basic-filters
#
# NOTE: Could eventually use https://pypi.org/project/jq/ to process them
#
assert loc_to_jq_filter(("a",)) == ".a", "Single field name failed"
assert loc_to_jq_filter((0,)) == "[0]", "Single index failed"
assert loc_to_jq_filter(("a", 0)) == ".a[0]", "Field name followed by index failed"
assert loc_to_jq_filter((0, "a")) == "[0].a", "Index followed by field name failed"
assert (
loc_to_jq_filter(("a", 0, "b")) == ".a[0].b"
), "Field name, index, field name sequence failed"
assert (
loc_to_jq_filter((0, "a", 1)) == "[0].a[1]"
), "Index, field name, index sequence failed"
assert (
loc_to_jq_filter(("a", 0, "b", 1, "c")) == ".a[0].b[1].c"
), "Complex sequence with multiple fields and indices failed"
assert (
loc_to_jq_filter(("a", -1)) == ".a[-1]"
), "Field name with negative index failed"


class A(BaseModel):
index: int
required: bool


class B(BaseModel):
items: list[A]
obj: A


@pytest.fixture
def one_error_exc(request: pytest.FixtureRequest) -> ValidationError:
try:
B.parse_obj(
{
"items": [
{"index": 33, "required": True},
{"index": "not an int", "required": True}, # `index` not int!
],
"obj": {"index": 42, "required": False},
}
)
except ValidationError as err:
return err


@pytest.fixture
def many_error_exc(request: pytest.FixtureRequest) -> ValidationError:
try:
B.parse_obj(
{
"items": [
{"index": "not an int", "required": False}, # `index` not int !
],
"obj": {"index": 33}, # `required` missing !
}
)
except ValidationError as err:
return err


def test_error_model_from_validation_error(
faker: Faker, one_error_exc: ValidationError
):
# HTTP_422_UNPROCESSABLE_ENTITY
error_from_validation = create_error_model_from_validation_error(
one_error_exc, msg="Unprocessable entity in request"
)

assert isinstance(error_from_validation, OneError)


def test_error_model_from_many_validation_error(
faker: Faker, many_error_exc: ValidationError
):

# HTTP_422_UNPROCESSABLE_ENTITY
error_from_validation = create_error_model_from_validation_error(
many_error_exc, msg="Unprocessable entity in request"
)

assert isinstance(error_from_validation, ManyErrors)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from aiohttp import ClientResponse
from servicelib.aiohttp import status
from servicelib.aiohttp.rest_responses import unwrap_envelope
from servicelib.status_utils import get_code_display_name, is_error
from servicelib.status_codes_utils import get_display_name, is_error


async def assert_status(
Expand Down Expand Up @@ -81,13 +81,12 @@ def _do_assert_error(

assert is_error(expected_status_code)

assert len(error["errors"]) == 1
assert error.get("message") # required & non-nullable

err = error["errors"][0]
if expected_msg:
assert expected_msg in err["message"]
assert expected_msg in error["errors"][0]["message"]

if expected_error_code:
assert expected_error_code == err["code"]
assert expected_error_code == error["errors"][0]["code"]

return data, error
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from unittest import mock

from servicelib.aiohttp import status
from servicelib.status_utils import get_code_display_name
from servicelib.status_codes_utils import get_display_name
from simcore_postgres_database.models.users import UserRole


Expand Down
Loading
Loading