diff --git a/.env.template b/.env.template index 8c7259d..a2511f8 100644 --- a/.env.template +++ b/.env.template @@ -3,8 +3,6 @@ SOLESEARCH_DEFAULT_LIMIT=20 # The maximum number of sneakers to return in a single request SOLESEARCH_MAX_LIMIT=100 -# The default number of sneakers to skip in a single request -SOLESEARCH_DEFAULT_OFFSET=0 # ==================== # === MongoDB === @@ -21,5 +19,7 @@ SOLESEARCH_DB_PRIMARY_COLLECTION=sneakers SOLESEARCH_STOCKX_API_KEY= SOLESEARCH_STOCKX_CLIENT_ID= SOLESEARCH_STOCKX_CLIENT_SECRET= -SOLESEARCH_STOCKX_CALLBACK_URL=https://localhost:3000/auth/stockx +SOLESEARCH_STOCKX_CALLBACK_URL=https://localhost:8000/auth/stockx +SOLESEARCH_STOCKX_CALLBACK_STATE= +SOLESEARCH_SESSION_SECRET= # =================== \ No newline at end of file diff --git a/README.md b/README.md index 85df333..fb12b8c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ By default, all products in the database are returned when calling `/sneakers`. - releaseDate - released -Filtering is case insensitive, but the search will start from the beginning of each field. For example, a search `/sneakers?brand=jordan` will return all sneakers with a brand starting with `Jordan`, `jordan`, `JOrdan`, etc... but a search `/sneakers?brand=ordan` will not find any `Jordan`s. +Filtering is case insensitive, but the search will always start at the beginning of each field. For example, a search `/sneakers?brand=jordan` will return all sneakers with a brand starting with `Jordan`, `jordan`, `JORdan`, etc... but a search `/sneakers?brand=ordan` will not find any `Jordan` brand sneakers. For this reason, those needing more comprehensive "search" functionality should use the `/search` endpoint. ### Sorting diff --git a/pyproject.toml b/pyproject.toml index 2214361..70d462d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,4 +49,5 @@ install-name = "api" sources = ["src/api"] [tool.hatch.build.targets.zipped-directory.force-include] -".env" = "/.env" \ No newline at end of file +".env" = "/.env" +"static" = "/static" \ No newline at end of file diff --git a/src/api/data/models.py b/src/api/data/models.py index f6a1003..056514e 100644 --- a/src/api/data/models.py +++ b/src/api/data/models.py @@ -1,10 +1,10 @@ from enum import Enum from typing import List +from beanie import Document from pydantic import BaseModel -from beanie import Document -from core.models.shoes import Sneaker +from core.models.shoes import Sneaker, SneakerView class Token(Document): @@ -21,7 +21,7 @@ class PaginatedSneakersResponse(BaseModel): pageSize: int nextPage: str | None previousPage: str | None - items: List[Sneaker] + items: List[SneakerView] class SortKey(str, Enum): diff --git a/src/api/favicon.ico b/src/api/favicon.ico deleted file mode 100644 index eed1cb6..0000000 Binary files a/src/api/favicon.ico and /dev/null differ diff --git a/src/api/main.py b/src/api/main.py index 05888f8..c51e406 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -10,14 +10,16 @@ load_dotenv(os.path.join(os.getcwd(), ".env")) from beanie import init_beanie -from core.models.shoes import Sneaker from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.staticfiles import StaticFiles from mangum import Mangum from starlette.middleware.sessions import SessionMiddleware from api.data.instance import DATABASE_NAME, client from api.routes import auth, sneakers +from core.models.shoes import Sneaker desc = """ ### The Bloomberg Terminal of Sneakers @@ -27,14 +29,18 @@ """ app = FastAPI( - redoc_url=None, # Disable redoc, keep only swagger + redoc_url=None, + docs_url=None, title="SoleSearch", version=__version__, - contact={"name": "SoleSearch Developer Support", "email": "support@solesearch.io"}, + contact={"name": "SoleSearch Email Support", "email": "support@solesearch.io"}, description=desc, responses={404: {"description": "Not found"}}, # Custom 404 page ) +# Serve static files from the /static directory +app.mount("/static", StaticFiles(directory="static"), name="static") + # Enable CORS app.add_middleware( CORSMiddleware, @@ -43,7 +49,8 @@ allow_headers=["*"], ) # Enable session handling for StocxkX OAuth flow -app.add_middleware(SessionMiddleware, secret_key="vT!y!r5s#bwcDxDG") +SESSION_SECRET = os.environ.get("SOLESEARCH_SESSION_SECRET", "this should be a secret") +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) @app.on_event("startup") @@ -58,13 +65,23 @@ async def startup_event(): app.include_router(auth.router) +@app.get("/docs", include_in_schema=False) +async def swagger_ui_html(): + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=app.title + " - Documentation", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + swagger_favicon_url="/static/favicon.png", + ) + + # This is the entry point for AWS Lambda handler = Mangum(app) if __name__ == "__main__": import uvicorn - # Run the app locally using Uvicorn + # Run the app locally using Uvicorn, with SSL enabled uvicorn.run( app, host="localhost", diff --git a/src/api/routes/auth.py b/src/api/routes/auth.py index 16e7bc9..c5b5326 100644 --- a/src/api/routes/auth.py +++ b/src/api/routes/auth.py @@ -11,6 +11,9 @@ STOCKX_CLIENT_ID = os.environ.get("SOLESEARCH_STOCKX_CLIENT_ID", None) STOCKX_CLIENT_SECRET = os.environ.get("SOLESEARCH_STOCKX_CLIENT_SECRET", None) STOCKX_API_KEY = os.environ.get("SOLESEARCH_STOCKX_API_KEY", None) +STOCKX_STATE = os.environ.get( + "SOLESEARCH_STOCKX_CALLBACK_STATE", "this should be a secret string" +) session = requests.session() @@ -22,7 +25,7 @@ @router.get("/stockx") async def login_via_stockx(state: str, request: Request): - if state != "YTPc2DqAwnmhHGzSQVtzwEPq2eEgprUi": + if state != STOCKX_STATE: raise HTTPException(status_code=400, detail="Bad state. Nice try, buster.") auth_url = "https://accounts.stockx.com/authorize" print(STOCKX_CLIENT_ID, STOCKX_CLIENT_SECRET, STOCKX_API_KEY) @@ -42,7 +45,7 @@ async def login_via_stockx(state: str, request: Request): async def stockx_oauth_callback(state: str, code: str, request: Request): if code is None: raise HTTPException(status_code=400, detail="No code returned from StockX.") - if state != "YTPc2DqAwnmhHGzSQVtzwEPq2eEgprUi": + if state != STOCKX_STATE: raise HTTPException(status_code=400, detail="Bad state. Nice try, buster.") try: headers = { diff --git a/src/api/routes/search.py b/src/api/routes/search.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/routes/sneakers.py b/src/api/routes/sneakers.py index 5916231..b421ce0 100644 --- a/src/api/routes/sneakers.py +++ b/src/api/routes/sneakers.py @@ -1,14 +1,13 @@ import os from datetime import UTC, datetime -import os from typing import Annotated -from core.models.details import Audience -from core.models.shoes import Sneaker from fastapi import APIRouter, HTTPException, Query, Request from api.data.models import PaginatedSneakersResponse, SortKey, SortOrder from api.util import url_for_query +from core.models.details import Audience +from core.models.shoes import Sneaker, SneakerView router = APIRouter( prefix="/sneakers", @@ -21,16 +20,79 @@ @router.get("/") async def get_sneakers( request: Request, - brand: str | None = None, - name: str | None = None, - colorway: str | None = None, - audience: Audience | None = None, - releaseDate: str | None = None, - released: bool | None = None, - sort: SortKey = SortKey.RELEASE_DATE, - order: SortOrder = SortOrder.DESCENDING, - page: Annotated[int | None, Query(gte=1)] = None, - pageSize: Annotated[int | None, Query(gte=1, lte=MAX_LIMIT)] = None, + brand: Annotated[ + str | None, + Query( + title="Brand", description="Filter by the brand of the shoes.", min_length=3 + ), + ] = None, + name: Annotated[ + str | None, + Query( + title="Product Name", + description="Filter by the name of the shoes.", + min_length=3, + ), + ] = None, + colorway: Annotated[ + str | None, + Query( + title="Colorway", + description="Filter by the colorway of the shoes.", + min_length=3, + ), + ] = None, + audience: Annotated[ + Audience | None, + Query( + title="Audience", + description="Filter on the gender/audience of the shoes. See Audience for possible values.", + min_length=3, + ), + ] = None, + releaseDate: Annotated[ + str | None, + Query( + title="Release Date", + description="Filter by the release date of the shoes. Can be a specific date or an inequality. Operators are (lt, lte, gt, gte). Example usage: lt:2021-01-01", + ), + ] = None, + released: Annotated[ + bool | None, + Query( + title="Released?", + description="Filter by whether the shoes have been released or not. Overrides any filter on releaseDate if set.", + ), + ] = None, + sort: Annotated[ + SortKey, + Query( + title="Sort By", + description="The field to sort by.", + ), + ] = SortKey.RELEASE_DATE, + order: Annotated[ + SortOrder, + Query( + title="Sort Order", + description="The order to sort in based on the sort key.", + ), + ] = SortOrder.DESCENDING, + page: Annotated[ + int | None, + Query( + gte=1, title="Page Number", description="The page number of the result set." + ), + ] = None, + pageSize: Annotated[ + int | None, + Query( + gte=1, + lte=MAX_LIMIT, + title="Page Size", + description=f"The number of items on each page. Must be in the range [1-{MAX_LIMIT}] (inclusive).", + ), + ] = None, ) -> PaginatedSneakersResponse: query = Sneaker.find() if not page: @@ -44,7 +106,9 @@ async def get_sneakers( if colorway: query = query.find({"colorway": {"$regex": f"^{colorway}", "$options": "i"}}) if audience: - query = query.find({"audience": {"$regex": f"^{audience.value}"}}) + query = query.find( + {"audience": {"$regex": f"^{audience.value}", "$options": "i"}} + ) if released is not None: now = datetime.now(UTC) if released: @@ -82,6 +146,7 @@ async def get_sneakers( await query.sort(f"{'+' if order == SortOrder.ASCENDING else '-'}{sort.value}") .skip((page - 1) * pageSize) .limit(pageSize) + .project(SneakerView) .to_list() ) result = PaginatedSneakersResponse( diff --git a/src/api/util.py b/src/api/util.py index 861df38..48a945c 100644 --- a/src/api/util.py +++ b/src/api/util.py @@ -3,8 +3,8 @@ from fastapi import Request -def url_for_query(request: Request, name: str, **params: str) -> str: - url = str(request.url_for(name)) +def url_for_query(request: Request, fastapi_function_name: str, **params: str) -> str: + url = str(request.url_for(fastapi_function_name)) parsed = urlparse(url) parsed = parsed._replace(query=urlencode(params)) return urlunparse(parsed) diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..78d89f3 Binary files /dev/null and b/static/favicon.png differ