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

🎨 web-api: Simpler error models and auto-generated errors in OAS #6855

Merged
merged 13 commits into from
Nov 28, 2024
Merged
26 changes: 13 additions & 13 deletions api/specs/web-server/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from typing import Any

from _common import Error, Log
from fastapi import APIRouter, status
from models_library.api_schemas_webserver.auth import (
AccountRequestInfo,
Expand All @@ -15,6 +14,7 @@
UnregisterCheck,
)
from models_library.generics import Envelope
from models_library.rest_error import EnvelopedError, Log
from pydantic import BaseModel, Field, confloat
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.login._2fa_handlers import Resend2faBody
Expand Down Expand Up @@ -75,7 +75,7 @@ async def register(_body: RegisterBody):
"/auth/unregister",
response_model=Envelope[Log],
status_code=status.HTTP_200_OK,
responses={status.HTTP_409_CONFLICT: {"model": Envelope[Error]}},
responses={status.HTTP_409_CONFLICT: {"model": EnvelopedError}},
)
async def unregister_account(_body: UnregisterCheck):
...
Expand Down Expand Up @@ -107,7 +107,7 @@ async def phone_confirmation(_body: PhoneConfirmationBody):
responses={
# status.HTTP_503_SERVICE_UNAVAILABLE
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
Expand All @@ -122,7 +122,7 @@ async def login(_body: LoginBody):
operation_id="auth_login_2fa",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
Expand All @@ -137,7 +137,7 @@ async def login_2fa(_body: LoginTwoFactorAuthBody):
operation_id="auth_resend_2fa_code",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
Expand All @@ -161,7 +161,7 @@ async def logout(_body: LogoutBody):
status_code=status.HTTP_204_NO_CONTENT,
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
Expand All @@ -174,7 +174,7 @@ async def check_auth():
"/auth/reset-password",
response_model=Envelope[Log],
operation_id="auth_reset_password",
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"model": Envelope[Error]}},
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"model": EnvelopedError}},
)
async def reset_password(_body: ResetPasswordBody):
"""a non logged-in user requests a password reset"""
Expand All @@ -186,7 +186,7 @@ async def reset_password(_body: ResetPasswordBody):
operation_id="auth_reset_password_allowed",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized reset due to invalid token code",
}
},
Expand All @@ -201,11 +201,11 @@ async def reset_password_allowed(code: str, _body: ResetPasswordConfirmation):
operation_id="auth_change_email",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized user. Login required",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unable to send confirmation email",
},
},
Expand Down Expand Up @@ -233,15 +233,15 @@ class PasswordCheckSchema(BaseModel):
operation_id="auth_change_password",
responses={
status.HTTP_401_UNAUTHORIZED: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "unauthorized user. Login required",
},
status.HTTP_409_CONFLICT: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "mismatch between new and confirmation passwords",
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
"model": Envelope[Error],
"model": EnvelopedError,
"description": "current password is invalid",
},
},
Expand Down
54 changes: 17 additions & 37 deletions api/specs/web-server/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, NamedTuple, Optional, Union, get_args, get_origin
from typing import (
Annotated,
Any,
Generic,
NamedTuple,
Optional,
TypeVar,
Union,
get_args,
get_origin,
)

from common_library.json_serialization import json_dumps
from common_library.pydantic_fields_extension import get_type
from fastapi import Query
from models_library.basic_types import LogLevel
from pydantic import BaseModel, ConfigDict, Field, Json, create_model
from pydantic import BaseModel, Json, create_model
from pydantic.fields import FieldInfo

CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent
Expand Down Expand Up @@ -78,43 +87,14 @@ def as_query(model_class: type[BaseModel]) -> type[BaseModel]:
return create_model(new_model_name, **fields)


class Log(BaseModel):
level: LogLevel | None = Field("INFO", description="log level")
message: str = Field(
...,
description="log message. If logger is USER, then it MUST be human readable",
)
logger: str | None = Field(
None, description="name of the logger receiving this message"
)

model_config = ConfigDict(
json_schema_extra={
"example": {
"message": "Hi there, Mr user",
"level": "INFO",
"logger": "user-logger",
}
}
)

ErrorT = TypeVar("ErrorT")

class ErrorItem(BaseModel):
code: str = Field(
...,
description="Typically the name of the exception that produced it otherwise some known error code",
)
message: str = Field(..., description="Error message specific to this item")
resource: str | None = Field(
None, description="API resource affected by this error"
)
field: str | None = Field(None, description="Specific field within the resource")

class EnvelopeE(BaseModel, Generic[ErrorT]):
"""Complementary to models_library.generics.Envelope just for the generators"""

class Error(BaseModel):
logs: list[Log] | None = Field(None, description="log messages")
errors: list[ErrorItem] | None = Field(None, description="errors metadata")
status: int | None = Field(None, description="HTTP error code")
error: ErrorT | None = None
data: Any | None = None


