diff --git a/src/routers/poe.py b/src/routers/poe.py index 5562f75..500ca8e 100644 --- a/src/routers/poe.py +++ b/src/routers/poe.py @@ -1,29 +1,36 @@ from fastapi import APIRouter, Depends, Query from src import dependencies as deps -from src.schemas.web_responses import users as resp +from src.schemas.requests import FilterSortInput, PaginationInput +from src.schemas.web_responses import poe as resp from src.schemas.responses import AppResponse, BaseResponse from src.services import poe as service +from src.utils.routers import create_pagination_response dependencies = [Depends(deps.check_access_token)] router = APIRouter(prefix="/poe", tags=["Path of Exile"], dependencies=dependencies) -# TODO: add responses -@router.get("/categories", responses=resp.GET_USERS_RESPONSES) +@router.get("/categories", responses=resp.GET_CATEGORIES_RESPONSES) async def get_all_categories(): """Gets a list of all item categories from the database, mapped by their group names.""" item_categories = await service.get_item_categories() item_category_mapping = service.group_item_categories(item_categories) - return AppResponse(BaseResponse(data=item_category_mapping)) + return AppResponse(BaseResponse(data=item_category_mapping, key="category_groups")) -@router.get("/items", responses=resp.GET_USERS_RESPONSES) -async def get_items_by_group(category_group: str = Query(..., min_length=3, max_length=50)): +@router.get("/items", responses=resp.GET_ITEMS_RESPONSES) +async def get_items_by_group( + category_group: str | None = Query(None, min_length=3, max_length=50), + pagination: PaginationInput = Depends(), # using Depends allows us to encapsulate Query params within Pydantic models + filter_sort_input: FilterSortInput | None = None, +): """Gets a list of all items belonging to the given category group.""" - items = await service.get_items_by_group(category_group) - return AppResponse(BaseResponse(data=items)) + items, total_items = await service.get_items(category_group, pagination, filter_sort_input) + + response = create_pagination_response(items, total_items, pagination, "items") + return AppResponse(response) diff --git a/src/schemas/poe.py b/src/schemas/poe.py index 5090d86..2fe8e0b 100644 --- a/src/schemas/poe.py +++ b/src/schemas/poe.py @@ -1,6 +1,7 @@ import datetime as dt from decimal import Decimal from enum import Enum +from typing import TypedDict from pydantic import BaseModel, Field, Json @@ -32,7 +33,7 @@ class ItemCategoryResponse(BaseModel): name: str internal_name: str - group: str + group: str = Field(exclude=True) class ItemBase(BaseModel): @@ -46,3 +47,10 @@ class ItemBase(BaseModel): variant: str | None = None icon_url: str | None = None enabled: bool = True + + +class ItemGroupMapping(TypedDict): + """ItemGroupMapping maps Category instances to the group that they belong to, in a standardized format.""" + + group: str + members: list[ItemCategoryResponse] diff --git a/src/schemas/web_responses/poe.py b/src/schemas/web_responses/poe.py new file mode 100644 index 0000000..82d0b72 --- /dev/null +++ b/src/schemas/web_responses/poe.py @@ -0,0 +1,113 @@ +from typing import Any, Dict + +from src.schemas.web_responses.common import COMMON_RESPONSES + + +GET_CATEGORIES_RESPONSES: Dict[int | str, Dict[str, Any]] = { + 200: { + "content": { + "application/json": { + "example": { + "error": "null", + "data": { + "category_groups": [ + { + "group": "Currency", + "members": [ + {"name": "Currency", "internal_name": "Currency"}, + {"name": "Fragments", "internal_name": "Fragment"}, + ], + } + ] + }, + } + } + } + }, + **COMMON_RESPONSES, +} + +GET_ITEMS_RESPONSES: Dict[int | str, Dict[str, Any]] = { + 200: { + "content": { + "application/json": { + "example": { + "error": None, + "pagination": {"page": 1, "per_page": 1, "total_items": 27431, "total_pages": 27431}, + "data": { + "items": [ + { + "poe_ninja_id": 81, + "id_type": "pay", + "name": "Mirror Shard", + "price": { + "price": "4", + "currency": "chaos", + "price_history": { + "2024-06-16T14:33:41.439096Z": "3", + "2024-06-17T14:33:41.439099Z": "3", + "2024-06-18T14:33:41.439101Z": "3", + "2024-06-19T14:33:41.439104Z": "4", + "2024-06-20T14:33:41.439106Z": "4", + "2024-06-21T14:33:41.439108Z": "4", + "2024-06-22T14:33:41.439110Z": "4", + }, + "price_history_currency": "chaos", + "price_prediction": { + "2024-06-23T14:33:41.438877Z": "5365.58", + "2024-06-24T14:33:41.438877Z": "5402.02", + "2024-06-25T14:33:41.438877Z": "5435.12", + "2024-06-26T14:33:41.438877Z": "5464.90", + }, + "price_prediction_currency": "chaos", + }, + "type_": None, + "variant": None, + "icon_url": "https://web.poecdn.com/6604b7aa32/MirrorShard.png", + "enabled": True, + } + ] + }, + } + } + } + }, + 400: { + "content": { + "application/json": { + "example": { + "error": { + "type": "invalid_input", + "message": "Invalid category group.", + "fields": None, + }, + } + } + }, + "data": None, + }, + 422: { + "content": { + "application/json": { + "example": { + "data": None, + "error": { + "type": "validation_error", + "message": "Input failed validation.", + "fields": [ + {"error_type": "greater_than", "field": "page"}, + {"error_type": "expected_int", "field": "page"}, + {"error_type": "greater_than", "field": "per_page"}, + {"error_type": "less_than_equal", "field": "per_page"}, + {"error_type": "expected_int", "field": "per_page"}, + {"error_type": "list_type", "field": "filter"}, + {"error_type": "list_type", "field": "sort"}, + ], + }, + } + } + }, + "data": None, + }, + **COMMON_RESPONSES, +} diff --git a/src/services/poe.py b/src/services/poe.py index 66aac00..72e5d67 100644 --- a/src/services/poe.py +++ b/src/services/poe.py @@ -5,7 +5,9 @@ from starlette import status from src.models.poe import Item, ItemCategory -from src.schemas.poe import ItemBase, ItemCategoryResponse +from src.schemas.poe import ItemBase, ItemGroupMapping, ItemCategoryResponse +from src.schemas.requests import FilterSortInput, PaginationInput +from src.utils.services import QueryChainer async def get_item_categories() -> list[ItemCategoryResponse]: @@ -20,42 +22,75 @@ async def get_item_categories() -> list[ItemCategoryResponse]: return item_categories -def group_item_categories(item_categories: list[ItemCategoryResponse]) -> dict[str, list[ItemCategoryResponse]]: - """Groups item category documents by their category group.""" +def group_item_categories(item_categories: list[ItemCategoryResponse]) -> list[ItemGroupMapping]: + """Gathers and groups categories by their parent groups, in a consistent hashmap format.""" - item_category_mapping = defaultdict(list) + category_group_map = defaultdict(list) + item_category_groups: list[ItemGroupMapping] = [] for category in item_categories: - item_category_mapping[category.group].append(category) + category_group_map[category.group].append(category) - return item_category_mapping + for group, members in category_group_map.items(): + group: str + category_group_map = ItemGroupMapping(group=group, members=members) + item_category_groups.append(category_group_map) + return item_category_groups -async def get_items_by_group(category_group: str) -> list[ItemBase]: + +async def get_items( + category_group: str | None, pagination: PaginationInput, filter_sort_input: FilterSortInput | None +) -> tuple[list[ItemBase], int]: """ - Gets items by given category group. Raises a 400 error if category group is invalid. + Gets items by given category group, and the total items' count in the database. Raises a 400 error if category + group is invalid. """ - try: - item_category = await ItemCategory.find_one(ItemCategory.group == category_group) - except Exception as exc: - logger.error(f"error getting item category by group '{category_group}': {exc} ") - raise + item_category = None + if category_group is not None: + try: + item_category = await ItemCategory.find_one(ItemCategory.group == category_group) + except Exception as exc: + logger.error(f"error getting item category by group '{category_group}': {exc} ") + raise + + if item_category is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid category group.") if item_category is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid category group.") + query = Item.find() + else: + query = Item.find(Item.category.group == category_group) # type: ignore + + chainer = QueryChainer(query, Item) + + if filter_sort_input is None: + items_count = await query.find(fetch_links=True).count() + items = await chainer.paginate(pagination).query.find(fetch_links=True).project(ItemBase).to_list() + + return items, items_count + + base_query_chain = chainer.filter(filter_sort_input.filter_).sort(filter_sort_input.sort) + + # * clone the query for use with total record counts and pagination calculations + count_query = ( + base_query_chain.filter(filter_sort_input.filter_) + .sort(filter_sort_input.sort) + .clone() + .query.find(fetch_links=True) + .count() + ) + + paginated_query = base_query_chain.paginate(pagination).query.find(fetch_links=True).project(ItemBase).to_list() try: - items = ( - await Item.find( - Item.category.group == category_group, # type: ignore - fetch_links=True, - ) - .project(ItemBase) - .to_list() - ) + items = await paginated_query + items_count = await count_query except Exception as exc: - logger.error(f"error getting item by category group '{category_group}': {exc}") + logger.error( + f"error getting items from database category_group:'{category_group}'; filter_sort: {filter_sort_input}: {exc}" + ) raise - return items + return items, items_count diff --git a/src/utils/services.py b/src/utils/services.py index 1358a4c..378d48c 100644 --- a/src/utils/services.py +++ b/src/utils/services.py @@ -4,7 +4,6 @@ from beanie import Document from beanie.odm.operators.find.evaluation import RegEx as RegExOperator -from fastapi import Body from loguru import logger import orjson import pymongo