From 59201771cbbd8083d8e4bc26414ea1d8be7938be Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 8 Dec 2025 16:25:43 -0500 Subject: [PATCH 01/26] change dependency --- mflix/server/python-fastapi/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mflix/server/python-fastapi/requirements.txt b/mflix/server/python-fastapi/requirements.txt index 682f33f..7062f53 100644 --- a/mflix/server/python-fastapi/requirements.txt +++ b/mflix/server/python-fastapi/requirements.txt @@ -218,7 +218,7 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic -urllib3==2.5.0 +urllib3==2.6.0 # via # requests # sentry-sdk From a1afa135a961c48065c85e10c44d56eeade04863 Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 8 Dec 2025 16:29:40 -0500 Subject: [PATCH 02/26] fix --- mflix/server/python-fastapi/requirements.in | 1 + mflix/server/python-fastapi/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mflix/server/python-fastapi/requirements.in b/mflix/server/python-fastapi/requirements.in index b6000a1..b1542a4 100644 --- a/mflix/server/python-fastapi/requirements.in +++ b/mflix/server/python-fastapi/requirements.in @@ -32,6 +32,7 @@ dnspython~=2.8.0 # Required for SRV record lookups by pymongo (e.g., Mong httpx~=0.28.0 # Asynchronous HTTP client for requests to external APIs email-validator~=2.3.0 # Utility for validating email addresses voyageai~=0.3.5 # Vector embeddings API client +urllib3>=2.6.0 # HTTP library # ============================================================================== # 5. CLI & DEVELOPMENT TOOLS diff --git a/mflix/server/python-fastapi/requirements.txt b/mflix/server/python-fastapi/requirements.txt index 7062f53..682f33f 100644 --- a/mflix/server/python-fastapi/requirements.txt +++ b/mflix/server/python-fastapi/requirements.txt @@ -218,7 +218,7 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic -urllib3==2.6.0 +urllib3==2.5.0 # via # requests # sentry-sdk From ef7802331bad0d33741f2315f1989466814ff0b5 Mon Sep 17 00:00:00 2001 From: Angela Date: Mon, 8 Dec 2025 16:38:05 -0500 Subject: [PATCH 03/26] change file --- mflix/server/python-fastapi/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mflix/server/python-fastapi/requirements.txt b/mflix/server/python-fastapi/requirements.txt index 682f33f..e2a8635 100644 --- a/mflix/server/python-fastapi/requirements.txt +++ b/mflix/server/python-fastapi/requirements.txt @@ -218,8 +218,9 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic -urllib3==2.5.0 +urllib3==2.6.1 # via + # -r requirements.in # requests # sentry-sdk uvicorn[standard]==0.38.0 From 8e52fbe6a915b79e99a4ba602320d432878520df Mon Sep 17 00:00:00 2001 From: "Taylor M." Date: Fri, 12 Dec 2025 10:49:41 -0500 Subject: [PATCH 04/26] chore(fix): Removed Python Custom Error Handler & Fixed Python Bug in ReportingByComment (#45) * chore(fix): Removed Python custom error handler and replaced with HTTPException. Removed all files and instances related to custom Python error handler Fixed Python bug in ReportingByComment * Adding docstring * chore: corrected tests * (fix): implementing feedback changes & testing cli pipeline * (fix): increase wait time for integration tests * bug fix * bug fix - Devnull * Update run-python-tests.yml * Update run-python-tests.yml Remove tmate for debugging * Update run-python-tests.yml direction connection change * Update run-python-tests.yml Adding index creation * Update run-python-tests.yml * Update run-python-tests.yml * Update run-python-tests.yml * Update run-python-tests.yml --- .github/workflows/run-python-tests.yml | 59 +++- mflix/server/python-fastapi/main.py | 32 +- .../src/database/mongo_client.py | 2 + .../python-fastapi/src/models/models.py | 13 - .../python-fastapi/src/routers/movies.py | 332 ++++++++---------- .../python-fastapi/src/utils/errorHandler.py | 109 ------ .../src/utils/successResponse.py | 24 ++ .../tests/integration/conftest.py | 10 +- .../test_movie_routes_integration.py | 20 +- .../python-fastapi/tests/test_movie_routes.py | 196 ++++++----- 10 files changed, 380 insertions(+), 417 deletions(-) delete mode 100644 mflix/server/python-fastapi/src/utils/errorHandler.py create mode 100644 mflix/server/python-fastapi/src/utils/successResponse.py diff --git a/.github/workflows/run-python-tests.yml b/.github/workflows/run-python-tests.yml index ecbd341..4a24fe6 100644 --- a/.github/workflows/run-python-tests.yml +++ b/.github/workflows/run-python-tests.yml @@ -38,9 +38,58 @@ jobs: - name: Download sample data run: curl https://atlas-education.s3.amazonaws.com/sampledata.archive -o sampledata.archive - - name: Add sample data to database - run: mongorestore --archive=sampledata.archive --port=27017 + - name: Setup Database (Data & Indexes) + run: | + # 1. Restore the data + mongorestore --archive=sampledata.archive --port=27017 + + # 2. Prepare the Search Index Definition + echo '{ + "name": "movieSearchIndex", + "database": "sample_mflix", + "collectionName": "movies", + "mappings": { + "dynamic": false, + "fields": { + "plot": {"type": "string", "analyzer": "lucene.standard"}, + "fullplot": {"type": "string", "analyzer": "lucene.standard"}, + "directors": {"type": "string", "analyzer": "lucene.standard"}, + "writers": {"type": "string", "analyzer": "lucene.standard"}, + "cast": {"type": "string", "analyzer": "lucene.standard"} + } + } + }' > search_index.json + + # 3. Create the Search Index + atlas deployments search indexes create \ + --deploymentName myLocalRs1 \ + --file search_index.json + + # 4. Prepare the Vector Index Definition + echo '{ + "name": "vector_index", + "database": "sample_mflix", + "collectionName": "embedded_movies", + "type": "vectorSearch", + "fields": [ + { + "type": "vector", + "path": "plot_embedding_voyage_3_large", + "numDimensions": 2048, + "similarity": "cosine" + } + ] + }' > vector_index.json + # 5. Create the Vector Index + atlas deployments search indexes create \ + --deploymentName myLocalRs1 \ + --file vector_index.json + + # 6. Wait for indexes to build + echo "Waiting for indexes to build..." + sleep 20 + - name: Set up Python uses: actions/setup-python@v5 with: @@ -63,10 +112,10 @@ jobs: - name: Run integration tests working-directory: mflix/server/python-fastapi - run: pytest -m integration --verbose --tb=short --junit-xml=test-results-integration.xml || true + run: pytest -m integration --verbose --tb=short --junit-xml=test-results-integration.xml env: - MONGO_URI: mongodb://localhost:27017 - MONGO_DB: sample_mflix + MONGO_URI: mongodb://localhost:27017/?directConnection=true + MONGO_DB: sample_mflix - name: Upload test results uses: actions/upload-artifact@v4 diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 9d2290e..6a770cb 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -2,7 +2,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from src.routers import movies -from src.utils.errorHandler import register_error_handlers from src.database.mongo_client import db, get_collection import os @@ -15,8 +14,9 @@ @asynccontextmanager async def lifespan(app: FastAPI): # Startup: Create search indexes - await ensure_search_index() - await vector_search_index() + await ensure_mongodb_search_index() + await ensure_vector_search_index() + await ensure_standard_index() # Print server information print(f"\n{'='*60}") @@ -30,10 +30,9 @@ async def lifespan(app: FastAPI): # Add any cleanup code here -async def ensure_search_index(): +async def ensure_mongodb_search_index(): try: movies_collection = db.get_collection("movies") - comments_collection = db.get_collection("comments") # Check and create search index for movies collection result = await movies_collection.list_search_indexes() @@ -71,7 +70,7 @@ async def ensure_search_index(): ) -async def vector_search_index(): +async def ensure_vector_search_index(): """ Creates vector search index on application startup if it doesn't already exist. This ensures the index is ready before any vector search requests are made. @@ -114,6 +113,26 @@ async def vector_search_index(): f"and verify the 'embedded_movies' collection exists with the required embedding field." ) +async def ensure_standard_index(): + """ + Creates a standard MongoDB index on the comments collection on application startup. + This improves performance for queries filtering by movie_id such as ReportingByComments(). + """ + + try: + comments_collection = db.get_collection("comments") + + existing_indexes_cursor = await comments_collection.list_indexes() + existing_indexes = [index async for index in existing_indexes_cursor] + index_names = [index.get("name") for index in existing_indexes] + standard_index_name = "movie_id_index" + if standard_index_name not in index_names: + await comments_collection.create_index([("movie_id", 1)], name=standard_index_name) + + except Exception as e: + print(f"Failed to create standard index on 'comments' collection: {str(e)}. ") + print(f"Performance may be degraded. Please check your MongoDB configuration.") + app = FastAPI(lifespan=lifespan) @@ -127,6 +146,5 @@ async def vector_search_index(): allow_headers=["*"], ) -register_error_handlers(app) app.include_router(movies.router, prefix="/api/movies", tags=["movies"]) diff --git a/mflix/server/python-fastapi/src/database/mongo_client.py b/mflix/server/python-fastapi/src/database/mongo_client.py index 0ff350e..024fbdd 100644 --- a/mflix/server/python-fastapi/src/database/mongo_client.py +++ b/mflix/server/python-fastapi/src/database/mongo_client.py @@ -18,4 +18,6 @@ def get_collection(name:str): def voyage_ai_available(): """Check if Voyage API Key is available and valid.""" api_key = os.getenv("VOYAGE_API_KEY") + if api_key is None or api_key =="your_voyage_api_key": + return None return api_key is not None and api_key.strip() != "" \ No newline at end of file diff --git a/mflix/server/python-fastapi/src/models/models.py b/mflix/server/python-fastapi/src/models/models.py index f9494c7..01c1489 100644 --- a/mflix/server/python-fastapi/src/models/models.py +++ b/mflix/server/python-fastapi/src/models/models.py @@ -127,22 +127,9 @@ class SuccessResponse(BaseModel, Generic[T]): timestamp: str pagination: Optional[Pagination] = None - -class ErrorDetails(BaseModel): - message: str - code: Optional[str] - details: Optional[Any] = None - class BatchUpdateRequest(BaseModel): filter: MovieFilter update: UpdateMovieRequest class BatchDeleteRequest(BaseModel): filter: MovieFilter - -class ErrorResponse(BaseModel): - success: bool = False - message: str - error: ErrorDetails - timestamp: str - \ No newline at end of file diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 4cc2154..d8fa176 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -1,10 +1,9 @@ -from fastapi import APIRouter, Query, Path, Body +from fastapi import APIRouter, Query, Path, Body, HTTPException from src.database.mongo_client import get_collection, voyage_ai_available from src.models.models import VectorSearchResult, CreateMovieRequest, Movie, SuccessResponse, UpdateMovieRequest, SearchMoviesResponse - -from typing import Any, List -from src.utils.errorHandler import create_success_response, create_error_response -from bson import ObjectId +from typing import Any, List, Optional +from src.utils.successResponse import create_success_response +from bson import ObjectId, errors import re from bson.errors import InvalidId import voyageai @@ -105,15 +104,15 @@ @router.get( "/search", response_model=SuccessResponse[SearchMoviesResponse], - status_code=200, + status_code = 200, summary="Search movies using MongoDB Search." ) async def search_movies( - plot: str = Query(default=None), - fullplot: str = Query(default=None), - directors: str = Query(default=None), - writers: str = Query(default=None), - cast: str = Query(default=None), + plot: Optional[str] = None, + fullplot: Optional[str] = None, + directors: Optional[str] = None, + writers: Optional[str] = None, + cast: Optional[str] = None, limit:int = Query(default=20, ge=1, le=100), skip:int = Query(default=0, ge=0), search_operator: str = Query(default="must", alias="searchOperator") @@ -123,17 +122,17 @@ async def search_movies( # Validate the search_operator parameter to ensure it's a valid compound operator valid_operators = {"must", "should", "mustNot", "filter"} + if search_operator not in valid_operators: - return create_error_response( - message=f"Invalid search_operator '{search_operator}'. The search_operator must be one of {valid_operators}.", - code="INVALID_SEARCH_OPERATOR", - details=None - ) + raise HTTPException( + status_code = 400, + detail=f"Invalid search operator '{search_operator}'. The search operator must be one of {valid_operators}." + ) # Build the search_phrases list based on which fields were provided by the user. # Each phrase becomes a separate clause in the MongoDB Search compound query. - if plot: + if plot is not None: search_phrases.append({ # The phrase operator performs an exact phrase match on the specified field. This is useful for searching for specific phrases within text fields. # The text operator is more flexible and allows for fuzzy matching, making it suitable for fields like names where typos may occur. @@ -142,14 +141,14 @@ async def search_movies( "path": "plot", } }) - if fullplot: + if fullplot is not None: search_phrases.append({ "phrase": { "query": fullplot, "path": "fullplot", } }) - if directors: + if directors is not None: # The "fuzzy" option enables typo-tolerant (fuzzy) search within MongoDB Search. # - maxEdits: The maximum number of single-character edits (insertions, deletions, or substitutions) # allowed when matching the search term to indexed terms. (Range: 1-2; higher = more tolerant) @@ -165,7 +164,7 @@ async def search_movies( } }) - if writers: + if writers is not None: # See comments above regarding fuzzy search options. search_phrases.append({ "text": { @@ -174,7 +173,7 @@ async def search_movies( "fuzzy":{"maxEdits":1, "prefixLength":5} } }) - if cast: + if cast is not None: # See comments above regarding fuzzy search options. search_phrases.append({ "text": { @@ -185,10 +184,9 @@ async def search_movies( }) if not search_phrases: - return create_error_response( - message="At least one search parameter must be provided.", - code="NO_SEARCH_PARAMETERS", - details=None + raise HTTPException( + status_code = 400, + detail="At least one search parameter must be provided." ) # Build the aggregation pipeline for MongoDB Search. @@ -241,11 +239,11 @@ async def search_movies( try: results = await execute_aggregation(aggregation_pipeline) except Exception as e: - return create_error_response( - message="An error occurred while performing the search.", - code="DATABASE_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred while performing the search: {str(e)}" ) + # Extract total count and movies from facet results with proper bounds checking if not results or len(results) == 0: @@ -319,12 +317,10 @@ async def vector_search_movies( SuccessResponse containing a list of movies with similarity scores """ if not voyage_ai_available(): - return create_error_response( - message="Vector search unavailable", - code="SERVICE_UNAVAILABLE", - details="VOYAGE_API_KEY not configured. Please add your API key to your .env file." + raise HTTPException( + status_code = 503, + detail="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to your .env file." ) - try: # Initialize the client here to avoid import-time errors vo = voyageai.Client() @@ -395,10 +391,9 @@ async def vector_search_movies( ) except Exception as e: - return create_error_response( - message="Vector search failed", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred during vector search: {str(e)}" ) """ @@ -412,34 +407,32 @@ async def vector_search_movies( @router.get("/{id}", response_model=SuccessResponse[Movie], - status_code=200, + status_code = 200, summary="Retrieve a single movie by its ID.") async def get_movie_by_id(id: str): # Validate ObjectId format try: object_id = ObjectId(id) - except InvalidId: - return create_error_response( - message="Invalid movie ID format", - code="INTERNAL_SERVER_ERROR", - details=f"The provided ID '{id}' is not a valid ObjectId" + except errors.InvalidId: + raise HTTPException( + status_code = 400, + detail=f"The provided ID '{id}' is not a valid ObjectId" ) movies_collection = get_collection("movies") try: movie = await movies_collection.find_one({"_id": object_id}) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) + if movie is None: - return create_error_response( - message="Movie not found", - code="INTERNAL_SERVER_ERROR", - details=f"No movie found with ID: {id}" + raise HTTPException( + status_code = 404, + detail=f"No movie found with ID: {id}" ) movie["_id"] = str(movie["_id"]) # Convert ObjectId to string @@ -468,7 +461,7 @@ async def get_movie_by_id(id: str): @router.get("/", response_model=SuccessResponse[List[Movie]], - status_code=200, + status_code = 200, summary="Retrieve a list of movies with optional filtering, sorting, and pagination.") # Validate the query parameters using FastAPI's Query functionality. async def get_all_movies( @@ -511,25 +504,25 @@ async def get_all_movies( try: result = movies_collection.find(filter_dict).sort(sort).skip(skip).limit(limit) except Exception as e: - return create_error_response( - message="An error occurred while fetching movies.", - code="DATABASE_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred while fetching movies. {str(e)}" ) movies = [] async for movie in result: - movie["_id"] = str(movie["_id"]) # Convert ObjectId to string - # Ensure that the year field contains int value. - if "year" in movie and not isinstance(movie["year"], int): - cleaned_year = re.sub(r"\D", "", str(movie["year"])) - try: - movie["year"] = int(cleaned_year) if cleaned_year else None - except ValueError: - movie["year"] = None - - movies.append(movie) + if "title" in movie: + movie["_id"] = str(movie["_id"]) # Convert ObjectId to string + # Ensure that the year field contains int value. + if "year" in movie and not isinstance(movie["year"], int): + cleaned_year = re.sub(r"\D", "", str(movie["year"])) + try: + movie["year"] = int(cleaned_year) if cleaned_year else None + except ValueError: + movie["year"] = None + + movies.append(movie) # Return the results wrapped in a SuccessResponse return create_success_response(movies, f"Found {len(movies)} movies.") @@ -545,7 +538,7 @@ async def get_all_movies( @router.post("/", response_model=SuccessResponse[Movie], - status_code=201, + status_code = 201, summary="Creates a new movie in the database.") async def create_movie(movie: CreateMovieRequest): # Pydantic automatically validates the structure @@ -555,35 +548,31 @@ async def create_movie(movie: CreateMovieRequest): try: result = await movies_collection.insert_one(movie_data) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) # Verify that the document was created before querying it if not result.acknowledged: - return create_error_response( - message="Failed to create movie", - code="INTERNAL_SERVER_ERROR", - details="The database did not acknowledge the insert operation" + raise HTTPException( + status_code = 500, + detail="Failed to create movie: The database did not acknowledge the insert operation" ) try: # Retrieve the created document to return complete data created_movie = await movies_collection.find_one({"_id": result.inserted_id}) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) if created_movie is None: - return create_error_response( - message="Movie creation verification failed", - code="INTERNAL_SERVER_ERROR", - details="Movie was created but could not be retrieved for verification" + raise HTTPException( + status_code = 500, + detail="Movie was created but could not be retrieved for verification" ) created_movie["_id"] = str(created_movie["_id"]) # Convert ObjectId to string @@ -627,10 +616,9 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons #Verify that the movies list is not empty if not movies: - return create_error_response( - message="Request body must be a non-empty list of movies.", - code="INVALID_INPUT", - details=None + raise HTTPException( + status_code = 400, + detail="Request body must be a non-empty list of movies." ) movies_dicts = [] @@ -650,10 +638,9 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons f"Successfully created {len(result.inserted_ids)} movies." ) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) """ @@ -673,7 +660,7 @@ async def create_movies_batch(movies: List[CreateMovieRequest]) ->SuccessRespons @router.patch( "/{id}", response_model=SuccessResponse[Movie], - status_code=200, + status_code = 200, summary="Update a single movie by its ID.") async def update_movie( movie_data: UpdateMovieRequest, @@ -686,20 +673,18 @@ async def update_movie( try: movie_id = ObjectId(movie_id) except Exception : - return create_error_response( - message="Invalid movie_id format.", - code="INVALID_OBJECT_ID", - details=str(movie_id) + raise HTTPException( + status_code = 400, + detail=f"Invalid movie_id format: {movie_id}" ) update_dict = movie_data.model_dump(exclude_unset=True, exclude_none=True) # Validate that the dict is not empty if not update_dict: - return create_error_response( - message="No valid fields provided for update.", - code="NO_UPDATE_DATA", - details=None + raise HTTPException( + status_code = 400, + detail="No valid fields provided for update." ) try: @@ -708,17 +693,15 @@ async def update_movie( {"$set":update_dict} ) except Exception as e: - return create_error_response( - message="An error occurred while updating the movie.", - code="DATABASE_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred while updating the movie: {str(e)}" ) if result.matched_count == 0: - return create_error_response( - message="No movie with that _id was found.", - code="MOVIE_NOT_FOUND", - details=str(movie_id) + raise HTTPException( + status_code = 404, + detail=f"No movie with that _id was found: {movie_id}" ) updatedMovie = await movies_collection.find_one({"_id": movie_id}) @@ -740,7 +723,7 @@ async def update_movie( @router.patch("/", response_model=SuccessResponse[dict], - status_code=200, + status_code = 200, summary="Batch update movies matching the given filter." ) async def update_movies_batch( @@ -753,10 +736,9 @@ async def update_movies_batch( update_data = request_body.get("update", {}) if not filter_data or not update_data: - return create_error_response( - message="Both filter and update objects are required", - code="MISSING_REQUIRED_FIELDS", - details=None + raise HTTPException( + status_code = 400, + detail="Both filter and update objects are required" ) # Convert string IDs to ObjectIds if _id filter is present @@ -766,19 +748,17 @@ async def update_movies_batch( try: filter_data["_id"]["$in"] = [ObjectId(id_str) for id_str in filter_data["_id"]["$in"]] except Exception: - return create_error_response( - message="Invalid ObjectId format in filter", - code="INVALID_OBJECT_ID", - details=None + raise HTTPException( + status_code = 400, + detail="Invalid ObjectId format in filter", ) try: result = await movies_collection.update_many(filter_data, {"$set": update_data}) except Exception as e: - return create_error_response( - message="An error occurred while updating movies.", - code="DATABASE_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred while updating movies: {str(e)}" ) return create_success_response({ @@ -799,16 +779,15 @@ async def update_movies_batch( @router.delete("/{id}", response_model=SuccessResponse[dict], - status_code=200, + status_code = 200, summary="Delete a single movie by its ID.") async def delete_movie_by_id(id: str): try: object_id = ObjectId(id) - except InvalidId: - return create_error_response( - message="Invalid movie ID format", - code="INTERNAL_SERVER_ERROR", - details=f"The provided ID '{id}' is not a valid ObjectId" + except errors.InvalidId: + raise HTTPException( + status_code = 400, + detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId" ) movies_collection = get_collection("movies") @@ -816,17 +795,15 @@ async def delete_movie_by_id(id: str): # Use deleteOne() to remove a single document result = await movies_collection.delete_one({"_id": object_id}) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) if result.deleted_count == 0: - return create_error_response( - message="Movie not found", - code="INTERNAL_SERVER_ERROR", - details=f"No movie found with ID: {id}" + raise HTTPException( + status_code = 404, + detail=f"No movie found with ID: {id}" ) return create_success_response( @@ -849,7 +826,7 @@ async def delete_movie_by_id(id: str): @router.delete( "/", response_model=SuccessResponse[dict], - status_code=200, + status_code = 200, summary="Delete multiple movies matching the given filter." ) async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse[dict]: @@ -860,10 +837,9 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse filter_data = request_body.get("filter", {}) if not filter_data: - return create_error_response( - message="Filter object is required and cannot be empty.", - code="MISSING_FILTER", - details=None + raise HTTPException( + status_code = 400, + detail="Filter object is required and cannot be empty." ) # Convert string IDs to ObjectIds if _id filter is present @@ -873,19 +849,17 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse try: filter_data["_id"]["$in"] = [ObjectId(id_str) for id_str in filter_data["_id"]["$in"]] except Exception: - return create_error_response( - message="Invalid ObjectId format in filter", - code="INVALID_OBJECT_ID", - details=None + raise HTTPException( + status_code = 400, + detail="Invalid ObjectId format in filter." ) try: result = await movies_collection.delete_many(filter_data) except Exception as e: - return create_error_response( - message="An error occurred while deleting movies.", - code="DATABASE_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"An error occurred while deleting movies: {str(e)}" ) return create_success_response( @@ -905,16 +879,15 @@ async def delete_movies_batch(request_body: dict = Body(...)) -> SuccessResponse @router.delete("/{id}/find-and-delete", response_model=SuccessResponse[Movie], - status_code=200, + status_code = 200, summary="Find and delete a movie in a single operation.") async def find_and_delete_movie(id: str): try: object_id = ObjectId(id) - except InvalidId: - return create_error_response( - message="Invalid movie ID format", - code="INTERNAL_SERVER_ERROR", - details=f"The provided ID '{id}' is not a valid ObjectId" + except errors.InvalidId: + raise HTTPException( + status_code = 400, + detail=f"Invalid movie ID format: The provided ID '{id}' is not a valid ObjectId" ) movies_collection = get_collection("movies") @@ -924,17 +897,15 @@ async def find_and_delete_movie(id: str): try: deleted_movie = await movies_collection.find_one_and_delete({"_id": object_id}) except Exception as e: - return create_error_response( - message="Database error occurred", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred: {str(e)}" ) if deleted_movie is None: - return create_error_response( - message="Movie not found", - code="INTERNAL_SERVER_ERROR", - details=f"No movie found with ID: {id}" + raise HTTPException( + status_code = 404, + detail=f"No movie found with ID: {id}" ) deleted_movie["_id"] = str(deleted_movie["_id"]) # Convert ObjectId to string @@ -953,7 +924,7 @@ async def find_and_delete_movie(id: str): @router.get("/aggregations/reportingByComments", response_model=SuccessResponse[List[dict]], - status_code=200, + status_code = 200, summary="Aggregate movies with their most recent comments.") async def aggregate_movies_recent_commented( limit: int = Query(default=10, ge=1, le=50), @@ -984,10 +955,9 @@ async def aggregate_movies_recent_commented( object_id = ObjectId(movie_id) pipeline[0]["$match"]["_id"] = object_id except Exception: - return create_error_response( - message="Invalid movie ID format", - code="INTERNAL_SERVER_ERROR", - details="The provided movie_id is not a valid ObjectId" + raise HTTPException( + status_code = 400, + detail="The provided movie_id is not a valid ObjectId" ) # Add remaining pipeline stages @@ -1074,10 +1044,9 @@ async def aggregate_movies_recent_commented( try: results = await execute_aggregation(pipeline) except Exception as e: - return create_error_response( - message="Database error occurred during aggregation", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred during aggregation: {str(e)}" ) # Convert ObjectId to string for response @@ -1103,7 +1072,7 @@ async def aggregate_movies_recent_commented( @router.get("/aggregations/reportingByYear", response_model=SuccessResponse[List[dict]], - status_code=200, + status_code = 200, summary="Aggregate movies by year with average rating and movie count.") async def aggregate_movies_by_year(): # Define aggregation pipeline to group movies by year with statistics @@ -1205,10 +1174,9 @@ async def aggregate_movies_by_year(): try: results = await execute_aggregation(pipeline) except Exception as e: - return create_error_response( - message="Database error occurred during aggregation", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred during aggregation: {str(e)}" ) return create_success_response( @@ -1228,7 +1196,7 @@ async def aggregate_movies_by_year(): @router.get("/aggregations/reportingByDirectors", response_model=SuccessResponse[List[dict]], - status_code=200, + status_code = 200, summary="Aggregate directors with the most movies and their statistics.") async def aggregate_directors_most_movies( limit: int = Query(default=20, ge=1, le=100) @@ -1304,10 +1272,9 @@ async def aggregate_directors_most_movies( try: results = await execute_aggregation(pipeline) except Exception as e: - return create_error_response( - message="Database error occurred during aggregation", - code="INTERNAL_SERVER_ERROR", - details=str(e) + raise HTTPException( + status_code = 500, + detail=f"Database error occurred during aggregation: {str(e)}" ) return create_success_response( @@ -1389,3 +1356,4 @@ def get_embedding(data, input_type = "document", client=None): data, model = model, output_dimension = outputDimension, input_type = input_type ).embeddings return embeddings[0] + diff --git a/mflix/server/python-fastapi/src/utils/errorHandler.py b/mflix/server/python-fastapi/src/utils/errorHandler.py deleted file mode 100644 index 5e235ec..0000000 --- a/mflix/server/python-fastapi/src/utils/errorHandler.py +++ /dev/null @@ -1,109 +0,0 @@ -from fastapi import Request -from fastapi.responses import JSONResponse -from pymongo.errors import PyMongoError, DuplicateKeyError, WriteError -from datetime import datetime, timezone -from typing import Any, Optional -from src.models.models import ErrorDetails, ErrorResponse, SuccessResponse, T - -''' -Creates a standardized success response. - - -Args: - data (T): The data to include in the response. - message (Optional[str]): An optional message to include. - -Returns: - SuccessResponse[T]: A standardized success response object. - ''' - -def create_success_response(data:T, message: Optional[str] = None) -> SuccessResponse[T]: - return SuccessResponse( - message=message or "Operation completed successfully.", - data=data, - timestamp=datetime.now(timezone.utc).isoformat() + "Z", - - ) - -''' -Creates a standardized error response. - -Args: - message (str): The error message. - code (Optional[str]): An optional error code. - details (Optional[Any]): Additional error details. - -Returns: - ErrorResponse: A standardized error response object. - -''' - -def create_error_response(message: str, code: Optional[str]=None, details: Optional[Any]=None) -> ErrorResponse: - return ErrorResponse( - message=message, - error=ErrorDetails( - message=message, - code=code, - details=details - ), - timestamp=datetime.now(timezone.utc).isoformat() + "Z", - ) - - -def parse_mongo_exception(exc: Exception) -> dict: - if isinstance(exc, DuplicateKeyError): - return{ - "message": "Duplicate key error occurred.", - "code": "DUPLICATE_KEY_ERROR", - "details": "A document with the same key already exists.", - "statusCode":409 - } - - # This is stating that the data that you are trying to implement is the wrong shape - # for the schema implemented in MongoDB. - elif isinstance(exc, WriteError): - return{ - "message": "Document validation failed.", - "code": "WRITE_ERROR", - "details": str(exc), - "statusCode":400 - } - - elif isinstance(exc, PyMongoError): - return { - "message" : "A database error occurred.", - "code": "DATABASE_ERROR", - "details": str(exc), - "statusCode":500 - } - return { - "message": "An unknown error occurred.", - "code": "UNKNOWN_ERROR", - "details": str(exc), - "statusCode": 500 - } - -def register_error_handlers(app): - - @app.exception_handler(PyMongoError) - async def mongo_exception_handler(request: Request, exc: PyMongoError): - error_details = parse_mongo_exception(exc) - return JSONResponse( - status_code = error_details["statusCode"], - content=create_error_response( - message=error_details["message"], - code=error_details["code"], - details=error_details["details"] - ).model_dump() - ) - - @app.exception_handler(Exception) - async def generic_exception_handler(request: Request, exc: Exception): - return JSONResponse( - status_code=500, - content=create_error_response( - message=str(exc), - code="INTERNAL_SERVER_ERROR", - details=getattr(exc, 'detail', None) or getattr(exc, 'args', None) - ).model_dump() - ) diff --git a/mflix/server/python-fastapi/src/utils/successResponse.py b/mflix/server/python-fastapi/src/utils/successResponse.py new file mode 100644 index 0000000..1353dff --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/successResponse.py @@ -0,0 +1,24 @@ +from datetime import datetime, timezone +from typing import Optional +from src.models.models import SuccessResponse, T + +''' +Creates a standardized success response. + + +Args: + data (T): The data to include in the response. + message (Optional[str]): An optional message to include. + +Returns: + SuccessResponse[T]: A standardized success response object. + ''' + +def create_success_response(data:T, message: Optional[str] = None) -> SuccessResponse[T]: + return SuccessResponse( + message=message or "Operation completed successfully.", + data=data, + timestamp=datetime.now(timezone.utc).isoformat() + "Z", + + ) + diff --git a/mflix/server/python-fastapi/tests/integration/conftest.py b/mflix/server/python-fastapi/tests/integration/conftest.py index 6fb4e2d..80ed68d 100644 --- a/mflix/server/python-fastapi/tests/integration/conftest.py +++ b/mflix/server/python-fastapi/tests/integration/conftest.py @@ -56,13 +56,13 @@ def server(): # Start the server process process = subprocess.Popen( [sys.executable, "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", str(test_port)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, cwd=server_python_dir ) - # Wait for server to be ready (max 10 seconds) - max_wait = 10 + # Wait for server to be ready (max 30 seconds) + max_wait = 30 start_time = time.time() while time.time() - start_time < max_wait: if is_port_in_use(test_port): @@ -209,5 +209,5 @@ async def test_batch_operation(multiple_test_movies): if response_data.get("success") is False and "not found" in response_data.get("error", {}).get("message", "").lower(): # Movie was already deleted, which is fine continue - assert cleanup_response.status_code == 200, f"Failed to clean up movie {movie_id}" + assert cleanup_response.status_code in [200,404], f"Failed to clean up movie {movie_id}" diff --git a/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py b/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py index f018bbd..c9c9730 100644 --- a/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py +++ b/mflix/server/python-fastapi/tests/integration/test_movie_routes_integration.py @@ -64,7 +64,7 @@ async def test_create_and_retrieve_movie(self, client, test_movie_data): finally: # Cleanup: Delete the test movie delete_response = await client.delete(f"/api/movies/{movie_id}") - assert delete_response.status_code == 200 + assert delete_response.status_code in [200, 404] @pytest.mark.asyncio async def test_update_movie(self, client, created_movie): @@ -123,12 +123,11 @@ async def test_delete_movie(self, client, test_movie_data): assert delete_data["success"] is True # Verify movie no longer exists - # Note: The API returns 200 with INTERNAL_SERVER_ERROR code, not 404 get_response = await client.get(f"/api/movies/{movie_id}") + assert get_response.status_code == 404 error_data = get_response.json() - assert error_data["success"] is False - assert error_data["error"]["code"] == "INTERNAL_SERVER_ERROR" - assert "not found" in error_data["error"]["message"].lower() + assert "detail" in error_data + assert "no movie found" in error_data["detail"].lower() # No cleanup needed - movie already deleted @@ -240,7 +239,8 @@ async def test_batch_create_movies(self, client): finally: # Cleanup: Delete all created movies for movie_id in created_ids: - await client.delete(f"/api/movies/{movie_id}") + delete_response = await client.delete(f"/api/movies/{movie_id}") + assert delete_response.status_code in [200, 404] @pytest.mark.asyncio async def test_batch_delete_movies(self, client, multiple_test_movies): @@ -279,10 +279,10 @@ async def test_batch_delete_movies(self, client, multiple_test_movies): # Note: The API returns 200 with INTERNAL_SERVER_ERROR code, not 404 for movie_id in multiple_test_movies: get_response = await client.get(f"/api/movies/{movie_id}") - response_data = get_response.json() - assert response_data["success"] is False - assert response_data["error"]["code"] == "INTERNAL_SERVER_ERROR" - assert "not found" in response_data["error"]["message"].lower() + assert get_response.status_code == 404 + error_data = get_response.json() + assert "detail" in error_data + assert "no movie found" in error_data["detail"].lower() # Note: Fixture cleanup will try to delete but movies are already gone # The fixture should handle this gracefully diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index b98937f..ce4b3b6 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -9,6 +9,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch from bson import ObjectId +from fastapi import HTTPException from src.models.models import CreateMovieRequest, UpdateMovieRequest @@ -57,21 +58,23 @@ async def test_get_movie_by_id_not_found(self, mock_get_collection): # Import and call the route handler from src.routers.movies import get_movie_by_id - result = await get_movie_by_id(TEST_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await get_movie_by_id(TEST_MOVIE_ID) # Assertions - assert result.success is False - assert "not found" in result.message.lower() + assert e.value.status_code == 404 + assert "no movie found" in str(e.value.detail).lower() async def test_get_movie_by_id_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Import and call the route handler from src.routers.movies import get_movie_by_id - result = await get_movie_by_id(INVALID_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await get_movie_by_id(INVALID_MOVIE_ID) # Assertions - assert result.success is False - assert "invalid" in result.message.lower() + assert e.value.status_code == 400 + assert " not a valid" in str(e.value.detail).lower() @patch('src.routers.movies.get_collection') async def test_get_movie_by_id_database_error(self, mock_get_collection): @@ -83,11 +86,12 @@ async def test_get_movie_by_id_database_error(self, mock_get_collection): # Import and call the route handler from src.routers.movies import get_movie_by_id - result = await get_movie_by_id(TEST_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await get_movie_by_id(TEST_MOVIE_ID) # Assertions - assert result.success is False - assert "error" in result.message.lower() + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @pytest.mark.unit @@ -140,11 +144,12 @@ async def test_create_movie_database_error(self, mock_get_collection): # Create request from src.routers.movies import create_movie movie_request = CreateMovieRequest(title="New Movie") - result = await create_movie(movie_request) + with pytest.raises(HTTPException) as e: + await create_movie(movie_request) # Assertions - assert result.success is False - assert "error" in result.message.lower() + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @pytest.mark.unit @@ -194,22 +199,26 @@ async def test_update_movie_not_found(self, mock_get_collection): # Create request from src.routers.movies import update_movie update_request = UpdateMovieRequest(title="Updated Movie") - result = await update_movie(update_request, TEST_MOVIE_ID) - - # Assertions - assert result.success is False - assert "was found" in result.message.lower() or "not found" in result.message.lower() + + with pytest.raises(HTTPException) as e: + await update_movie(update_request, TEST_MOVIE_ID) + + #Assertions + assert e.value.status_code == 404 + assert "no movie" in str(e.value.detail.lower()) async def test_update_movie_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Create request from src.routers.movies import update_movie update_request = UpdateMovieRequest(title="Updated Movie") - result = await update_movie(update_request, INVALID_MOVIE_ID) + + with pytest.raises(HTTPException) as e: + await update_movie(update_request, INVALID_MOVIE_ID) - # Assertions - assert result.success is False - assert "invalid" in result.message.lower() + # Assertions + assert e.value.status_code == 400 + assert "invalid" in str(e.value.detail.lower()) @pytest.mark.unit @@ -248,21 +257,23 @@ async def test_delete_movie_not_found(self, mock_get_collection): # Call the route handler from src.routers.movies import delete_movie_by_id - result = await delete_movie_by_id(TEST_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await delete_movie_by_id(TEST_MOVIE_ID) - # Assertions - assert result.success is False - assert "not found" in result.message.lower() + # Assertions + assert e.value.status_code == 404 + assert "no movie" in str(e.value.detail.lower()) async def test_delete_movie_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Call the route handler from src.routers.movies import delete_movie_by_id - result = await delete_movie_by_id(INVALID_MOVIE_ID) - + with pytest.raises(HTTPException) as e: + await delete_movie_by_id(INVALID_MOVIE_ID) + # Assertions - assert result.success is False - assert "invalid" in result.message.lower() + assert e.value.status_code == 400 + assert "invalid movie id" in str(e.value.detail.lower()) @patch('src.routers.movies.get_collection') async def test_delete_movie_database_error(self, mock_get_collection): @@ -274,14 +285,12 @@ async def test_delete_movie_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import delete_movie_by_id - result = await delete_movie_by_id(TEST_MOVIE_ID) - - # Assertions - assert result.success is False - assert "error" in result.message.lower() - - + with pytest.raises(HTTPException) as e: + await delete_movie_by_id(TEST_MOVIE_ID) + # Assertions + assert e.value.status_code == 500 + assert "error" in str(e.value.detail.lower()) @pytest.mark.unit @pytest.mark.asyncio @@ -384,11 +393,12 @@ async def test_get_all_movies_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import get_all_movies - result = await get_all_movies() + with pytest.raises(HTTPException) as e: + await get_all_movies() # Assertions - assert result.success is False - assert "error" in result.message.lower() + assert e.value.status_code == 500 + assert "error" in str(e.value.detail.lower()) @pytest.mark.unit @@ -430,11 +440,12 @@ async def test_create_movies_batch_empty_list(self, mock_get_collection): # Create request with empty list from src.routers.movies import create_movies_batch - result = await create_movies_batch([]) + with pytest.raises(HTTPException) as e: + await create_movies_batch([]) # Assertions - assert result.success is False - assert "empty" in result.message.lower() + assert e.value.status_code == 400 + assert "empty" in str(e.value.detail.lower()) @patch('src.routers.movies.get_collection') async def test_delete_movies_batch_success(self, mock_get_collection): @@ -464,11 +475,12 @@ async def test_delete_movies_batch_missing_filter(self, mock_get_collection): # Create request without filter from src.routers.movies import delete_movies_batch request_body = {} - result = await delete_movies_batch(request_body) + with pytest.raises(HTTPException) as e: + await delete_movies_batch(request_body) # Assertions - assert result.success is False - assert "filter" in result.message.lower() + assert e.value.status_code == 400 + assert "filter" in e.value.detail.lower() @@ -510,21 +522,23 @@ async def test_find_and_delete_not_found(self, mock_get_collection): # Call the route handler from src.routers.movies import find_and_delete_movie - result = await find_and_delete_movie(TEST_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await find_and_delete_movie(TEST_MOVIE_ID) # Assertions - assert result.success is False - assert "not found" in result.message.lower() + assert e.value.status_code == 404 + assert "no movie" in str(e.value.detail.lower()) async def test_find_and_delete_invalid_id(self): """Should return error when invalid ObjectId format is provided.""" # Call the route handler from src.routers.movies import find_and_delete_movie - result = await find_and_delete_movie(INVALID_MOVIE_ID) + with pytest.raises(HTTPException) as e: + await find_and_delete_movie(INVALID_MOVIE_ID) # Assertions - assert result.success is False - assert "invalid" in result.message.lower() + assert e.value.status_code == 400 + assert "invalid" in str(e.value.detail.lower()) @pytest.mark.unit @@ -565,11 +579,12 @@ async def test_update_movies_batch_missing_filter(self, mock_get_collection): # Create request without filter from src.routers.movies import update_movies_batch request_body = {"update": {"$set": {"rated": "PG-13"}}} - result = await update_movies_batch(request_body) + with pytest.raises(HTTPException) as e: + await update_movies_batch(request_body) # Assertions - assert result.success is False - assert "filter" in result.message.lower() or "required" in result.message.lower() + assert e.value.status_code == 400 + assert "filter" in str(e.value.detail).lower() @patch('src.routers.movies.get_collection') async def test_update_movies_batch_missing_update(self, mock_get_collection): @@ -579,11 +594,12 @@ async def test_update_movies_batch_missing_update(self, mock_get_collection): # Create request without update from src.routers.movies import update_movies_batch request_body = {"filter": {"year": 2020}} - result = await update_movies_batch(request_body) + with pytest.raises(HTTPException) as e: + await update_movies_batch(request_body) # Assertions - assert result.success is False - assert "update" in result.message.lower() or "required" in result.message.lower() + assert e.value.status_code == 400 + assert "update" in str(e.value.detail).lower() @patch('src.routers.movies.get_collection') async def test_update_movies_batch_no_matches(self, mock_get_collection): @@ -683,20 +699,22 @@ async def test_search_movies_with_pagination(self, mock_execute_aggregation): async def test_search_movies_no_parameters(self): """Should return error when no search parameters provided.""" from src.routers.movies import search_movies - result = await search_movies(search_operator="must") + with pytest.raises(HTTPException) as e: + await search_movies(search_operator="must") # Assertions - assert result.success is False - assert result.error.code == "DATABASE_ERROR" + assert e.value.status_code == 400 + assert "one search parameter" in str(e.value.detail).lower() async def test_search_movies_invalid_operator(self): """Should return error for invalid search operator.""" from src.routers.movies import search_movies - result = await search_movies(plot="test", search_operator="invalid") + with pytest.raises(HTTPException) as e: + await search_movies(plot="test", search_operator="invalid") # Assertions - assert result.success is False - assert result.error.code == "INVALID_SEARCH_OPERATOR" + assert e.value.status_code == 400 + assert "invalid search operator" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_search_movies_database_error(self, mock_execute_aggregation): @@ -706,11 +724,12 @@ async def test_search_movies_database_error(self, mock_execute_aggregation): # Call the route handler from src.routers.movies import search_movies - result = await search_movies(plot="test", search_operator="must") + with pytest.raises(HTTPException) as e: + await search_movies(plot="test", search_operator="must") # Assertions - assert result.success is False - assert result.error.code == "DATABASE_ERROR" + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_search_movies_empty_results(self, mock_execute_aggregation): @@ -744,12 +763,13 @@ async def test_vector_search_unavailable(self, mock_voyage_available): # Call the route handler from src.routers.movies import vector_search_movies - result = await vector_search_movies(q="action movie") + with pytest.raises(HTTPException) as e: + await vector_search_movies(q="action movie") # Assertions - assert result.success is False - assert result.error.code == "SERVICE_UNAVAILABLE" - assert "VOYAGE_API_KEY" in result.error.details + assert e.value.status_code == 503 + assert str("VOYAGE_API_KEY not configured").lower() in str(e.value.detail).lower() + @patch('src.routers.movies.voyage_ai_available') @patch('src.routers.movies.voyageai.Client') @@ -798,11 +818,12 @@ async def test_vector_search_embedding_error(self, mock_get_embedding, mock_voya # Call the route handler from src.routers.movies import vector_search_movies - result = await vector_search_movies(q="action movie") + with pytest.raises(HTTPException) as e: + await vector_search_movies(q="action movie") # Assertions - assert result.success is False - assert result.error.code == "INTERNAL_SERVER_ERROR" + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @patch('src.routers.movies.voyage_ai_available') @patch('src.routers.movies.voyageai.Client') @@ -896,12 +917,12 @@ async def test_aggregate_movies_by_movie_id(self, mock_execute_aggregation): async def test_aggregate_movies_invalid_movie_id(self): """Should return error for invalid movie ID format.""" from src.routers.movies import aggregate_movies_recent_commented - result = await aggregate_movies_recent_commented(movie_id="invalid_id") + with pytest.raises(HTTPException) as e: + await aggregate_movies_recent_commented(movie_id="invalid_id") # Assertions - assert result.success is False - assert result.error.code == "INTERNAL_SERVER_ERROR" - assert "ObjectId" in result.error.details + assert e.value.status_code == 400 + assert "movie_id is not" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_database_error(self, mock_execute_aggregation): @@ -911,11 +932,12 @@ async def test_aggregate_movies_database_error(self, mock_execute_aggregation): # Call the route handler from src.routers.movies import aggregate_movies_recent_commented - result = await aggregate_movies_recent_commented(limit=10, movie_id=None) + with pytest.raises(HTTPException) as e: + await aggregate_movies_recent_commented(limit=10, movie_id=None) # Assertions - assert result.success is False - assert result.error.code == "INTERNAL_SERVER_ERROR" + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_empty_results(self, mock_execute_aggregation): @@ -966,11 +988,12 @@ async def test_aggregate_movies_by_year_database_error(self, mock_execute_aggreg # Call the route handler from src.routers.movies import aggregate_movies_by_year - result = await aggregate_movies_by_year() + with pytest.raises(HTTPException) as e: + await aggregate_movies_by_year() # Assertions - assert result.success is False - assert result.error.code == "INTERNAL_SERVER_ERROR" + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_aggregate_movies_by_year_empty_results(self, mock_execute_aggregation): @@ -1039,11 +1062,12 @@ async def test_aggregate_directors_database_error(self, mock_execute_aggregation # Call the route handler from src.routers.movies import aggregate_directors_most_movies - result = await aggregate_directors_most_movies() + with pytest.raises(HTTPException) as e: + await aggregate_directors_most_movies() # Assertions - assert result.success is False - assert result.error.code == "INTERNAL_SERVER_ERROR" + assert e.value.status_code == 500 + assert "error" in str(e.value.detail).lower() @patch('src.routers.movies.execute_aggregation') async def test_aggregate_directors_empty_results(self, mock_execute_aggregation): From 89c8d6cc94e51ec4d277d7aa8bea85dc34afce9c Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 12 Dec 2025 10:58:20 -0500 Subject: [PATCH 05/26] Bump next to v16.0.10 --- mflix/client/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mflix/client/package.json b/mflix/client/package.json index 185d99e..8dc08fa 100644 --- a/mflix/client/package.json +++ b/mflix/client/package.json @@ -14,7 +14,7 @@ "dependencies": { "react": "19.2.0", "react-dom": "19.2.0", - "next": "16.0.7" + "next": "16.0.10" }, "devDependencies": { "typescript": "^5", @@ -22,7 +22,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.0.7", + "eslint-config-next": "16.0.10", "@eslint/eslintrc": "^3" } } From 65e487f09380b7f144c845de0605a321eeb98563 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Mon, 8 Dec 2025 18:07:54 -0500 Subject: [PATCH 06/26] Add error handling for missing/invalid Voyage API key - Add custom error classes (VoyageAuthError, VoyageAPIError) to distinguish error types - Update generateVoyageEmbedding to throw VoyageAuthError for 401 responses - Return 401 status for authentication errors, 503 for API errors - Improve client-side error handling with user-friendly messages - Update tests to verify proper error handling for different scenarios --- mflix/client/app/aggregations/page.tsx | 4 +- mflix/client/app/lib/api.ts | 32 +++++-- mflix/client/app/movie/[id]/error.tsx | 2 +- mflix/client/app/movie/[id]/loading.tsx | 2 +- .../src/controllers/movieController.ts | 84 +++++++++++++++++-- .../tests/controllers/movieController.test.ts | 29 +++++-- 6 files changed, 131 insertions(+), 22 deletions(-) diff --git a/mflix/client/app/aggregations/page.tsx b/mflix/client/app/aggregations/page.tsx index a211141..00b8d16 100644 --- a/mflix/client/app/aggregations/page.tsx +++ b/mflix/client/app/aggregations/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '../lib/api'; -import { MovieWithComments, YearlyStats, DirectorStats } from '../types/aggregations'; +import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '@/lib/api'; +import { MovieWithComments, YearlyStats, DirectorStats } from '@/types/aggregations'; import styles from './aggregations.module.css'; export default async function AggregationsPage() { diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index 9c77b47..2d20442 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -680,16 +680,36 @@ export async function vectorSearchMovies(searchParams: { const result = await response.json(); if (!response.ok) { - return { - success: false, - error: result.error || `Failed to perform vector search: ${response.status}` + // Extract error message from the standardized error response + const errorMessage = result.message || result.error?.message || `Failed to perform vector search: ${response.status}`; + const errorCode = result.error?.code; + + // Provide user-friendly messages for specific error codes + if (errorCode === 'VOYAGE_AUTH_ERROR') { + return { + success: false, + error: 'Vector search unavailable: Your Voyage AI API key is missing or invalid. Please add a valid VOYAGE_API_KEY to your .env file and restart the server.' + }; + } + + if (errorCode === 'SERVICE_UNAVAILABLE' || errorCode === 'VOYAGE_API_ERROR') { + return { + success: false, + error: errorMessage || 'Vector search service is currently unavailable. Please try again later.' + }; + } + + return { + success: false, + error: errorMessage }; } if (!result.success) { - return { - success: false, - error: result.error || 'API returned error response' + const errorMessage = result.message || result.error?.message || 'API returned error response'; + return { + success: false, + error: errorMessage }; } diff --git a/mflix/client/app/movie/[id]/error.tsx b/mflix/client/app/movie/[id]/error.tsx index 2e76a32..300904b 100644 --- a/mflix/client/app/movie/[id]/error.tsx +++ b/mflix/client/app/movie/[id]/error.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; -import { ROUTES } from '../../lib/constants'; +import { ROUTES } from '@/lib/constants'; import styles from './error.module.css'; export default function MovieDetailsError({ diff --git a/mflix/client/app/movie/[id]/loading.tsx b/mflix/client/app/movie/[id]/loading.tsx index dace3cf..89e004e 100644 --- a/mflix/client/app/movie/[id]/loading.tsx +++ b/mflix/client/app/movie/[id]/loading.tsx @@ -1,5 +1,5 @@ import pageStyles from './page.module.css'; -import { MovieDetailsSkeleton } from '../../components/LoadingSkeleton'; +import { MovieDetailsSkeleton } from '@/components'; export default function MovieDetailsLoading() { return ( diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index d913a09..86febc3 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -835,6 +835,36 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise representing the embedding vector + * @throws VoyageAuthError if the API key is invalid (401) + * @throws VoyageAPIError for other API errors */ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { // Build the request body with output_dimension set to 2048 @@ -1192,21 +1247,36 @@ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { ); }); - it("should handle Voyage AI API errors", async () => { + it("should handle Voyage AI authentication errors with 401 status", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, @@ -891,11 +891,30 @@ describe("Movie Controller Tests", () => { await vectorSearchMovies(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(500); + expect(mockStatus).toHaveBeenCalledWith(401); expect(mockCreateErrorResponse).toHaveBeenCalledWith( - "Error performing vector search", - "VECTOR_SEARCH_ERROR", - expect.stringContaining("Voyage AI API returned status 401") + "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "VOYAGE_AUTH_ERROR", + "Please verify your VOYAGE_API_KEY is correct in the .env file" + ); + }); + + it("should handle other Voyage AI API errors with 503 status", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + } as any); + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(503); + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + "Vector search service unavailable", + "VOYAGE_API_ERROR", + expect.stringContaining("Voyage AI API returned status 500") ); }); From 7b68bdc7e2d34530743ede9c5be559fd922e2901 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 12 Dec 2025 17:59:07 -0500 Subject: [PATCH 07/26] Add Voyage AI error handling improvements to Python and Java backends - Python FastAPI backend: - Created custom exception classes (VoyageAuthError, VoyageAPIError) - Created error response utility function - Added global exception handlers in main.py - Updated vector search endpoint to use custom exceptions - Returns 400 for missing API key (SERVICE_UNAVAILABLE) - Returns 401 for invalid API key (VOYAGE_AUTH_ERROR) - Returns 503 for Voyage AI API errors (VOYAGE_API_ERROR) - Updated test to match new error handling behavior - Java Spring backend: - Created custom exception classes (VoyageAuthException, VoyageAPIException, ServiceUnavailableException) - Updated GlobalExceptionHandler with handlers for new exceptions - Updated MovieServiceImpl to throw custom exceptions - Returns 400 for missing API key (SERVICE_UNAVAILABLE) - Returns 401 for invalid API key (VOYAGE_AUTH_ERROR) - Returns 503 for Voyage AI API errors (VOYAGE_API_ERROR) - Updated tests to expect ServiceUnavailableException instead of ValidationException All three backends (Express, Python, Java) now have consistent Voyage AI error handling. --- .../exception/GlobalExceptionHandler.java | 55 +++++++++++++++++++ .../ServiceUnavailableException.java | 17 ++++++ .../exception/VoyageAPIException.java | 29 ++++++++++ .../exception/VoyageAuthException.java | 18 ++++++ .../samplemflix/service/MovieServiceImpl.java | 24 ++++++-- .../samplemflix/service/MovieServiceTest.java | 9 +-- mflix/server/python-fastapi/.python-version | 1 + mflix/server/python-fastapi/main.py | 30 +++++++++- .../python-fastapi/src/routers/movies.py | 55 ++++++++++++++++--- .../python-fastapi/src/utils/errorResponse.py | 38 +++++++++++++ .../python-fastapi/src/utils/exceptions.py | 36 ++++++++++++ .../python-fastapi/tests/test_movie_routes.py | 16 ++++-- 12 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java create mode 100644 mflix/server/python-fastapi/.python-version create mode 100644 mflix/server/python-fastapi/src/utils/errorResponse.py create mode 100644 mflix/server/python-fastapi/src/utils/exceptions.py diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java index aa6e544..3776dae 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java @@ -79,6 +79,61 @@ public ResponseEntity handleMissingServletRequestParameter( return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(ServiceUnavailableException.class) + public ResponseEntity handleServiceUnavailableException( + ServiceUnavailableException ex, WebRequest request) { + logger.error("Service unavailable: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("SERVICE_UNAVAILABLE") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(VoyageAuthException.class) + public ResponseEntity handleVoyageAuthException( + VoyageAuthException ex, WebRequest request) { + logger.error("Voyage AI authentication error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_AUTH_ERROR") + .details("Please verify your VOYAGE_API_KEY is correct in the .env file") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(VoyageAPIException.class) + public ResponseEntity handleVoyageAPIException( + VoyageAPIException ex, WebRequest request) { + logger.error("Voyage AI API error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message("Vector search service unavailable") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_API_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE); + } + @ExceptionHandler(DatabaseOperationException.class) public ResponseEntity handleDatabaseOperationException( DatabaseOperationException ex, WebRequest request) { diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..c515631 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java @@ -0,0 +1,17 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when a required service is unavailable or not configured. + * + * This exception results in a 400 Bad Request response with SERVICE_UNAVAILABLE code. + * Typically occurs when: + * - A required API key is not configured + * - A required service is not available + */ +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java new file mode 100644 index 0000000..6279a5c --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java @@ -0,0 +1,29 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API returns an error. + * + * This exception results in a 503 Service Unavailable response. + * Typically occurs when: + * - The Voyage AI API is down or unavailable + * - The API returns an error response + * - Network issues prevent communication with the API + */ +public class VoyageAPIException extends RuntimeException { + + private final int statusCode; + + public VoyageAPIException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public VoyageAPIException(String message) { + this(message, 503); + } + + public int getStatusCode() { + return statusCode; + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java new file mode 100644 index 0000000..2445db8 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java @@ -0,0 +1,18 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API authentication fails. + * + * This exception results in a 401 Unauthorized response. + * Typically occurs when: + * - The API key is invalid + * - The API key is missing + * - The API key has expired + */ +public class VoyageAuthException extends RuntimeException { + + public VoyageAuthException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index f86bbb5..a0a8943 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -6,7 +6,10 @@ import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.DatabaseOperationException; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.exception.VoyageAPIException; +import com.mongodb.samplemflix.exception.VoyageAuthException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.*; import com.mongodb.samplemflix.repository.MovieRepository; @@ -821,8 +824,8 @@ public List vectorSearchMovies(String query, Integer limit) // Check if Voyage API key is configured if (voyageApiKey == null || voyageApiKey.trim().isEmpty() || voyageApiKey.equals("your_voyage_api_key")) { - throw new ValidationException( - "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your Voyage AI API key to the .env file" + throw new ServiceUnavailableException( + "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file" ); } @@ -929,10 +932,16 @@ public List vectorSearchMovies(String query, Integer limit) return results; + } catch (VoyageAuthException e) { + // Re-raise Voyage AI authentication errors to be handled by GlobalExceptionHandler + throw e; + } catch (VoyageAPIException e) { + // Re-raise Voyage AI API errors to be handled by GlobalExceptionHandler + throw e; } catch (IOException e) { - // Handle Voyage AI API errors + // Handle network errors calling Voyage AI API String errorMsg = e.getMessage() != null ? e.getMessage() : "Network error calling Voyage AI API"; - throw new DatabaseOperationException("Error performing vector search: " + errorMsg); + throw new VoyageAPIException("Error performing vector search: " + errorMsg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new DatabaseOperationException("Vector search was interrupted"); @@ -981,9 +990,12 @@ private List generateVoyageEmbedding(String text, String apiKey) throws if (response.statusCode() != 200) { // Handle authentication errors specifically if (response.statusCode() == 401) { - throw new IOException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); + throw new VoyageAuthException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); } - throw new IOException("Voyage AI API returned status code " + response.statusCode() + ": " + response.body()); + throw new VoyageAPIException( + "Voyage AI API returned status code " + response.statusCode() + ": " + response.body(), + response.statusCode() + ); } // Parse the JSON response to extract the embedding diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java index 6773e0b..335e763 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java @@ -8,6 +8,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.BatchInsertResponse; @@ -736,23 +737,23 @@ void testVectorSearchMovies_EmptyQuery() { } @Test - @DisplayName("Should throw ValidationException when API key is missing in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is missing in vector search") void testVectorSearchMovies_MissingApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", null); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test - @DisplayName("Should throw ValidationException when API key is placeholder value in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is placeholder value in vector search") void testVectorSearchMovies_PlaceholderApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", "your_voyage_api_key"); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test diff --git a/mflix/server/python-fastapi/.python-version b/mflix/server/python-fastapi/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/mflix/server/python-fastapi/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 6a770cb..99913ae 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -1,8 +1,11 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from src.routers import movies from src.database.mongo_client import db, get_collection +from src.utils.exceptions import VoyageAuthError, VoyageAPIError +from src.utils.errorResponse import create_error_response import os from dotenv import load_dotenv @@ -136,6 +139,31 @@ async def ensure_standard_index(): app = FastAPI(lifespan=lifespan) +# Add custom exception handlers +@app.exception_handler(VoyageAuthError) +async def voyage_auth_error_handler(request: Request, exc: VoyageAuthError): + """Handle Voyage AI authentication errors with 401 status.""" + return JSONResponse( + status_code=401, + content=create_error_response( + message=exc.message, + code="VOYAGE_AUTH_ERROR", + details="Please verify your VOYAGE_API_KEY is correct in the .env file" + ) + ) + +@app.exception_handler(VoyageAPIError) +async def voyage_api_error_handler(request: Request, exc: VoyageAPIError): + """Handle Voyage AI API errors with 503 status.""" + return JSONResponse( + status_code=503, + content=create_error_response( + message="Vector search service unavailable", + code="VOYAGE_API_ERROR", + details=exc.message + ) + ) + # Add CORS middleware cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",") app.add_middleware( diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index d8fa176..77b1142 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -1,12 +1,16 @@ from fastapi import APIRouter, Query, Path, Body, HTTPException +from fastapi.responses import JSONResponse from src.database.mongo_client import get_collection, voyage_ai_available from src.models.models import VectorSearchResult, CreateMovieRequest, Movie, SuccessResponse, UpdateMovieRequest, SearchMoviesResponse from typing import Any, List, Optional from src.utils.successResponse import create_success_response +from src.utils.errorResponse import create_error_response +from src.utils.exceptions import VoyageAuthError, VoyageAPIError from bson import ObjectId, errors import re from bson.errors import InvalidId import voyageai +import os ''' @@ -316,11 +320,16 @@ async def vector_search_movies( Returns: SuccessResponse containing a list of movies with similarity scores """ + # Check if Voyage AI API key is configured if not voyage_ai_available(): - raise HTTPException( - status_code = 503, - detail="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to your .env file." + return JSONResponse( + status_code=400, + content=create_error_response( + message="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + code="SERVICE_UNAVAILABLE" + ) ) + try: # Initialize the client here to avoid import-time errors vo = voyageai.Client() @@ -390,10 +399,20 @@ async def vector_search_movies( f"Found {len(results)} similar movies for query: '{q}'" ) + except VoyageAuthError: + # Re-raise custom exceptions to be handled by the exception handlers + raise + except VoyageAPIError: + # Re-raise custom exceptions to be handled by the exception handlers + raise except Exception as e: + # Log the error for debugging + print(f"Vector search error: {str(e)}") + + # Handle generic errors raise HTTPException( - status_code = 500, - detail=f"An error occurred during vector search: {str(e)}" + status_code=500, + detail=f"Error performing vector search: {str(e)}" ) """ @@ -1348,12 +1367,30 @@ def get_embedding(data, input_type = "document", client=None): Returns: Vector embeddings for the given input + + Raises: + VoyageAuthError: If the API key is invalid (401) + VoyageAPIError: For other API errors """ if client is None: client = voyageai.Client() - embeddings = client.embed( - data, model = model, output_dimension = outputDimension, input_type = input_type - ).embeddings - return embeddings[0] + try: + embeddings = client.embed( + data, model = model, output_dimension = outputDimension, input_type = input_type + ).embeddings + return embeddings[0] + except Exception as e: + error_message = str(e).lower() + + # Check for authentication errors + if "401" in error_message or "unauthorized" in error_message or "invalid api key" in error_message: + raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") + + # Check for other API errors + if "api" in error_message or "voyage" in error_message: + raise VoyageAPIError(f"Voyage AI API error: {str(e)}", 503) + + # Re-raise other exceptions + raise VoyageAPIError(f"Failed to generate embedding: {str(e)}", 500) diff --git a/mflix/server/python-fastapi/src/utils/errorResponse.py b/mflix/server/python-fastapi/src/utils/errorResponse.py new file mode 100644 index 0000000..020d01f --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/errorResponse.py @@ -0,0 +1,38 @@ +""" +Utility functions for creating standardized error responses. + +This module provides functions to create consistent error response structures +that match the Express backend's error format. +""" + +from datetime import datetime +from typing import Optional, Any + + +def create_error_response( + message: str, + code: Optional[str] = None, + details: Optional[Any] = None +) -> dict: + """ + Creates a standardized error response. + + Args: + message: The error message to display + code: Optional error code (e.g., 'VOYAGE_AUTH_ERROR', 'VOYAGE_API_ERROR') + details: Optional additional error details + + Returns: + A dictionary containing the standardized error response + """ + return { + "success": False, + "message": message, + "error": { + "message": message, + "code": code, + "details": details + }, + "timestamp": datetime.utcnow().isoformat() + "Z" + } + diff --git a/mflix/server/python-fastapi/src/utils/exceptions.py b/mflix/server/python-fastapi/src/utils/exceptions.py new file mode 100644 index 0000000..7ef6467 --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/exceptions.py @@ -0,0 +1,36 @@ +""" +Custom exception classes for the FastAPI application. + +This module defines custom exceptions for handling specific error scenarios, +particularly for Voyage AI API interactions. +""" + +class VoyageAuthError(Exception): + """ + Exception raised when Voyage AI API authentication fails. + + This typically occurs when: + - The API key is invalid + - The API key is missing + - The API key has expired + """ + def __init__(self, message: str = "Invalid Voyage AI API key"): + self.message = message + super().__init__(self.message) + + +class VoyageAPIError(Exception): + """ + Exception raised when Voyage AI API returns an error. + + This covers general API errors such as: + - Rate limiting + - Service unavailability + - Invalid requests + - Server errors + """ + def __init__(self, message: str, status_code: int = 500): + self.message = message + self.status_code = status_code + super().__init__(self.message) + diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index ce4b3b6..edca2bc 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -763,12 +763,20 @@ async def test_vector_search_unavailable(self, mock_voyage_available): # Call the route handler from src.routers.movies import vector_search_movies - with pytest.raises(HTTPException) as e: - await vector_search_movies(q="action movie") + from fastapi.responses import JSONResponse + + response = await vector_search_movies(q="action movie") # Assertions - assert e.value.status_code == 503 - assert str("VOYAGE_API_KEY not configured").lower() in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Parse the response body + import json + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "SERVICE_UNAVAILABLE" + assert "VOYAGE_API_KEY not configured" in body["message"] @patch('src.routers.movies.voyage_ai_available') From 695161c58919570d5ce8f49e5b89ebca6d4b6c05 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 12 Dec 2025 18:00:53 -0500 Subject: [PATCH 08/26] Fix deprecated datetime.utcnow() warning in Python error response Replace datetime.utcnow() with datetime.now(timezone.utc) to fix deprecation warning in Python 3.12+. --- mflix/server/python-fastapi/src/utils/errorResponse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mflix/server/python-fastapi/src/utils/errorResponse.py b/mflix/server/python-fastapi/src/utils/errorResponse.py index 020d01f..d82e144 100644 --- a/mflix/server/python-fastapi/src/utils/errorResponse.py +++ b/mflix/server/python-fastapi/src/utils/errorResponse.py @@ -5,7 +5,7 @@ that match the Express backend's error format. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, Any @@ -33,6 +33,6 @@ def create_error_response( "code": code, "details": details }, - "timestamp": datetime.utcnow().isoformat() + "Z" + "timestamp": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') } From 727f84f912e2f9f136e2f80d09c5b5cec1bbf4ae Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Mon, 15 Dec 2025 15:27:05 -0500 Subject: [PATCH 09/26] Fix Python Voyage AI error handling to use SDK exception types - Remove accidentally added .python-version file - Update get_embedding() to catch specific voyageai.error exceptions: - AuthenticationError (401) -> VoyageAuthError - InvalidRequestError (400) -> VoyageAPIError - RateLimitError (429) -> VoyageAPIError - ServiceUnavailableError (503) -> VoyageAPIError - VoyageError (other) -> VoyageAPIError - Import VoyageAuthError and VoyageAPIError in tests for future use This fixes the issue where authentication errors were not being properly detected because the code was relying on string matching instead of catching the SDK's specific exception types. --- mflix/server/python-fastapi/.python-version | 1 - .../python-fastapi/src/routers/movies.py | 28 +++++++++++-------- .../python-fastapi/tests/test_movie_routes.py | 1 + 3 files changed, 18 insertions(+), 12 deletions(-) delete mode 100644 mflix/server/python-fastapi/.python-version diff --git a/mflix/server/python-fastapi/.python-version b/mflix/server/python-fastapi/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/mflix/server/python-fastapi/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 77b1142..2f70458 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -10,6 +10,7 @@ import re from bson.errors import InvalidId import voyageai +import voyageai.error as voyage_error import os @@ -1380,17 +1381,22 @@ def get_embedding(data, input_type = "document", client=None): data, model = model, output_dimension = outputDimension, input_type = input_type ).embeddings return embeddings[0] + except voyage_error.AuthenticationError as e: + # Handle authentication errors (401) from Voyage AI SDK + raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") + except voyage_error.InvalidRequestError as e: + # Handle invalid request errors (400) - often due to malformed API key + raise VoyageAPIError(f"Invalid request to Voyage AI API: {str(e)}", 400) + except voyage_error.RateLimitError as e: + # Handle rate limiting errors (429) + raise VoyageAPIError(f"Voyage AI API rate limit exceeded: {str(e)}", 429) + except voyage_error.ServiceUnavailableError as e: + # Handle service unavailable errors (502, 503, 504) + raise VoyageAPIError(f"Voyage AI service unavailable: {str(e)}", 503) + except voyage_error.VoyageError as e: + # Handle any other Voyage AI SDK errors + raise VoyageAPIError(f"Voyage AI API error: {str(e)}", getattr(e, 'http_status', 500) or 500) except Exception as e: - error_message = str(e).lower() - - # Check for authentication errors - if "401" in error_message or "unauthorized" in error_message or "invalid api key" in error_message: - raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") - - # Check for other API errors - if "api" in error_message or "voyage" in error_message: - raise VoyageAPIError(f"Voyage AI API error: {str(e)}", 503) - - # Re-raise other exceptions + # Handle unexpected errors raise VoyageAPIError(f"Failed to generate embedding: {str(e)}", 500) diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index edca2bc..5256fab 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from src.models.models import CreateMovieRequest, UpdateMovieRequest +from src.utils.exceptions import VoyageAuthError, VoyageAPIError # Test constants From 4972a4e8b94a75004e311395814d3dd76fb1130e Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 09:40:14 -0500 Subject: [PATCH 10/26] Fix: Move voyageai.Client() creation inside try/except block The voyageai.Client() constructor can raise AuthenticationError if the API key is empty or invalid. Previously, this was happening outside the try/except block that catches Voyage AI exceptions, causing the error to be caught by the generic Exception handler and returned as a 500 error instead of a 401. This fix moves the client creation inside the try block so that AuthenticationError from the constructor is properly caught and converted to a VoyageAuthError with a 401 status code. --- mflix/server/python-fastapi/src/routers/movies.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 2f70458..f15d6bc 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -332,12 +332,9 @@ async def vector_search_movies( ) try: - # Initialize the client here to avoid import-time errors - vo = voyageai.Client() - # The vector search index was already created at startup time - # Generate embedding for the search query - query_embedding = get_embedding(q, input_type="query", client=vo) + # Generate embedding for the search query (client is created inside get_embedding) + query_embedding = get_embedding(q, input_type="query") # Get the embedded movies collection embedded_movies_collection = get_collection("embedded_movies") @@ -1373,10 +1370,10 @@ def get_embedding(data, input_type = "document", client=None): VoyageAuthError: If the API key is invalid (401) VoyageAPIError: For other API errors """ - if client is None: - client = voyageai.Client() - try: + if client is None: + client = voyageai.Client() + embeddings = client.embed( data, model = model, output_dimension = outputDimension, input_type = input_type ).embeddings From 5e6f4a09f42aa82dc520920641c7ca41f1678b84 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 12:40:06 -0500 Subject: [PATCH 11/26] Fix MongoDB Atlas Search returning too many results for multi-word queries Change directors, writers, and cast fields from text operator to phrase operator in the search endpoint across all three backend implementations. The text operator with fuzzy matching tokenizes multi-word queries into individual terms and matches using OR logic, causing searches like 'james cameron' to return ~240 results instead of ~10-15. The phrase operator performs exact phrase matching, ensuring that only documents where the full phrase appears are returned. Affected files: - Python FastAPI: mflix/server/python-fastapi/src/routers/movies.py - Express TypeScript: mflix/server/js-express/src/controllers/movieController.ts - Java Spring: mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java --- .../samplemflix/service/MovieServiceImpl.java | 26 ++++++------------- .../src/controllers/movieController.ts | 12 ++++----- .../python-fastapi/src/routers/movies.py | 24 ++++++----------- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index a0a8943..6a94d35 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -634,39 +634,29 @@ public List searchMovies(MovieSearchRequest searchRequest) { )); } - // Add directors search if provided (using text operator with fuzzy matching) + // Add directors search if provided (using phrase operator for exact phrase matching) + // This ensures that searching for "james cameron" only matches documents where + // "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". if (searchRequest.getDirectors() != null && !searchRequest.getDirectors().trim().isEmpty()) { - searchPhrases.add(new Document("text", new Document() + searchPhrases.add(new Document("phrase", new Document() .append("query", searchRequest.getDirectors().trim()) .append("path", Movie.Fields.DIRECTORS) - .append("fuzzy", new Document() - .append("maxEdits", 1) - .append("prefixLength", 5) - ) )); } - // Add writers search if provided (using text operator with fuzzy matching) + // Add writers search if provided (using phrase operator for exact phrase matching) if (searchRequest.getWriters() != null && !searchRequest.getWriters().trim().isEmpty()) { - searchPhrases.add(new Document("text", new Document() + searchPhrases.add(new Document("phrase", new Document() .append("query", searchRequest.getWriters().trim()) .append("path", Movie.Fields.WRITERS) - .append("fuzzy", new Document() - .append("maxEdits", 1) - .append("prefixLength", 5) - ) )); } - // Add cast search if provided (using text operator with fuzzy matching) + // Add cast search if provided (using phrase operator for exact phrase matching) if (searchRequest.getCast() != null && !searchRequest.getCast().trim().isEmpty()) { - searchPhrases.add(new Document("text", new Document() + searchPhrases.add(new Document("phrase", new Document() .append("query", searchRequest.getCast().trim()) .append("path", Movie.Fields.CAST) - .append("fuzzy", new Document() - .append("maxEdits", 1) - .append("prefixLength", 5) - ) )); } diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 86febc3..c2129f1 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -578,32 +578,32 @@ export async function searchMovies(req: Request, res: Response): Promise { }); } + // The phrase operator performs an exact phrase match on the specified field. + // This ensures that searching for "james cameron" only matches documents where + // "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". if (directors) { searchPhrases.push({ - text: { + phrase: { query: directors, path: "directors", - fuzzy: { maxEdits: 1, prefixLength: 5 }, }, }); } if (writers) { searchPhrases.push({ - text: { + phrase: { query: writers, path: "writers", - fuzzy: { maxEdits: 1, prefixLength: 5 }, }, }); } if (cast) { searchPhrases.push({ - text: { + phrase: { query: cast, path: "cast", - fuzzy: { maxEdits: 1, prefixLength: 5 }, }, }); } diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index f15d6bc..6934ee0 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -154,37 +154,29 @@ async def search_movies( } }) if directors is not None: - # The "fuzzy" option enables typo-tolerant (fuzzy) search within MongoDB Search. - # - maxEdits: The maximum number of single-character edits (insertions, deletions, or substitutions) - # allowed when matching the search term to indexed terms. (Range: 1-2; higher = more tolerant) - # - prefixLength: The number of initial characters that must exactly match before fuzzy matching is applied. - # (Higher values make the search stricter and faster.) - # For more details, see: https://www.mongodb.com/docs/atlas/atlas-search/operators-collectors/text/ - + # The phrase operator performs an exact phrase match on the specified field. + # This ensures that searching for "james cameron" only matches documents where + # "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". search_phrases.append({ - "text": { + "phrase": { "query": directors, "path": "directors", - "fuzzy":{"maxEdits":1, "prefixLength":5} - } }) if writers is not None: - # See comments above regarding fuzzy search options. + # The phrase operator performs an exact phrase match on the specified field. search_phrases.append({ - "text": { + "phrase": { "query": writers, "path": "writers", - "fuzzy":{"maxEdits":1, "prefixLength":5} } }) if cast is not None: - # See comments above regarding fuzzy search options. + # The phrase operator performs an exact phrase match on the specified field. search_phrases.append({ - "text": { + "phrase": { "query": cast, "path": "cast", - "fuzzy":{"maxEdits":1, "prefixLength":5} } }) From 010b1468324ebfc642c6565c526e8dd381030c51 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 14:56:11 -0500 Subject: [PATCH 12/26] Fix MongoDB Atlas Search returning too many results for multi-word queries Use compound queries with AND logic for directors, writers, and cast fields to require ALL search terms to match, preventing 'james cameron' from matching any director with 'James' OR 'Cameron'. Changes: - Split multi-word queries into individual terms - Wrap terms in compound 'must' clause (AND logic) - Adjust fuzzy settings: maxEdits=1, prefixLength=2 for better typo tolerance without over-matching (e.g., prevents 'james' matching 'jane') Single-word queries continue to use simple text operator with fuzzy matching. Affected files: - Python FastAPI: mflix/server/python-fastapi/src/routers/movies.py - Express TypeScript: mflix/server/js-express/src/controllers/movieController.ts - Express types: mflix/server/js-express/src/types/index.ts - Java Spring: mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java --- .../samplemflix/service/MovieServiceImpl.java | 98 +++++++++++++--- .../src/controllers/movieController.ts | 93 +++++++++++---- mflix/server/js-express/src/types/index.ts | 9 ++ .../python-fastapi/src/routers/movies.py | 108 ++++++++++++++---- 4 files changed, 247 insertions(+), 61 deletions(-) diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index 6a94d35..c0931f2 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -634,30 +634,94 @@ public List searchMovies(MovieSearchRequest searchRequest) { )); } - // Add directors search if provided (using phrase operator for exact phrase matching) - // This ensures that searching for "james cameron" only matches documents where - // "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". + // Add directors search if provided + // Split multi-word queries into individual terms and require ALL terms to match (AND logic). + // This prevents "james cameron" from matching any director with "James" OR "Cameron", + // while still allowing fuzzy matching for typo tolerance. + // Fuzzy settings: maxEdits=1 allows up to 1 character edit, prefixLength=2 requires + // only the first 2 characters to match exactly before fuzzy matching kicks in. if (searchRequest.getDirectors() != null && !searchRequest.getDirectors().trim().isEmpty()) { - searchPhrases.add(new Document("phrase", new Document() - .append("query", searchRequest.getDirectors().trim()) - .append("path", Movie.Fields.DIRECTORS) - )); + String[] directorTerms = searchRequest.getDirectors().trim().split("\\s+"); + if (directorTerms.length == 1) { + searchPhrases.add(new Document("text", new Document() + .append("query", searchRequest.getDirectors().trim()) + .append("path", Movie.Fields.DIRECTORS) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )); + } else { + // Use compound must clause to require all terms match (AND logic) + java.util.List mustClauses = java.util.Arrays.stream(directorTerms) + .filter(term -> !term.isEmpty()) + .map(term -> new Document("text", new Document() + .append("query", term) + .append("path", Movie.Fields.DIRECTORS) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )) + .collect(java.util.stream.Collectors.toList()); + searchPhrases.add(new Document("compound", new Document("must", mustClauses))); + } } - // Add writers search if provided (using phrase operator for exact phrase matching) + // Add writers search if provided (see directors comments for AND logic explanation) if (searchRequest.getWriters() != null && !searchRequest.getWriters().trim().isEmpty()) { - searchPhrases.add(new Document("phrase", new Document() - .append("query", searchRequest.getWriters().trim()) - .append("path", Movie.Fields.WRITERS) - )); + String[] writerTerms = searchRequest.getWriters().trim().split("\\s+"); + if (writerTerms.length == 1) { + searchPhrases.add(new Document("text", new Document() + .append("query", searchRequest.getWriters().trim()) + .append("path", Movie.Fields.WRITERS) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )); + } else { + java.util.List mustClauses = java.util.Arrays.stream(writerTerms) + .filter(term -> !term.isEmpty()) + .map(term -> new Document("text", new Document() + .append("query", term) + .append("path", Movie.Fields.WRITERS) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )) + .collect(java.util.stream.Collectors.toList()); + searchPhrases.add(new Document("compound", new Document("must", mustClauses))); + } } - // Add cast search if provided (using phrase operator for exact phrase matching) + // Add cast search if provided (see directors comments for AND logic explanation) if (searchRequest.getCast() != null && !searchRequest.getCast().trim().isEmpty()) { - searchPhrases.add(new Document("phrase", new Document() - .append("query", searchRequest.getCast().trim()) - .append("path", Movie.Fields.CAST) - )); + String[] castTerms = searchRequest.getCast().trim().split("\\s+"); + if (castTerms.length == 1) { + searchPhrases.add(new Document("text", new Document() + .append("query", searchRequest.getCast().trim()) + .append("path", Movie.Fields.CAST) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )); + } else { + java.util.List mustClauses = java.util.Arrays.stream(castTerms) + .filter(term -> !term.isEmpty()) + .map(term -> new Document("text", new Document() + .append("query", term) + .append("path", Movie.Fields.CAST) + .append("fuzzy", new Document() + .append("maxEdits", 1) + .append("prefixLength", 2) + ) + )) + .collect(java.util.stream.Collectors.toList()); + searchPhrases.add(new Document("compound", new Document("must", mustClauses))); + } } // Build the $search aggregation stage with compound operator diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index c2129f1..b346b04 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -578,34 +578,85 @@ export async function searchMovies(req: Request, res: Response): Promise { }); } - // The phrase operator performs an exact phrase match on the specified field. - // This ensures that searching for "james cameron" only matches documents where - // "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". + // Split multi-word queries into individual terms and require ALL terms to match (AND logic). + // This prevents "james cameron" from matching any director with "James" OR "Cameron", + // while still allowing fuzzy matching for typo tolerance. + // Fuzzy settings: maxEdits=2 allows up to 2 character edits, prefixLength=2 requires + // only the first 2 characters to match exactly before fuzzy matching kicks in. if (directors) { - searchPhrases.push({ - phrase: { - query: directors, - path: "directors", - }, - }); + const directorTerms = directors.split(/\s+/).filter((t) => t.length > 0); + if (directorTerms.length === 1) { + searchPhrases.push({ + text: { + query: directors, + path: "directors", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + }); + } else { + // Use compound must clause to require all terms match (AND logic) + searchPhrases.push({ + compound: { + must: directorTerms.map((term) => ({ + text: { + query: term, + path: "directors", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + })), + }, + }); + } } if (writers) { - searchPhrases.push({ - phrase: { - query: writers, - path: "writers", - }, - }); + const writerTerms = writers.split(/\s+/).filter((t) => t.length > 0); + if (writerTerms.length === 1) { + searchPhrases.push({ + text: { + query: writers, + path: "writers", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + }); + } else { + searchPhrases.push({ + compound: { + must: writerTerms.map((term) => ({ + text: { + query: term, + path: "writers", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + })), + }, + }); + } } if (cast) { - searchPhrases.push({ - phrase: { - query: cast, - path: "cast", - }, - }); + const castTerms = cast.split(/\s+/).filter((t) => t.length > 0); + if (castTerms.length === 1) { + searchPhrases.push({ + text: { + query: cast, + path: "cast", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + }); + } else { + searchPhrases.push({ + compound: { + must: castTerms.map((term) => ({ + text: { + query: term, + path: "cast", + fuzzy: { maxEdits: 1, prefixLength: 2 }, + }, + })), + }, + }); + } } if (searchPhrases.length === 0) { diff --git a/mflix/server/js-express/src/types/index.ts b/mflix/server/js-express/src/types/index.ts index 34e3fc7..04ca6d6 100644 --- a/mflix/server/js-express/src/types/index.ts +++ b/mflix/server/js-express/src/types/index.ts @@ -298,6 +298,15 @@ export interface SearchPhrase { path: string; fuzzy?: { maxEdits: number; prefixLength: number }; }; + compound?: { + must?: Array<{ + text: { + query: string; + path: string; + fuzzy?: { maxEdits: number; prefixLength: number }; + }; + }>; + }; } /** diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 6934ee0..21fda81 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -154,31 +154,93 @@ async def search_movies( } }) if directors is not None: - # The phrase operator performs an exact phrase match on the specified field. - # This ensures that searching for "james cameron" only matches documents where - # "James Cameron" appears as an exact phrase, not documents containing "James" OR "Cameron". - search_phrases.append({ - "phrase": { - "query": directors, - "path": "directors", - } - }) + # Split multi-word queries into individual terms and require ALL terms to match (AND logic). + # This prevents "james cameron" from matching any director with "James" OR "Cameron". + # The "fuzzy" option enables typo-tolerant search within MongoDB Search. + # - maxEdits: The maximum number of single-character edits (insertions, deletions, or substitutions) + # allowed when matching the search term to indexed terms. (Range: 1-2; higher = more tolerant) + # - prefixLength: The number of initial characters that must exactly match before fuzzy matching is applied. + # (Lower values allow typos earlier in the word but may be slower.) + # For more details, see: https://www.mongodb.com/docs/atlas/atlas-search/operators-collectors/text/ + director_terms = directors.split() + if len(director_terms) == 1: + search_phrases.append({ + "text": { + "query": directors, + "path": "directors", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + }) + else: + # Use compound must clause to require all terms match (AND logic) + search_phrases.append({ + "compound": { + "must": [ + { + "text": { + "query": term, + "path": "directors", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + } + for term in director_terms + ] + } + }) + if writers is not None: - # The phrase operator performs an exact phrase match on the specified field. - search_phrases.append({ - "phrase": { - "query": writers, - "path": "writers", - } - }) + # See comments above regarding fuzzy search and AND logic for multi-word queries. + writer_terms = writers.split() + if len(writer_terms) == 1: + search_phrases.append({ + "text": { + "query": writers, + "path": "writers", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + }) + else: + search_phrases.append({ + "compound": { + "must": [ + { + "text": { + "query": term, + "path": "writers", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + } + for term in writer_terms + ] + } + }) + if cast is not None: - # The phrase operator performs an exact phrase match on the specified field. - search_phrases.append({ - "phrase": { - "query": cast, - "path": "cast", - } - }) + # See comments above regarding fuzzy search and AND logic for multi-word queries. + cast_terms = cast.split() + if len(cast_terms) == 1: + search_phrases.append({ + "text": { + "query": cast, + "path": "cast", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + }) + else: + search_phrases.append({ + "compound": { + "must": [ + { + "text": { + "query": term, + "path": "cast", + "fuzzy": {"maxEdits": 1, "prefixLength": 2} + } + } + for term in cast_terms + ] + } + }) if not search_phrases: raise HTTPException( From b1073cbca541103f27e654a266593133942d56b4 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 15:15:19 -0500 Subject: [PATCH 13/26] Add fuzzy search hints to directors, writers, and cast input fields - Update placeholder text with example names (e.g. James Cameron) - Add helper text indicating fuzzy matching support for typo tolerance --- .../SearchMovieModal/SearchMovieModal.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx index 642ec0b..4fce344 100644 --- a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx +++ b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx @@ -256,9 +256,12 @@ export default function SearchMovieModal({ onChange={(e) => handleInputChange('directors', e.target.value)} className={`${styles.input} ${errors.directors ? styles.inputError : ''}`} disabled={isLoading} - placeholder="Director names" + placeholder="e.g. James Cameron" /> {errors.directors && {errors.directors}} + + Fuzzy matching enabled – tolerates minor typos + {/* Writers Search */} @@ -273,9 +276,12 @@ export default function SearchMovieModal({ onChange={(e) => handleInputChange('writers', e.target.value)} className={`${styles.input} ${errors.writers ? styles.inputError : ''}`} disabled={isLoading} - placeholder="Writer names" + placeholder="e.g. Aaron Sorkin" /> {errors.writers && {errors.writers}} + + Fuzzy matching enabled – tolerates minor typos + {/* Cast Search */} @@ -290,9 +296,12 @@ export default function SearchMovieModal({ onChange={(e) => handleInputChange('cast', e.target.value)} className={`${styles.input} ${errors.cast ? styles.inputError : ''}`} disabled={isLoading} - placeholder="Actor names" + placeholder="e.g. Tom Hanks" /> {errors.cast && {errors.cast}} + + Fuzzy matching enabled – tolerates minor typos + {/* Limit */} From 28d9a1c292cadadcb766456a45dffe4fc1fb3d92 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 15:20:47 -0500 Subject: [PATCH 14/26] Improve search modal UI layout and aesthetics - Group related fields into visual sections (Plot, People, Options) - Add section headers with uppercase styling - Use 3-column grid for directors/writers/cast fields - Consolidate fuzzy matching hint at section level - Improve spacing, padding, and border-radius - Add gradient styling to primary search button - Softer button styles (outline for Clear, subtle for Close) - Better input hover/focus states and placeholder colors - Improved responsive breakpoints for mobile - Cleaner vector search layout with dedicated section --- .../SearchMovieModal.module.css | 210 +++++++----- .../SearchMovieModal/SearchMovieModal.tsx | 309 +++++++++--------- 2 files changed, 289 insertions(+), 230 deletions(-) diff --git a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.module.css b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.module.css index 6805e8c..b757b2f 100644 --- a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.module.css +++ b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.module.css @@ -1,45 +1,47 @@ /** * Search Movie Modal Styles - * + * * CSS Module for the search movie modal component. * Provides consistent styling with the rest of the application. */ .formContainer { background: white; - border-radius: 12px; - padding: 2rem; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border-radius: 16px; + padding: 2.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); margin-bottom: 2rem; + max-width: 800px; + margin-left: auto; + margin-right: auto; } .formTitle { - font-size: 1.75rem; - font-weight: bold; - color: #333; - margin: 0 0 1.5rem 0; + font-size: 1.875rem; + font-weight: 700; + color: #1a1a2e; + margin: 0 0 0.5rem 0; text-align: center; } .batchDescription { - background-color: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1.5rem; - color: #495057; - font-size: 0.9rem; - line-height: 1.4; + background-color: transparent; + border: none; + padding: 0; + margin-bottom: 2rem; + color: #6b7280; + font-size: 1rem; + line-height: 1.5; text-align: center; } .generalError { - background-color: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; - padding: 0.75rem 1rem; - border-radius: 8px; - margin-bottom: 1rem; + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 0.875rem 1.25rem; + border-radius: 10px; + margin-bottom: 1.5rem; font-size: 0.9rem; text-align: center; } @@ -48,138 +50,183 @@ width: 100%; } +/* Section styling for grouped fields */ +.fieldSection { + background: #f8fafc; + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.sectionTitle { + font-size: 0.8rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 1rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e2e8f0; +} + .formGrid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; + grid-template-columns: repeat(2, 1fr); + gap: 1.25rem; margin-bottom: 1.5rem; } +.formGridThreeCol { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; +} + .formGroup { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.375rem; +} + +.formGroupFullWidth { + grid-column: 1 / -1; } .label { - font-weight: 500; - color: #333; - font-size: 0.9rem; + font-weight: 600; + color: #374151; + font-size: 0.875rem; } .input, .textarea { - padding: 0.75rem; - border: 2px solid #e1e5e9; - border-radius: 8px; - font-size: 1rem; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + padding: 0.875rem 1rem; + border: 1.5px solid #e2e8f0; + border-radius: 10px; + font-size: 0.95rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; background: white; } +.input::placeholder, +.textarea::placeholder { + color: #9ca3af; +} + +.input:hover:not(:disabled), +.textarea:hover:not(:disabled) { + border-color: #cbd5e1; +} + .input:focus, .textarea:focus { outline: none; - border-color: #0070f3; - box-shadow: 0 0 0 3px rgba(0, 112, 243, 0.1); + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + background: white; } .input:disabled, .textarea:disabled { - background: #f8f9fa; - color: #6c757d; + background: #f1f5f9; + color: #94a3b8; cursor: not-allowed; } .inputError { - border-color: #dc2626 !important; + border-color: #ef4444 !important; } .inputError:focus { - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15) !important; } .error { - color: #dc2626; - font-size: 0.875rem; + color: #ef4444; + font-size: 0.8rem; margin-top: 0.25rem; } .searchOperatorDescription { - color: #6c757d; - font-size: 0.875rem; + color: #64748b; + font-size: 0.8rem; margin-top: 0.25rem; display: block; + line-height: 1.4; } .formActions { display: flex; - gap: 1rem; + gap: 0.75rem; justify-content: flex-end; - padding-top: 1.5rem; - border-top: 1px solid #e1e5e9; + padding-top: 1.75rem; + margin-top: 0.5rem; + border-top: 1px solid #e2e8f0; } .button { padding: 0.75rem 1.5rem; border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 500; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 600; cursor: pointer; transition: all 0.2s ease; - min-width: 120px; + min-width: 100px; } .button:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; transform: none; } .saveButton { - background: #0070f3; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; - border: 1px solid #0070f3; + border: none; + min-width: 140px; } .saveButton:hover:not(:disabled) { - background: #0051cc; - border-color: #0051cc; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 112, 243, 0.3); + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35); } .cancelButton { - background: #6c757d; - color: white; - border: 1px solid #6c757d; + background: #f1f5f9; + color: #475569; + border: 1.5px solid #e2e8f0; } .cancelButton:hover:not(:disabled) { - background: #5a6268; - border-color: #5a6268; + background: #e2e8f0; + border-color: #cbd5e1; transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(108, 117, 125, 0.3); } .clearButton { - background: #6c757d; - color: white; - border: 1px solid #6c757d; + background: transparent; + color: #64748b; + border: 1.5px solid #e2e8f0; } .clearButton:hover:not(:disabled) { - background: #5a6268; - border-color: #5a6268; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(108, 117, 125, 0.3); + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; } /* Responsive Design */ @media (max-width: 768px) { .formContainer { - padding: 1.5rem; + padding: 1.75rem; + border-radius: 12px; + } + + .fieldSection { + padding: 1.25rem; } .formGrid { @@ -187,32 +234,43 @@ gap: 1rem; } + .formGridThreeCol { + grid-template-columns: 1fr; + gap: 1rem; + } + .formActions { flex-direction: column-reverse; - gap: 0.75rem; + gap: 0.625rem; } .button { width: 100%; + padding: 0.875rem 1.5rem; } } @media (max-width: 480px) { .formContainer { - padding: 1rem; + padding: 1.25rem; } .formTitle { font-size: 1.5rem; } - .formGrid { - gap: 0.75rem; + .fieldSection { + padding: 1rem; + } + + .formGrid, + .formGridThreeCol { + gap: 0.875rem; } .input, .textarea { - padding: 0.625rem; + padding: 0.75rem; font-size: 0.9rem; } diff --git a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx index 4fce344..40944e7 100644 --- a/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx +++ b/mflix/client/app/components/SearchMovieModal/SearchMovieModal.tsx @@ -208,186 +208,187 @@ export default function SearchMovieModal({ {/* Conditional Form Fields */} {formData.searchType === 'mongodb-search' ? ( <> - {/* MongoDB Search Fields */} -
- {/* Plot Search */} -
- - handleInputChange('plot', e.target.value)} - className={`${styles.input} ${errors.plot ? styles.inputError : ''}`} - disabled={isLoading} - placeholder="Exact phrase search in plot summaries" - /> - {errors.plot && {errors.plot}} + {/* Plot Search Section */} +
+

