diff --git a/.github/workflows/run-express-tests.yml b/.github/workflows/run-express-tests.yml index 7f3e960..d434da2 100644 --- a/.github/workflows/run-express-tests.yml +++ b/.github/workflows/run-express-tests.yml @@ -5,12 +5,12 @@ on: branches: - development paths: - - 'server/js-express/**' + - 'mflix/server/js-express/**' push: branches: - development paths: - - 'server/js-express/**' + - 'mflix/server/js-express/**' jobs: test: @@ -47,17 +47,17 @@ jobs: node-version: '20' - name: Install dependencies - working-directory: server/js-express + working-directory: mflix/server/js-express run: npm install - name: Run unit tests - working-directory: server/js-express + working-directory: mflix/server/js-express run: npm run test:unit -- --json --outputFile=test-results-unit.json || true env: MONGODB_URI: mongodb://localhost:27017/sample_mflix - name: Run integration tests - working-directory: server/js-express + working-directory: mflix/server/js-express run: npm run test:integration -- --json --outputFile=test-results-integration.json || true env: MONGODB_URI: mongodb://localhost:27017/sample_mflix @@ -71,9 +71,9 @@ jobs: with: name: test-results path: | - server/js-express/coverage/ - server/js-express/test-results-unit.json - server/js-express/test-results-integration.json + mflix/server/js-express/coverage/ + mflix/server/js-express/test-results-unit.json + mflix/server/js-express/test-results-integration.json retention-days: 30 - name: Generate Test Summary @@ -81,5 +81,5 @@ jobs: run: | chmod +x .github/scripts/generate-test-summary-jest.sh .github/scripts/generate-test-summary-jest.sh \ - server/js-express/test-results-unit.json \ - server/js-express/test-results-integration.json + mflix/server/js-express/test-results-unit.json \ + mflix/server/js-express/test-results-integration.json diff --git a/mflix/client/app/components/FilterBar/FilterBar.module.css b/mflix/client/app/components/FilterBar/FilterBar.module.css new file mode 100644 index 0000000..ee17f25 --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.module.css @@ -0,0 +1,212 @@ +/** + * FilterBar Component Styles + * + * CSS Module for the movie filter bar component. + * Provides a horizontal filter bar for filtering movies by genre, year, rating, etc. + */ + +.filterBar { + background: white; + border-radius: 12px; + padding: 1.25rem 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin-bottom: 1.5rem; +} + +.filterHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.filterTitle { + font-size: 0.875rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.clearFiltersButton { + background: transparent; + border: 1px solid #e2e8f0; + color: #64748b; + padding: 0.375rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.clearFiltersButton:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.filterControls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 140px; +} + +.filterLabel { + font-size: 0.75rem; + font-weight: 500; + color: #64748b; +} + +.filterSelect, +.filterInput { + padding: 0.5rem 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + background: white; + color: #374151; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + min-width: 120px; +} + +.filterSelect:hover, +.filterInput:hover { + border-color: #cbd5e1; +} + +.filterSelect:focus, +.filterInput:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.ratingGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ratingInput { + width: 70px; +} + +.ratingDivider { + color: #94a3b8; + font-size: 0.875rem; +} + +.applyButton { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.applyButton:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +.applyButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.activeFilters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.filterChip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: #eff6ff; + color: #2563eb; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.chipRemove { + background: none; + border: none; + color: #2563eb; + cursor: pointer; + padding: 0; + font-size: 1rem; + line-height: 1; + opacity: 0.7; +} + +.chipRemove:hover { + opacity: 1; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .filterBar { + padding: 1rem; + } + + .filterControls { + flex-direction: column; + align-items: stretch; + } + + .filterGroup { + min-width: 100%; + } + + .ratingGroup { + flex-wrap: wrap; + } + + .ratingInput { + flex: 1; + min-width: 80px; + } + + .applyButton { + width: 100%; + padding: 0.75rem 1rem; + } +} + +@media (max-width: 480px) { + .filterHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .clearFiltersButton { + width: 100%; + } +} diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..324840d --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import styles from './FilterBar.module.css'; +import { fetchGenres, type MovieFilterParams } from '@/lib/api'; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'year', label: 'Year' }, + { value: 'imdb.rating', label: 'IMDB Rating' }, +]; + +interface FilterBarProps { + onFilterChange: (filters: MovieFilterParams) => void; + isLoading?: boolean; + initialFilters?: MovieFilterParams; +} + +/** + * Compares two MovieFilterParams objects for equality. + * Returns true if all filter values match. + */ +function areFiltersEqual(a: MovieFilterParams, b: MovieFilterParams): boolean { + return ( + a.genre === b.genre && + a.year === b.year && + a.minRating === b.minRating && + a.maxRating === b.maxRating && + a.sortBy === b.sortBy && + a.sortOrder === b.sortOrder + ); +} + +export default function FilterBar({ + onFilterChange, + isLoading = false, + initialFilters = {} +}: FilterBarProps) { + const [filters, setFilters] = useState(initialFilters); + const [genres, setGenres] = useState([]); + const [isLoadingGenres, setIsLoadingGenres] = useState(true); + + // Track previous initialFilters to detect changes + const prevInitialFiltersRef = useRef(initialFilters); + + // Fetch genres from the API on mount + useEffect(() => { + async function loadGenres() { + setIsLoadingGenres(true); + const fetchedGenres = await fetchGenres(); + setGenres(fetchedGenres); + setIsLoadingGenres(false); + } + loadGenres(); + }, []); + + // Sync internal state when initialFilters changes (e.g. from URL navigation) + useEffect(() => { + if (!areFiltersEqual(prevInitialFiltersRef.current, initialFilters)) { + setFilters(initialFilters); + prevInitialFiltersRef.current = initialFilters; + } + }, [initialFilters]); + + const handleFilterChange = useCallback((key: keyof MovieFilterParams, value: string | number | undefined) => { + setFilters(prev => { + const newFilters = { ...prev }; + if (value === '' || value === undefined) { + delete newFilters[key]; + } else { + (newFilters as Record)[key] = value; + } + return newFilters; + }); + }, []); + + const handleApplyFilters = useCallback(() => { + onFilterChange(filters); + }, [filters, onFilterChange]); + + const handleClearFilters = useCallback(() => { + setFilters({}); + onFilterChange({}); + }, [onFilterChange]); + + const hasActiveFilters = Object.keys(filters).length > 0; + + const activeFilterChips: { key: string; label: string }[] = []; + if (filters.genre) activeFilterChips.push({ key: 'genre', label: `Genre: ${filters.genre}` }); + if (filters.year) activeFilterChips.push({ key: 'year', label: `Year: ${filters.year}` }); + if (filters.minRating !== undefined) activeFilterChips.push({ key: 'minRating', label: `Min Rating: ${filters.minRating}` }); + if (filters.maxRating !== undefined) activeFilterChips.push({ key: 'maxRating', label: `Max Rating: ${filters.maxRating}` }); + if (filters.sortBy) { + const sortLabel = SORT_OPTIONS.find(o => o.value === filters.sortBy)?.label || filters.sortBy; + activeFilterChips.push({ key: 'sort', label: `Sort: ${sortLabel} (${filters.sortOrder || 'asc'})` }); + } + + const removeFilter = (key: string) => { + if (key === 'sort') { + handleFilterChange('sortBy', undefined); + handleFilterChange('sortOrder', undefined); + } else { + handleFilterChange(key as keyof MovieFilterParams, undefined); + } + }; + + return ( +
+
+