class ParamSpec(NamedTuple):
Expand Down
5 changes: 5 additions & 0 deletions api/specs/web-server/_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
FolderReplaceBodyParams,
)
from models_library.generics import Envelope
from models_library.rest_error import EnvelopedError
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.folders._exceptions_handlers import _TO_HTTP_ERROR_MAP
from simcore_service_webserver.folders._models import (
FolderSearchQueryParams,
FoldersListQueryParams,
Expand All @@ -29,6 +31,9 @@
tags=[
"folders",
],
responses={
i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values()
},
)


Expand Down
5 changes: 5 additions & 0 deletions api/specs/web-server/_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
WorkspaceReplaceBodyParams,
)
from models_library.generics import Envelope
from models_library.rest_error import EnvelopedError
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.folders._exceptions_handlers import _TO_HTTP_ERROR_MAP
from simcore_service_webserver.workspaces._groups_api import WorkspaceGroupGet
from simcore_service_webserver.workspaces._models import (
WorkspacesGroupsBodyParams,
Expand All @@ -31,6 +33,9 @@
tags=[
"workspaces",
],
responses={
i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values()
},
)


Expand Down
121 changes: 121 additions & 0 deletions packages/models-library/src/models_library/rest_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from dataclasses import dataclass
from typing import Annotated

from models_library.generics import Envelope
from pydantic import BaseModel, ConfigDict, Field

from .basic_types import IDStr, LogLevel


class Log(BaseModel):
level: LogLevel | None = Field("INFO", description="log level")
message: str = Field(
...,
description="log message. If logger is USER, then it MUST be human readable",
)
logger: str | None = Field(
None, description="name of the logger receiving this message"
)

model_config = ConfigDict(
json_schema_extra={
"example": {
"message": "Hi there, Mr user",
"level": "INFO",
"logger": "user-logger",
}
}
)


class ErrorItem(BaseModel):
code: str = Field(
...,
description="Typically the name of the exception that produced it otherwise some known error code",
)
message: str = Field(..., description="Error message specific to this item")
resource: str | None = Field(
None, description="API resource affected by this error"
)
field: str | None = Field(None, description="Specific field within the resource")


@dataclass
class LogMessageType:
# NOTE: deprecated!
message: str
level: str = "INFO"
logger: str = "user"


@dataclass
class ErrorItemType:
# NOTE: deprecated!
code: str
message: str
resource: str | None
field: str | None

@classmethod
def from_error(cls, err: BaseException):
return cls(
code=err.__class__.__name__, message=str(err), resource=None, field=None
)


class ErrorGet(BaseModel):
message: Annotated[
str,
Field(
min_length=5,
description="Message displayed to the user",
),
]
support_id: Annotated[
IDStr | None,
Field(description="ID to track the incident during support", alias="supportId"),
] = None

# NOTE: The fields blow are DEPRECATED. Still here to keep compatibilty with front-end until updated
status: Annotated[int, Field(deprecated=True)] = 400
errors: Annotated[
list[ErrorItemType],
Field(deprecated=True, default_factory=list, json_schema_extra={"default": []}),
]
logs: Annotated[
list[LogMessageType],
Field(deprecated=True, default_factory=list, json_schema_extra={"default": []}),
]

model_config = ConfigDict(
populate_by_name=True,
extra="ignore", # Used to prune extra fields from internal data
frozen=True,
json_schema_extra={
"examples": [
{
"message": "Sorry you do not have sufficient access rights for product"
},
{
"message": "Opps this error was unexpected. We are working on that!",
"supportId": "OEC:12346789",
},
]
},
)


class EnvelopedError(Envelope[None]):
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
error: ErrorGet

model_config = ConfigDict(
json_schema_extra={
"examples": [
{"error": {"message": "display error message here"}},
{
"error": {"message": "failure", "supportId": "OEC:123455"},
"data": None,
},
]
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,17 @@ def _do_assert_error(

assert is_error(expected_status_code)

assert len(error["errors"]) >= 1
# New versions of the error models might not have this attribute
details = error.get("errors", [])

if expected_msg:
messages = [detail["message"] for detail in error["errors"]]
assert details
messages = [e["message"] for e in details]
assert expected_msg in messages

if expected_error_code:
codes = [detail["code"] for detail in error["errors"]]
assert details
codes = [e["code"] for e in details]
assert expected_error_code in codes

return data, error
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
from aiohttp.web_response import StreamResponse
from common_library.error_codes import create_error_code
from common_library.json_serialization import json_dumps
from models_library.rest_error import ErrorGet, ErrorItemType, LogMessageType

from ..logging_errors import create_troubleshotting_log_kwargs
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
from ..utils import is_production_environ
from .rest_models import ErrorItemType, ErrorType, LogMessageType
from .rest_responses import (
create_data_response,
create_http_error,
Expand Down Expand Up @@ -98,7 +98,7 @@ async def _middleware_handler(request: web.Request, handler: Handler):
err.content_type = MIMETYPE_APPLICATION_JSON

if not err.text or not is_enveloped_from_text(err.text):
error = ErrorType(
error = ErrorGet(
errors=[
ErrorItemType.from_error(err),
],
Expand Down
Loading
Loading