Plot Search

+
+
+ + handleInputChange('plot', e.target.value)} + className={`${styles.input} ${errors.plot ? styles.inputError : ''}`} + disabled={isLoading} + placeholder="Search in short plot summaries" + /> + {errors.plot && {errors.plot}} +
+ +
+ + handleInputChange('fullplot', e.target.value)} + className={`${styles.input} ${errors.fullplot ? styles.inputError : ''}`} + disabled={isLoading} + placeholder="Search in detailed plot descriptions" + /> + {errors.fullplot && {errors.fullplot}} +
+
- {/* Full Plot Search */} -
- - handleInputChange('fullplot', e.target.value)} - className={`${styles.input} ${errors.fullplot ? styles.inputError : ''}`} - disabled={isLoading} - placeholder="Search in full plot descriptions" - /> - {errors.fullplot && {errors.fullplot}} + {/* People Search Section */} +
+

People Search

+ + Fuzzy matching enabled – tolerates minor typos + +
+
+ + handleInputChange('directors', e.target.value)} + className={`${styles.input} ${errors.directors ? styles.inputError : ''}`} + disabled={isLoading} + placeholder="e.g. James Cameron" + /> + {errors.directors && {errors.directors}} +
+ +
+ + handleInputChange('writers', e.target.value)} + className={`${styles.input} ${errors.writers ? styles.inputError : ''}`} + disabled={isLoading} + placeholder="e.g. Aaron Sorkin" + /> + {errors.writers && {errors.writers}} +
+ +
+ + handleInputChange('cast', e.target.value)} + className={`${styles.input} ${errors.cast ? styles.inputError : ''}`} + disabled={isLoading} + placeholder="e.g. Tom Hanks" + /> + {errors.cast && {errors.cast}} +
+
- {/* Directors Search */} -
- - handleInputChange('directors', e.target.value)} - className={`${styles.input} ${errors.directors ? styles.inputError : ''}`} - disabled={isLoading} - placeholder="e.g. James Cameron" - /> - {errors.directors && {errors.directors}} - - Fuzzy matching enabled – tolerates minor typos - + {/* Search Options Section */} +
+

Search Options

+
+
+ + + + {searchOperatorOptions.find(opt => opt.value === formData.searchOperator)?.description} + +
+ +
+ + handleInputChange('limit', e.target.value)} + className={`${styles.input} ${errors.limit ? styles.inputError : ''}`} + disabled={isLoading} + min="1" + max="100" + /> + {errors.limit && {errors.limit}} +
- - {/* Writers Search */} -
- - handleInputChange('writers', e.target.value)} - className={`${styles.input} ${errors.writers ? styles.inputError : ''}`} - disabled={isLoading} - placeholder="e.g. Aaron Sorkin" - /> - {errors.writers && {errors.writers}} - - Fuzzy matching enabled – tolerates minor typos - -
- - {/* Cast Search */} +
+ + ) : ( + <> + {/* Vector Search Fields */} +
+

Semantic Search

-