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

Redesign API errors handling #156

Merged
merged 3 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
795 changes: 692 additions & 103 deletions Tekst-API/openapi.json

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion Tekst-API/tekst/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette_csrf import CSRFMiddleware

from tekst.config import TekstConfig, get_config
Expand Down Expand Up @@ -86,3 +89,17 @@ async def lifespan(app: FastAPI):
allow_methods=_cfg.cors_allow_methods,
allow_headers=_cfg.cors_allow_headers,
)


@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request, exc):
return await http_exception_handler(
request,
HTTPException(
status_code=exc.status_code,
detail=exc.detail.model_dump(),
headers=exc.headers,
)
if isinstance(exc.detail, BaseModel)
else exc,
)
3 changes: 3 additions & 0 deletions Tekst-API/tekst/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ async def create(self, user_create, **kwargs) -> fapi_users_models.UP:
if await UserDocument.find_one(
UserDocument.username == user_create.username
).exists():
# We're not using the prepared errors from tekst.errors because
# the auth-related endpoints from fastapi-users have a different error model
# that we want to follow here.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="REGISTER_USERNAME_ALREADY_EXISTS",
Expand Down
309 changes: 309 additions & 0 deletions Tekst-API/tekst/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
from typing import Any, Literal

from fastapi import HTTPException, status

from tekst.config import TekstConfig, get_config
from tekst.models.common import ModelBase


_cfg: TekstConfig = get_config()


class ErrorDetail(ModelBase):
key: str
msg: str | None = None
values: dict[str, str | int | float | bool] | None = None


class TekstErrorModel(ModelBase):
detail: ErrorDetail


class TekstHTTPException(HTTPException):
def __init__(
self,
status_code: int,
detail: TekstErrorModel | None = None,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(status_code=status_code, detail=detail, headers=headers)


def responses(
errors: list[TekstHTTPException],
) -> dict[int, dict[Literal["model"], type[TekstErrorModel]]]:
d = {}
for error in errors:
if error.status_code not in d:
d[error.status_code] = {}
d[error.status_code]["model"] = TekstErrorModel
return d


def error_instance(
status_code: int,
key: str,
msg: str | None = None,
values: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
):
return TekstHTTPException(
status_code=status_code,
headers=headers,
detail=TekstErrorModel(
detail=ErrorDetail(
key=key,
msg=msg,
values=values,
)
),
)


# PLATFORM API HTTP ERRORS DEFINED BELOW

E_409_RESOURCES_LIMIT_REACHED = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="resourcesLimitReached",
msg="Resources limit reached for this user",
values={
"limit": _cfg.limits_max_resources_per_user,
},
)

E_404_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="notFound",
msg="Whatever was requested could not be found",
)

E_404_RESOURCE_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="resourceNotFound",
msg="The resource could not be found",
)

E_404_BOOKMARK_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="bookmarkNotFound",
msg="The bookmark could not be found",
)

E_409_BOOKMARK_EXISTS = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="bookmarkExists",
msg="A bookmark for this location already exists",
)

E_409_BOOKMARKS_LIMIT_REACHED = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="bookmarksLimitReached",
msg="User cannot have more than 1000 bookmarks",
values={
"limit": 1000,
},
)

E_404_LOCATION_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="locationNotFound",
msg="The location could not be found",
)

E_404_TEXT_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="textNotFound",
msg="The text could not be found",
)

E_400_RESOURCE_INVALID_LEVEL = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceInvalidLevel",
msg="The level of the resource is invalid",
)

E_400_RESOURCE_VERSION_OF_VERSION = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceVersionOfVersion",
msg="The resource is already a version of another resource",
)

E_400_SHARED_WITH_USER_NON_EXISTENT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="sharedWithUserNonExistent",
msg="Shared-with user doesn't exist",
)

E_400_RESOURCE_PUBLIC_DELETE = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourcePublicDelete",
msg="Cannot delete a published resource",
)

E_400_RESOURCE_PROPOSED_DELETE = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceProposedDelete",
msg="Cannot delete a proposed resource",
)

E_400_RESOURCE_PUBLIC_PROPOSED_TRANSFER = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourcePublishedProposedTransfer",
msg="Resource is published or proposed for publication and cannot be deleted",
)

E_400_TARGET_USER_NON_EXISTENT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="targetUserNonExistent",
msg="Target user doesn't exist",
)

E_403_FORBIDDEN = error_instance(
status_code=status.HTTP_403_FORBIDDEN,
key="forbidden",
msg="You have no permission to perform this action",
)

