From 3c4a7bacdf94cc2b9ce4e56fec79a09bdb4d5781 Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Tue, 30 May 2023 15:27:29 +0100 Subject: [PATCH] refactor: fastapi dependency injections and consistent route naming --- backend/todo/crud/todos.py | 5 ++- backend/todo/crud/users.py | 5 ++- backend/todo/dependencies/common.py | 43 +++++++++++++++++++ backend/todo/dependencies/todos.py | 35 +++++++++++++++ backend/todo/dependencies/users.py | 35 +++++++++++++++ backend/todo/models/common.py | 26 ----------- backend/todo/models/todos.py | 19 +------- backend/todo/models/users.py | 17 -------- backend/todo/routes/todos.py | 18 +++++--- backend/todo/routes/users.py | 17 +++++--- backend/todo/schemas/common.py | 12 ++++++ backend/todo/schemas/todos.py | 8 ++++ backend/todo/schemas/users.py | 8 ++++ .../{user => users}/[user]/+page.svelte | 0 .../routes/{user => users}/[user]/+page.ts | 0 .../{user => users}/[user]/todos/+page.svelte | 4 +- .../{user => users}/[user]/todos/+page.ts | 0 .../routes/{user => users}/all/+page.svelte | 0 18 files changed, 172 insertions(+), 80 deletions(-) create mode 100644 backend/todo/dependencies/common.py create mode 100644 backend/todo/dependencies/todos.py create mode 100644 backend/todo/dependencies/users.py delete mode 100644 backend/todo/models/common.py create mode 100644 backend/todo/schemas/common.py rename frontend/src/routes/{user => users}/[user]/+page.svelte (100%) rename frontend/src/routes/{user => users}/[user]/+page.ts (100%) rename frontend/src/routes/{user => users}/[user]/todos/+page.svelte (89%) rename frontend/src/routes/{user => users}/[user]/todos/+page.ts (100%) rename frontend/src/routes/{user => users}/all/+page.svelte (100%) diff --git a/backend/todo/crud/todos.py b/backend/todo/crud/todos.py index 03f1bdf..bbb56df 100644 --- a/backend/todo/crud/todos.py +++ b/backend/todo/crud/todos.py @@ -8,7 +8,8 @@ from todo.models import todos as todomodel from todo.models import users as usermodel from todo.schemas import todos as todoschema from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException -from todo.models.common import SortOrder +from todo.dependencies.common import SortOrder +from todo.dependencies.todos import SortableTodoItemField def create_todo(db: Session, todo: todoschema.TodoItemCreate, user_id: int) -> todoschema.TodoItem: @@ -44,7 +45,7 @@ def read_todos_for_user( db: Session, user_id: int, skip: int = 0, limit: int = 100, - sortby: todomodel.SortableTodoItemField = todomodel.SortableTodoItemField('updated'), + sortby: SortableTodoItemField = SortableTodoItemField('updated'), sortorder: SortOrder = SortOrder['desc'], ) -> list[todoschema.TodoItem]: """Returns a range of todo-items of the user with the specified user_id from the database.""" diff --git a/backend/todo/crud/users.py b/backend/todo/crud/users.py index 0b450d2..1be672f 100644 --- a/backend/todo/crud/users.py +++ b/backend/todo/crud/users.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from todo.models import users as usermodel from todo.schemas import users as userschema -from todo.models.common import SortOrder +from todo.dependencies.common import SortOrder +from todo.dependencies.users import SortableUserField from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException @@ -60,7 +61,7 @@ def read_user_by_email(db: Session, email: str) -> userschema.User: def read_users( db: Session, skip: int = 0, limit: int = 100, - sortby: usermodel.SortableUserField = usermodel.SortableUserField('id'), + sortby: SortableUserField = SortableUserField('id'), sortorder: SortOrder = SortOrder['asc'], ) -> list[userschema.User]: """Returns an range of users from the database.""" diff --git a/backend/todo/dependencies/common.py b/backend/todo/dependencies/common.py new file mode 100644 index 0000000..21719d9 --- /dev/null +++ b/backend/todo/dependencies/common.py @@ -0,0 +1,43 @@ +"""This module provides FastAPI dependencies for commonly used query parameters.""" + + +from enum import Enum +from typing import Callable + +import sqlalchemy + +from todo.database.engine import Base + + +class SortOrder(Enum): + """Possible sort orders for database queries.""" + + asc = 'asc' + desc = 'desc' + + @property + def call(self) -> Callable: + """Returns the sqlalchemy sort function depending on the instance value.""" + + if self.value == 'asc': + return sqlalchemy.asc + elif self.value == 'desc': + return sqlalchemy.desc + else: + raise RuntimeError("Logic error.") + + +class PaginationParams(): + """Represents query parameters used for pagination, when querying for a list of items. + + Attributes + ---------- + skip : int + The number of items to be skipped from the start of the start of the list. + limit : int + Limits the total number of returned list items to the specified number. + """ + + def __init__(self, skip: int = 0, limit: int = 100): + self.skip = skip + self.limit = limit diff --git a/backend/todo/dependencies/todos.py b/backend/todo/dependencies/todos.py new file mode 100644 index 0000000..5d41c32 --- /dev/null +++ b/backend/todo/dependencies/todos.py @@ -0,0 +1,35 @@ +"""Query parameters used to sort todo-items.""" + +from enum import Enum + +from sqlalchemy import Column + +from todo.models.todos import TodoItem +from todo.dependencies.common import PaginationParams, SortOrder + + +class SortableTodoItemField(Enum): + """Defines which fields todo-item lists can be sorted on.""" + + id = 'id' + title = 'title' + done = 'done' + created = 'created' + updated = 'updated' + finished = 'finished' + + @property + def field(self) -> Column: + return getattr(TodoItem, self.value) + + +class TodoItemSortablePaginationParams(PaginationParams): + def __init__( + self, + skip: int = 0, limit: int = 100, + sortby: SortableTodoItemField = SortableTodoItemField['updated'], + sortorder: SortOrder = SortOrder['desc'], + ): + super().__init__(skip=skip, limit=limit) + self.sortby = sortby + self.sortorder = sortorder diff --git a/backend/todo/dependencies/users.py b/backend/todo/dependencies/users.py new file mode 100644 index 0000000..c3cca43 --- /dev/null +++ b/backend/todo/dependencies/users.py @@ -0,0 +1,35 @@ +"""Query parameters used to sort users.""" + +from enum import Enum + +from sqlalchemy import Column + +from todo.models.users import User +from todo.dependencies.common import PaginationParams, SortOrder + + +class SortableUserField(Enum): + """Defines which fields user lists can be sorted on.""" + + id = 'id' + email = 'email' + created = 'created' + updated = 'updated' + first_name = 'first_name' + last_name = 'last_name' + + @property + def field(self) -> Column: + return getattr(User, self.value) + + +class UserSortablePaginationParams(PaginationParams): + def __init__( + self, + skip: int = 0, limit: int = 100, + sortby: SortableUserField = SortableUserField['id'], + sortorder: SortOrder = SortOrder['asc'], + ): + super().__init__(skip=skip, limit=limit) + self.sortby = sortby + self.sortorder = sortorder diff --git a/backend/todo/models/common.py b/backend/todo/models/common.py deleted file mode 100644 index ba3a9ab..0000000 --- a/backend/todo/models/common.py +++ /dev/null @@ -1,26 +0,0 @@ -"""This module contains common utilities for handling models.""" - -import enum -from typing import Callable - -import sqlalchemy - - -class SortOrder(enum.Enum): - """Possible sort orders for database queries.""" - - asc = 'asc' - ASC = 'asc' - desc = 'desc' - DESC = 'desc' - - @property - def call(self) -> Callable: - """Returns the sqlalchemy sort function depending on the instance value.""" - - if self.value == 'asc': - return sqlalchemy.asc - elif self.value == 'desc': - return sqlalchemy.desc - else: - raise RuntimeError("Logic error.") diff --git a/backend/todo/models/todos.py b/backend/todo/models/todos.py index 388ad8e..5c046da 100644 --- a/backend/todo/models/todos.py +++ b/backend/todo/models/todos.py @@ -1,8 +1,6 @@ """This module defines the SQL data model for todo items.""" -import enum - -from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Date, Enum, CheckConstraint, Boolean +from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean from sqlalchemy.sql.functions import now from sqlalchemy.orm import relationship @@ -26,18 +24,3 @@ class TodoItem(Base): user_id = Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False, index=True) user = relationship(User, back_populates="todo_items", uselist=False) - - -class SortableTodoItemField(enum.Enum): - """Defines which fields todo-item lists can be sorted on.""" - - id = 'id' - title = 'title' - done = 'done' - created = 'created' - updated = 'updated' - finished = 'finished' - - @property - def field(self) -> Column: - return getattr(TodoItem, self.value) diff --git a/backend/todo/models/users.py b/backend/todo/models/users.py index eda1682..0688ea0 100644 --- a/backend/todo/models/users.py +++ b/backend/todo/models/users.py @@ -1,7 +1,5 @@ """This module defines the SQL data model for users.""" -import enum - from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.sql.functions import now from sqlalchemy.orm import relationship @@ -23,18 +21,3 @@ class User(Base): last_name = Column('last_name', String, nullable=False, index=True) todo_items = relationship("TodoItem", back_populates="user", uselist=True, cascade="all, delete") - - -class SortableUserField(enum.Enum): - """Defines which fields user lists can be sorted on.""" - - id = 'id' - email = 'email' - created = 'created' - updated = 'updated' - first_name = 'first_name' - last_name = 'last_name' - - @property - def field(self) -> Column: - return getattr(User, self.value) diff --git a/backend/todo/routes/todos.py b/backend/todo/routes/todos.py index 79e872d..f25e64a 100644 --- a/backend/todo/routes/todos.py +++ b/backend/todo/routes/todos.py @@ -1,5 +1,7 @@ """This module contains endpoints for operations related to users.""" +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -8,12 +10,11 @@ from todo.schemas import todos as todoschema from todo.crud import todos as todocrud from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import create_exception_dict as fmt -from todo.models.todos import SortableTodoItemField -from todo.models.common import SortOrder +from todo.dependencies.todos import TodoItemSortablePaginationParams router = APIRouter( - prefix="/todo", + prefix="/todos", tags=["todo-items"] ) @@ -42,13 +43,16 @@ def read_todo(todo_id: int, db: Session = Depends(get_db)): @router.get("/user/{user_id}", response_model=list[todoschema.TodoItem]) def read_todos( user_id: int, - skip: int = 0, limit: int = 100, - sortby: SortableTodoItemField = SortableTodoItemField['updated'], - sortorder: SortOrder = SortOrder['desc'], + commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)], db: Session = Depends(get_db) ): try: - return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder) + return todocrud.read_todos_for_user( + db=db, + user_id=user_id, + skip=commons.skip, limit=commons.limit, + sortby=commons.sortby, sortorder=commons.sortorder, + ) except InvalidFilterParameterException as e: raise HTTPException(400, fmt(str(e))) except NotFoundException as e: diff --git a/backend/todo/routes/users.py b/backend/todo/routes/users.py index f93a303..8421d8f 100644 --- a/backend/todo/routes/users.py +++ b/backend/todo/routes/users.py @@ -1,5 +1,7 @@ """This module contains endpoints for operations related to users.""" +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -8,8 +10,7 @@ from todo.schemas import users as userschema from todo.crud import users as usercrud from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import create_exception_dict as fmt -from todo.models.common import SortOrder -from todo.models.users import SortableUserField +from todo.dependencies.users import UserSortablePaginationParams router = APIRouter( @@ -43,13 +44,17 @@ def read_user(id: int, db: Session = Depends(get_db)): @router.get("/", response_model=list[userschema.User]) def read_users( - skip: int = 0, limit: int = 100, - sortby: SortableUserField = SortableUserField['id'], - sortorder: SortOrder = SortOrder['asc'], + commons: Annotated[UserSortablePaginationParams, Depends(UserSortablePaginationParams)], db: Session = Depends(get_db) ): try: - return usercrud.read_users(db=db, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder) + return usercrud.read_users( + db=db, + skip=commons.skip, + limit=commons.limit, + sortby=commons.sortby, + sortorder=commons.sortorder + ) except InvalidFilterParameterException as e: raise HTTPException(400, fmt(str(e))) diff --git a/backend/todo/schemas/common.py b/backend/todo/schemas/common.py new file mode 100644 index 0000000..22bff70 --- /dev/null +++ b/backend/todo/schemas/common.py @@ -0,0 +1,12 @@ +"""This module contains common functions for schema validation.""" + +def is_valid_id(id: int) -> bool: + """Checks whether the specified id is a valid ID. + + Performs a shallow check on whether the ID is a valid primary key. + """ + + if not isinstance(id, int): + raise TypeError(f"Expected an integer but got: {type(id)}") + + return id > 0 diff --git a/backend/todo/schemas/todos.py b/backend/todo/schemas/todos.py index 550ff24..30f858f 100644 --- a/backend/todo/schemas/todos.py +++ b/backend/todo/schemas/todos.py @@ -6,6 +6,8 @@ from typing import Optional from pydantic import BaseModel, validator +from todo.schemas.common import is_valid_id + class AbstractTodoItemValidator(BaseModel, ABC): """Base class for todo-item validators shared with child classes.""" @@ -55,5 +57,11 @@ class TodoItem(AbstractTodoItem): updated: datetime finished: datetime | None + @validator('id') + def assert_id_is_valid(cls, id): + if not is_valid_id(id): + raise ValueError("ID is invalid.") + return id + class Config: orm_mode = True diff --git a/backend/todo/schemas/users.py b/backend/todo/schemas/users.py index 310246e..09115c8 100644 --- a/backend/todo/schemas/users.py +++ b/backend/todo/schemas/users.py @@ -7,6 +7,8 @@ from re import compile from pydantic import BaseModel, validator +from todo.schemas.common import is_valid_id + def is_valid_email_format(input_str: str) -> bool: """Checks whether the input string is a valid email address format. @@ -106,5 +108,11 @@ class User(AbstractUser): created: datetime updated: datetime + @validator('id') + def assert_id_is_valid(cls, id): + if not is_valid_id(id): + raise ValueError("ID is invalid.") + return id + class Config: orm_mode = True diff --git a/frontend/src/routes/user/[user]/+page.svelte b/frontend/src/routes/users/[user]/+page.svelte similarity index 100% rename from frontend/src/routes/user/[user]/+page.svelte rename to frontend/src/routes/users/[user]/+page.svelte diff --git a/frontend/src/routes/user/[user]/+page.ts b/frontend/src/routes/users/[user]/+page.ts similarity index 100% rename from frontend/src/routes/user/[user]/+page.ts rename to frontend/src/routes/users/[user]/+page.ts diff --git a/frontend/src/routes/user/[user]/todos/+page.svelte b/frontend/src/routes/users/[user]/todos/+page.svelte similarity index 89% rename from frontend/src/routes/user/[user]/todos/+page.svelte rename to frontend/src/routes/users/[user]/todos/+page.svelte index 872534a..3755edc 100644 --- a/frontend/src/routes/user/[user]/todos/+page.svelte +++ b/frontend/src/routes/users/[user]/todos/+page.svelte @@ -44,8 +44,8 @@ const table = { caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`, columns: cols, - itemsEndpoint: `/api/todo/user/${data.user.id}/`, - itemCountEndpoint: `/api/todo/user/${data.user.id}/total/`, + itemsEndpoint: `/api/todos/user/${data.user.id}/`, + itemCountEndpoint: `/api/todos/user/${data.user.id}/total/`, }; diff --git a/frontend/src/routes/user/[user]/todos/+page.ts b/frontend/src/routes/users/[user]/todos/+page.ts similarity index 100% rename from frontend/src/routes/user/[user]/todos/+page.ts rename to frontend/src/routes/users/[user]/todos/+page.ts diff --git a/frontend/src/routes/user/all/+page.svelte b/frontend/src/routes/users/all/+page.svelte similarity index 100% rename from frontend/src/routes/user/all/+page.svelte rename to frontend/src/routes/users/all/+page.svelte