Skip to content

Commit

Permalink
Implement Core Path of Exile APIs (#21)
Browse files Browse the repository at this point in the history
* feat: initialize PoE APIs, add 'get category' API

* chore: use json-compatible types when preparing response data

* feat: add initial implementation for get items API

- refactor core Item model's fields into ItemBase schema model
- use ItemBase as response model type

* feat: implement filter-sort and pagination

* chore: add openapi responses

* refactor: standardize categories API's response structure

- add nested key to data values
- exclude unneeded group field from response
- define apt parent and member columns
  • Loading branch information
dhruv-ahuja authored Jul 11, 2024
1 parent a42ee2c commit 491f40c
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 34 deletions.
23 changes: 15 additions & 8 deletions src/routers/poe.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 9 additions & 1 deletion src/schemas/poe.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -32,7 +33,7 @@ class ItemCategoryResponse(BaseModel):

name: str
internal_name: str
group: str
group: str = Field(exclude=True)


class ItemBase(BaseModel):
Expand All @@ -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]
113 changes: 113 additions & 0 deletions src/schemas/web_responses/poe.py
Original file line number Diff line number Diff line change
@@ -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,
}
83 changes: 59 additions & 24 deletions src/services/poe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
1 change: 0 additions & 1 deletion src/utils/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 491f40c

Please sign in to comment.