+ Filter Movies +

+ {hasActiveFilters && ( + + )} +
+ +
+
+ + +
+ +
+ + handleFilterChange('year', e.target.value ? parseInt(e.target.value) : undefined)} + disabled={isLoading} + min={1900} + max={2030} + /> +
+ +
+ +
+ handleFilterChange('minRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> + to + handleFilterChange('maxRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> +
+
+ +
+ + +
+ +
+ + +
+ + +
+ + {activeFilterChips.length > 0 && ( +
+ {activeFilterChips.map(chip => ( + + {chip.label} + + + ))} +
+ )} +
+ ); +} + diff --git a/mflix/client/app/components/FilterBar/index.ts b/mflix/client/app/components/FilterBar/index.ts new file mode 100644 index 0000000..839567f --- /dev/null +++ b/mflix/client/app/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { default as FilterBar } from './FilterBar'; + diff --git a/mflix/client/app/components/MovieCard/MovieCard.tsx b/mflix/client/app/components/MovieCard/MovieCard.tsx index 1ff2c9d..7fa702c 100644 --- a/mflix/client/app/components/MovieCard/MovieCard.tsx +++ b/mflix/client/app/components/MovieCard/MovieCard.tsx @@ -3,12 +3,13 @@ import Image from 'next/image'; import Link from 'next/link'; import movieStyles from "./MovieCard.module.css"; -import { Movie } from "../../types/movie"; -import { ROUTES } from "../../lib/constants"; +import { Movie } from "@/types/movie"; +import { ROUTES } from "@/lib/constants"; +import React from "react"; /** * Movie Card Client Component - * + * * This component handles the interactive parts of the movie card, * such as image error handling and selection checkbox. */ diff --git a/mflix/client/app/components/index.ts b/mflix/client/app/components/index.ts index 03c422b..11b6de1 100644 --- a/mflix/client/app/components/index.ts +++ b/mflix/client/app/components/index.ts @@ -7,6 +7,7 @@ export { default as EditMovieForm } from './EditMovieForm'; export { default as AddMovieForm } from './AddMovieForm'; export { default as BatchEditMovieForm } from './BatchEditMovieForm'; export { default as SearchMovieModal } from './SearchMovieModal'; +export { FilterBar } from './FilterBar'; export { Skeleton, MovieCardSkeleton, diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index c99c08f..7ac8976 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -1,4 +1,4 @@ -import { Movie, MoviesApiResponse } from '../types/movie'; +import { Movie, MoviesApiResponse } from '@/types/movie'; /** * API configuration and helper functions @@ -7,17 +7,59 @@ import { Movie, MoviesApiResponse } from '../types/movie'; const API_BASE_URL = process.env.API_URL || 'http://localhost:3001'; /** - * Fetches movies from the Express API with pagination support - * This function runs on the server during SSR + * Filter parameters for the movies endpoint + * These map to MongoDB find() query operators + */ +export interface MovieFilterParams { + genre?: string; + year?: number; + minRating?: number; + maxRating?: number; + sortBy?: 'title' | 'year' | 'imdb.rating'; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Fetches movies from the backend API with pagination and filtering support + * using MongoDB find() with query filters. */ export async function fetchMovies( - limit: number = 20, - skip: number = 0 + limit: number = 20, + skip: number = 0, + filters?: MovieFilterParams ): Promise<{ movies: Movie[]; hasNextPage: boolean; hasPrevPage: boolean }> { try { + // Build query parameters + const queryParams = new URLSearchParams(); + // Request one extra movie to check if there's a next page const requestLimit = Math.min(limit + 1, 100); - const response = await fetch(`${API_BASE_URL}/api/movies?limit=${requestLimit}&skip=${skip}`, { + queryParams.append('limit', requestLimit.toString()); + queryParams.append('skip', skip.toString()); + + // Add filter parameters if provided + if (filters) { + if (filters.genre) { + queryParams.append('genre', filters.genre); + } + if (filters.year !== undefined) { + queryParams.append('year', filters.year.toString()); + } + if (filters.minRating !== undefined) { + queryParams.append('minRating', filters.minRating.toString()); + } + if (filters.maxRating !== undefined) { + queryParams.append('maxRating', filters.maxRating.toString()); + } + if (filters.sortBy) { + queryParams.append('sortBy', filters.sortBy); + } + if (filters.sortOrder) { + queryParams.append('sortOrder', filters.sortOrder); + } + } + + const response = await fetch(`${API_BASE_URL}/api/movies?${queryParams}`, { next: { revalidate: 300 }, // Revalidate every 5 minutes }); @@ -26,7 +68,7 @@ export async function fetchMovies( } const result: MoviesApiResponse = await response.json(); - + if (!result.success) { throw new Error('API returned error response'); } @@ -42,12 +84,12 @@ export async function fetchMovies( }; } catch (error) { console.error('Error fetching movies:', error); - + // In development, throw the error to help with debugging if (process.env.NODE_ENV === 'development') { throw error; } - + // In production, return empty result with logged error to prevent page crash return { movies: [], @@ -57,6 +99,34 @@ export async function fetchMovies( } } +/** + * Fetches all unique genres from the backend API + * using MongoDB's distinct() operation. + */ +export async function fetchGenres(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/movies/genres`, { + next: { revalidate: 3600 }, // Cache genres for 1 hour since they rarely change + }); + + if (!response.ok) { + throw new Error(`Failed to fetch genres: ${response.status}`); + } + + const result: { success: boolean; data: string[]; message: string } = await response.json(); + + if (!result.success) { + throw new Error('API returned error response'); + } + + return result.data; + } catch (error) { + console.error('Error fetching genres:', error); + // Return empty array on error - FilterBar will handle gracefully + return []; + } +} + /** * Fetch a single movie by ID */ diff --git a/mflix/client/app/movie/[id]/page.tsx b/mflix/client/app/movie/[id]/page.tsx index 42dade2..86395c4 100644 --- a/mflix/client/app/movie/[id]/page.tsx +++ b/mflix/client/app/movie/[id]/page.tsx @@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { fetchMovieById, updateMovie, deleteMovie } from '../../lib/api'; +import { fetchMovieById, updateMovie, deleteMovie } from '@/lib/api'; import { ActionButtons, EditMovieForm } from '../../components'; import { ErrorDisplay, LoadingSpinner } from '../../components/ui'; -import { Movie } from '../../types/movie'; -import { ROUTES } from '../../lib/constants'; +import { Movie } from '@/types/movie'; +import { ROUTES } from '@/lib/constants'; import pageStyles from './page.module.css'; interface MovieDetailsPageProps { @@ -205,7 +205,7 @@ export default function MovieDetailsPage({ params }: MovieDetailsPageProps) { {movie.poster ? (
{`${movie.title}(null); - + + // Parse filter parameters from URL for persistence + const parseFiltersFromUrl = (): MovieFilterParams => { + const filters: MovieFilterParams = {}; + + const genre = searchParams.get('genre'); + const year = searchParams.get('year'); + const minRating = searchParams.get('minRating'); + const maxRating = searchParams.get('maxRating'); + const sortBy = searchParams.get('sortBy'); + const sortOrder = searchParams.get('sortOrder'); + + if (genre) filters.genre = genre; + if (year) filters.year = parseInt(year); + if (minRating) filters.minRating = parseFloat(minRating); + if (maxRating) filters.maxRating = parseFloat(maxRating); + if (sortBy && ['title', 'year', 'imdb.rating'].includes(sortBy)) { + filters.sortBy = sortBy as MovieFilterParams['sortBy']; + } + if (sortOrder && ['asc', 'desc'].includes(sortOrder)) { + filters.sortOrder = sortOrder as 'asc' | 'desc'; + } + + return filters; + }; + + // Build URL with filter parameters + const buildUrlWithFilters = (newPage: number, newLimit: number, filters: MovieFilterParams): string => { + const params = new URLSearchParams(); + params.set('page', newPage.toString()); + params.set('limit', newLimit.toString()); + + if (filters.genre) params.set('genre', filters.genre); + if (filters.year !== undefined) params.set('year', filters.year.toString()); + if (filters.minRating !== undefined) params.set('minRating', filters.minRating.toString()); + if (filters.maxRating !== undefined) params.set('maxRating', filters.maxRating.toString()); + if (filters.sortBy) params.set('sortBy', filters.sortBy); + if (filters.sortOrder) params.set('sortOrder', filters.sortOrder); + + return `${ROUTES.movies}?${params.toString()}`; + }; + + // Get filters from URL on initial load and when URL changes + const urlFilters = parseFiltersFromUrl(); + const hasUrlFilters = Object.keys(urlFilters).length > 0; + const page = parseInt(searchParams.get('page') || '1'); const limit = Math.min( - parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), + parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), APP_CONFIG.maxMovieLimit ); const skip = (page - 1) * limit; - const loadMovies = async () => { + const loadMovies = async (filters?: MovieFilterParams) => { setIsLoading(true); setError(null); - + try { - const result = await fetchMovies(limit, skip); + const result = await fetchMovies(limit, skip, filters); setMovies(result.movies); setHasNextPage(result.hasNextPage); setHasPrevPage(result.hasPrevPage); @@ -73,13 +118,23 @@ export default function Movies() { setError('Failed to load movies. Make sure the server is running on port 3001.'); setMovies([]); } - + setIsLoading(false); }; useEffect(() => { - loadMovies(); - }, [page, limit]); + // Load movies with filters from URL when page/limit/filters change + loadMovies(hasUrlFilters ? urlFilters : undefined); + }, [searchParams]); // Re-run when any URL param changes + + // Handler for filter changes from FilterBar - updates URL + const handleFilterChange = (filters: MovieFilterParams) => { + const hasFilters = Object.keys(filters).length > 0; + + // Always reset to page 1 when filters change and update URL + const newUrl = buildUrlWithFilters(1, limit, filters); + router.push(newUrl); + }; const handleAddMovie = () => { setShowAddForm(true); @@ -462,7 +517,7 @@ export default function Movies() {

- {isSearchMode ? `Search Results` : 'Movies'} + {isSearchMode ? `Search Results` : hasUrlFilters ? 'Filtered Movies' : 'Movies'}

@@ -572,20 +627,31 @@ export default function Movies() { {/* Page Size Selector - only show for regular mode */} {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && } - + + {/* Filter Bar - display when not in search mode and not showing forms */} + {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && ( + + )} + {/* Movies Content */} {!showAddForm && !showBatchEditForm && !showSearchModal && ( <> {error && displayMovies.length === 0 ? ( - handleSearchSubmit(currentSearchParams!) : loadMovies} + handleSearchSubmit(currentSearchParams!) : () => loadMovies(hasUrlFilters ? urlFilters : undefined)} /> ) : displayMovies.length === 0 ? (

- {isSearchMode + {isSearchMode ? 'No movies found matching your search criteria. Try different search terms.' + : hasUrlFilters + ? 'No movies found matching your filter criteria. Try adjusting your filters.' : 'No movies found. Make sure the server is running on port 3001.' }

diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java new file mode 100644 index 0000000..6097939 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java @@ -0,0 +1,105 @@ +package com.mongodb.samplemflix.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * HTTP Request Logging Filter + * + *

This filter logs all incoming HTTP requests with useful information + * including method, URL, status code, and response time. + * It helps with debugging and monitoring application traffic. + * + *

Log output format: + *

+ * INFO  - GET /api/movies 200 - 45ms
+ * WARN  - GET /api/movies/invalid 400 - 2ms
+ * ERROR - POST /api/movies 500 - 120ms
+ * 
+ * + *

The filter is ordered to run first in the filter chain to ensure + * accurate timing measurements. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Record the start time + long startTime = System.currentTimeMillis(); + + // Log incoming request at debug level + logger.debug("Incoming request: {} {} from {}", + request.getMethod(), + request.getRequestURI(), + request.getRemoteAddr()); + + try { + // Continue with the filter chain + filterChain.doFilter(request, response); + } finally { + // Calculate response time + long responseTime = System.currentTimeMillis() - startTime; + + // Log the completed request with appropriate level based on status code + logRequest(request.getMethod(), request.getRequestURI(), response.getStatus(), responseTime); + } + } + + /** + * Logs the HTTP request with appropriate log level based on status code. + * + *

Log levels: + *

    + *
  • ERROR: 5xx server errors
  • + *
  • WARN: 4xx client errors
  • + *
  • INFO: 2xx and 3xx success/redirect
  • + *
+ * + * @param method HTTP method (GET, POST, etc.) + * @param uri Request URI + * @param statusCode HTTP response status code + * @param responseTime Response time in milliseconds + */ + private void logRequest(String method, String uri, int statusCode, long responseTime) { + String message = String.format("%s %s %d - %dms", method, uri, statusCode, responseTime); + + if (statusCode >= 500) { + logger.error(message); + } else if (statusCode >= 400) { + logger.warn(message); + } else { + logger.info(message); + } + } + + /** + * Skip logging for static resources and health checks to reduce noise. + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/swagger-ui") + || path.startsWith("/api-docs") + || path.startsWith("/v3/api-docs") + || path.equals("/favicon.ico") + || path.startsWith("/actuator"); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java index 5ca9ceb..d696c3e 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java @@ -109,7 +109,26 @@ public ResponseEntity>> getAllMovies( return ResponseEntity.ok(response); } - + + @Operation( + summary = "Get all distinct genres", + description = "Retrieve a list of all unique genre values from the movies collection. " + + "Demonstrates the distinct() operation. Returns genres sorted alphabetically." + ) + @GetMapping("/genres") + public ResponseEntity>> getDistinctGenres() { + List genres = movieService.getDistinctGenres(); + + SuccessResponse> response = SuccessResponse.>builder() + .success(true) + .message("Found " + genres.size() + " distinct genres") + .data(genres) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + @Operation( summary = "Get a single movie by ID", description = "Retrieve a single movie by its MongoDB ObjectId." diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java index 8221b27..4a592cb 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java @@ -21,6 +21,14 @@ public interface MovieService { List getAllMovies(MovieSearchQuery query); + /** + * Gets all distinct genre values from the movies collection. + * Demonstrates the distinct() operation. + * + * @return List of unique genre strings, sorted alphabetically + */ + List getDistinctGenres(); + Movie getMovieById(String id); Movie createMovie(CreateMovieRequest request); 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 138444f..d293b23 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 @@ -85,7 +85,25 @@ public List getAllMovies(MovieSearchQuery query) { return mongoTemplate.find(mongoQuery, Movie.class); } - + + @Override + public List getDistinctGenres() { + // Use MongoTemplate's findDistinct to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + List genres = mongoTemplate.findDistinct( + new Query(), + Movie.Fields.GENRES, + Movie.class, + String.class + ); + + // Filter out null/empty values and sort alphabetically + return genres.stream() + .filter(genre -> genre != null && !genre.isEmpty()) + .sorted(String::compareTo) + .collect(Collectors.toList()); + } + @Override public Movie getMovieById(String id) { if (!ObjectId.isValid(id)) { diff --git a/mflix/server/java-spring/src/main/resources/application.properties b/mflix/server/java-spring/src/main/resources/application.properties index 58b8785..ffe611f 100644 --- a/mflix/server/java-spring/src/main/resources/application.properties +++ b/mflix/server/java-spring/src/main/resources/application.properties @@ -19,11 +19,22 @@ voyage.api.key=${VOYAGE_API_KEY:} spring.application.name=sample-app-java-mflix # Logging Configuration -logging.level.com.mongodb.samplemflix=INFO +# Log level can be overridden with LOG_LEVEL environment variable +# Available levels: TRACE, DEBUG, INFO, WARN, ERROR +logging.level.com.mongodb.samplemflix=${LOG_LEVEL:INFO} logging.level.org.mongodb.driver=WARN # Suppress connection pool maintenance warnings (these are usually harmless) logging.level.org.mongodb.driver.connection=ERROR +# Console logging pattern with colors and timestamps +logging.pattern.console=%clr(%d{HH:mm:ss}){faint} %clr(%5p){highlight} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n + +# File logging (optional - enabled when LOG_FILE is set) +logging.file.name=${LOG_FILE:} +logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %5p --- [%15.15t] %-40.40logger{39} : %m%n +logging.logback.rollingpolicy.max-file-size=5MB +logging.logback.rollingpolicy.max-history=5 + # Jackson Configuration (JSON serialization) spring.jackson.default-property-inclusion=non_null spring.jackson.serialization.write-dates-as-timestamps=false diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java index 0f63dfd..1add6ed 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java @@ -936,4 +936,38 @@ void testDeleteMoviesBatch_EmptyFilter() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.deletedCount").value(0)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("GET /api/movies/genres - Should return list of distinct genres") + void testGetDistinctGenres_Success() throws Exception { + // Arrange + List genres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(movieService.getDistinctGenres()).thenReturn(genres); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(5))) + .andExpect(jsonPath("$.data[0]").value("Action")) + .andExpect(jsonPath("$.data[1]").value("Comedy")) + .andExpect(jsonPath("$.data[2]").value("Drama")); + } + + @Test + @DisplayName("GET /api/movies/genres - Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() throws Exception { + // Arrange + when(movieService.getDistinctGenres()).thenReturn(Arrays.asList()); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(0))); + } } 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 335e763..8c0db87 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 @@ -801,4 +801,82 @@ void testFindSimilarMovies_MovieNotFound() { // Act & Assert assertThrows(ResourceNotFoundException.class, () -> movieService.findSimilarMovies(movieId, 10)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("Should get distinct genres successfully") + void testGetDistinctGenres_Success() { + // Arrange + List expectedGenres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(expectedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(5, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() { + // Arrange + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(Arrays.asList()); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(0, result.size()); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should filter out null and empty genres") + void testGetDistinctGenres_FiltersNullAndEmpty() { + // Arrange + List genresWithNulls = new ArrayList<>(Arrays.asList("Action", null, "", "Drama", "Comedy")); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(genresWithNulls); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + // The service should filter out null and empty values + assertEquals(3, result.size()); + assertTrue(result.contains("Action")); + assertTrue(result.contains("Drama")); + assertTrue(result.contains("Comedy")); + assertFalse(result.contains(null)); + assertFalse(result.contains("")); + } + + @Test + @DisplayName("Should return genres sorted alphabetically") + void testGetDistinctGenres_SortedAlphabetically() { + // Arrange + List unsortedGenres = Arrays.asList("Drama", "Action", "Comedy"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(unsortedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + assertEquals("Drama", result.get(2)); + } } diff --git a/mflix/server/js-express/.env.example b/mflix/server/js-express/.env.example index 725cc0d..c870ee0 100644 --- a/mflix/server/js-express/.env.example +++ b/mflix/server/js-express/.env.example @@ -10,6 +10,10 @@ VOYAGE_API_KEY=your_voyage_api_key PORT=3001 NODE_ENV=development +# Logging Configuration +# Available levels: error, warn, info, http, debug +# Default: debug (development), info (production), error (test) +LOG_LEVEL=debug # CORS Configuration # Allowed origin for cross-origin requests (frontend URL) diff --git a/mflix/server/js-express/package.json b/mflix/server/js-express/package.json index 01311ba..85f09a2 100644 --- a/mflix/server/js-express/package.json +++ b/mflix/server/js-express/package.json @@ -24,7 +24,8 @@ "express": "^5.1.0", "mongodb": "^7.0.0", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "winston": "^3.19.0" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/mflix/server/js-express/src/app.ts b/mflix/server/js-express/src/app.ts index 9c5a7e8..6e3d389 100644 --- a/mflix/server/js-express/src/app.ts +++ b/mflix/server/js-express/src/app.ts @@ -18,6 +18,8 @@ import { import { errorHandler } from "./utils/errorHandler"; import moviesRouter from "./routes/movies"; import { swaggerSpec } from "./config/swagger"; +import logger from "./utils/logger"; +import { requestLogger } from "./middleware/requestLogger"; // Load environment variables from .env file // This must be called before any other imports that use environment variables @@ -46,6 +48,12 @@ app.use( app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); +/** + * Request Logging Middleware + * Logs all incoming HTTP requests with method, URL, status code, and response time + */ +app.use(requestLogger); + /** * Swagger API Documentation * Provides interactive API documentation at /api-docs @@ -119,25 +127,25 @@ app.use(errorHandler); */ async function startServer() { try { - console.log("Starting MongoDB Sample MFlix API..."); + logger.info("Starting MongoDB Sample MFlix API..."); // Connect to MongoDB database - console.log("Connecting to MongoDB..."); + logger.info("Connecting to MongoDB..."); await connectToDatabase(); - console.log("Connected to MongoDB successfully"); + logger.info("Connected to MongoDB successfully"); // Verify that all required indexes and sample data exist - console.log("Verifying requirements (indexes and sample data)..."); + logger.info("Verifying requirements (indexes and sample data)..."); await verifyRequirements(); - console.log("All requirements verified successfully"); + logger.info("All requirements verified successfully"); // Start the Express server app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - console.log(`API documentation available at http://localhost:${PORT}/api-docs`); + logger.info(`Server running on port ${PORT}`); + logger.info(`API documentation available at http://localhost:${PORT}/api-docs`); }); } catch (error) { - console.error("Failed to start server:", error); + logger.error("Failed to start server:", error); // Exit the process if we can't start properly // This ensures the application doesn't run in a broken state @@ -150,13 +158,13 @@ async function startServer() { * Ensures the application shuts down cleanly when terminated */ process.on("SIGINT", () => { - console.log("\nReceived SIGINT. Shutting down..."); + logger.info("Received SIGINT. Shutting down gracefully..."); closeDatabaseConnection(); process.exit(0); }); process.on("SIGTERM", () => { - console.log("\nReceived SIGTERM. Shutting down..."); + logger.info("Received SIGTERM. Shutting down gracefully..."); closeDatabaseConnection(); process.exit(0); }); diff --git a/mflix/server/js-express/src/config/database.ts b/mflix/server/js-express/src/config/database.ts index 09c5884..62d5a7e 100644 --- a/mflix/server/js-express/src/config/database.ts +++ b/mflix/server/js-express/src/config/database.ts @@ -7,6 +7,7 @@ */ import { MongoClient, Db, Collection, Document } from "mongodb"; +import logger from "../utils/logger"; let client: MongoClient; let database: Db; @@ -40,7 +41,7 @@ async function _connectToDatabase(): Promise { // Get reference to the sample_mflix database database = client.db("sample_mflix"); - console.log(`Connected to database: ${database.databaseName}`); + logger.debug(`Connected to database: ${database.databaseName}`); return database; } catch (error) { @@ -85,7 +86,7 @@ export function getCollection( export async function closeDatabaseConnection(): Promise { if (client) { await client.close(); - console.log("Database connection closed"); + logger.info("Database connection closed"); } } @@ -100,9 +101,9 @@ export async function verifyRequirements(): Promise { // Check if the movies collection exists and has data await verifyMoviesCollection(db); - console.log("All database requirements verified successfully"); + logger.debug("All database requirements verified successfully"); } catch (error) { - console.error("Requirements verification failed:", error); + logger.error("Requirements verification failed:", error); throw error; } } @@ -117,22 +118,51 @@ async function verifyMoviesCollection(db: Db): Promise { const movieCount = await moviesCollection.estimatedDocumentCount(); if (movieCount === 0) { - console.warn( + logger.warn( "Movies collection is empty. Please ensure sample_mflix data is loaded." ); } // Create text search index on plot field for full-text search + await createTextSearchIndex(moviesCollection); +} + +/** + * Creates a text search index on the movies collection if it doesn't already exist. + * + * MongoDB only allows one text index per collection, so we check for any existing + * text index before attempting to create one. + */ +async function createTextSearchIndex(moviesCollection: Collection): Promise { + const TEXT_INDEX_NAME = "text_search_index"; + try { + // Check if any text index already exists + const existingIndexes = await moviesCollection.listIndexes().toArray(); + const textIndexExists = existingIndexes.some( + (index) => index.key && index.key._fts === "text" + ); + + if (textIndexExists) { + const existingTextIndex = existingIndexes.find( + (index) => index.key && index.key._fts === "text" + ); + logger.debug(`Text search index '${existingTextIndex?.name}' already exists on movies collection`); + return; + } + + // Create the text index await moviesCollection.createIndex( { plot: "text", title: "text", fullplot: "text" }, { - name: "text_search_index", + name: TEXT_INDEX_NAME, background: true, } ); - console.log("Text search index created for movies collection"); + logger.info(`Text search index '${TEXT_INDEX_NAME}' created successfully for movies collection`); } catch (error) { - console.error("Could not create text search index:", error); + // Log as warning, not error - the application can still function without the index + logger.warn("Could not create text search index:", error); + logger.warn("Text search functionality may not work without the index"); } } diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 0d107e5..78437ea 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -24,6 +24,7 @@ import { createSuccessResponse, validateRequiredFields, } from "../utils/errorHandler"; +import logger from "../utils/logger"; import { CreateMovieRequest, UpdateMovieRequest, @@ -134,6 +135,34 @@ export async function getAllMovies(req: Request, res: Response): Promise { res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); } +/** + * GET /api/movies/genres + * + * Retrieves all unique genres from the movies collection. + * Demonstrates the distinct() operation. + * + * Returns an array of unique genre strings, sorted alphabetically. + */ +export async function getDistinctGenres( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + + // Use distinct() to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + const genres = await moviesCollection.distinct("genres"); + + // Filter out null/empty values and sort alphabetically + const validGenres = genres + .filter((genre): genre is string => typeof genre === "string" && genre.length > 0) + .sort((a, b) => a.localeCompare(b)); + + res.json( + createSuccessResponse(validGenres, `Found ${validGenres.length} distinct genres`) + ); +} + /** * GET /api/movies/:id * @@ -874,7 +903,7 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise 0 ? req.query : undefined, + ip: req.ip, + }); + + // Log when response finishes + res.on("finish", () => { + const responseTime = Date.now() - startTime; + logHttpRequest(req.method, req.url, res.statusCode, responseTime); + }); + + next(); +} diff --git a/mflix/server/js-express/src/routes/movies.ts b/mflix/server/js-express/src/routes/movies.ts index ed2c9d0..4be4df2 100644 --- a/mflix/server/js-express/src/routes/movies.ts +++ b/mflix/server/js-express/src/routes/movies.ts @@ -118,6 +118,31 @@ const router = express.Router(); */ router.get("/", asyncHandler(movieController.getAllMovies)); +/** + * @swagger + * /api/movies/genres: + * get: + * summary: Get all distinct genres + * description: Retrieves all unique genres from the movies collection. Demonstrates the MongoDB distinct() operation. + * tags: [Movies] + * responses: + * 200: + * description: List of distinct genres + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: array + * items: + * type: string + * example: ["Action", "Adventure", "Animation", "Comedy", "Drama"] + */ +router.get("/genres", asyncHandler(movieController.getDistinctGenres)); + /** * @swagger * /api/movies/search: diff --git a/mflix/server/js-express/src/utils/errorHandler.ts b/mflix/server/js-express/src/utils/errorHandler.ts index 35d79a6..865f906 100644 --- a/mflix/server/js-express/src/utils/errorHandler.ts +++ b/mflix/server/js-express/src/utils/errorHandler.ts @@ -8,6 +8,7 @@ import { Request, Response, NextFunction } from "express"; import { MongoError } from "mongodb"; import { SuccessResponse, ErrorResponse } from "../types"; +import logger from "./logger"; /** * Custom ValidationError class for field validation errors @@ -37,17 +38,13 @@ export function errorHandler( next: NextFunction ): void { // Log the error for debugging purposes - // In production, we recommend using a logging service - // Suppress error logging during tests to keep test output clean - if (process.env.NODE_ENV !== "test") { - console.error("Error occurred:", { - message: err.message, - stack: err.stack, - url: req.url, - method: req.method, - timestamp: new Date().toISOString(), - }); - } + // The logger automatically handles environment-specific behavior + logger.error("Error occurred:", { + message: err.message, + stack: err.stack, + url: req.url, + method: req.method, + }); // Determine the appropriate HTTP status code and error message const errorDetails = parseErrorDetails(err); diff --git a/mflix/server/js-express/src/utils/logger.ts b/mflix/server/js-express/src/utils/logger.ts new file mode 100644 index 0000000..60474e1 --- /dev/null +++ b/mflix/server/js-express/src/utils/logger.ts @@ -0,0 +1,171 @@ +/** + * Logger Utility + * + * This module provides a centralized logging solution using Winston. + * It supports multiple log levels, console and file transports, and + * environment-aware formatting for better developer and user experience. + * + * Log Levels (from highest to lowest priority): + * - error: Error events that might still allow the application to continue + * - warn: Potentially harmful situations + * - info: Informational messages highlighting application progress + * - http: HTTP request logging + * - debug: Detailed debug information + */ + +import winston from "winston"; +import path from "path"; + +// Define log levels with custom colors +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; + +// Define colors for each log level +const colors = { + error: "red", + warn: "yellow", + info: "green", + http: "magenta", + debug: "cyan", +}; + +// Add colors to Winston +winston.addColors(colors); + +/** + * Determine the log level based on environment + * - In development: show all logs (debug level) + * - In production: show info and above + * - In test: show only errors (or suppress entirely) + */ +function getLogLevel(): string { + const env = process.env.NODE_ENV || "development"; + const envLogLevel = process.env.LOG_LEVEL; + + // Allow explicit override via LOG_LEVEL env var + if (envLogLevel) { + return envLogLevel; + } + + // Default levels based on environment + switch (env) { + case "production": + return "info"; + case "test": + return "error"; + default: + return "debug"; + } +} + +/** + * Console format for development - colorized and readable + */ +const devConsoleFormat = winston.format.combine( + winston.format.timestamp({ format: "HH:mm:ss" }), + winston.format.colorize({ all: true }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ""; + return `[${timestamp}] ${level}: ${message}${metaStr}`; + }) +); + +/** + * Console format for production - structured JSON + */ +const prodConsoleFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.json() +); + +/** + * File format - JSON for easy parsing + */ +const fileFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.uncolorize(), + winston.format.json() +); + +/** + * Create transports based on environment + */ +function createTransports(): winston.transport[] { + const env = process.env.NODE_ENV || "development"; + const transports: winston.transport[] = []; + + // Console transport - always enabled except in test + if (env !== "test") { + transports.push( + new winston.transports.Console({ + format: env === "production" ? prodConsoleFormat : devConsoleFormat, + }) + ); + } + + // File transports - enabled in production and development (not test) + if (env !== "test") { + const logsDir = path.join(process.cwd(), "logs"); + + // Error log - only errors + transports.push( + new winston.transports.File({ + filename: path.join(logsDir, "error.log"), + level: "error", + format: fileFormat, + maxsize: 5242880, // 5MB + maxFiles: 5, + }) + ); + + // Combined log - all logs + transports.push( + new winston.transports.File({ + filename: path.join(logsDir, "combined.log"), + format: fileFormat, + maxsize: 5242880, // 5MB + maxFiles: 5, + }) + ); + } + + return transports; +} + +/** + * Create the Winston logger instance + */ +const logger = winston.createLogger({ + level: getLogLevel(), + levels, + transports: createTransports(), + // Don't exit on handled exceptions + exitOnError: false, +}); + +/** + * Log an HTTP request + * @param method - HTTP method (GET, POST, etc.) + * @param url - Request URL + * @param statusCode - Response status code + * @param responseTime - Response time in milliseconds + */ +export function logHttpRequest( + method: string, + url: string, + statusCode: number, + responseTime: number +): void { + const statusColor = + statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "http"; + + logger.log(statusColor, `${method} ${url} ${statusCode} - ${responseTime}ms`); +} + +export default logger; + diff --git a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts index 71702e2..65e4553 100644 --- a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts +++ b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts @@ -455,6 +455,53 @@ describeSearch("MongoDB Search Integration Tests", () => { expect(response.body.error).toBeDefined(); }); }); + + describe("GET /api/movies/genres", () => { + test("should return list of distinct genres", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + + // Verify genres are strings + response.body.data.forEach((genre: any) => { + expect(typeof genre).toBe("string"); + expect(genre.length).toBeGreaterThan(0); + }); + }); + + test("should return genres sorted alphabetically", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // Verify alphabetical sorting + for (let i = 0; i < genres.length - 1; i++) { + expect(genres[i].localeCompare(genres[i + 1])).toBeLessThanOrEqual(0); + } + }); + + test("should include common genres like Action, Drama, Comedy", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // The sample_mflix dataset should contain these common genres + expect(genres).toContain("Action"); + expect(genres).toContain("Drama"); + expect(genres).toContain("Comedy"); + }); + }); }); diff --git a/mflix/server/python-fastapi/.env.example b/mflix/server/python-fastapi/.env.example index d42141d..6dc1d0d 100644 --- a/mflix/server/python-fastapi/.env.example +++ b/mflix/server/python-fastapi/.env.example @@ -10,3 +10,9 @@ VOYAGE_API_KEY=your_voyage_api_key # CORS Configuration # Comma-separated list of allowed origins for CORS CORS_ORIGINS="http://localhost:3000,http://localhost:3001" + +# Logging Configuration +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) +LOG_LEVEL=INFO +# Optional: Path to log file (if not set, logs only to console) +# LOG_FILE=app.log diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 99913ae..81048f9 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -6,6 +6,8 @@ from src.database.mongo_client import db, get_collection from src.utils.exceptions import VoyageAuthError, VoyageAPIError from src.utils.errorResponse import create_error_response +from src.utils.logger import logger +from src.middleware.request_logging import RequestLoggingMiddleware import os from dotenv import load_dotenv @@ -21,12 +23,12 @@ async def lifespan(app: FastAPI): await ensure_vector_search_index() await ensure_standard_index() - # Print server information - print(f"\n{'='*60}") - print(f" Server started at http://127.0.0.1:3001") - print(f" Documentation at http://127.0.0.1:3001/docs") - print(f" Interactive API docs at http://127.0.0.1:3001/redoc") - print(f"{'='*60}\n") + # Log server information + logger.info("=" * 60) + logger.info(" Server started at http://127.0.0.1:3001") + logger.info(" Documentation at http://127.0.0.1:3001/docs") + logger.info(" Interactive API docs at http://127.0.0.1:3001/redoc") + logger.info("=" * 60) yield # Shutdown: Clean up resources if needed @@ -133,8 +135,8 @@ async def ensure_standard_index(): 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.") + logger.warning(f"Failed to create standard index on 'comments' collection: {str(e)}") + logger.warning("Performance may be degraded. Please check your MongoDB configuration.") app = FastAPI(lifespan=lifespan) @@ -174,5 +176,8 @@ async def voyage_api_error_handler(request: Request, exc: VoyageAPIError): allow_headers=["*"], ) +# Add request logging middleware +app.add_middleware(RequestLoggingMiddleware) + app.include_router(movies.router, prefix="/api/movies", tags=["movies"]) diff --git a/mflix/server/python-fastapi/requirements.in b/mflix/server/python-fastapi/requirements.in index fb49b72..09ec646 100644 --- a/mflix/server/python-fastapi/requirements.in +++ b/mflix/server/python-fastapi/requirements.in @@ -22,7 +22,7 @@ PyYAML~=6.0.0 # For handling YAML configuration or data # 3. DATABASE & CONNECTIVITY # Database driver and necessary utilities. # ------------------------------------------------------------------------------ -pymongo~=4.15.0 # MongoDB driver +pymongo~=4.16.0 # MongoDB driver dnspython~=2.8.0 # Required for SRV record lookups by pymongo (e.g., MongoDB Atlas) # ============================================================================== @@ -61,5 +61,6 @@ rich-toolkit~=0.15.0 # Extensions for the 'rich' library # 8. TRANSITIVE DEPENDENCY CONSTRAINTS # Minimum versions for indirect dependencies. # ------------------------------------------------------------------------------ -filelock>=3.20.1 # Transitive dep via huggingface-hub +filelock>=3.20.3 # Transitive dep via huggingface-hub aiohttp>=3.13.3 # Transitive dep via voyageai +orjson>=3.11.5 # Transitive dep via langsmith (CVE fix) diff --git a/mflix/server/python-fastapi/requirements.txt b/mflix/server/python-fastapi/requirements.txt index 9a62bbb..4b92831 100644 --- a/mflix/server/python-fastapi/requirements.txt +++ b/mflix/server/python-fastapi/requirements.txt @@ -54,7 +54,7 @@ fastapi-cli==0.0.14 # via -r requirements.in fastapi-cloud-cli==0.3.1 # via -r requirements.in -filelock==3.20.1 +filelock==3.20.3 # via # -r requirements.in # huggingface-hub @@ -111,8 +111,10 @@ multidict==6.7.0 # yarl numpy==2.3.4 # via voyageai -orjson==3.11.4 - # via langsmith +orjson==3.11.5 + # via + # -r requirements.in + # langsmith packaging==25.0 # via # huggingface-hub @@ -141,7 +143,7 @@ pygments==2.19.2 # via # pytest # rich -pymongo==4.15.3 +pymongo==4.16.0 # via -r requirements.in pytest==8.4.2 # via diff --git a/mflix/server/python-fastapi/src/middleware/__init__.py b/mflix/server/python-fastapi/src/middleware/__init__.py new file mode 100644 index 0000000..674d994 --- /dev/null +++ b/mflix/server/python-fastapi/src/middleware/__init__.py @@ -0,0 +1,6 @@ +"""Middleware package for FastAPI application.""" + +from src.middleware.request_logging import RequestLoggingMiddleware + +__all__ = ["RequestLoggingMiddleware"] + diff --git a/mflix/server/python-fastapi/src/middleware/request_logging.py b/mflix/server/python-fastapi/src/middleware/request_logging.py new file mode 100644 index 0000000..fc997bf --- /dev/null +++ b/mflix/server/python-fastapi/src/middleware/request_logging.py @@ -0,0 +1,97 @@ +""" +Request logging middleware for FastAPI. + +This middleware logs all incoming HTTP requests with useful information +including method, URL, status code, and response time. + +Log output format: + INFO - GET /api/movies 200 - 45ms + WARN - GET /api/movies/invalid 400 - 2ms + ERROR - POST /api/movies 500 - 120ms +""" + +import time +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from src.utils.logger import logger + + +# Paths to skip logging (reduces noise) +SKIP_PATHS = { + "/docs", + "/redoc", + "/openapi.json", + "/favicon.ico", + "/health", +} + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """ + Middleware that logs HTTP requests with timing information. + + Features: + - Logs method, path, status code, and response time + - Uses appropriate log level based on status code: + - ERROR: 5xx server errors + - WARNING: 4xx client errors + - INFO: 2xx and 3xx success/redirect + - Skips logging for documentation and static paths + """ + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + # Skip logging for certain paths + if request.url.path in SKIP_PATHS: + return await call_next(request) + + # Record start time + start_time = time.perf_counter() + + # Log incoming request at debug level + logger.debug( + f"Incoming request: {request.method} {request.url.path} from {request.client.host if request.client else 'unknown'}" + ) + + # Process the request + response = await call_next(request) + + # Calculate response time in milliseconds + response_time_ms = (time.perf_counter() - start_time) * 1000 + + # Log the completed request with appropriate level + self._log_request( + method=request.method, + path=request.url.path, + status_code=response.status_code, + response_time_ms=response_time_ms + ) + + return response + + def _log_request( + self, + method: str, + path: str, + status_code: int, + response_time_ms: float + ) -> None: + """ + Log the HTTP request with appropriate log level based on status code. + + Args: + method: HTTP method (GET, POST, etc.) + path: Request path + status_code: HTTP response status code + response_time_ms: Response time in milliseconds + """ + message = f"{method} {path} {status_code} - {response_time_ms:.0f}ms" + + if status_code >= 500: + logger.error(message) + elif status_code >= 400: + logger.warning(message) + else: + logger.info(message) + diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 32f04a3..bb30c32 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -32,6 +32,10 @@ Search movies using MongoDB Vector Search to enable semantic search capabilities over the plot field. +- GET /api/movies/genres : + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + - GET /api/movies/{id} : Retrieve a single movie by its ID. @@ -427,6 +431,41 @@ async def vector_search_movies( detail=f"Error performing vector search: {str(e)}" ) +""" + GET /api/movies/genres + + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + + Returns: + SuccessResponse[List[str]]: A response object containing the list of unique genres, sorted alphabetically. +""" + +@router.get("/genres", + response_model=SuccessResponse[List[str]], + status_code=200, + summary="Retrieve all distinct genres from the movies collection.") +async def get_distinct_genres(): + movies_collection = get_collection("movies") + + try: + # Use distinct() to get all unique values from the genres array field + # MongoDB automatically flattens array fields when using distinct() + genres = await movies_collection.distinct("genres") + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Database error occurred: {str(e)}" + ) + + # Filter out null/empty values and sort alphabetically + valid_genres = sorted([ + genre for genre in genres + if isinstance(genre, str) and len(genre) > 0 + ]) + + return create_success_response(valid_genres, f"Found {len(valid_genres)} distinct genres") + """ GET /api/movies/{id} Retrieve a single movie by its ID. diff --git a/mflix/server/python-fastapi/src/utils/logger.py b/mflix/server/python-fastapi/src/utils/logger.py new file mode 100644 index 0000000..703ce3b --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/logger.py @@ -0,0 +1,155 @@ +""" +Logging configuration for the FastAPI application. + +This module provides a centralized logging setup with: +- Colorized console output for better readability +- Configurable log levels via environment variables +- Optional file logging +- Request logging middleware + +Usage: + from src.utils.logger import logger + + logger.info("Server started") + logger.debug("Processing request") + logger.error("Something went wrong", exc_info=True) +""" + +import logging +import os +import sys +from datetime import datetime +from typing import Optional + +# ANSI color codes for terminal output +class Colors: + """ANSI color codes for colorized log output.""" + RESET = "\033[0m" + BOLD = "\033[1m" + FAINT = "\033[2m" + + # Log level colors + DEBUG = "\033[36m" # Cyan + INFO = "\033[32m" # Green + WARNING = "\033[33m" # Yellow + ERROR = "\033[31m" # Red + CRITICAL = "\033[35m" # Magenta + + # Component colors + TIMESTAMP = "\033[90m" # Gray + LOGGER_NAME = "\033[36m" # Cyan + + +class ColoredFormatter(logging.Formatter): + """ + Custom formatter that adds colors to log output. + + Format: HH:MM:SS LEVEL --- [logger_name] : message + """ + + LEVEL_COLORS = { + logging.DEBUG: Colors.DEBUG, + logging.INFO: Colors.INFO, + logging.WARNING: Colors.WARNING, + logging.ERROR: Colors.ERROR, + logging.CRITICAL: Colors.CRITICAL, + } + + def format(self, record: logging.LogRecord) -> str: + # Get the color for this log level + level_color = self.LEVEL_COLORS.get(record.levelno, Colors.RESET) + + # Format timestamp + timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") + + # Format level name (padded to 5 chars) + level_name = f"{record.levelname:>5}" + + # Format logger name (truncated to 40 chars) + logger_name = record.name[-40:] if len(record.name) > 40 else record.name + + # Build the formatted message + formatted = ( + f"{Colors.FAINT}{timestamp}{Colors.RESET} " + f"{level_color}{level_name}{Colors.RESET} " + f"{Colors.FAINT}---{Colors.RESET} " + f"{Colors.FAINT}[{Colors.RESET}" + f"{Colors.LOGGER_NAME}{logger_name:>40}{Colors.RESET}" + f"{Colors.FAINT}]{Colors.RESET} " + f"{Colors.FAINT}:{Colors.RESET} " + f"{record.getMessage()}" + ) + + # Add exception info if present + if record.exc_info: + formatted += "\n" + self.formatException(record.exc_info) + + return formatted + + +class PlainFormatter(logging.Formatter): + """Plain text formatter for file logging (no colors).""" + + def format(self, record: logging.LogRecord) -> str: + timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") + level_name = f"{record.levelname:>5}" + logger_name = record.name[-40:] if len(record.name) > 40 else record.name + + formatted = f"{timestamp} {level_name} --- [{logger_name:>40}] : {record.getMessage()}" + + if record.exc_info: + formatted += "\n" + self.formatException(record.exc_info) + + return formatted + + +def setup_logger( + name: str = "mflix", + level: Optional[str] = None, + log_file: Optional[str] = None +) -> logging.Logger: + """ + Set up and configure a logger instance. + + Args: + name: Logger name (default: "mflix") + level: Log level (default: from LOG_LEVEL env var or INFO) + log_file: Optional file path for file logging + + Returns: + Configured logger instance + """ + # Get log level from environment or parameter + log_level_str = level or os.getenv("LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_str, logging.INFO) + + # Create logger + logger = logging.getLogger(name) + logger.setLevel(log_level) + + # Remove existing handlers to avoid duplicates + logger.handlers.clear() + + # Console handler with colors + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(ColoredFormatter()) + logger.addHandler(console_handler) + + # File handler (optional) + file_path = log_file or os.getenv("LOG_FILE") + if file_path: + file_handler = logging.FileHandler(file_path) + file_handler.setLevel(log_level) + file_handler.setFormatter(PlainFormatter()) + logger.addHandler(file_handler) + + # Prevent propagation to root logger + logger.propagate = False + + return logger + + +# Create the default application logger +logger = setup_logger() + diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index 5256fab..5d74779 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -1091,3 +1091,82 @@ async def test_aggregate_directors_empty_results(self, mock_execute_aggregation) # Assertions assert result.success is True assert len(result.data) == 0 + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGetDistinctGenres: + """Tests for GET /api/movies/genres endpoint.""" + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_success(self, mock_get_collection): + """Should return list of distinct genres sorted alphabetically.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Drama", "Action", "Comedy", "Horror", "Sci-Fi"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 5 + # Verify alphabetical sorting + assert result.data == ["Action", "Comedy", "Drama", "Horror", "Sci-Fi"] + mock_collection.distinct.assert_called_once_with("genres") + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_empty_list(self, mock_get_collection): + """Should return empty list when no genres exist.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = [] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 0 + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_filters_null_and_empty(self, mock_get_collection): + """Should filter out null and empty genre values.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Action", None, "", "Drama", "Comedy"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 3 + assert "Action" in result.data + assert "Drama" in result.data + assert "Comedy" in result.data + assert None not in result.data + assert "" not in result.data + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_database_error(self, mock_get_collection): + """Should handle database errors gracefully.""" + # Setup mock to raise exception + mock_collection = AsyncMock() + mock_collection.distinct.side_effect = Exception("Database connection failed") + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + with pytest.raises(HTTPException) as exc_info: + await get_distinct_genres() + + # Assertions + assert exc_info.value.status_code == 500 + assert "Database error" in str(exc_info.value.detail)