refactor(backend): ♻️ change error messages to follow standard pattern to match locals on frontend (#668)

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-02 21:33:18 -08:00 committed by GitHub
parent abc0d0d59f
commit b550dae593
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 415 additions and 72 deletions

View file

@ -0,0 +1,159 @@
import json
import re
from dataclasses import dataclass
from pathlib import Path
from slugify import slugify
CWD = Path(__file__).parent
PROJECT_BASE = CWD.parent.parent
server_side_msgs = PROJECT_BASE / "mealie" / "utils" / "error_messages.py"
en_us_msgs = PROJECT_BASE / "frontend" / "lang" / "errors" / "en-US.json"
client_side_msgs = PROJECT_BASE / "frontend" / "utils" / "error-messages.ts"
GENERATE_MESSAGES = [
# User Related
"user",
"webhook",
"token",
# Group Related
"group",
"cookbook",
"mealplan",
# Recipe Related
"scraper",
"recipe",
"ingredient",
"food",
"unit",
# Admin Related
"backup",
"migration",
"event",
]
class ErrorMessage:
def __init__(self, prefix, verb) -> None:
self.message = f"{prefix.title()} {verb.title()} Failed"
self.snake = slugify(self.message, separator="_")
self.kabab = slugify(self.message, separator="-")
def factory(prefix) -> list["ErrorMessage"]:
verbs = ["Create", "Update", "Delete"]
return [ErrorMessage(prefix, verb) for verb in verbs]
@dataclass
class CodeGenLines:
start: int
end: int
indentation: str
text: list[str]
_next_line = None
def purge_lines(self) -> None:
start = self.start + 1
end = self.end
del self.text[start:end]
def push_line(self, string: str) -> None:
self._next_line = self._next_line or self.start + 1
self.text.insert(self._next_line, self.indentation + string)
self._next_line += 1
def find_start(file_text: list[str], gen_id: str):
for x, line in enumerate(file_text):
if "CODE_GEN_ID:" in line and gen_id in line:
return x, line
return None
def find_end(file_text: list[str], gen_id: str):
for x, line in enumerate(file_text):
if f"END {gen_id}" in line:
return x, line
return None
def get_indentation_of_string(line: str):
return re.sub(r"#.*", "", line).removesuffix("\n")
def get_messages(message_prefix: str) -> str:
prefix = message_prefix.lower()
return [
f'{prefix}_create_failure = "{prefix}-create-failure"\n',
f'{prefix}_update_failure = "{prefix}-update-failure"\n',
f'{prefix}_delete_failure = "{prefix}-delete-failure"\n',
]
def code_gen_factory(file_path: Path) -> CodeGenLines:
with open(file_path, "r") as file:
text = file.readlines()
start_num, line = find_start(text, "ERROR_MESSAGE_ENUMS")
indentation = get_indentation_of_string(line)
end_num, line = find_end(text, "ERROR_MESSAGE_ENUMS")
return CodeGenLines(
start=start_num,
end=end_num,
indentation=indentation,
text=text,
)
def write_to_locals(messages: list[ErrorMessage]) -> None:
with open(en_us_msgs, "r") as f:
existing_msg = json.loads(f.read())
for msg in messages:
if msg.kabab in existing_msg:
continue
existing_msg[msg.kabab] = msg.message
print(f"Added Key {msg.kabab} to 'en-US.json'")
with open(en_us_msgs, "w") as f:
f.write(json.dumps(existing_msg, indent=4))
def main():
print("Starting...")
GENERATE_MESSAGES.sort()
code_gen = code_gen_factory(server_side_msgs)
code_gen.purge_lines()
messages = []
for msg_type in GENERATE_MESSAGES:
messages += get_messages(msg_type)
messages.append("\n")
for msg in messages:
code_gen.push_line(msg)
with open(server_side_msgs, "w") as file:
file.writelines(code_gen.text)
# Locals
local_msgs = []
for msg_type in GENERATE_MESSAGES:
local_msgs += ErrorMessage.factory(msg_type)
write_to_locals(local_msgs)
print("Done!")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,44 @@
{
"backup-create-failed": "Backup Create Failed",
"backup-update-failed": "Backup Update Failed",
"backup-delete-failed": "Backup Delete Failed",
"cookbook-create-failed": "Cookbook Create Failed",
"cookbook-update-failed": "Cookbook Update Failed",
"cookbook-delete-failed": "Cookbook Delete Failed",
"event-create-failed": "Event Create Failed",
"event-update-failed": "Event Update Failed",
"event-delete-failed": "Event Delete Failed",
"food-create-failed": "Food Create Failed",
"food-update-failed": "Food Update Failed",
"food-delete-failed": "Food Delete Failed",
"group-create-failed": "Group Create Failed",
"group-update-failed": "Group Update Failed",
"group-delete-failed": "Group Delete Failed",
"ingredient-create-failed": "Ingredient Create Failed",
"ingredient-update-failed": "Ingredient Update Failed",
"ingredient-delete-failed": "Ingredient Delete Failed",
"mealplan-create-failed": "Mealplan Create Failed",
"mealplan-update-failed": "Mealplan Update Failed",
"mealplan-delete-failed": "Mealplan Delete Failed",
"migration-create-failed": "Migration Create Failed",
"migration-update-failed": "Migration Update Failed",
"migration-delete-failed": "Migration Delete Failed",
"recipe-create-failed": "Recipe Create Failed",
"recipe-update-failed": "Recipe Update Failed",
"recipe-delete-failed": "Recipe Delete Failed",
"scraper-create-failed": "Scraper Create Failed",
"scraper-update-failed": "Scraper Update Failed",
"scraper-delete-failed": "Scraper Delete Failed",
"token-create-failed": "Token Create Failed",
"token-update-failed": "Token Update Failed",
"token-delete-failed": "Token Delete Failed",
"unit-create-failed": "Unit Create Failed",
"unit-update-failed": "Unit Update Failed",
"unit-delete-failed": "Unit Delete Failed",
"user-create-failed": "User Create Failed",
"user-update-failed": "User Update Failed",
"user-delete-failed": "User Delete Failed",
"webhook-create-failed": "Webhook Create Failed",
"webhook-update-failed": "Webhook Update Failed",
"webhook-delete-failed": "Webhook Delete Failed"
}

View file

@ -3,12 +3,12 @@ from sqlalchemy.orm.session import Session
from mealie.schema.user.user import PrivateUser
from .dependencies import generate_session, get_current_user, is_logged_in
from .dependencies import generate_session, get_admin_user, get_current_user, is_logged_in
class ReadDeps:
class PublicDeps:
"""
ReadDeps contains the common dependencies for all read operations through the API.
PublicDeps contains the common dependencies for all read operations through the API.
Note: The user object is used to definer what assets the user has access to.
Args:
@ -28,9 +28,9 @@ class ReadDeps:
self.user: bool = user
class WriteDeps:
class UserDeps:
"""
WriteDeps contains the common dependencies for all read operations through the API.
UserDeps contains the common dependencies for all read operations through the API.
Note: The user must be logged in or the route will return a 401 error.
Args:
@ -48,3 +48,15 @@ class WriteDeps:
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: PrivateUser = user
class AdminDeps:
def __init__(
self,
background_tasks: BackgroundTasks,
session: Session = Depends(generate_session),
user=Depends(get_admin_user),
):
self.session: Session = session
self.bg_task: BackgroundTasks = background_tasks
self.user: PrivateUser = user

View file

@ -1,11 +1,11 @@
from abc import ABC, abstractmethod
from typing import Callable, Generic, TypeVar
from typing import Callable, Generic, Type, TypeVar
from fastapi import BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs, get_settings
from mealie.core.dependencies.grouped import ReadDeps, WriteDeps
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.db.db_setup import SessionLocal
@ -17,25 +17,25 @@ T = TypeVar("T")
D = TypeVar("D")
CLS_DEP = TypeVar("CLS_DEP") # Generic Used for the class method dependencies
class BaseHttpService(Generic[T, D], ABC):
"""The BaseHttpService class is a generic class that can be used to create
"""
The BaseHttpService class is a generic class that can be used to create
http services that are injected via `Depends` into a route function. To use,
you must define the Generic type arguments:
`T`: The type passed into the *_existing functions (e.g. id) which is then passed into assert_existing
`D`: Item returned from database layer
Child Requirements:
Define the following functions:
`assert_existing(self, data: T) -> None:`
Define the following variables:
`event_func`: A function that is called when an event is created.
"""
item: D = None
# Function that Generate Corrsesponding Routes through RouterFactor
# Function that Generate Corrsesponding Routes through RouterFactory:
# if the method is defined or != `None` than the corresponding route is defined through the RouterFactory.
# If the method is not defined, then the route will be excluded from creation. This service based articheture
# is being adopted as apart of the v1 migration
get_all: Callable = None
create_one: Callable = None
update_one: Callable = None
@ -75,48 +75,35 @@ class BaseHttpService(Generic[T, D], ABC):
self._group_id_cache = group.id
return self._group_id_cache
@classmethod
def read_existing(cls, item_id: T, deps: ReadDeps = Depends()):
"""
Used for dependency injection for routes that require an existing recipe. If the recipe doesn't exist
or the user doens't not have the required permissions, the proper HTTP Status code will be raised.
"""
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(item_id)
return new_class
def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)):
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(item_id)
return new_class
@classmethod
def write_existing(cls, item_id: T, deps: WriteDeps = Depends()):
"""
Used for dependency injection for routes that require an existing recipe. The only difference between
read_existing and write_existing is that the user is required to be logged in on write_existing method.
"""
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class.assert_existing(item_id)
return new_class
return classmethod(cls_method)
@classmethod
def public(cls, deps: ReadDeps = Depends()):
"""
A Base instance to be used as a router dependency
"""
return cls(deps.session, deps.user, deps.bg_task)
def _class_method_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, deps: CLS_DEP = Depends(dependency)):
return cls(deps.session, deps.user, deps.bg_task)
@classmethod
def private(cls, deps: WriteDeps = Depends()):
"""
A Base instance to be used as a router dependency
"""
return cls(deps.session, deps.user, deps.bg_task)
return classmethod(cls_method)
@abstractmethod
def populate_item(self) -> None:
...
# TODO: Refactor to allow for configurable dependencies base on substantiation
read_existing = _existing_factory(PublicDeps)
write_existing = _existing_factory(UserDeps)
public = _class_method_factory(PublicDeps)
private = _class_method_factory(UserDeps)
def assert_existing(self, id: T) -> None:
self.populate_item(id)
self._check_item()
@abstractmethod
def populate_item(self) -> None:
...
def _check_item(self) -> None:
if not self.item:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View file

@ -0,0 +1,60 @@
from abc import abstractmethod
from typing import TypeVar
from mealie.core.dependencies.grouped import AdminDeps, PublicDeps, UserDeps
from .base_http_service import BaseHttpService
T = TypeVar("T")
D = TypeVar("D")
class PublicHttpService(BaseHttpService[T, D]):
"""
PublicHttpService sets the class methods to PublicDeps for read actions
and UserDeps for write actions which are inaccessible to not logged in users.
"""
read_existing = BaseHttpService._existing_factory(PublicDeps)
write_existing = BaseHttpService._existing_factory(UserDeps)
public = BaseHttpService._class_method_factory(PublicDeps)
private = BaseHttpService._class_method_factory(UserDeps)
@abstractmethod
def populate_item(self) -> None:
...
class UserHttpService(BaseHttpService[T, D]):
"""
UserHttpService sets the class methods to UserDeps which are inaccessible
to not logged in users.
"""
read_existing = BaseHttpService._existing_factory(UserDeps)
write_existing = BaseHttpService._existing_factory(UserDeps)
public = BaseHttpService._class_method_factory(UserDeps)
private = BaseHttpService._class_method_factory(UserDeps)
@abstractmethod
def populate_item(self) -> None:
...
class AdminHttpService(BaseHttpService[T, D]):
"""
AdminHttpService restricts the class methods to AdminDeps which are restricts
all class methods to users who are administrators.
"""
read_existing = BaseHttpService._existing_factory(AdminDeps)
write_existing = BaseHttpService._existing_factory(AdminDeps)
public = BaseHttpService._class_method_factory(AdminDeps)
private = BaseHttpService._class_method_factory(AdminDeps)
@abstractmethod
def populate_item(self) -> None:
...

View file

@ -1,3 +1,4 @@
import inspect
from typing import Any, Callable, Optional, Sequence, Type, TypeVar
from fastapi import APIRouter
@ -23,15 +24,7 @@ class RouterFactory(APIRouter):
update_schema: Type[T]
_base_path: str = "/"
def __init__(
self,
service: Type[S],
prefix: Optional[str] = None,
tags: Optional[list[str]] = None,
*args,
**kwargs,
):
def __init__(self, service: Type[S], prefix: Optional[str] = None, tags: Optional[list[str]] = None, *_, **kwargs):
self.service: Type[S] = service
self.schema: Type[T] = service._schema
@ -57,6 +50,7 @@ class RouterFactory(APIRouter):
methods=["GET"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Get All",
description=inspect.cleandoc(self.service.get_all.__doc__ or ""),
)
if self.service.create_one:
@ -66,6 +60,7 @@ class RouterFactory(APIRouter):
methods=["POST"],
response_model=self.schema,
summary="Create One",
description=inspect.cleandoc(self.service.create_one.__doc__ or ""),
)
if self.service.update_many:
@ -75,6 +70,7 @@ class RouterFactory(APIRouter):
methods=["PUT"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Update Many",
description=inspect.cleandoc(self.service.update_many.__doc__ or ""),
)
if self.service.delete_all:
@ -84,6 +80,7 @@ class RouterFactory(APIRouter):
methods=["DELETE"],
response_model=Optional[list[self.schema]], # type: ignore
summary="Delete All",
description=inspect.cleandoc(self.service.delete_all.__doc__ or ""),
)
if self.service.populate_item:
@ -93,6 +90,7 @@ class RouterFactory(APIRouter):
methods=["GET"],
response_model=self.get_one_schema,
summary="Get One",
description=inspect.cleandoc(self.service.populate_item.__doc__ or ""),
)
if self.service.update_one:
@ -102,15 +100,18 @@ class RouterFactory(APIRouter):
methods=["PUT"],
response_model=self.schema,
summary="Update One",
description=inspect.cleandoc(self.service.update_one.__doc__ or ""),
)
if self.service.delete_one:
print(self.service.delete_one.__doc__)
self._add_api_route(
"/{item_id}",
self._delete_one(),
methods=["DELETE"],
response_model=self.schema,
summary="Delete One",
description=inspect.cleandoc(self.service.delete_one.__doc__ or ""),
)
def _add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:

View file

@ -4,13 +4,13 @@ from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.services.base_http_service.base_http_service import BaseHttpService
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
class CookbookService(BaseHttpService[int, ReadCookBook]):
class CookbookService(UserHttpService[int, ReadCookBook]):
event_func = create_group_event
_restrict_by_group = True
@ -19,17 +19,17 @@ class CookbookService(BaseHttpService[int, ReadCookBook]):
_update_schema = UpdateCookBook
_get_one_schema = RecipeCookBook
def populate_item(self, id: int | str):
def populate_item(self, item_id: int | str):
try:
id = int(id)
item_id = int(item_id)
except Exception:
pass
if isinstance(id, int):
self.item = self.db.cookbooks.get_one(self.session, id, override_schema=RecipeCookBook)
if isinstance(item_id, int):
self.item = self.db.cookbooks.get_one(self.session, item_id, override_schema=RecipeCookBook)
else:
self.item = self.db.cookbooks.get_one(self.session, id, key="slug", override_schema=RecipeCookBook)
self.item = self.db.cookbooks.get_one(self.session, item_id, key="slug", override_schema=RecipeCookBook)
def get_all(self) -> list[ReadCookBook]:
items = self.db.cookbooks.get(self.session, self.group_id, "group_id", limit=999)

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import WriteDeps
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
@ -18,12 +18,12 @@ class GroupSelfService(BaseHttpService[int, str]):
item: GroupInDB
@classmethod
def read_existing(cls, deps: WriteDeps = Depends()):
def read_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().read_existing(item_id=0, deps=deps)
@classmethod
def write_existing(cls, deps: WriteDeps = Depends()):
def write_existing(cls, deps: UserDeps = Depends()):
"""Override parent method to remove `item_id` from arguments"""
return super().write_existing(item_id=0, deps=deps)

View file

@ -5,7 +5,7 @@ from typing import Union
from fastapi import Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from mealie.core.dependencies.grouped import ReadDeps, WriteDeps
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.services.base_http_service.base_http_service import BaseHttpService
@ -25,11 +25,11 @@ class RecipeService(BaseHttpService[str, Recipe]):
event_func = create_recipe_event
@classmethod
def write_existing(cls, slug: str, deps: WriteDeps = Depends()):
def write_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps)
@classmethod
def read_existing(cls, slug: str, deps: ReadDeps = Depends()):
def read_existing(cls, slug: str, deps: PublicDeps = Depends()):
return super().write_existing(slug, deps)
def assert_existing(self, slug: str):

View file

@ -0,0 +1,81 @@
from dataclasses import dataclass
@dataclass
class ErrorMessages:
"""
This enum class holds the text values that represent the errors returned when
something goes wrong on the server side.
Example: {"details": "general-failure"}
The items contained within the '#' are automatically generated by a script in the scripts directory.
DO NOT EDIT THE CONTENTS BETWEEN THOSE. If you need to add a custom error message, do so in the lines
above.
Why Generate This!?!?! If we generate static errors on the backend we can ensure that a constant
set or error messages will be returned to the frontend. As such we can use the "details" key to
look up localized messages in the frontend. as such DO NOT change the generated or manual codes
without making the necessary changes on the client side code.
"""
general_failure = "general-failure"
# CODE_GEN_ID: ERROR_MESSAGE_ENUMS
backup_create_failure = "backup-create-failure"
backup_update_failure = "backup-update-failure"
backup_delete_failure = "backup-delete-failure"
cookbook_create_failure = "cookbook-create-failure"
cookbook_update_failure = "cookbook-update-failure"
cookbook_delete_failure = "cookbook-delete-failure"
event_create_failure = "event-create-failure"
event_update_failure = "event-update-failure"
event_delete_failure = "event-delete-failure"
food_create_failure = "food-create-failure"
food_update_failure = "food-update-failure"
food_delete_failure = "food-delete-failure"
group_create_failure = "group-create-failure"
group_update_failure = "group-update-failure"
group_delete_failure = "group-delete-failure"
ingredient_create_failure = "ingredient-create-failure"
ingredient_update_failure = "ingredient-update-failure"
ingredient_delete_failure = "ingredient-delete-failure"
mealplan_create_failure = "mealplan-create-failure"
mealplan_update_failure = "mealplan-update-failure"
mealplan_delete_failure = "mealplan-delete-failure"
migration_create_failure = "migration-create-failure"
migration_update_failure = "migration-update-failure"
migration_delete_failure = "migration-delete-failure"
recipe_create_failure = "recipe-create-failure"
recipe_update_failure = "recipe-update-failure"
recipe_delete_failure = "recipe-delete-failure"
scraper_create_failure = "scraper-create-failure"
scraper_update_failure = "scraper-update-failure"
scraper_delete_failure = "scraper-delete-failure"
token_create_failure = "token-create-failure"
token_update_failure = "token-update-failure"
token_delete_failure = "token-delete-failure"
unit_create_failure = "unit-create-failure"
unit_update_failure = "unit-update-failure"
unit_delete_failure = "unit-delete-failure"
user_create_failure = "user-create-failure"
user_update_failure = "user-update-failure"
user_delete_failure = "user-delete-failure"
webhook_create_failure = "webhook-create-failure"
webhook_update_failure = "webhook-update-failure"
webhook_delete_failure = "webhook-delete-failure"
# END ERROR_MESSAGE_ENUMS