From d4dfba9c6ab8497097d06bd237036e8119e1f4eb Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Fri, 16 May 2025 06:46:22 -0600 Subject: [PATCH 1/7] feat: add base templates for contextvards, exceptions, responses and internal_errors --- mypy.ini | 13 +++++++ src/shared/base_contextvars.py | 4 +++ src/shared/base_exceptions.py | 31 +++++++++++++++++ src/shared/base_internal_errors.py | 16 +++++++++ src/shared/base_responses.py | 55 ++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 src/shared/base_contextvars.py create mode 100644 src/shared/base_exceptions.py create mode 100644 src/shared/base_internal_errors.py create mode 100644 src/shared/base_responses.py diff --git a/mypy.ini b/mypy.ini index 53eb59b..33f7120 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,3 +7,16 @@ ignore_errors = True [mypy-src.shared.environment] ignore_errors = True + +[mypy-src.shared.base_error_internal] +ignore_errors = True + +[mypy-src.shared.base_contextvars] +ignore_errors = True + +[mypy-src.shared.base_responses] +ignore_errors = True + +[mypy-src.shared.base_exceptions] +ignore_errors = True + diff --git a/src/shared/base_contextvars.py b/src/shared/base_contextvars.py new file mode 100644 index 0000000..cb5b926 --- /dev/null +++ b/src/shared/base_contextvars.py @@ -0,0 +1,4 @@ +from contextvars import ContextVar + +ctx_trace_id = ContextVar("ctx_trace_id", default=None) +ctx_caller_id = ContextVar("ctx_caller_id", default=None) \ No newline at end of file diff --git a/src/shared/base_exceptions.py b/src/shared/base_exceptions.py new file mode 100644 index 0000000..04b4962 --- /dev/null +++ b/src/shared/base_exceptions.py @@ -0,0 +1,31 @@ +import fastapi +from typing import Optional, Any, Dict +from loguru import logger +from shared.base_internal_errors import ErrorCodes + +class BaseApiRestException(Exception): + GENERAL_STATUS_CODE_HTTP = fastapi.status.HTTP_400_BAD_REQUEST + GENERAL_ERROR_CODE = ErrorCodes.UNKNOW + + def __init__(self, + status_code_http: int = None, + error_code: ErrorCodes = None, + message: Optional[str] = None, + data: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.status_code_http = status_code_http if status_code_http else self.GENERAL_STATUS_CODE_HTTP + self.error_code = error_code if error_code else self.GENERAL_ERROR_CODE + self.data = data + self.message = message + logger.warning(self.__str__()) + + def __str__(self): + return f"[{self.status_code_http}] {self.error_code.description}: {self.message}" + + + +class UserException(BaseApiRestException): + GENERAL_ERROR_CODE = ErrorCodes.USER_ERROR + +class TokenException(BaseApiRestException): + GENERAL_ERROR_CODE = ErrorCodes.TOKEN_ERROR \ No newline at end of file diff --git a/src/shared/base_internal_errors.py b/src/shared/base_internal_errors.py new file mode 100644 index 0000000..9ab39a5 --- /dev/null +++ b/src/shared/base_internal_errors.py @@ -0,0 +1,16 @@ +from enum import Enum + +class ErrorCodes(Enum): + UNKNOW = 100, "Unknown Error" + PYDANTIC_VALIDATIONS_REQUEST = 8001, "Failed pydantic validations on request" + + + def __new__(cls, value: int, description: str) -> 'ErrorCodes': + obj = object.__new__(cls) + obj._value_ = value + obj._description = description + return obj + + @property + def description(self) -> str: + return self._description \ No newline at end of file diff --git a/src/shared/base_responses.py b/src/shared/base_responses.py new file mode 100644 index 0000000..1ff7d12 --- /dev/null +++ b/src/shared/base_responses.py @@ -0,0 +1,55 @@ +from typing import Any, Dict, Optional +from pydantic import BaseModel +from shared.base_contextvars import ctx_trace_id +from fastapi.responses import JSONResponse +from shared.base_internal_errors import ErrorCodes +import fastapi +import json + +class EnvelopeResponse(BaseModel): + success: bool + message: str + data: Dict[str, Any] | None = None + trace_id: str | None = None + +class ErrorDetailResponse(BaseModel): + internal_error: Dict[str, Any] + details: Dict[str, Any] + + @staticmethod + def from_error_code(error_code: ErrorCodes, details: Optional[Dict[str, Any]] = None) -> 'ErrorDetailResponse': + return ErrorDetailResponse( + internal_error={ + "code": error_code.value, + "description": error_code.description, + }, + details=details or {} + ).model_dump() + +def create_response_for_fast_api( + status_code_http: int = fastapi.status.HTTP_200_OK, + data: Any = None, + error_code: Optional[ErrorCodes] = ErrorCodes.UNKNOW, + message: Optional[str] = None +) -> JSONResponse: + success = 200 <= status_code_http < 300 + message = message or ("Operation successful" if success else "An error occurred") + + if isinstance(data, BaseModel): + data = data.model_dump_json() + data = json.loads(data) + + if not success: + data = ErrorDetailResponse.from_error_code(error_code=error_code, details=data) + + envelope_response = EnvelopeResponse( + success=success, + message=message, + data=data, + trace_id=ctx_trace_id.get() + ) + + return JSONResponse( + content=envelope_response.model_dump(), + status_code=status_code_http + ) \ No newline at end of file From 496247813f62820575ac1e793d80a0895f7d6889 Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 13:35:09 -0600 Subject: [PATCH 2/7] feat: change settings internal_codes --- src/core/internal_codes.py | 7 +++++++ src/shared/base_exceptions.py | 18 +++++------------ src/shared/base_internal_codes.py | 30 ++++++++++++++++++++++++++++ src/shared/base_internal_errors.py | 16 --------------- src/shared/base_responses.py | 32 ++++++++++++++++++++---------- 5 files changed, 64 insertions(+), 39 deletions(-) create mode 100644 src/core/internal_codes.py create mode 100644 src/shared/base_internal_codes.py delete mode 100644 src/shared/base_internal_errors.py diff --git a/src/core/internal_codes.py b/src/core/internal_codes.py new file mode 100644 index 0000000..ff782eb --- /dev/null +++ b/src/core/internal_codes.py @@ -0,0 +1,7 @@ +from shared.base_internal_codes import InternalCodeBase + +class InternalCodesApiBook(InternalCodeBase): + BOOK_API_ERROR = 1000, "Book API error" + BOOK_NOT_FOUND = 1001, "Book not found" + + \ No newline at end of file diff --git a/src/shared/base_exceptions.py b/src/shared/base_exceptions.py index 04b4962..9e29c33 100644 --- a/src/shared/base_exceptions.py +++ b/src/shared/base_exceptions.py @@ -1,17 +1,16 @@ import fastapi -from typing import Optional, Any, Dict from loguru import logger -from shared.base_internal_errors import ErrorCodes +from shared.base_internal_codes import CommonInternalCode, InternalCode class BaseApiRestException(Exception): GENERAL_STATUS_CODE_HTTP = fastapi.status.HTTP_400_BAD_REQUEST - GENERAL_ERROR_CODE = ErrorCodes.UNKNOW + GENERAL_ERROR_CODE = CommonInternalCode.UNKNOWN def __init__(self, status_code_http: int = None, - error_code: ErrorCodes = None, - message: Optional[str] = None, - data: Optional[Dict[str, Any]] = None): + error_code: InternalCode = None, + message: str | None = None, + data: dict[str, any] | None = None): super().__init__(message) self.status_code_http = status_code_http if status_code_http else self.GENERAL_STATUS_CODE_HTTP self.error_code = error_code if error_code else self.GENERAL_ERROR_CODE @@ -22,10 +21,3 @@ def __init__(self, def __str__(self): return f"[{self.status_code_http}] {self.error_code.description}: {self.message}" - - -class UserException(BaseApiRestException): - GENERAL_ERROR_CODE = ErrorCodes.USER_ERROR - -class TokenException(BaseApiRestException): - GENERAL_ERROR_CODE = ErrorCodes.TOKEN_ERROR \ No newline at end of file diff --git a/src/shared/base_internal_codes.py b/src/shared/base_internal_codes.py new file mode 100644 index 0000000..a7d89f1 --- /dev/null +++ b/src/shared/base_internal_codes.py @@ -0,0 +1,30 @@ +from enum import IntEnum +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class InternalCode(Protocol): + value: int + description: str + + def to_dict(self) -> dict[str, any]: ... + + +class InternalCodeBase(IntEnum): + def __new__(cls, value: int, description: str): + obj = int.__new__(cls, value) + obj._value_ = value + obj._description = description + return obj + + @property + def description(self) -> str: + return self._description + + def to_dict(self) -> dict: + return {"code": int(self), "description": self.description} + + +class CommonInternalCode(InternalCodeBase): + UNKNOWN = 100, "Unknown error" + PYDANTIC_VALIDATIONS_REQUEST = 8001, "Failed Pydantic validations on request" diff --git a/src/shared/base_internal_errors.py b/src/shared/base_internal_errors.py deleted file mode 100644 index 9ab39a5..0000000 --- a/src/shared/base_internal_errors.py +++ /dev/null @@ -1,16 +0,0 @@ -from enum import Enum - -class ErrorCodes(Enum): - UNKNOW = 100, "Unknown Error" - PYDANTIC_VALIDATIONS_REQUEST = 8001, "Failed pydantic validations on request" - - - def __new__(cls, value: int, description: str) -> 'ErrorCodes': - obj = object.__new__(cls) - obj._value_ = value - obj._description = description - return obj - - @property - def description(self) -> str: - return self._description \ No newline at end of file diff --git a/src/shared/base_responses.py b/src/shared/base_responses.py index 1ff7d12..c0cb17a 100644 --- a/src/shared/base_responses.py +++ b/src/shared/base_responses.py @@ -1,23 +1,26 @@ -from typing import Any, Dict, Optional +from typing import TypeVar, Any from pydantic import BaseModel from shared.base_contextvars import ctx_trace_id from fastapi.responses import JSONResponse -from shared.base_internal_errors import ErrorCodes import fastapi -import json +import json +from shared.base_internal_codes import InternalCode +from shared.base_internal_codes import CommonInternalCode as CC + +T = TypeVar("T", bound=InternalCode) class EnvelopeResponse(BaseModel): success: bool message: str - data: Dict[str, Any] | None = None + data: dict[str, Any] | list | None = None trace_id: str | None = None class ErrorDetailResponse(BaseModel): - internal_error: Dict[str, Any] - details: Dict[str, Any] + internal_error: dict[str, Any] + details: dict[str, Any] @staticmethod - def from_error_code(error_code: ErrorCodes, details: Optional[Dict[str, Any]] = None) -> 'ErrorDetailResponse': + def from_error_code(error_code: T | None = CC.UNKNOWN, details: dict[str, Any] | None = None) -> 'ErrorDetailResponse': return ErrorDetailResponse( internal_error={ "code": error_code.value, @@ -29,13 +32,22 @@ def from_error_code(error_code: ErrorCodes, details: Optional[Dict[str, Any]] = def create_response_for_fast_api( status_code_http: int = fastapi.status.HTTP_200_OK, data: Any = None, - error_code: Optional[ErrorCodes] = ErrorCodes.UNKNOW, - message: Optional[str] = None + error_code: T | None = CC.UNKNOWN, + message: str | None = None ) -> JSONResponse: success = 200 <= status_code_http < 300 message = message or ("Operation successful" if success else "An error occurred") - if isinstance(data, BaseModel): + if isinstance(data,list): + if len(data) == 0: + data = None + else: + first_element = data[0] + if isinstance(first_element,BaseModel): + data = [element.model_dump() for element in data] + + + elif isinstance(data, BaseModel): data = data.model_dump_json() data = json.loads(data) From 95d95b76e7a3dd0df13faed636b46e51554d10f0 Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 13:35:46 -0600 Subject: [PATCH 3/7] feat: add catcher for standars errors responses --- src/shared/middlewares/__init__.py | 4 ++ src/shared/middlewares/catcher_exceptions.py | 42 +++++++++++++++++++ .../middlewares/catcher_pydantic_errors.py | 24 +++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/shared/middlewares/__init__.py create mode 100644 src/shared/middlewares/catcher_exceptions.py create mode 100644 src/shared/middlewares/catcher_pydantic_errors.py diff --git a/src/shared/middlewares/__init__.py b/src/shared/middlewares/__init__.py new file mode 100644 index 0000000..2d168b6 --- /dev/null +++ b/src/shared/middlewares/__init__.py @@ -0,0 +1,4 @@ +from .catcher_exceptions import CatcherExceptions +from .catcher_pydantic_errors import CatcherExceptionsPydantic + +__all__ = ["CatcherExceptions", "CatcherExceptionsPydantic"] diff --git a/src/shared/middlewares/catcher_exceptions.py b/src/shared/middlewares/catcher_exceptions.py new file mode 100644 index 0000000..5afb23f --- /dev/null +++ b/src/shared/middlewares/catcher_exceptions.py @@ -0,0 +1,42 @@ +from fastapi import HTTPException, Request, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm.exc import NoResultFound +from starlette.middleware.base import BaseHTTPMiddleware + +from shared.base_exceptions import BaseApiRestException +from shared.base_responses import create_response_for_fast_api +from shared.base_internal_codes import CommonInternalCode + +class CatcherExceptions(BaseHTTPMiddleware): + def _init_(self, app): + super()._init_(app) + + async def dispatch(self, request: Request, call_next): + try: + return await call_next(request) + except Exception as e: # noqa: BLE001 + internal_error = CommonInternalCode.UNKNOWN + error_message = None + error_data = None + status_code_http = status.HTTP_500_INTERNAL_SERVER_ERROR + if isinstance(e, HTTPException): + error_data = {"detail": str(e.detail)} + status_code_http = e.status_code + if isinstance(e, NoResultFound): + error_data = {"detail": f"No found: {e}"} + status_code_http = status.HTTP_404_NOT_FOUND + elif isinstance(e, BaseApiRestException): + error_data = e.data + error_message = e.message + status_code_http = e.status_code_http + internal_error = e.error_code + else: + error_data = {"detail": str(e)} + status_code_http = status.HTTP_500_INTERNAL_SERVER_ERROR + + return create_response_for_fast_api( + status_code_http=status_code_http, + data=error_data, + error_code=internal_error, + message=error_message + ) \ No newline at end of file diff --git a/src/shared/middlewares/catcher_pydantic_errors.py b/src/shared/middlewares/catcher_pydantic_errors.py new file mode 100644 index 0000000..47c2905 --- /dev/null +++ b/src/shared/middlewares/catcher_pydantic_errors.py @@ -0,0 +1,24 @@ +from collections import defaultdict + +from fastapi import FastAPI, status +from fastapi.exceptions import RequestValidationError +from fastapi.requests import Request +from shared.base_internal_codes import CommonInternalCode + +from shared.base_responses import create_response_for_fast_api + + +def CatcherExceptionsPydantic(app: FastAPI): + @app.exception_handler(RequestValidationError) + async def validate(_: Request, exc: Exception): + error_detail = defaultdict(list) + for error in exc.errors(): + field = error["loc"][1] if "loc" in error else None + error_msg = error["msg"] + error_detail[field].append(error_msg) + + return create_response_for_fast_api( + data=error_detail, + status_code_http=status.HTTP_400_BAD_REQUEST, + error_code=CommonInternalCode.PYDANTIC_VALIDATIONS_REQUEST + ) \ No newline at end of file From c8c2df10f8f16475666d621ce8a670d74bbd1d20 Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 13:36:48 -0600 Subject: [PATCH 4/7] feat: apply standars responses,exceptions,internal_codes to api --- src/api/v1/endpoints.py | 80 +++++++++++++++++++++++++++++++---------- src/core/exceptions.py | 7 ++++ src/main.py | 11 ++++-- 3 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 src/core/exceptions.py diff --git a/src/api/v1/endpoints.py b/src/api/v1/endpoints.py index 77fa21b..d3eed73 100644 --- a/src/api/v1/endpoints.py +++ b/src/api/v1/endpoints.py @@ -1,10 +1,11 @@ -from fastapi import HTTPException from api.v1.schema import Book, BookCreate from loguru import logger - +from shared.base_responses import create_response_for_fast_api,EnvelopeResponse +from core.exceptions import BookException from fastapi import ( APIRouter, ) +from fastapi import Response router = APIRouter(prefix="/books",tags=["Books"]) @@ -15,43 +16,77 @@ # Get all books -@router.get("", response_model=list[Book]) -async def get_books() -> list[Book]: +@router.get("", response_model=EnvelopeResponse) +async def get_books(): logger.info("Retrieving all books") - return books_db + return create_response_for_fast_api( + data=books_db or None, + status_code_http=200 + ) -@router.post("", response_model=Book, status_code=201) -async def create_book(book: BookCreate) -> Book: +# Get all books +@router.get("", response_model=EnvelopeResponse) +async def get_books() -> EnvelopeResponse: + logger.info("Retrieving all books") + return create_response_for_fast_api( + data=books_db, + status_code_http=200 + ) + +@router.post("", response_model=EnvelopeResponse) +async def create_book(book: BookCreate) -> EnvelopeResponse: global counter_id counter_id += 1 new_book = Book(id=counter_id, **book.model_dump()) books_db.append(new_book) logger.info(f"Created new book with ID: {counter_id}") - return new_book + return create_response_for_fast_api( + data=new_book, + status_code_http=201 + ) # Get book by ID -@router.get("/{book_id}", response_model=Book) -async def get_book(book_id: int) -> Book: +@router.get("/{book_id}", response_model=EnvelopeResponse) +async def get_book(book_id: int) -> EnvelopeResponse: logger.info(f"Retrieving book with ID: {book_id}") for book in books_db: if book.id == book_id: - return book + return create_response_for_fast_api( + data=book, + status_code_http=201 + ) logger.error(f"Book with ID {book_id} not found") - raise HTTPException(status_code=404, detail="Book not found") + raise BookException( + message=f"Book with ID {book_id} not found", + data={ + "payload": { + "book_id": book_id + } + } + ) # Update a book -@router.put("/{book_id}", response_model=Book) -async def update_book(book_id: int, updated: BookCreate) -> Book: +@router.put("/{book_id}", response_model=EnvelopeResponse) +async def update_book(book_id: int, updated: BookCreate) -> EnvelopeResponse: logger.info(f"Attempting to update book with ID: {book_id}") for i, book in enumerate(books_db): if book.id == book_id: updated_book = Book(id=book_id, **updated.model_dump()) books_db[i] = updated_book logger.info(f"Successfully updated book with ID: {book_id}") - return updated_book + return create_response_for_fast_api( + data=updated_book, + status_code_http=200 + ) logger.error(f"Book with ID {book_id} not found for update") - raise HTTPException(status_code=404, detail="Book not found") - + raise BookException( + message=f"Book with ID {book_id} not found for update", + data={ + "payload": { + "book_id": book_id + } + } + ) # Delete a book @router.delete("/{book_id}", status_code=204) async def delete_book(book_id: int) -> None: @@ -60,6 +95,13 @@ async def delete_book(book_id: int) -> None: if book.id == book_id: books_db.pop(i) logger.info(f"Successfully deleted book with ID: {book_id}") - return + return Response(status_code=204) logger.error(f"Book with ID {book_id} not found for deletion") - raise HTTPException(status_code=404, detail="Book not found") + raise BookException( + message=f"Book with ID {book_id} not found for deletion", + data={ + "payload": { + "book_id": book_id + } + } + ) \ No newline at end of file diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..26dbd4c --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,7 @@ +from shared.base_exceptions import BaseApiRestException +from core.internal_codes import InternalCodesApiBook +import fastapi + +class BookException(BaseApiRestException): + GENERAL_ERROR_CODE = InternalCodesApiBook.BOOK_API_ERROR + GENERAL_STATUS_CODE_HTTP = fastapi.status.HTTP_400_BAD_REQUEST \ No newline at end of file diff --git a/src/main.py b/src/main.py index c645e31..c44f3b4 100644 --- a/src/main.py +++ b/src/main.py @@ -7,13 +7,20 @@ from api.endpoints import index_router from typing import Any from core.settings import settings +from shared.middlewares import ( + CatcherExceptions, + CatcherExceptionsPydantic +) +from fastapi.middleware import Middleware app = FastAPI( title=settings.PROJECT.NAME, version=settings.PROJECT.VERSION, description=settings.PROJECT.DESCRIPTION, root_path=settings.ROOT_PATH, - middleware=[] + middleware=[ + Middleware(CatcherExceptions) + ] ) @@ -34,5 +41,5 @@ def custom_openapi() -> dict[str, Any]: app.openapi = custom_openapi # type: ignore app.include_router(api_v1_router) app.include_router(index_router) - +CatcherExceptionsPydantic(app) handler = Mangum(app) From f158461b455e925de71ee58d10f2bcf5d19a1c6e Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 14:31:42 -0600 Subject: [PATCH 5/7] feat: add repositories to save data in posgresql database --- src/api/v1/endpoints.py | 142 +++++++++--------------- src/api/v1/repositories.py | 49 ++++++++ src/api/v1/schema.py | 17 ++- src/db/posgresql/base.py | 8 +- src/db/posgresql/models/public/books.py | 5 +- src/shared/base_responses.py | 4 +- 6 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 src/api/v1/repositories.py diff --git a/src/api/v1/endpoints.py b/src/api/v1/endpoints.py index d3eed73..17e2bc9 100644 --- a/src/api/v1/endpoints.py +++ b/src/api/v1/endpoints.py @@ -1,107 +1,69 @@ -from api.v1.schema import Book, BookCreate +from api.v1.schema import BookSchema, BookCreateSchema from loguru import logger -from shared.base_responses import create_response_for_fast_api,EnvelopeResponse +from shared.base_responses import create_response_for_fast_api, EnvelopeResponse from core.exceptions import BookException -from fastapi import ( - APIRouter, -) -from fastapi import Response +from fastapi import APIRouter, Response +from api.v1.repositories import BookRepository +from uuid import UUID -router = APIRouter(prefix="/books",tags=["Books"]) +router = APIRouter(prefix="/books", tags=["Books"]) -# Simulated database -books_db: list[Book] = [] -counter_id = 0 - - -# Get all books -@router.get("", response_model=EnvelopeResponse) -async def get_books(): - logger.info("Retrieving all books") - return create_response_for_fast_api( - data=books_db or None, - status_code_http=200 - ) - -# Get all books @router.get("", response_model=EnvelopeResponse) async def get_books() -> EnvelopeResponse: logger.info("Retrieving all books") - return create_response_for_fast_api( - data=books_db, - status_code_http=200 - ) + success, list_books = BookRepository.get_all() + list_books_schema = [BookSchema(**book.to_dict()) for book in list_books] + return create_response_for_fast_api(data=list_books_schema if success else None) + @router.post("", response_model=EnvelopeResponse) -async def create_book(book: BookCreate) -> EnvelopeResponse: - global counter_id - counter_id += 1 - new_book = Book(id=counter_id, **book.model_dump()) - books_db.append(new_book) - logger.info(f"Created new book with ID: {counter_id}") - return create_response_for_fast_api( - data=new_book, - status_code_http=201 - ) +async def create_book(book: BookCreateSchema) -> EnvelopeResponse: + logger.info("Creating new book") + success, new_book = BookRepository.create(book) + if not success: + logger.error("Book creation failed") + raise BookException(message="Failed to create book") + logger.info(f"Book created with ID: {new_book.id}") + return create_response_for_fast_api(data=BookSchema(**new_book.to_dict()), status_code_http=201) + -# Get book by ID @router.get("/{book_id}", response_model=EnvelopeResponse) -async def get_book(book_id: int) -> EnvelopeResponse: +async def get_book(book_id: UUID) -> EnvelopeResponse: logger.info(f"Retrieving book with ID: {book_id}") - for book in books_db: - if book.id == book_id: - return create_response_for_fast_api( - data=book, - status_code_http=201 - ) - logger.error(f"Book with ID {book_id} not found") - raise BookException( - message=f"Book with ID {book_id} not found", - data={ - "payload": { - "book_id": book_id - } - } - ) + success, book = BookRepository.get_by_id(book_id) + if not success: + logger.error(f"Book with ID {book_id} not found") + raise BookException( + message=f"Book with ID {book_id} not found", + data={"payload": {"book_id": str(book_id)}} + ) + return create_response_for_fast_api(data=BookSchema(**book.to_dict())) + -# Update a book @router.put("/{book_id}", response_model=EnvelopeResponse) -async def update_book(book_id: int, updated: BookCreate) -> EnvelopeResponse: - logger.info(f"Attempting to update book with ID: {book_id}") - for i, book in enumerate(books_db): - if book.id == book_id: - updated_book = Book(id=book_id, **updated.model_dump()) - books_db[i] = updated_book - logger.info(f"Successfully updated book with ID: {book_id}") - return create_response_for_fast_api( - data=updated_book, - status_code_http=200 - ) - logger.error(f"Book with ID {book_id} not found for update") - raise BookException( - message=f"Book with ID {book_id} not found for update", - data={ - "payload": { - "book_id": book_id - } - } - ) -# Delete a book +async def update_book(book_id: UUID, updated: BookCreateSchema) -> EnvelopeResponse: + logger.info(f"Updating book with ID: {book_id}") + success, updated_book = BookRepository.update(book_id, updated) + if not success: + logger.error(f"Book with ID {book_id} not found for update") + raise BookException( + message=f"Book with ID {book_id} not found for update", + data={"payload": {"book_id": str(book_id)}} + ) + logger.info(f"Successfully updated book with ID: {book_id}") + return create_response_for_fast_api(data=BookSchema(**updated_book.to_dict())) + + @router.delete("/{book_id}", status_code=204) -async def delete_book(book_id: int) -> None: +async def delete_book(book_id: UUID) -> None: logger.info(f"Attempting to delete book with ID: {book_id}") - for i, book in enumerate(books_db): - if book.id == book_id: - books_db.pop(i) - logger.info(f"Successfully deleted book with ID: {book_id}") - return Response(status_code=204) - logger.error(f"Book with ID {book_id} not found for deletion") - raise BookException( - message=f"Book with ID {book_id} not found for deletion", - data={ - "payload": { - "book_id": book_id - } - } - ) \ No newline at end of file + success, _ = BookRepository.delete(book_id) + if not success: + logger.error(f"Book with ID {book_id} not found for deletion") + raise BookException( + message=f"Book with ID {book_id} not found for deletion", + data={"payload": {"book_id": str(book_id)}} + ) + logger.info(f"Successfully deleted book with ID: {book_id}") + return Response(status_code=204) diff --git a/src/api/v1/repositories.py b/src/api/v1/repositories.py new file mode 100644 index 0000000..e59ac07 --- /dev/null +++ b/src/api/v1/repositories.py @@ -0,0 +1,49 @@ +from db.posgresql import get_db_context +from db.posgresql.models.public import Book +from api.v1.schema import BookCreateSchema +from sqlalchemy import select + +class BookRepository: + + @staticmethod + def get_all() -> tuple[bool, list[Book]]: + with get_db_context() as session: + books = session.scalars(select(Book)).all() + return True, books + + @staticmethod + def get_by_id(book_id: int) -> tuple[bool, Book | None]: + with get_db_context() as session: + book = session.get(Book, book_id) + return (True, book) if book else (False, None) + + @staticmethod + def create(book_create: BookCreateSchema) -> tuple[bool, Book]: + with get_db_context() as session: + new_book = Book(**book_create.model_dump()) + session.add(new_book) + session.commit() + session.refresh(new_book) + return True, new_book + + @staticmethod + def update(book_id: int, book_update: BookCreateSchema) -> tuple[bool, Book | None]: + with get_db_context() as session: + book = session.get(Book, book_id) + if not book: + return False, None + for key, value in book_update.model_dump().items(): + setattr(book, key, value) + session.commit() + session.refresh(book) + return True, book + + @staticmethod + def delete(book_id: int) -> tuple[bool, None]: + with get_db_context() as session: + book = session.get(Book, book_id) + if not book: + return False, None + session.delete(book) + session.commit() + return True, None diff --git a/src/api/v1/schema.py b/src/api/v1/schema.py index 7b986c3..c69def7 100644 --- a/src/api/v1/schema.py +++ b/src/api/v1/schema.py @@ -1,15 +1,24 @@ from pydantic import BaseModel +from db.posgresql.models.public import BookType +from uuid import UUID # Data model -class Book(BaseModel): - id: int +class BookSchema(BaseModel): + id: UUID title: str author: str year: int + type: BookType + class Config: + allow_population_by_field_name = False + json_encoders = { + UUID: lambda v: str(v), + } # Create a new book -class BookCreate(BaseModel): +class BookCreateSchema(BaseModel): title: str author: str - year: int \ No newline at end of file + year: int + type: BookType \ No newline at end of file diff --git a/src/db/posgresql/base.py b/src/db/posgresql/base.py index bf35622..1701886 100644 --- a/src/db/posgresql/base.py +++ b/src/db/posgresql/base.py @@ -24,4 +24,10 @@ def updated_at(cls): @declared_attr def deleted_at(cls): - return Column(DateTime, nullable=True) \ No newline at end of file + return Column(DateTime, nullable=True) + + def to_dict(self): + return { + column.name: getattr(self, column.name) + for column in self.__table__.columns + } \ No newline at end of file diff --git a/src/db/posgresql/models/public/books.py b/src/db/posgresql/models/public/books.py index 5302864..b58d8d3 100644 --- a/src/db/posgresql/models/public/books.py +++ b/src/db/posgresql/models/public/books.py @@ -10,7 +10,4 @@ class Book(Base, BaseModel): title: str = Column(String, nullable=False) author: str = Column(String, nullable=False) year: int = Column(Integer, nullable=False) - type: BookType = Column(Enum(BookType), nullable=False) - - - \ No newline at end of file + type: BookType = Column(Enum(BookType), nullable=False) \ No newline at end of file diff --git a/src/shared/base_responses.py b/src/shared/base_responses.py index c0cb17a..68eea48 100644 --- a/src/shared/base_responses.py +++ b/src/shared/base_responses.py @@ -44,11 +44,11 @@ def create_response_for_fast_api( else: first_element = data[0] if isinstance(first_element,BaseModel): - data = [element.model_dump() for element in data] + data = [element.model_dump(mode="json") for element in data] elif isinstance(data, BaseModel): - data = data.model_dump_json() + data = data.model_dump_json(mode="json") data = json.loads(data) if not success: From 848c69f4f875ee5bd17c354105e6c0389bdaccaf Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 15:06:50 -0600 Subject: [PATCH 6/7] refactor: add services.py --- src/api/v1/endpoints.py | 51 +++++++++++--------------------- src/api/v1/services.py | 57 ++++++++++++++++++++++++++++++++++++ src/shared/base_responses.py | 2 +- 3 files changed, 75 insertions(+), 35 deletions(-) create mode 100644 src/api/v1/services.py diff --git a/src/api/v1/endpoints.py b/src/api/v1/endpoints.py index 17e2bc9..e4f08f8 100644 --- a/src/api/v1/endpoints.py +++ b/src/api/v1/endpoints.py @@ -1,10 +1,15 @@ -from api.v1.schema import BookSchema, BookCreateSchema +from api.v1.schema import BookCreateSchema from loguru import logger from shared.base_responses import create_response_for_fast_api, EnvelopeResponse -from core.exceptions import BookException from fastapi import APIRouter, Response -from api.v1.repositories import BookRepository from uuid import UUID +from api.v1.services import ( + BooksListService, + BookCreateService, + BookRetrieveService, + BookUpdateService, + BookDeleteService, +) router = APIRouter(prefix="/books", tags=["Books"]) @@ -12,58 +17,36 @@ @router.get("", response_model=EnvelopeResponse) async def get_books() -> EnvelopeResponse: logger.info("Retrieving all books") - success, list_books = BookRepository.get_all() - list_books_schema = [BookSchema(**book.to_dict()) for book in list_books] - return create_response_for_fast_api(data=list_books_schema if success else None) + books = BooksListService.list() + return create_response_for_fast_api(data=books) @router.post("", response_model=EnvelopeResponse) async def create_book(book: BookCreateSchema) -> EnvelopeResponse: logger.info("Creating new book") - success, new_book = BookRepository.create(book) - if not success: - logger.error("Book creation failed") - raise BookException(message="Failed to create book") + new_book = BookCreateService.create(book) logger.info(f"Book created with ID: {new_book.id}") - return create_response_for_fast_api(data=BookSchema(**new_book.to_dict()), status_code_http=201) + return create_response_for_fast_api(data=new_book, status_code_http=201) @router.get("/{book_id}", response_model=EnvelopeResponse) async def get_book(book_id: UUID) -> EnvelopeResponse: logger.info(f"Retrieving book with ID: {book_id}") - success, book = BookRepository.get_by_id(book_id) - if not success: - logger.error(f"Book with ID {book_id} not found") - raise BookException( - message=f"Book with ID {book_id} not found", - data={"payload": {"book_id": str(book_id)}} - ) - return create_response_for_fast_api(data=BookSchema(**book.to_dict())) + book = BookRetrieveService.retrieve(book_id) + return create_response_for_fast_api(data=book) @router.put("/{book_id}", response_model=EnvelopeResponse) async def update_book(book_id: UUID, updated: BookCreateSchema) -> EnvelopeResponse: logger.info(f"Updating book with ID: {book_id}") - success, updated_book = BookRepository.update(book_id, updated) - if not success: - logger.error(f"Book with ID {book_id} not found for update") - raise BookException( - message=f"Book with ID {book_id} not found for update", - data={"payload": {"book_id": str(book_id)}} - ) + updated_book = BookUpdateService.update(book_id, updated) logger.info(f"Successfully updated book with ID: {book_id}") - return create_response_for_fast_api(data=BookSchema(**updated_book.to_dict())) + return create_response_for_fast_api(data=updated_book) @router.delete("/{book_id}", status_code=204) async def delete_book(book_id: UUID) -> None: logger.info(f"Attempting to delete book with ID: {book_id}") - success, _ = BookRepository.delete(book_id) - if not success: - logger.error(f"Book with ID {book_id} not found for deletion") - raise BookException( - message=f"Book with ID {book_id} not found for deletion", - data={"payload": {"book_id": str(book_id)}} - ) + BookDeleteService.delete(book_id) logger.info(f"Successfully deleted book with ID: {book_id}") return Response(status_code=204) diff --git a/src/api/v1/services.py b/src/api/v1/services.py new file mode 100644 index 0000000..bfece7f --- /dev/null +++ b/src/api/v1/services.py @@ -0,0 +1,57 @@ +from api.v1.repositories import BookRepository +from api.v1.schema import BookSchema, BookCreateSchema +from core.exceptions import BookException +from uuid import UUID + + +class BooksListService: + @staticmethod + def list() -> list[BookSchema]: + success, list_books = BookRepository.get_all() + if not success: + raise BookException(message="Failed to retrieve books") + return [BookSchema(**book.to_dict()) for book in list_books] + + +class BookCreateService: + @staticmethod + def create(book_data: BookCreateSchema) -> BookSchema: + success, new_book = BookRepository.create(book_data) + if not success: + raise BookException(message="Failed to create book") + return BookSchema(**new_book.to_dict()) + + +class BookRetrieveService: + @staticmethod + def retrieve(book_id: UUID) -> BookSchema: + success, book = BookRepository.get_by_id(book_id) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found", + data={"payload": {"book_id": str(book_id)}} + ) + return BookSchema(**book.to_dict()) + + +class BookUpdateService: + @staticmethod + def update(book_id: UUID, book_data: BookCreateSchema) -> BookSchema: + success, updated_book = BookRepository.update(book_id, book_data) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found for update", + data={"payload": {"book_id": str(book_id)}} + ) + return BookSchema(**updated_book.to_dict()) + + +class BookDeleteService: + @staticmethod + def delete(book_id: UUID) -> None: + success, _ = BookRepository.delete(book_id) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found for deletion", + data={"payload": {"book_id": str(book_id)}} + ) diff --git a/src/shared/base_responses.py b/src/shared/base_responses.py index 68eea48..e0e17b5 100644 --- a/src/shared/base_responses.py +++ b/src/shared/base_responses.py @@ -48,7 +48,7 @@ def create_response_for_fast_api( elif isinstance(data, BaseModel): - data = data.model_dump_json(mode="json") + data = data.model_dump_json() data = json.loads(data) if not success: From fddce0330a9efbd1f8ffd6a9ffa197e149b5a356 Mon Sep 17 00:00:00 2001 From: ronihdzz Date: Wed, 28 May 2025 15:20:22 -0600 Subject: [PATCH 7/7] feat: add tests --- src/api/v1/schema.py | 12 ++-- src/db/mongo/base.py | 13 ++-- src/tests/__init__.py | 3 +- src/tests/test_init.py | 108 --------------------------------- src/tests/test_repositories.py | 38 ++++++++++++ src/tests/test_responses.py | 17 ++++++ src/tests/test_services.py | 51 ++++++++++++++++ src/tests/tests_endpoints.py | 94 ++++++++++++++++++++++++++++ src/tests/utils.py | 50 +++++++++++++++ 9 files changed, 265 insertions(+), 121 deletions(-) delete mode 100644 src/tests/test_init.py create mode 100644 src/tests/test_repositories.py create mode 100644 src/tests/test_responses.py create mode 100644 src/tests/test_services.py create mode 100644 src/tests/tests_endpoints.py create mode 100644 src/tests/utils.py diff --git a/src/api/v1/schema.py b/src/api/v1/schema.py index c69def7..d020da4 100644 --- a/src/api/v1/schema.py +++ b/src/api/v1/schema.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, field_serializer from db.posgresql.models.public import BookType from uuid import UUID @@ -10,11 +10,11 @@ class BookSchema(BaseModel): year: int type: BookType - class Config: - allow_population_by_field_name = False - json_encoders = { - UUID: lambda v: str(v), - } + model_config = ConfigDict(validate_by_name=False) + + @field_serializer('id') + def serialize_id(self, v: UUID, _info): + return str(v) # Create a new book class BookCreateSchema(BaseModel): diff --git a/src/db/mongo/base.py b/src/db/mongo/base.py index 6975ffa..4ce31b2 100644 --- a/src/db/mongo/base.py +++ b/src/db/mongo/base.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict, field_serializer from shared.utils_dates import get_app_current_time @@ -24,11 +24,12 @@ class BaseMongoDocument(BaseModel): updated_at: datetime = Field(default_factory=default_mongodb_created_at) deleted_at: datetime | None = Field(default=None) - class Config: - allow_population_by_field_name = False - json_encoders = { - UUID: lambda v: str(v), - } + + model_config = ConfigDict(validate_by_name=False) + + @field_serializer('id') + def serialize_id(self, v: UUID, _info): + return str(v) class MongoAbstractRepository(ABC): diff --git a/src/tests/__init__.py b/src/tests/__init__.py index e22c3f8..bad3e76 100644 --- a/src/tests/__init__.py +++ b/src/tests/__init__.py @@ -7,7 +7,8 @@ from shared.environment import AppEnvironment from tests.create_databases import prepare_database -if settings.ENVIRONMENT in [AppEnvironment.TESTING, AppEnvironment.TESTING_DOCKER]: +logger.info(f"Check if ENVIRONMENT=testing: {settings.ENVIRONMENT} in {AppEnvironment.TESTING.value} or {AppEnvironment.TESTING_DOCKER.value}") +if settings.ENVIRONMENT in [AppEnvironment.TESTING.value, AppEnvironment.TESTING_DOCKER.value]: logger.info("Preparing database for tests") prepare_database( schemas_to_create=["public"], diff --git a/src/tests/test_init.py b/src/tests/test_init.py deleted file mode 100644 index a2befce..0000000 --- a/src/tests/test_init.py +++ /dev/null @@ -1,108 +0,0 @@ -import unittest -from fastapi.testclient import TestClient -from main import app -from api.v1.endpoints import books_db, counter_id -from unittest import TestCase - -class TestBooksAPI(TestCase): - def setUp(self) -> None: - # Reset global state before each test - global books_db, counter_id - books_db.clear() - counter_id = 0 - self.client = TestClient(app) - - def test_read_root(self) -> None: - response = self.client.get("/") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"message": "Books API πŸ“š"}) - - def test_get_books_empty(self) -> None: - response = self.client.get("/v1/books") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - def test_create_book(self) -> None: - payload = {"title": "Test Book", "author": "Author A", "year": 2020} - response = self.client.post("/v1/books", json=payload) - self.assertEqual(response.status_code, 201) - data = response.json() - self.assertEqual(data["id"], 1) - self.assertEqual(data["title"], payload["title"]) - self.assertEqual(data["author"], payload["author"]) - self.assertEqual(data["year"], payload["year"]) - - def test_get_book(self) -> None: - # First create a book - payload = {"title": "Test Book", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Retrieve the created book - response = self.client.get(f"/v1/books/{book_id}") - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["id"], book_id) - self.assertEqual(data["title"], payload["title"]) - - def test_update_book(self) -> None: - # Create a book - payload = {"title": "Old Title", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Update the book - updated_payload = {"title": "New Title", "author": "Author B", "year": 2021} - response = self.client.put(f"/v1/books/{book_id}", json=updated_payload) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["id"], book_id) - self.assertEqual(data["title"], updated_payload["title"]) - self.assertEqual(data["author"], updated_payload["author"]) - self.assertEqual(data["year"], updated_payload["year"]) - - def test_delete_book(self) -> None: - # Create a book to delete later - payload = {"title": "Delete Me", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Delete the book - response = self.client.delete(f"/v1/books/{book_id}") - self.assertEqual(response.status_code, 204) - - # Verify the book no longer exists - get_response = self.client.get(f"/v1/books/{book_id}") - self.assertEqual(get_response.status_code, 404) - self.assertEqual(get_response.json()["detail"], "Book not found") - - def test_get_nonexistent_book(self) -> None: - response = self.client.get("/v1/books/999") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_update_nonexistent_book(self) -> None: - updated_payload = {"title": "New Title", "author": "Author B", "year": 2021} - response = self.client.put("/v1/books/999", json=updated_payload) - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_delete_nonexistent_book(self) -> None: - response = self.client.delete("/v1/books/999") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_openapi_schema(self) -> None: - response = self.client.get("/openapi.json") - self.assertEqual(response.status_code, 200) - data = response.json() - # Verify that the openapi version is the forced one - self.assertEqual(data["openapi"], "3.0.3") - self.assertEqual(data["info"]["title"], "Books API") - self.assertEqual(data["info"]["version"], "1.0.0") - - def tearDown(self) -> None: - # Reset global state after each test - global books_db, counter_id - books_db.clear() - counter_id = 0 diff --git a/src/tests/test_repositories.py b/src/tests/test_repositories.py new file mode 100644 index 0000000..4239e43 --- /dev/null +++ b/src/tests/test_repositories.py @@ -0,0 +1,38 @@ +import unittest +from api.v1.repositories import BookRepository +from .utils import DBMixin + +# ───────────────────────── TESTS REPOSITORIO ────────────────────────── # + +class TestBooksRepository(DBMixin, unittest.TestCase): + + def test_repository_crud(self): + # CREATE + ok, book = BookRepository.create(self.create_schema()) + self.assertTrue(ok) + self.assertIsNotNone(book.id) + + # GET ALL + ok, all_books = BookRepository.get_all() + self.assertTrue(ok) + self.assertEqual(len(all_books), 1) + + # GET BY ID + ok, fetched = BookRepository.get_by_id(book.id) + self.assertTrue(ok) + self.assertEqual(fetched.title, book.title) + + # UPDATE + new_schema = self.create_schema(title="Pragmatic Programmer", year=1999) + ok, updated = BookRepository.update(book.id, new_schema) + self.assertTrue(ok) + self.assertEqual(updated.title, "Pragmatic Programmer") + + # DELETE + ok, _ = BookRepository.delete(book.id) + self.assertTrue(ok) + + # Ya no existe + ok, none = BookRepository.get_by_id(book.id) + self.assertFalse(ok) + self.assertIsNone(none) diff --git a/src/tests/test_responses.py b/src/tests/test_responses.py new file mode 100644 index 0000000..20160d4 --- /dev/null +++ b/src/tests/test_responses.py @@ -0,0 +1,17 @@ +import unittest +from .utils import DBMixin + +class TestEnvelopeFormat(DBMixin, unittest.TestCase): + """Comprueba estructura genΓ©rica del sobre de respuesta.""" + + def test_envelope_keys(self): + res = self.client.get("/v1/books") + env = res.json() + self.assertSetEqual( + set(env.keys()), {"success", "message", "data", "trace_id"} + ) + self.assertIsInstance(env["success"], bool) + self.assertTrue(env["message"]) + # trace_id puede ser None o str + self.assertTrue(env["trace_id"] is None or isinstance(env["trace_id"], str)) + diff --git a/src/tests/test_services.py b/src/tests/test_services.py new file mode 100644 index 0000000..ddb049d --- /dev/null +++ b/src/tests/test_services.py @@ -0,0 +1,51 @@ +import json +import uuid +import unittest +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import text + +from main import app +from db.posgresql import get_db_context +from db.posgresql.models.public import BookType +from api.v1.schema import BookCreateSchema +from api.v1.repositories import BookRepository +from api.v1.services import ( + BooksListService, + BookCreateService, + BookRetrieveService, + BookUpdateService, + BookDeleteService, +) +from .utils import DBMixin + +# ───────────────────────── TESTS SERVICIOS ────────────────────────── # + +class TestBooksServices(DBMixin, unittest.TestCase): + + def test_service_create_retrieve_update_delete_flow(self): + # CREATE + schema = self.create_schema() + created = BookCreateService.create(schema) + self.assertEqual(created.title, schema.title) + + # LIST debe contener el nuevo + ids = [b.id for b in BooksListService.list()] + self.assertIn(created.id, ids) + + # RETRIEVE + fetched = BookRetrieveService.retrieve(created.id) + self.assertEqual(fetched.author, schema.author) + + # UPDATE + upd_schema = self.create_schema(title="DDD Updated", year=2004) + updated = BookUpdateService.update(created.id, upd_schema) + self.assertEqual(updated.title, "DDD Updated") + self.assertEqual(updated.year, 2004) + + # DELETE + BookDeleteService.delete(created.id) + with self.assertRaises(Exception): + BookRetrieveService.retrieve(created.id) + diff --git a/src/tests/tests_endpoints.py b/src/tests/tests_endpoints.py new file mode 100644 index 0000000..e33dafc --- /dev/null +++ b/src/tests/tests_endpoints.py @@ -0,0 +1,94 @@ +import uuid +import unittest +from .utils import DBMixin + + +# ───────────────────────── TESTS ENDPOINTS ────────────────────────── # + +class TestBooksEndpoints(DBMixin, unittest.TestCase): + + # ---------- GET /books vacΓ­o ---------- # + def test_list_books_empty(self): + res = self.client.get("/v1/books") + self.assertEqual(res.status_code, 200) + env = res.json() + self.assertTrue(env["success"]) + self.assertIsNone(env["data"]) + + # ---------- POST /books ---------- # + def test_create_book_success(self): + res = self.client.post("/v1/books", json=self.payload()) + self.assertEqual(res.status_code, 201) + env = res.json() + book = env["data"] + # id vΓ‘lido + uuid.UUID(book["id"]) + self.assertEqual(book["title"], "Clean Code") + + def test_create_book_missing_field(self): + bad = self.payload() + bad.pop("title") + res = self.client.post("/v1/books", json=bad) + self.assertEqual(res.status_code, 422) # validation error + + # ---------- GET /books/{id} ---------- # + def test_get_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + res = self.client.get(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["data"]["id"], book_id) + + def test_get_book_not_found(self): + fake = "11111111-1111-1111-1111-111111111111" + res = self.client.get(f"/v1/books/{fake}") + self.assertEqual(res.status_code, 404) + env = res.json() + self.assertFalse(env["success"]) + self.assertEqual( + env["data"]["internal_error"]["code"], "BOOK_NOT_FOUND" + ) + + # ---------- PUT /books/{id} ---------- # + def test_update_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + new_payload = self.payload(title="Clean Architecture", year=2017) + res = self.client.put(f"/v1/books/{book_id}", json=new_payload) + self.assertEqual(res.status_code, 200) + book = res.json()["data"] + self.assertEqual(book["title"], "Clean Architecture") + self.assertEqual(book["year"], 2017) + + def test_update_book_not_found(self): + fake = "22222222-2222-2222-2222-222222222222" + res = self.client.put(f"/v1/books/{fake}", json=self.payload()) + self.assertEqual(res.status_code, 404) + + # ---------- DELETE /books/{id} ---------- # + def test_delete_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + res = self.client.delete(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 204) + + # ya no existe + res = self.client.get(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 404) + + def test_delete_book_not_found(self): + fake = "33333333-3333-3333-3333-333333333333" + res = self.client.delete(f"/v1/books/{fake}") + self.assertEqual(res.status_code, 404) + + # ---------- OPENAPI ---------- # + def test_openapi_schema_version(self): + res = self.client.get("/openapi.json") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["openapi"], "3.0.3") + self.assertEqual(data["info"]["title"], "Books API") + diff --git a/src/tests/utils.py b/src/tests/utils.py new file mode 100644 index 0000000..3e78219 --- /dev/null +++ b/src/tests/utils.py @@ -0,0 +1,50 @@ +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import text + +from main import app +from db.posgresql import get_db_context +from db.posgresql.models.public import BookType +from api.v1.schema import BookCreateSchema + +class DBMixin: + """MΓ©todos auxiliares para limpiar la tabla `book` entre tests.""" + + @staticmethod + def _truncate_books() -> None: + with get_db_context() as session: + session.execute( + text("TRUNCATE TABLE public.books RESTART IDENTITY CASCADE;") + ) + session.commit() + + def setUp(self) -> None: # se ejecuta antes de *cada* test + self._truncate_books() + self.client = TestClient(app) + + def tearDown(self) -> None: # limpieza final + self._truncate_books() + + # ---------- datos de apoyo ---------- # + @staticmethod + def payload(**overrides: Any) -> dict[str, Any]: + data = { + "title": "Clean Code", + "author": "Robert C. Martin", + "year": 2008, + "type": BookType.ONLINE.value, # Enum β†’ string para JSON + } + data.update(overrides) + return data + + @staticmethod + def create_schema(**overrides: Any) -> BookCreateSchema: + base = dict( + title="Domain-Driven Design", + author="Eric Evans", + year=2003, + type=BookType.ONLINE, + ) + base.update(overrides) + return BookCreateSchema(**base)