E_400_RESOURCE_VERSION_PROPOSE = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceVersionPropose",
msg="Cannot propose a resource version",
)

E_400_RESOURCE_PUBLISH_UNPROPOSED = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourcePublishUnproposed",
msg="Cannot publish an unproposed resource",
)

E_400_RESOURCE_PROPOSE_PUBLIC = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceProposePublic",
msg="Cannot propose a published resource",
)

E_400_RESOUCE_VERSION_PUBLISH = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="resourceVersionPublish",
msg="Cannot publish a resource version",
)

E_400_UPLOAD_INVALID_MIME_TYPE_NOT_JSON = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="uploadInvalidMimeTypeNotJson",
msg="Invalid file MIME type (must be 'application/json')",
)

E_400_UPLOAD_INVALID_JSON = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="uploadInvalidJson",
msg="Import data is not valid JSON",
)

E_400_IMPORT_ID_MISMATCH = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="importIdMismatch",
msg="Import data ID does not match the ID in the request",
)

E_400_IMPORT_ID_NON_EXISTENT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="importIdNonExistent",
msg="An ID in the import data does not exist",
)

E_400_IMPORT_INVALID_CONTENT_DATA = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="importInvalidContentData",
msg="Invalid content data in import data",
)

E_500_INTERNAL_SERVER_ERROR = error_instance(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
key="internalServerError",
msg="An internal server error occurred. How embarrassing :(",
)

E_409_CONTENT_CONFLICT = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="contentConflict",
msg="The properties of this content conflict with another content",
)

E_404_CONTENT_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="contentNotFound",
msg="The requested content could not be found",
)

E_400_CONTENT_ID_MISMATCH = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="contentIdMismatch",
msg="Referenced resource ID in updates doesn't match the one in target content",
)

E_400_CONTENT_TYPE_MISMATCH = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="contentTypeMismatch",
msg="Referenced resource type in updates doesn't match the one in target content",
)

E_400_INVALID_TEXT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="referencedInvalidText",
msg="Text ID in in request data doesn't reference an existing text",
)

E_400_INVALID_LEVEL = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="locationInvalidLevel",
msg="The level index passed does not exist in target text",
)

E_400_LOCATION_NO_LEVEL_NOR_PARENT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="locationNoLevelNorParent",
msg="Must have either 'level' or 'parentId' set",
)

E_400_LOCATION_CHILDREN_NO_PARENT_NOR_TEXT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="locationChildrenNoParentNorText",
msg="Must have either 'parentId' or 'textId' set",
)

E_404_USER_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="userNotFound",
msg="The requested user could not be found",
)

E_404_SEGMENT_NOT_FOUND = error_instance(
status_code=status.HTTP_404_NOT_FOUND,
key="segmentNotFound",
msg="The requested segment could not be found",
)

E_409_SEGMENT_KEY_LOCALE_CONFLICT = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="segmentKeyLocaleConflict",
msg="A segment with this key and language already exists",
)

E_409_TEXT_SAME_TITLE_OR_SLUG = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="textSameTitleOrSlug",
msg="An equal text already exists (same title or slug)",
)

E_409_TEXT_IMPORT_LOCATIONS_EXIST = error_instance(
status_code=status.HTTP_409_CONFLICT,
key="textImportLocationsExist",
msg="Text already has locations",
)

E_400_TEXT_DELETE_LAST_TEXT = error_instance(
status_code=status.HTTP_400_BAD_REQUEST,
key="textDeleteLastText",
msg="Cannot delete the last text",
)
3 changes: 0 additions & 3 deletions Tekst-API/tekst/models/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ class PlatformData(ModelBase):
tekst: dict[str, Any] = camelize(
_cfg.model_dump(include_keys_prefix="tekst_", strip_include_keys_prefix=True)
)
limits: dict[str, int] = camelize(
_cfg.model_dump(include_keys_prefix="limits_", strip_include_keys_prefix=True)
)


class TextStats(ModelBase):
Expand Down
2 changes: 1 addition & 1 deletion Tekst-API/tekst/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async def generate_openapi_schema(
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.get(f"{cfg.doc_openapi_url}")
if resp.status_code != 200:
raise HTTPException(resp.status_code)
raise HTTPException(resp.status_code, detail=resp.json())
else:
schema = resp.json()
json_dump_args = {
Expand Down
1 change: 0 additions & 1 deletion Tekst-API/tekst/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
router = APIRouter(
prefix="/admin",
tags=["admin"],
responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}},
)


Expand Down
Loading
Loading