diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f0749cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Git Attributes for docs-sample-apps +# Marks auto-generated files that are committed to version control +# so they're collapsed in GitHub PR diffs and excluded from code review + +# Maven Wrapper - Auto-generated by Apache Maven (committed to git) +# See: https://maven.apache.org/wrapper/ +server/java-spring/mvnw linguist-generated=true +server/java-spring/mvnw.cmd linguist-generated=true diff --git a/.gitignore b/.gitignore index e69de29..e90af98 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,54 @@ +# ============================================================================= +# Root .gitignore for MongoDB Sample Apps Monorepo +# ============================================================================= +# This file contains patterns common to all projects in the monorepo. +# Backend-specific patterns are defined in each backend's .gitignore file. +# ============================================================================= + +# Environment Variables (Global) +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +!.env.example + +# Operating System Files +.DS_Store +Thumbs.db + +# IDE and Editor Files (Global) +.idea/ +.vscode/ +*.swp +*.swo +*.swn +*.bak +*.tmp +*.iml +.project +.classpath +.settings/ +*.sublime-project +*.sublime-workspace + +# Logs (Global) +logs/ +*.log + +# Temporary Files (Global) +*.tmp +*.temp +.cache/ + +# Node.js (Global - applies to all Node.js projects) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Test Coverage (Global) +coverage/ +*.lcov +.nyc_output diff --git a/server/express/.gitignore b/server/express/.gitignore index 26db928..f9e1d4c 100644 --- a/server/express/.gitignore +++ b/server/express/.gitignore @@ -23,4 +23,5 @@ coverage/ .npm # macOS -.DS_Store \ No newline at end of file +.DS_Store + diff --git a/server/java-spring/.env.example b/server/java-spring/.env.example new file mode 100644 index 0000000..df7ce44 --- /dev/null +++ b/server/java-spring/.env.example @@ -0,0 +1,13 @@ +# MongoDB Connection +# Replace with your MongoDB Atlas connection string or local MongoDB URI +MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority + +# Server Configuration +# Port on which the Spring Boot application will run +PORT=3001 + +# CORS Configuration +# Allowed origin for cross-origin requests (frontend URL) +# For multiple origins, separate with commas +CORS_ORIGIN=http://localhost:3000 + diff --git a/server/java-spring/.gitignore b/server/java-spring/.gitignore new file mode 100644 index 0000000..7cf3f4a --- /dev/null +++ b/server/java-spring/.gitignore @@ -0,0 +1,64 @@ +# Java/Spring Boot - Specific Ignores +# Common patterns +.env +.DS_Store + +#Cache +.cache/ + +# Compiled Files +*.class +*.ctxt +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear + +# Crash Logs +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IntelliJ IDEA +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# Spring Boot +spring-boot-devtools.properties diff --git a/server/java-spring/.mvn/wrapper/maven-wrapper.properties b/server/java-spring/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..a57d0ee --- /dev/null +++ b/server/java-spring/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,13 @@ +# Maven Wrapper Configuration +# Use ./mvnw instead of mvn to automatically download and use the specified Maven version. + +# Maven Wrapper version (not the Maven version itself) +wrapperVersion=3.3.4 + +# Download only Maven binaries (recommended for most projects) +distributionType=only-script + +# Maven version to download - pins everyone to Maven 3.8.6 for consistent builds +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip + +# Usage: ./mvnw clean install (Unix/macOS) or mvnw.cmd clean install (Windows) diff --git a/server/java-spring/README.md b/server/java-spring/README.md new file mode 100644 index 0000000..90b020f --- /dev/null +++ b/server/java-spring/README.md @@ -0,0 +1,201 @@ +# sample-app-java-mflix (INTERNAL) + +A Spring Boot REST API demonstrating MongoDB CRUD operations using Spring Data MongoDB with the sample_mflix database. + +## Overview + +This application provides a REST API for managing movie data from MongoDB's sample_mflix database. It demonstrates: + +- Spring Data MongoDB for simplified data access +- CRUD operations (Create, Read, Update, Delete) +- Text search functionality +- Filtering, sorting, and pagination +- Comprehensive error handling +- API documentation with Swagger/OpenAPI +- MongoTemplate for complex queries + +## Prerequisites + +- Java 21 or later +- Maven 3.6 or later +- MongoDB Atlas account or local MongoDB instance with sample_mflix database + +## Project Structure + +``` +server/java-spring/ +├── src/ +│ ├── main/ +│ │ ├── java/com/mongodb/samplemflix/ +│ │ │ ├── SampleMflixApplication.java # Main application class +│ │ │ ├── config/ # Configuration classes +│ │ │ │ ├── MongoConfig.java # MongoDB client configuration +│ │ │ │ ├── CorsConfig.java # CORS configuration +│ │ │ │ └── DatabaseVerification.java # Startup database verification +│ │ │ ├── controller/ # REST controllers +│ │ │ ├── service/ # Business logic layer +│ │ │ ├── repository/ # Data access layer +│ │ │ ├── model/ # Domain models and DTOs +│ │ │ ├── exception/ # Custom exceptions +│ │ │ └── util/ # Utility classes +│ │ └── resources/ +│ │ └── application.properties # Application configuration +│ └── test/ # Test classes +├── pom.xml # Maven dependencies +└── README.md +``` + +## Setup Instructions + +### 1. Clone the Repository + +```bash +git clone +cd server/java-spring +``` + +### 2. Configure Environment Variables + +Copy the example environment file and update with your MongoDB connection details: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your MongoDB connection string: + +```properties +MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority +PORT=3001 +CORS_ORIGIN=http://localhost:3000 +``` + +> **Note**: This project uses [spring-dotenv](https://github.com/paulschwarz/spring-dotenv) to automatically load `.env` files, similar to Node.js applications. The `.env` file will be loaded automatically when you run the application. + +### 3. Load Sample Data + +If you haven't already, load the `sample_mflix` database into your MongoDB instance: + +- **MongoDB Atlas**: Use the "Load Sample Dataset" option in your cluster +- **Local MongoDB**: Follow the [MongoDB sample data documentation](https://www.mongodb.com/docs/atlas/sample-data/) + +### 4. Build the Project + +```bash +mvn clean install +``` + +### 5. Run the Application + +```bash +mvn spring-boot:run +``` + +The application will start on `http://localhost:3001` (or the port specified in your `.env` file). + +## API Documentation + +Once the application is running, you can access: + +- **Swagger UI**: http://localhost:3001/swagger-ui.html +- **OpenAPI JSON**: http://localhost:3001/api-docs + +## API Endpoints + +### Movies (✅ Implemented) + +- `GET /api/movies` - Get all movies (with filtering, sorting, pagination) +- `GET /api/movies/{id}` - Get a single movie by ID +- `POST /api/movies` - Create a new movie +- `POST /api/movies/batch` - Create multiple movies +- `PUT /api/movies/{id}` - Update a movie +- `PATCH /api/movies` - Update multiple movies +- `DELETE /api/movies/{id}` - Delete a movie +- `DELETE /api/movies` - Delete multiple movies +- `DELETE /api/movies/{id}/find-and-delete` - Find and delete a movie + +## Development + +### Running Tests + +```bash +# Run all tests +mvn test + +# Run tests with coverage +mvn test jacoco:report +``` + +### Building for Production + +```bash +mvn clean package +java -jar target/sample-mflix-spring-1.0.0.jar +``` + +## Implementation Status + +### Completed Features + +- **Movies CRUD API** - Full create, read, update, delete operations +- **Spring Data MongoDB** - Repository pattern with MongoTemplate for complex queries +- **Text Search** - Full-text search on movie titles, plots, and genres +- **Filtering & Pagination** - Query parameters for filtering, sorting, and pagination +- **Custom Exception Handling** - Global exception handler with proper HTTP status codes +- **Type-Safe DTOs** - Specific response types instead of generic Maps +- **Unit Tests** - 35 tests covering service and controller layers +- **OpenAPI Documentation** - Swagger UI available at `/swagger-ui.html` +- **Database Verification** - Startup checks for database connectivity and indexes + + +## Technology Stack + +- **Framework**: Spring Boot 3.5.7 +- **Java Version**: 21 +- **MongoDB**: Spring Data MongoDB 4.5.5 +- **Build Tool**: Maven +- **API Documentation**: SpringDoc OpenAPI 2.8.13 +- **Testing**: JUnit 5, Mockito, Spring Boot Test + +## Educational Purpose + +This application is designed as an educational sample to demonstrate: + +1. How to use Spring Data MongoDB for simplified data access +2. Best practices for Spring Boot REST API development +3. Proper separation of concerns (Controller → Service → Repository) +4. MongoDB CRUD operations and query patterns +5. Error handling and validation in Spring Boot +6. Using MongoTemplate for complex queries alongside Spring Data repositories + +## Troubleshooting + +### Connection Issues + +If you encounter connection issues: + +1. Verify your `MONGODB_URI` is correct +2. Check that your IP address is whitelisted in MongoDB Atlas +3. Ensure the sample_mflix database exists and contains data +4. Check the application logs for detailed error messages + +### Build Issues + +If Maven build fails: + +1. Ensure you have Java 21 or later installed: `java -version` +2. Ensure Maven is installed: `mvn -version` +3. Clear Maven cache: `mvn clean` +4. Try rebuilding: `mvn clean install` + +## License + +[TBD] + +## Contributing + +[TBD] + +## Issues + +[TBD] diff --git a/server/java-spring/mvnw b/server/java-spring/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/server/java-spring/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/server/java-spring/mvnw.cmd b/server/java-spring/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/server/java-spring/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/server/java-spring/pom.xml b/server/java-spring/pom.xml new file mode 100644 index 0000000..d6bc07b --- /dev/null +++ b/server/java-spring/pom.xml @@ -0,0 +1,139 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + + com.mongodb + sample-app-java-mflix + 1.0.0 + sample-app-java-mflix + Java Spring Boot backend for MongoDB sample_mflix application demonstrating CRUD operations using Spring Data MongoDB + + + 21 + 2.8.13 + 4.0.0 + 3.19.0 + 1.12.0 + 1.17.8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + me.paulschwarz + spring-dotenv + ${dotenv.version} + + + + + org.projectlombok + lombok + true + + + + + org.apache.commons + commons-lang3 + ${commons.lang3.version} + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.apache.commons + commons-lang3 + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + -javaagent:${settings.localRepository}/net/bytebuddy/byte-buddy-agent/${byte-buddy.version}/byte-buddy-agent-${byte-buddy.version}.jar + -Xshare:off + + + + + + net.revelc.code + impsort-maven-plugin + ${impsort.plugin.version} + + + ../.cache/impsort-maven-plugin-${impsort.plugin.version} + * + true + + + + sort-imports + + sort + + + + + + + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/SampleMflixApplication.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/SampleMflixApplication.java new file mode 100644 index 0000000..b3aff5b --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/SampleMflixApplication.java @@ -0,0 +1,39 @@ +package com.mongodb.samplemflix; + +import java.util.Map; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Main Spring Boot application class for the MongoDB Sample MFlix API. + * + *

This application demonstrates MongoDB CRUD operations using the MongoDB Java Driver + * in a Spring Boot environment. It provides a REST API for managing movie data from + * the sample_mflix database. + * + * @author MongoDB Documentation Team + * @version 1.0 + */ +@SpringBootApplication +@RestController +public class SampleMflixApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleMflixApplication.class, args); + } + + /** + * Root endpoint providing basic information about the API. + */ + @GetMapping("/") + public Map root() { + return Map.of( + "name", "sample-app-java-mflix", + "version", "1.0.0", + "description", "Java Spring Boot backend demonstrating MongoDB operations with the sample_mflix dataset", + "endpoints", Map.of("movies", "/api/movies") + ); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/config/CorsConfig.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/CorsConfig.java new file mode 100644 index 0000000..8e2a31b --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/CorsConfig.java @@ -0,0 +1,51 @@ +package com.mongodb.samplemflix.config; + +import java.util.Arrays; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * CORS (Cross-Origin Resource Sharing) configuration for the Sample MFlix API. + * + *

This configuration allows the frontend application (typically running on a different port + * during development) to make requests to this backend API. + * + *

The allowed origins are configured via the CORS_ORIGIN environment variable. + */ +@Configuration +public class CorsConfig { + + @Value("${cors.allowed.origins}") + private String allowedOrigins; + + /** + * Configures CORS filter to allow cross-origin requests from the frontend. + * + * @return configured CorsFilter + */ + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + + // Allow credentials (cookies, authorization headers) + config.setAllowCredentials(true); + + // Set allowed origins from environment variable + config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); + + // Allow all headers + config.addAllowedHeader("*"); + + // Allow all HTTP methods + config.addAllowedMethod("*"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsFilter(source); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/config/DatabaseVerification.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/DatabaseVerification.java new file mode 100644 index 0000000..fef480f --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/DatabaseVerification.java @@ -0,0 +1,149 @@ +package com.mongodb.samplemflix.config; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.samplemflix.model.Movie; +import jakarta.annotation.PostConstruct; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Database verification component that runs on application startup. + * + *

This component performs pre-flight checks to ensure the MongoDB database + * is properly configured and contains the expected data and indexes. + * + *

Verification steps: + * 1. Check if the movies collection exists + * 2. Verify the collection contains documents + * 3. Check for text search indexes on plot, title, and fullplot fields + * 4. Create text search index if missing + *

+ * This matches the behavior of the Express.js backend's verifyRequirements() function. + * The verification is non-blocking - the application will start even if verification fails, + * but warnings will be logged to help developers identify configuration issues. + */ +@Component +public class DatabaseVerification { + + private static final Logger logger = LoggerFactory.getLogger(DatabaseVerification.class); + + private static final String MOVIES_COLLECTION = "movies"; + private static final String TEXT_INDEX_NAME = "text_search_index"; + + private final MongoDatabase database; + + public DatabaseVerification(MongoDatabase database) { + this.database = database; + } + + /** + * Runs database verification checks after the bean is constructed. + * + *

This method is called automatically by Spring after dependency injection + * is complete. It performs all verification steps and logs the results. + * + *

The method catches all exceptions to prevent application startup failure, + * but logs errors to help developers identify issues. + */ + @PostConstruct + public void verifyDatabase() { + logger.info("Starting database verification for '{}'...", database.getName()); + + try { + // Verify movies collection exists and has data + verifyMoviesCollection(); + + logger.info("Database verification completed successfully"); + + } catch (Exception e) { + logger.error("Database verification failed: {}", e.getMessage(), e); + // Don't throw exception - allow application to start even if verification fails + // This allows developers to troubleshoot connection issues without preventing startup + } + } + + /** + * Verifies the movies collection exists, contains data, and has required indexes. + * + *

This method: + *

+     * 1. Checks if the movies collection exists (implicitly by accessing it)
+     * 2. Counts documents to verify sample data is loaded
+     * 3. Creates a text search index on plot, title, and fullplot fields
+     *
+ *

The text search index enables full-text search functionality across movie + * descriptions and titles, which is used by the search endpoint. + */ + private void verifyMoviesCollection() { + MongoCollection moviesCollection = database.getCollection(MOVIES_COLLECTION); + + // Check if collection has documents + // Using estimatedDocumentCount() for better performance (doesn't scan all documents) + long count = moviesCollection.estimatedDocumentCount(); + + logger.info("Movies collection found with {} documents", count); + + if (count == 0) { + logger.warn( + "Movies collection is empty. Please ensure sample_mflix data is loaded. " + + "Visit https://www.mongodb.com/docs/atlas/sample-data/ for instructions." + ); + } + + // Create text search index for full-text search functionality + createTextSearchIndex(moviesCollection); + } + + /** + * Creates a text search index on the movies collection if it doesn't already exist. + * + *

The index is created on three fields: + *

+     * - plot: Short movie description
+     * - title: Movie title
+     * - fullplot: Full movie description
+     * 
+ *

This enables the $text search operator to perform full-text search across + * these fields, which is used by the search endpoint in the API. + * + *

The index is created in the background to avoid blocking other operations. + * If the index already exists, MongoDB will ignore the duplicate creation request. + * + * @param moviesCollection the movies collection to create the index on + */ + private void createTextSearchIndex(MongoCollection moviesCollection) { + try { + // Create compound text index on plot, title, and fullplot fields + // The background option allows the index to be built without blocking other operations + IndexOptions indexOptions = new IndexOptions() + .name(TEXT_INDEX_NAME) + .background(true); + + // Create the text index using field name constants from Movie.Fields + // This makes the coupling between Movie class and index creation explicit + // and allows IDE "Find Usages" to track dependencies + // MongoDB will automatically ignore this if the index already exists + moviesCollection.createIndex( + Indexes.compoundIndex( + Indexes.text(Movie.Fields.PLOT), + Indexes.text(Movie.Fields.TITLE), + Indexes.text(Movie.Fields.FULLPLOT) + ), + indexOptions + ); + + logger.info("Text search index '{}' created/verified for movies collection", TEXT_INDEX_NAME); + + } catch (Exception e) { + // Log error but don't fail - the application can still function without the index + // (though text search queries will fail) + logger.error("Could not create text search index: {}", e.getMessage()); + logger.warn("Text search functionality may not work without the index"); + } + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java new file mode 100644 index 0000000..b0fcfce --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/MongoConfig.java @@ -0,0 +1,96 @@ +package com.mongodb.samplemflix.config; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoDatabase; +import java.util.concurrent.TimeUnit; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +/** + * MongoDB configuration class for the Sample MFlix application using Spring Data MongoDB. + * + *

This class extends AbstractMongoClientConfiguration to customize MongoDB client settings + * while leveraging Spring Data MongoDB's auto-configuration for repositories and templates. + * + *

Key features: + *

+ * - Connection pooling with configurable settings (max 100 connections, min 10)
+ * - Connection timeout configuration (10 seconds for connect and read)
+ * - Automatic POJO mapping (no manual codec configuration needed)
+ * - Repository scanning and auto-configuration
+ * - MongoTemplate bean creation for complex queries
+ * 
+ *

Spring Data MongoDB automatically: + *

+ * - Creates MongoClient and MongoTemplate beans
+ * - Handles POJO to BSON conversion
+ * - Manages connection lifecycle
+ * - Provides repository implementations
+ * 
+ */ +@Configuration +@EnableMongoRepositories(basePackages = "com.mongodb.samplemflix.repository") +public class MongoConfig extends AbstractMongoClientConfiguration { + + @Value("${spring.data.mongodb.uri}") + private String mongoUri; + + @Value("${spring.data.mongodb.database}") + private String databaseName; + + @Override + protected String getDatabaseName() { + return databaseName; + } + + @Override + protected void configureClientSettings(MongoClientSettings.Builder builder) { + // Validate connection string is not empty + if (mongoUri == null || mongoUri.trim().isEmpty()) { + throw new IllegalArgumentException( + "MONGODB_URI is not configured. Please check application.properties" + ); + } + + // Parse and validate the connection string + ConnectionString connectionString = new ConnectionString(mongoUri); + + // Apply connection string and custom settings + builder.applyConnectionString(connectionString) + // Configure connection pool for optimal performance + .applyToConnectionPoolSettings(poolBuilder -> + poolBuilder.maxSize(100) // Maximum connections in pool + .minSize(10) // Minimum connections to maintain + .maxConnectionIdleTime(60000, TimeUnit.MILLISECONDS) // Release idle connections after 60s + .maxWaitTime(10000, TimeUnit.MILLISECONDS) // Wait up to 10s for available connection + ) + // Configure socket timeouts to prevent hanging connections + .applyToSocketSettings(socketBuilder -> + socketBuilder.connectTimeout(10000, TimeUnit.MILLISECONDS) // 10s to establish connection + .readTimeout(10000, TimeUnit.MILLISECONDS) // 10s to wait for server response + ) + // Configure server selection timeout + .applyToClusterSettings(clusterBuilder -> + clusterBuilder.serverSelectionTimeout(10000, TimeUnit.MILLISECONDS) // 10s to select server + ); + } + + /** + * Provides a MongoDatabase bean for direct MongoDB driver access. + * + *

This bean is needed for components that require direct access to the MongoDB + * driver API (like DatabaseVerification), while still using Spring Data MongoDB + * for repository operations. + * + * @return the configured MongoDatabase instance + */ + @Bean + public MongoDatabase mongoDatabase() { + return mongoClient().getDatabase(databaseName); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectIdSerializer.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectIdSerializer.java new file mode 100644 index 0000000..bd50571 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectIdSerializer.java @@ -0,0 +1,30 @@ +package com.mongodb.samplemflix.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; +import org.bson.types.ObjectId; + +/** + * Custom serializer for MongoDB's ObjectId to convert it to a string representation. + * + *

MongoDB's ObjectId is a 12-byte unique identifier. By default, Jackson will serialize it + * as a base64 string, but we want to use the more human-readable hex string representation. + * + *

This custom serializer teaches Jackson to convert ObjectId to a hex string when + * writing JSON. + */ + +public class ObjectIdSerializer extends StdSerializer { + + public ObjectIdSerializer() { + super(ObjectId.class); + } + + @Override + public void serialize(ObjectId value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeString(value.toHexString()); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectMapperConfig.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectMapperConfig.java new file mode 100644 index 0000000..6be474d --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/config/ObjectMapperConfig.java @@ -0,0 +1,47 @@ +package com.mongodb.samplemflix.config; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.bson.types.ObjectId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; + +/** + * Configuration for customizing the ObjectMapper used for JSON serialization and deserialization. + * + *

This configuration disables the default timestamp serialization for dates and registers a + * custom serializer for MongoDB's ObjectId to convert it to a string representation. + * + *

It also registers a JavaTimeModule to handle Java 8 date and time types. + */ + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper(JsonFactory jsonFactory) { + ObjectMapper mapper = + new ObjectMapper(jsonFactory) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .registerModule(new JavaTimeModule()); + SimpleModule module = new SimpleModule(); + module.addSerializer(ObjectId.class, new ObjectIdSerializer()); + mapper.registerModule(module); + return mapper; + } + + @Bean + public JsonFactory jsonFactory() { + return new JsonFactory(); + } + + @Bean + public DataBufferFactory dataBufferFactory() { + return new DefaultDataBufferFactory(); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java new file mode 100644 index 0000000..0d243c3 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java @@ -0,0 +1,252 @@ +package com.mongodb.samplemflix.controller; + +import com.mongodb.samplemflix.model.Movie; +import com.mongodb.samplemflix.model.dto.BatchInsertResponse; +import com.mongodb.samplemflix.model.dto.BatchUpdateResponse; +import com.mongodb.samplemflix.model.dto.CreateMovieRequest; +import com.mongodb.samplemflix.model.dto.DeleteResponse; +import com.mongodb.samplemflix.model.dto.MovieSearchQuery; +import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; +import com.mongodb.samplemflix.model.response.SuccessResponse; +import com.mongodb.samplemflix.service.MovieService; +import jakarta.validation.Valid; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.bson.Document; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * REST controller for movie-related endpoints. + *

+ * This controller handles all HTTP requests for movie operations including: + *

+ * - GET /api/movies - Get all movies with filtering, sorting, and pagination
+ * - GET /api/movies/{id} - Get a single movie by ID
+ * - POST /api/movies - Create a new movie
+ * - POST /api/movies/batch - Create multiple movies
+ * - PUT /api/movies/{id} - Update a movie
+ * - PATCH /api/movies - Update multiple movies
+ * - DELETE /api/movies/{id} - Delete a movie
+ * - DELETE /api/movies - Delete multiple movies
+ * - DELETE /api/movies/{id}/find-and-delete - Find and delete a movie
+ * 
+ */ +@RestController +@RequestMapping("/api/movies") +public class MovieControllerImpl { + + private final MovieService movieService; + + public MovieControllerImpl(MovieService movieService) { + this.movieService = movieService; + } + + /** + * GET /api/movies + * + *

Retrieves multiple movies with optional filtering, sorting, and pagination. + */ + @GetMapping + public ResponseEntity>> getAllMovies( + @RequestParam(required = false) String q, + @RequestParam(required = false) String genre, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Double minRating, + @RequestParam(required = false) Double maxRating, + @RequestParam(defaultValue = "20") Integer limit, + @RequestParam(defaultValue = "0") Integer skip, + @RequestParam(defaultValue = "title") String sortBy, + @RequestParam(defaultValue = "asc") String sortOrder) { + + MovieSearchQuery query = MovieSearchQuery.builder() + .q(q) + .genre(genre) + .year(year) + .minRating(minRating) + .maxRating(maxRating) + .limit(limit) + .skip(skip) + .sortBy(sortBy) + .sortOrder(sortOrder) + .build(); + + List movies = movieService.getAllMovies(query); + + SuccessResponse> response = SuccessResponse.>builder() + .success(true) + .message("Found " + movies.size() + " movies") + .data(movies) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * GET /api/movies/{id} + * + *

Retrieves a single movie by its ObjectId. + */ + @GetMapping("/{id}") + public ResponseEntity> getMovieById(@PathVariable String id) { + Movie movie = movieService.getMovieById(id); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Movie retrieved successfully") + .data(movie) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * POST /api/movies + * + *

Creates a single new movie document. + */ + @PostMapping + public ResponseEntity> createMovie(@Valid @RequestBody CreateMovieRequest request) { + Movie movie = movieService.createMovie(request); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Movie '" + request.getTitle() + "' created successfully") + .data(movie) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * POST /api/movies/batch + * + *

Creates multiple movie documents in a single operation. + */ + @PostMapping("/batch") + public ResponseEntity> createMoviesBatch( + @RequestBody List requests) { + BatchInsertResponse result = movieService.createMoviesBatch(requests); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Successfully created " + result.getInsertedCount() + " movies") + .data(result) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * PUT /api/movies/{id} + * + *

Updates a single movie document. + */ + @PutMapping("/{id}") + public ResponseEntity> updateMovie( + @PathVariable String id, + @RequestBody UpdateMovieRequest request) { + Movie movie = movieService.updateMovie(id, request); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Movie updated successfully") + .data(movie) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * PATCH /api/movies + * + *

Updates multiple movies based on a filter. + */ + @SuppressWarnings("unchecked") + @PatchMapping + public ResponseEntity> updateMoviesBatch( + @RequestBody Map body) { + Document filter = new Document((Map) body.get("filter")); + Document update = new Document((Map) body.get("update")); + + BatchUpdateResponse result = movieService.updateMoviesBatch(filter, update); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Update operation completed. Matched " + result.getMatchedCount() + + " documents, modified " + result.getModifiedCount() + " documents.") + .data(result) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * DELETE /api/movies/{id}/find-and-delete + * + *

Finds and deletes a movie in a single atomic operation. + */ + @DeleteMapping("/{id}/find-and-delete") + public ResponseEntity> findAndDeleteMovie(@PathVariable String id) { + Movie movie = movieService.findAndDeleteMovie(id); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Movie found and deleted successfully") + .data(movie) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * DELETE /api/movies/{id} + * + *

Deletes a single movie document. + */ + @DeleteMapping("/{id}") + public ResponseEntity> deleteMovie(@PathVariable String id) { + DeleteResponse result = movieService.deleteMovie(id); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Movie deleted successfully") + .data(result) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * DELETE /api/movies + * + *

Deletes multiple movies based on a filter. + */ + @SuppressWarnings("unchecked") + @DeleteMapping + public ResponseEntity> deleteMoviesBatch( + @RequestBody Map body) { + Document filter = new Document((Map) body.get("filter")); + + DeleteResponse result = movieService.deleteMoviesBatch(filter); + + SuccessResponse response = SuccessResponse.builder() + .success(true) + .message("Delete operation completed. Removed " + result.getDeletedCount() + " documents.") + .data(result) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/DatabaseOperationException.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/DatabaseOperationException.java new file mode 100644 index 0000000..44cfd52 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/DatabaseOperationException.java @@ -0,0 +1,20 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when a database operation fails unexpectedly. + * + * This exception results in a 500 Internal Server Error response. + * Used for cases where database operations are not acknowledged or + * fail to complete as expected. + */ +public class DatabaseOperationException extends RuntimeException { + + public DatabaseOperationException(String message) { + super(message); + } + + public DatabaseOperationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ef5f11c --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java @@ -0,0 +1,125 @@ +package com.mongodb.samplemflix.exception; + +import com.mongodb.MongoWriteException; +import com.mongodb.samplemflix.model.response.ErrorResponse; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; + +/** + * Global exception handler for the application. + * + * This class uses @ControllerAdvice to handle exceptions thrown by controllers + * and convert them into appropriate HTTP responses. + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleResourceNotFoundException( + ResourceNotFoundException ex, WebRequest request) { + logger.error("Resource not found: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("RESOURCE_NOT_FOUND") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity handleValidationException( + ValidationException ex, WebRequest request) { + logger.error("Validation error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message("Validation failed") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VALIDATION_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(DatabaseOperationException.class) + public ResponseEntity handleDatabaseOperationException( + DatabaseOperationException ex, WebRequest request) { + logger.error("Database operation error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message("Database operation failed") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("DATABASE_OPERATION_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(MongoWriteException.class) + public ResponseEntity handleMongoWriteException( + MongoWriteException ex, WebRequest request) { + logger.error("MongoDB write error: {}", ex.getMessage()); + + String message = "Database error"; + String code = "DATABASE_ERROR"; + HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; + + if (ex.getError().getCode() == 11000) { + message = "Duplicate key error"; + code = "DUPLICATE_KEY"; + status = HttpStatus.CONFLICT; + } + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(message) + .error(ErrorResponse.ErrorDetails.builder() + .message(message) + .code(code) + .details(ex.getError().getCode()) + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, status); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, WebRequest request) { + logger.error("Unexpected error occurred", ex); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage() != null ? ex.getMessage() : "Internal server error") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage() != null ? ex.getMessage() : "Internal server error") + .code("INTERNAL_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ResourceNotFoundException.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..ac2c81c --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ResourceNotFoundException.java @@ -0,0 +1,20 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when a requested resource is not found. + * + * This exception results in a 404 Not Found response. + * + * TODO: Phase 7 - Implement custom exception + */ +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ValidationException.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ValidationException.java new file mode 100644 index 0000000..84175df --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ValidationException.java @@ -0,0 +1,20 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when request validation fails. + * + * This exception results in a 400 Bad Request response. + * + * TODO: Phase 7 - Implement custom exception + */ +public class ValidationException extends RuntimeException { + + public ValidationException(String message) { + super(message); + } + + public ValidationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Comment.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Comment.java new file mode 100644 index 0000000..15d9c94 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Comment.java @@ -0,0 +1,69 @@ +package com.mongodb.samplemflix.model; + +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Domain model representing a comment document from the MongoDB comments collection. + * + *

This class maps to the comments collection in the sample_mflix database. + * Comments are user reviews/comments associated with movies. + * + *

TODO: Implement Comment functionality: + * - Create CommentRepository extending MongoRepository + * - Create CommentService and CommentServiceImpl + * - Create CommentController with REST endpoints + * - Add validation annotations (@NotNull, @Email, etc.) + * - Add unit tests for Comment service and controller + * - Add integration tests + * - Implement query methods (findByMovieId, findByEmail, etc.) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "comments") +public class Comment { + + /** + * MongoDB document ID. + * Maps to the _id field in MongoDB. + */ + @Id + private ObjectId id; + + /** + * Name of the commenter. + */ + private String name; + + /** + * Email address of the commenter. + */ + private String email; + + /** + * ID of the movie this comment is associated with. + * References a document in the movies collection. + */ + @Field("movie_id") + private ObjectId movieId; + + /** + * Comment text content. + */ + private String text; + + /** + * Date when the comment was posted. + */ + private Date date; + +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java new file mode 100644 index 0000000..eb2fb83 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java @@ -0,0 +1,309 @@ +package com.mongodb.samplemflix.model; + +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * Domain model representing a movie document from the MongoDB movies collection. + * + *

This class maps to the movies collection in the sample_mflix database. + * It includes all fields from the movie documents including nested objects + * for awards, IMDB ratings, and Tomatoes ratings. + * + *

Note: We use Lombok annotations to reduce boilerplate code: + * - @Data: Generates getters, setters, toString, equals, and hashCode + * - @Builder: Provides a fluent builder pattern for object construction + * - @NoArgsConstructor: Generates a no-argument constructor (required by MongoDB driver) + * - @AllArgsConstructor: Generates a constructor with all fields + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "movies") +public class Movie { + + /** + * Field name constants for MongoDB operations. + * + *

These constants should be used when referencing field names in queries, filters, + * indexes, and other MongoDB operations to ensure type safety and enable IDE + * "Find Usages" functionality. + * + *

Example usage: + *

+     * filter.append(Movie.Fields.TITLE, "The Matrix");
+     * Indexes.text(Movie.Fields.PLOT);
+     * 
+ */ + public static class Fields { + public static final String ID = "_id"; + public static final String TITLE = "title"; + public static final String YEAR = "year"; + public static final String PLOT = "plot"; + public static final String FULLPLOT = "fullplot"; + public static final String RELEASED = "released"; + public static final String RUNTIME = "runtime"; + public static final String POSTER = "poster"; + public static final String GENRES = "genres"; + public static final String DIRECTORS = "directors"; + public static final String WRITERS = "writers"; + public static final String CAST = "cast"; + public static final String COUNTRIES = "countries"; + public static final String LANGUAGES = "languages"; + public static final String RATED = "rated"; + public static final String AWARDS = "awards"; + public static final String IMDB = "imdb"; + public static final String IMDB_RATING = "imdb.rating"; + public static final String TOMATOES = "tomatoes"; + public static final String METACRITIC = "metacritic"; + public static final String TYPE = "type"; + + private Fields() { + // Private constructor to prevent instantiation + } + } + + /** + * MongoDB document ID. + * Maps to the _id field in MongoDB. + * Can be null for new documents (MongoDB will generate it). + */ + private ObjectId id; + + /** + * Movie title (required field). + */ + private String title; + + /** + * Release year. + */ + private Integer year; + + /** + * Short plot summary. + */ + private String plot; + + /** + * Full plot description. + */ + private String fullplot; + + /** + * Release date. + */ + private Date released; + + /** + * Runtime in minutes. + */ + private Integer runtime; + + /** + * Poster image URL. + */ + private String poster; + + /** + * List of genres (e.g., "Action", "Drama", "Comedy"). + */ + private List genres; + + /** + * List of directors. + */ + private List directors; + + /** + * List of writers. + */ + private List writers; + + /** + * List of cast members. + */ + private List cast; + + /** + * List of countries where the movie was produced. + */ + private List countries; + + /** + * List of languages in the movie. + */ + private List languages; + + /** + * Movie rating (e.g., "PG", "PG-13", "R"). + */ + private String rated; + + /** + * Awards information (wins, nominations, text). + */ + private Awards awards; + + /** + * IMDB rating information. + */ + private Imdb imdb; + + /** + * Rotten Tomatoes rating information. + */ + private Tomatoes tomatoes; + + /** + * Metacritic score. + */ + private Integer metacritic; + + /** + * Type of content (e.g., "movie", "series"). + */ + private String type; + + /** + * Nested class representing awards information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Awards { + /** + * Number of awards won. + */ + private Integer wins; + + /** + * Number of nominations. + */ + private Integer nominations; + + /** + * Text description of awards. + */ + private String text; + } + + /** + * Nested class representing IMDB rating information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Imdb { + /** + * IMDB rating (0.0 to 10.0). + */ + private Double rating; + + /** + * Number of votes. + */ + private Integer votes; + + /** + * IMDB ID number. + */ + private Integer id; + } + + /** + * Nested class representing Rotten Tomatoes rating information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Tomatoes { + /** + * Viewer ratings information. + */ + private Viewer viewer; + + /** + * Critic ratings information. + */ + private Critic critic; + + /** + * Number of fresh reviews. + */ + private Integer fresh; + + /** + * Number of rotten reviews. + */ + private Integer rotten; + + /** + * Production company. + */ + private String production; + + /** + * Last updated date. + */ + private Date lastUpdated; + + /** + * Nested class for viewer ratings. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Viewer { + /** + * Viewer rating (0.0 to 5.0). + */ + private Double rating; + + /** + * Number of viewer reviews. + */ + private Integer numReviews; + + /** + * Viewer meter percentage (0-100). + */ + private Integer meter; + } + + /** + * Nested class for critic ratings. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Critic { + /** + * Critic rating (0.0 to 5.0). + */ + private Double rating; + + /** + * Number of critic reviews. + */ + private Integer numReviews; + + /** + * Critic meter percentage (0-100). + */ + private Integer meter; + } + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Theater.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Theater.java new file mode 100644 index 0000000..c0d2045 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Theater.java @@ -0,0 +1,119 @@ +package com.mongodb.samplemflix.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * Domain model representing a theater document from the MongoDB theaters collection. + * + *

This class maps to the theaters collection in the sample_mflix database. + * It includes location information with address and geospatial coordinates. + * + *

TODO: Implement Theater functionality: + * - Create TheaterRepository extending MongoRepository + * - Create TheaterService and TheaterServiceImpl + * - Create TheaterController with REST endpoints + * - Implement geospatial queries (findNear, findWithinRadius) + * - Add validation annotations + * - Add unit tests for Theater service and controller + * - Add integration tests with geospatial queries + * - Add GeoJSON support for location queries + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "theaters") +public class Theater { + + /** + * MongoDB document ID. + * Maps to the _id field in MongoDB. + */ + @Id + private ObjectId id; + + /** + * Theater ID number. + */ + private Integer theaterId; + + /** + * Location information including address and geospatial coordinates. + */ + private Location location; + + /** + * Nested class representing location information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Location { + /** + * Address information. + */ + private Address address; + + /** + * Geospatial coordinates. + */ + private Geo geo; + + /** + * Nested class for address information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Address { + /** + * Street address line 1. + */ + private String street1; + + /** + * City name. + */ + private String city; + + /** + * State or province. + */ + private String state; + + /** + * ZIP or postal code. + */ + private String zipcode; + } + + /** + * Nested class for geospatial coordinates. + * Uses GeoJSON format for MongoDB geospatial queries. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Geo { + /** + * GeoJSON type (always "Point" for theater locations). + */ + private String type; + + /** + * Coordinates array: [longitude, latitude]. + * Note: GeoJSON uses longitude first, then latitude. + */ + private double[] coordinates; + } + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java new file mode 100644 index 0000000..94edcd0 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java @@ -0,0 +1,19 @@ +package com.mongodb.samplemflix.model.dto; + +import java.util.Collection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.bson.BsonValue; + +/** + * Response DTO for batch insert operations. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BatchInsertResponse { + private int insertedCount; + private Collection insertedIds; +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java new file mode 100644 index 0000000..354bdbc --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java @@ -0,0 +1,17 @@ +package com.mongodb.samplemflix.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Response DTO for batch update operations. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BatchUpdateResponse { + private long matchedCount; + private long modifiedCount; +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java new file mode 100644 index 0000000..c333064 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java @@ -0,0 +1,89 @@ +package com.mongodb.samplemflix.model.dto; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for creating a new movie. + * + *

This DTO is used for POST /api/movies requests. + * It includes validation annotations to ensure required fields are present. + * Only the title field is required; all other fields are optional. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateMovieRequest { + + /** + * Movie title (required). + * Must not be blank. + */ + @NotBlank(message = "Title is required") + private String title; + + /** + * Release year (optional). + */ + private Integer year; + + /** + * Short plot summary (optional). + */ + private String plot; + + /** + * Full plot description (optional). + */ + private String fullplot; + + /** + * List of genres (optional). + */ + private List genres; + + /** + * List of directors (optional). + */ + private List directors; + + /** + * List of writers (optional). + */ + private List writers; + + /** + * List of cast members (optional). + */ + private List cast; + + /** + * List of countries (optional). + */ + private List countries; + + /** + * List of languages (optional). + */ + private List languages; + + /** + * Movie rating (optional). + */ + private String rated; + + /** + * Runtime in minutes (optional). + */ + private Integer runtime; + + /** + * Poster image URL (optional). + */ + private String poster; +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java new file mode 100644 index 0000000..b7ddc85 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java @@ -0,0 +1,16 @@ +package com.mongodb.samplemflix.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Response DTO for delete operations. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeleteResponse { + private long deletedCount; +} + diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java new file mode 100644 index 0000000..dc0ea46 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java @@ -0,0 +1,67 @@ +package com.mongodb.samplemflix.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for movie search query parameters. + * + *

This DTO is used to parse and validate query parameters for GET /api/movies requests. + * It supports full-text search, filtering by genre/year/rating, sorting, and pagination. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MovieSearchQuery { + + /** + * Full-text search query. + * Searches across plot, title, and fullplot fields using MongoDB text index. + */ + private String q; + + /** + * Filter by genre (case-insensitive partial match). + */ + private String genre; + + /** + * Filter by exact year. + */ + private Integer year; + + /** + * Minimum IMDB rating (inclusive). + */ + private Double minRating; + + /** + * Maximum IMDB rating (inclusive). + */ + private Double maxRating; + + /** + * Number of results to return (default: 20, max: 100). + */ + private Integer limit; + + /** + * Number of results to skip for pagination (default: 0). + */ + private Integer skip; + + /** + * Field to sort by (e.g., "title", "year", "imdb.rating"). + * Default: "title" + */ + private String sortBy; + + /** + * Sort order: "asc" or "desc". + * Default: "asc" + */ + private String sortOrder; +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java new file mode 100644 index 0000000..f855276 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java @@ -0,0 +1,86 @@ +package com.mongodb.samplemflix.model.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for updating an existing movie. + * + *

This DTO is used for PUT /api/movies/{id} requests. + * All fields are optional since partial updates are allowed. + * Any field that is null will not be updated in the database. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateMovieRequest { + + /** + * Movie title (optional). + */ + private String title; + + /** + * Release year (optional). + */ + private Integer year; + + /** + * Short plot summary (optional). + */ + private String plot; + + /** + * Full plot description (optional). + */ + private String fullplot; + + /** + * List of genres (optional). + */ + private List genres; + + /** + * List of directors (optional). + */ + private List directors; + + /** + * List of writers (optional). + */ + private List writers; + + /** + * List of cast members (optional). + */ + private List cast; + + /** + * List of countries (optional). + */ + private List countries; + + /** + * List of languages (optional). + */ + private List languages; + + /** + * Movie rating (optional). + */ + private String rated; + + /** + * Runtime in minutes (optional). + */ + private Integer runtime; + + /** + * Poster image URL (optional). + */ + private String poster; +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java new file mode 100644 index 0000000..1374efb --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java @@ -0,0 +1,28 @@ +package com.mongodb.samplemflix.model.response; + +/** + * Generic API response interface. + * + *

This interface is implemented by both SuccessResponse and ErrorResponse + * to provide a consistent response structure across all API endpoints. + * + *

All API responses include: + * - success: boolean indicating if the request was successful + * - timestamp: ISO 8601 timestamp of when the response was generated + */ +public interface ApiResponse { + + /** + * Indicates whether the request was successful. + * + * @return true for successful responses, false for error responses + */ + boolean isSuccess(); + + /** + * Gets the timestamp when the response was generated. + * + * @return ISO 8601 formatted timestamp string + */ + String getTimestamp(); +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java new file mode 100644 index 0000000..6fab419 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java @@ -0,0 +1,78 @@ +package com.mongodb.samplemflix.model.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Error response wrapper for API error responses. + * + *

This class wraps error responses with error codes, messages, and metadata. + * + *

 {
+ *   success: false,
+ *   message: string,
+ *   error: {
+ *     message: string,
+ *     code?: string,
+ *     details?: any
+ *   },
+ *   timestamp: string
+ * }
+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse implements ApiResponse { + + /** + * Always false for error responses. + */ + @Builder.Default + private boolean success = false; + + /** + * High-level error message. + */ + private String message; + + /** + * Detailed error information. + */ + private ErrorDetails error; + + /** + * ISO 8601 timestamp when the error occurred. + */ + @Builder.Default + private String timestamp = Instant.now().toString(); + + /** + * Nested class for detailed error information. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ErrorDetails { + /** + * Detailed error message. + */ + private String message; + + /** + * Error code (e.g., "VALIDATION_ERROR", "NOT_FOUND"). + */ + private String code; + + /** + * Additional error details (optional). + */ + private Object details; + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java new file mode 100644 index 0000000..69550ee --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java @@ -0,0 +1,86 @@ +package com.mongodb.samplemflix.model.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Success response wrapper for API responses. + * + *

This class wraps successful API responses with metadata like timestamp and pagination. + * It uses a generic type parameter T to hold the response data. + * + *

  {
+ *   success: true,
+ *   message?: string,
+ *   data: T,
+ *   timestamp: string,
+ *   pagination?: { page, limit, total, pages }
+ * }
+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SuccessResponse implements ApiResponse { + + /** + * Always true for success responses. + */ + @Builder.Default + private boolean success = true; + + /** + * Optional success message. + */ + private String message; + + /** + * The response data (generic type). + */ + private T data; + + /** + * ISO 8601 timestamp when the response was generated. + */ + @Builder.Default + private String timestamp = Instant.now().toString(); + + /** + * Optional pagination metadata (for list responses). + */ + private Pagination pagination; + + /** + * Nested class for pagination metadata. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Pagination { + /** + * Current page number (1-based). + */ + private int page; + + /** + * Number of items per page. + */ + private int limit; + + /** + * Total number of items. + */ + private long total; + + /** + * Total number of pages. + */ + private int pages; + } +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/repository/MovieRepository.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/repository/MovieRepository.java new file mode 100644 index 0000000..77cb013 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/repository/MovieRepository.java @@ -0,0 +1,37 @@ +package com.mongodb.samplemflix.repository; + +import com.mongodb.samplemflix.model.Movie; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data MongoDB repository for movie data access. + * + * This repository extends MongoRepository which provides: + * - Basic CRUD operations (save, findById, findAll, delete, etc.) + * - Pagination and sorting support + * - Query derivation from method names + * - Custom query support via @Query annotation + * + * For complex queries not supported by Spring Data, you can inject MongoTemplate + * in the service layer. + */ +@Repository +public interface MovieRepository extends MongoRepository { + + // Spring Data MongoDB provides these methods automatically: + // - save(Movie movie) - insert or update + // - saveAll(Iterable movies) - batch insert/update + // - findById(ObjectId id) - find by ID + // - findAll() - find all documents + // - findAll(Pageable pageable) - find with pagination + // - deleteById(ObjectId id) - delete by ID + // - delete(Movie movie) - delete entity + // - count() - count all documents + // - existsById(ObjectId id) - check if exists + + // Custom query methods can be added here using method name conventions: + // Example: List findByGenresContaining(String genre); + // Example: List findByYearBetween(Integer startYear, Integer endYear); +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java new file mode 100644 index 0000000..7327749 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java @@ -0,0 +1,35 @@ +package com.mongodb.samplemflix.service; + +import com.mongodb.samplemflix.model.Movie; +import com.mongodb.samplemflix.model.dto.BatchInsertResponse; +import com.mongodb.samplemflix.model.dto.BatchUpdateResponse; +import com.mongodb.samplemflix.model.dto.CreateMovieRequest; +import com.mongodb.samplemflix.model.dto.DeleteResponse; +import com.mongodb.samplemflix.model.dto.MovieSearchQuery; +import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; +import java.util.List; +import org.bson.Document; + +/** + * Service interface for movie business logic. + */ +public interface MovieService { + + List getAllMovies(MovieSearchQuery query); + + Movie getMovieById(String id); + + Movie createMovie(CreateMovieRequest request); + + BatchInsertResponse createMoviesBatch(List requests); + + Movie updateMovie(String id, UpdateMovieRequest request); + + BatchUpdateResponse updateMoviesBatch(Document filter, Document update); + + DeleteResponse deleteMovie(String id); + + DeleteResponse deleteMoviesBatch(Document filter); + + Movie findAndDeleteMovie(String id); +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java new file mode 100644 index 0000000..571cd07 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -0,0 +1,341 @@ +package com.mongodb.samplemflix.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.result.DeleteResult; +import com.mongodb.client.result.UpdateResult; +import com.mongodb.samplemflix.exception.DatabaseOperationException; +import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.model.Movie; +import com.mongodb.samplemflix.model.dto.BatchInsertResponse; +import com.mongodb.samplemflix.model.dto.BatchUpdateResponse; +import com.mongodb.samplemflix.model.dto.CreateMovieRequest; +import com.mongodb.samplemflix.model.dto.DeleteResponse; +import com.mongodb.samplemflix.model.dto.MovieSearchQuery; +import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; +import com.mongodb.samplemflix.repository.MovieRepository; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.bson.BsonValue; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.TextCriteria; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +/** + * Service layer for movie business logic using Spring Data MongoDB. + * + *

This service handles: + *

+ * - Business logic and validation
+ * - Query construction using Spring Data MongoDB Query API
+ * - Data transformation between DTOs and entities
+ * - Error handling and exception throwing
+ * 
+ * Uses both: + *
+ * - MovieRepository (Spring Data) for simple CRUD operations
+ * - MongoTemplate for complex queries and batch operations
+ * 
+ */ +@Service +public class MovieServiceImpl implements MovieService { + + private final MovieRepository movieRepository; + private final MongoTemplate mongoTemplate; + private final ObjectMapper objectMapper; + + public MovieServiceImpl(MovieRepository movieRepository, MongoTemplate mongoTemplate, ObjectMapper objectMapper) { + this.movieRepository = movieRepository; + this.mongoTemplate = mongoTemplate; + this.objectMapper = objectMapper; + } + + @Override + public List getAllMovies(MovieSearchQuery query) { + Query mongoQuery = buildQuery(query); + + int limit = Math.clamp(query.getLimit() != null ? query.getLimit() : 20, 1, 100); + int skip = Math.max(query.getSkip() != null ? query.getSkip() : 0, 0); + + mongoQuery.skip(skip).limit(limit); + mongoQuery.with(buildSort(query.getSortBy(), query.getSortOrder())); + + return mongoTemplate.find(mongoQuery, Movie.class); + } + + @Override + public Movie getMovieById(String id) { + if (!ObjectId.isValid(id)) { + throw new ValidationException("Invalid movie ID format"); + } + + return movieRepository.findById(new ObjectId(id)) + .orElseThrow(() -> new ResourceNotFoundException("Movie not found")); + } + + @Override + public Movie createMovie(CreateMovieRequest request) { + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new ValidationException("Title is required"); + } + + Movie movie = Movie.builder() + .title(request.getTitle()) + .year(request.getYear()) + .plot(request.getPlot()) + .fullplot(request.getFullplot()) + .genres(request.getGenres()) + .directors(request.getDirectors()) + .writers(request.getWriters()) + .cast(request.getCast()) + .countries(request.getCountries()) + .languages(request.getLanguages()) + .rated(request.getRated()) + .runtime(request.getRuntime()) + .poster(request.getPoster()) + .build(); + + // Spring Data MongoDB's save() method inserts or updates + return movieRepository.save(movie); + } + + @Override + public BatchInsertResponse createMoviesBatch(List requests) { + if (requests == null || requests.isEmpty()) { + throw new ValidationException("Request body must be a non-empty array of movie objects"); + } + + for (int i = 0; i < requests.size(); i++) { + CreateMovieRequest request = requests.get(i); + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new ValidationException("Movie at index " + i + ": Title is required"); + } + } + + List movies = requests.stream() + .map(request -> Movie.builder() + .title(request.getTitle()) + .year(request.getYear()) + .plot(request.getPlot()) + .fullplot(request.getFullplot()) + .genres(request.getGenres()) + .directors(request.getDirectors()) + .writers(request.getWriters()) + .cast(request.getCast()) + .countries(request.getCountries()) + .languages(request.getLanguages()) + .rated(request.getRated()) + .runtime(request.getRuntime()) + .poster(request.getPoster()) + .build()) + .toList(); + + // Spring Data MongoDB's saveAll() method for batch insert + List savedMovies = movieRepository.saveAll(movies); + + // Extract IDs from saved movies + Collection insertedIds = savedMovies.stream() + .map(movie -> new org.bson.BsonObjectId(movie.getId())) + .collect(Collectors.toList()); + + return new BatchInsertResponse( + savedMovies.size(), + insertedIds + ); + } + + @Override + public Movie updateMovie(String id, UpdateMovieRequest request) { + if (!ObjectId.isValid(id)) { + throw new ValidationException("Invalid movie ID format"); + } + + if (request == null || isUpdateRequestEmpty(request)) { + throw new ValidationException("No update data provided"); + } + + ObjectId objectId = new ObjectId(id); + + // Build Spring Data MongoDB Update object + Update update = buildUpdate(request); + + // Use MongoTemplate for update operation + Query query = new Query(Criteria.where("_id").is(objectId)); + UpdateResult result = mongoTemplate.updateFirst(query, update, Movie.class); + + if (result.getMatchedCount() == 0) { + throw new ResourceNotFoundException("Movie not found"); + } + + return movieRepository.findById(objectId) + .orElseThrow(() -> new DatabaseOperationException("Failed to retrieve updated movie")); + } + + @Override + public BatchUpdateResponse updateMoviesBatch(Document filter, Document update) { + if (filter == null || update == null) { + throw new ValidationException("Both filter and update objects are required"); + } + + if (update.isEmpty()) { + throw new ValidationException("Update object cannot be empty"); + } + + // Convert Document filter to Spring Data Query + Query query = new Query(); + filter.forEach((key, value) -> query.addCriteria(Criteria.where(key).is(value))); + + // Convert Document update to Spring Data Update + Update mongoUpdate = new Update(); + update.forEach(mongoUpdate::set); + + UpdateResult result = mongoTemplate.updateMulti(query, mongoUpdate, Movie.class); + + return new BatchUpdateResponse( + result.getMatchedCount(), + result.getModifiedCount() + ); + } + + @Override + public DeleteResponse deleteMovie(String id) { + if (!ObjectId.isValid(id)) { + throw new ValidationException("Invalid movie ID format"); + } + + ObjectId objectId = new ObjectId(id); + + // Check if movie exists before deleting + if (!movieRepository.existsById(objectId)) { + throw new ResourceNotFoundException("Movie not found"); + } + + movieRepository.deleteById(objectId); + + return new DeleteResponse(1L); + } + + @Override + public DeleteResponse deleteMoviesBatch(Document filter) { + if (filter == null || filter.isEmpty()) { + throw new ValidationException("Filter object is required and cannot be empty. This prevents accidental deletion of all documents."); + } + + // Convert Document filter to Spring Data Query + Query query = new Query(); + filter.forEach((key, value) -> query.addCriteria(Criteria.where(key).is(value))); + + DeleteResult result = mongoTemplate.remove(query, Movie.class); + + return new DeleteResponse(result.getDeletedCount()); + } + + @Override + public Movie findAndDeleteMovie(String id) { + if (!ObjectId.isValid(id)) { + throw new ValidationException("Invalid movie ID format"); + } + + ObjectId objectId = new ObjectId(id); + Query query = new Query(Criteria.where("_id").is(objectId)); + + Movie movie = mongoTemplate.findAndRemove(query, Movie.class); + + if (movie == null) { + throw new ResourceNotFoundException("Movie not found"); + } + + return movie; + } + + /** + * Builds a Spring Data MongoDB Query from the search parameters. + */ + private Query buildQuery(MovieSearchQuery query) { + Query mongoQuery = new Query(); + + // Text search + if (query.getQ() != null && !query.getQ().trim().isEmpty()) { + TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching(query.getQ()); + mongoQuery.addCriteria(textCriteria); + } + + // Genre filter (case-insensitive regex) + if (query.getGenre() != null && !query.getGenre().trim().isEmpty()) { + mongoQuery.addCriteria(Criteria.where(Movie.Fields.GENRES) + .regex(Pattern.compile(query.getGenre(), Pattern.CASE_INSENSITIVE))); + } + + // Year filter + if (query.getYear() != null) { + mongoQuery.addCriteria(Criteria.where(Movie.Fields.YEAR).is(query.getYear())); + } + + // Rating range filter + if (query.getMinRating() != null || query.getMaxRating() != null) { + Criteria ratingCriteria = Criteria.where(Movie.Fields.IMDB_RATING); + if (query.getMinRating() != null) { + ratingCriteria = ratingCriteria.gte(query.getMinRating()); + } + if (query.getMaxRating() != null) { + ratingCriteria = ratingCriteria.lte(query.getMaxRating()); + } + mongoQuery.addCriteria(ratingCriteria); + } + + return mongoQuery; + } + + /** + * Builds a Spring Data Sort object from sort parameters. + */ + private Sort buildSort(String sortBy, String sortOrder) { + String field = sortBy != null && !sortBy.trim().isEmpty() ? sortBy : Movie.Fields.TITLE; + Sort.Direction direction = "desc".equalsIgnoreCase(sortOrder) ? Sort.Direction.DESC : Sort.Direction.ASC; + return Sort.by(direction, field); + } + + /** + * Checks if the update request has any non-null fields. + */ + private boolean isUpdateRequestEmpty(UpdateMovieRequest request) { + @SuppressWarnings("unchecked") + Map requestMap = objectMapper.convertValue(request, Map.class); + return requestMap.values().stream().allMatch(java.util.Objects::isNull); + } + + /** + * Builds a Spring Data MongoDB Update object from the update request. + */ + private Update buildUpdate(UpdateMovieRequest request) { + @SuppressWarnings("unchecked") + Map requestMap = objectMapper.convertValue(request, Map.class); + + Update update = new Update(); + requestMap.forEach((key, value) -> { + if (value != null) { + update.set(key, value); + } + }); + + return update; + } + + // TODO: Add advanced query methods + // - getMoviesByGenreStatistics() - Aggregation pipeline for genre statistics + // - getTopRatedMovies(int limit) - Movies sorted by rating + // - getMoviesByDecade(int decade) - Movies from a specific decade + // - getDirectorFilmography(String director) - All movies by a director + // - getActorFilmography(String actor) - All movies featuring an actor + // - searchSimilarMovies(String movieId) - Vector search on plot_embedding field + // - getMovieRecommendations(String userId) - Personalized recommendations +} diff --git a/server/java-spring/src/main/java/com/mongodb/samplemflix/util/ValidationUtils.java b/server/java-spring/src/main/java/com/mongodb/samplemflix/util/ValidationUtils.java new file mode 100644 index 0000000..a496b14 --- /dev/null +++ b/server/java-spring/src/main/java/com/mongodb/samplemflix/util/ValidationUtils.java @@ -0,0 +1,28 @@ +package com.mongodb.samplemflix.util; + +/** + * Utility class for validation operations. + * + * This class provides helper methods for validating request data. + * + * TODO: Implement validation utility methods: + * - ObjectId format validation + * - Date range validation + * - String sanitization + * - URL validation + */ +public class ValidationUtils { + + private ValidationUtils() { + // Private constructor to prevent instantiation + } + + // TODO: Add ObjectId validation (currently duplicated in service layer) + // public static boolean isValidObjectId(String id) + + // TODO: Add string sanitization for XSS prevention + // public static String sanitize(String input) + + // TODO: Add URL validation for poster and trailer fields + // public static boolean isValidUrl(String url) +} diff --git a/server/java-spring/src/main/resources/application.properties b/server/java-spring/src/main/resources/application.properties new file mode 100644 index 0000000..ff84d9a --- /dev/null +++ b/server/java-spring/src/main/resources/application.properties @@ -0,0 +1,28 @@ +# MongoDB Configuration +# Connection URI should be provided with the MONGODB_URI environment variable +spring.data.mongodb.uri=${MONGODB_URI} +spring.data.mongodb.database=sample_mflix + +# Server Configuration +# Default port is 3001, can be overridden with the PORT environment variable +server.port=${PORT:3001} + +# CORS Configuration +# Allowed origins for cross-origin requests (typically the frontend URL) +cors.allowed.origins=${CORS_ORIGIN:http://localhost:3000} + +# Application Info +spring.application.name=sample-app-java-mflix + +# Logging Configuration +logging.level.com.mongodb.samplemflix=INFO +logging.level.org.mongodb.driver=WARN + +# Jackson Configuration (JSON serialization) +spring.jackson.default-property-inclusion=non_null +spring.jackson.serialization.write-dates-as-timestamps=false + +# API Documentation (Swagger/OpenAPI) +springdoc.api-docs.path=/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.operationsSorter=method diff --git a/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java b/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java new file mode 100644 index 0000000..b9b5a65 --- /dev/null +++ b/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java @@ -0,0 +1,327 @@ +package com.mongodb.samplemflix.controller; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.model.Movie; +import com.mongodb.samplemflix.model.dto.BatchInsertResponse; +import com.mongodb.samplemflix.model.dto.CreateMovieRequest; +import com.mongodb.samplemflix.model.dto.DeleteResponse; +import com.mongodb.samplemflix.model.dto.MovieSearchQuery; +import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; +import com.mongodb.samplemflix.service.MovieService; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.bson.BsonObjectId; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Unit tests for MovieControllerImpl. + * + * These tests verify the REST API endpoints by mocking the service layer. + * Uses Spring's MockMvc for testing HTTP requests and responses. + */ +@WebMvcTest(MovieControllerImpl.class) +@DisplayName("MovieController Unit Tests") +class MovieControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MovieService movieService; + + private ObjectId testId; + private Movie testMovie; + private CreateMovieRequest createRequest; + private UpdateMovieRequest updateRequest; + + @BeforeEach + void setUp() { + testId = new ObjectId(); + + testMovie = Movie.builder() + .id(testId) + .title("Test Movie") + .year(2024) + .plot("A test plot") + .genres(Arrays.asList("Action", "Drama")) + .build(); + + createRequest = CreateMovieRequest.builder() + .title("New Movie") + .year(2024) + .plot("A new movie plot") + .build(); + + updateRequest = UpdateMovieRequest.builder() + .title("Updated Title") + .year(2025) + .build(); + } + + // ==================== GET ALL MOVIES TESTS ==================== + + @Test + @DisplayName("GET /api/movies - Should return list of movies") + void testGetAllMovies_Success() throws Exception { + // Arrange + List movies = Arrays.asList(testMovie); + when(movieService.getAllMovies(any(MovieSearchQuery.class))).thenReturn(movies); + + // Act & Assert + mockMvc.perform(get("/api/movies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].title").value("Test Movie")) + .andExpect(jsonPath("$.data[0].year").value(2024)); + } + + @Test + @DisplayName("GET /api/movies - Should handle query parameters") + void testGetAllMovies_WithQueryParams() throws Exception { + // Arrange + List movies = Arrays.asList(testMovie); + when(movieService.getAllMovies(any(MovieSearchQuery.class))).thenReturn(movies); + + // Act & Assert + mockMvc.perform(get("/api/movies") + .param("q", "test") + .param("genre", "Action") + .param("year", "2024") + .param("limit", "10") + .param("skip", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()); + } + + // ==================== GET MOVIE BY ID TESTS ==================== + + @Test + @DisplayName("GET /api/movies/{id} - Should return movie by ID") + void testGetMovieById_Success() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.getMovieById(movieId)).thenReturn(testMovie); + + // Act & Assert + mockMvc.perform(get("/api/movies/{id}", movieId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Test Movie")) + .andExpect(jsonPath("$.data.year").value(2024)); + } + + @Test + @DisplayName("GET /api/movies/{id} - Should return 404 when movie not found") + void testGetMovieById_NotFound() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.getMovieById(movieId)) + .thenThrow(new ResourceNotFoundException("Movie not found")); + + // Act & Assert + mockMvc.perform(get("/api/movies/{id}", movieId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.error.message").value("Movie not found")); + } + + @Test + @DisplayName("GET /api/movies/{id} - Should return 400 for invalid ID") + void testGetMovieById_InvalidId() throws Exception { + // Arrange + String invalidId = "invalid-id"; + when(movieService.getMovieById(invalidId)) + .thenThrow(new ValidationException("Invalid movie ID format")); + + // Act & Assert + mockMvc.perform(get("/api/movies/{id}", invalidId)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } + + // ==================== CREATE MOVIE TESTS ==================== + + @Test + @DisplayName("POST /api/movies - Should create movie successfully") + void testCreateMovie_Success() throws Exception { + // Arrange + when(movieService.createMovie(any(CreateMovieRequest.class))).thenReturn(testMovie); + + // Act & Assert + mockMvc.perform(post("/api/movies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Test Movie")); + } + + @Test + @DisplayName("POST /api/movies - Should return 400 for validation error") + void testCreateMovie_ValidationError() throws Exception { + // Arrange + when(movieService.createMovie(any(CreateMovieRequest.class))) + .thenThrow(new ValidationException("Title is required")); + + // Act & Assert + mockMvc.perform(post("/api/movies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } + + @Test + @DisplayName("POST /api/movies/batch - Should create movies batch successfully") + void testCreateMoviesBatch_Success() throws Exception { + // Arrange + List requests = Arrays.asList(createRequest, createRequest); + Map insertedIds = new HashMap<>(); + insertedIds.put(0, new BsonObjectId(new ObjectId())); + insertedIds.put(1, new BsonObjectId(new ObjectId())); + BatchInsertResponse response = new BatchInsertResponse(2, insertedIds.values()); + + when(movieService.createMoviesBatch(anyList())).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/movies/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requests))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.insertedCount").value(2)); + } + + // ==================== UPDATE MOVIE TESTS ==================== + + @Test + @DisplayName("PUT /api/movies/{id} - Should update movie successfully") + void testUpdateMovie_Success() throws Exception { + // Arrange + String movieId = testId.toHexString(); + Movie updatedMovie = Movie.builder() + .id(testId) + .title("Updated Title") + .year(2025) + .build(); + + when(movieService.updateMovie(eq(movieId), any(UpdateMovieRequest.class))) + .thenReturn(updatedMovie); + + // Act & Assert + mockMvc.perform(put("/api/movies/{id}", movieId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Updated Title")) + .andExpect(jsonPath("$.data.year").value(2025)); + } + + @Test + @DisplayName("PUT /api/movies/{id} - Should return 404 when movie not found") + void testUpdateMovie_NotFound() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.updateMovie(eq(movieId), any(UpdateMovieRequest.class))) + .thenThrow(new ResourceNotFoundException("Movie not found")); + + // Act & Assert + mockMvc.perform(put("/api/movies/{id}", movieId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); + } + + // ==================== DELETE MOVIE TESTS ==================== + + @Test + @DisplayName("DELETE /api/movies/{id} - Should delete movie successfully") + void testDeleteMovie_Success() throws Exception { + // Arrange + String movieId = testId.toHexString(); + DeleteResponse response = new DeleteResponse(1L); + + when(movieService.deleteMovie(movieId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(delete("/api/movies/{id}", movieId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.deletedCount").value(1)); + } + + @Test + @DisplayName("DELETE /api/movies/{id} - Should return 404 when movie not found") + void testDeleteMovie_NotFound() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.deleteMovie(movieId)) + .thenThrow(new ResourceNotFoundException("Movie not found")); + + // Act & Assert + mockMvc.perform(delete("/api/movies/{id}", movieId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); + } + + @Test + @DisplayName("DELETE /api/movies/{id}/find-and-delete - Should find and delete movie successfully") + void testFindAndDeleteMovie_Success() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.findAndDeleteMovie(movieId)).thenReturn(testMovie); + + // Act & Assert + mockMvc.perform(delete("/api/movies/{id}/find-and-delete", movieId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.title").value("Test Movie")); + } + + @Test + @DisplayName("DELETE /api/movies/{id}/find-and-delete - Should return 404 when movie not found") + void testFindAndDeleteMovie_NotFound() throws Exception { + // Arrange + String movieId = testId.toHexString(); + when(movieService.findAndDeleteMovie(movieId)) + .thenThrow(new ResourceNotFoundException("Movie not found")); + + // Act & Assert + mockMvc.perform(delete("/api/movies/{id}/find-and-delete", movieId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); + } +} diff --git a/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MovieIntegrationTest.java b/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MovieIntegrationTest.java new file mode 100644 index 0000000..200368f --- /dev/null +++ b/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MovieIntegrationTest.java @@ -0,0 +1,42 @@ +package com.mongodb.samplemflix.integration; + +/** + * Integration tests for the movie API. + * + * These tests verify the full request/response cycle including database operations. + * + * TODO: Set up Testcontainers for MongoDB + * - Add testcontainers dependency to pom.xml + * - Configure MongoDB test container + * - Set up test data initialization + * + * TODO: Implement integration tests for all endpoints: + * - GET /api/movies (with various filters) + * - GET /api/movies/{id} + * - POST /api/movies + * - POST /api/movies/batch + * - PUT /api/movies/{id} + * - PATCH /api/movies + * - DELETE /api/movies/{id} + * - DELETE /api/movies + * - DELETE /api/movies/{id}/find-and-delete + * + * TODO: Test error scenarios: + * - Invalid ObjectId format + * - Resource not found + * - Validation errors + * - Database connection failures + * + * TODO: Test performance: + * - Large dataset queries + * - Pagination performance + * - Text search performance + */ +public class MovieIntegrationTest { + + // TODO: Add @SpringBootTest annotation + // TODO: Add @Testcontainers annotation + // TODO: Add MongoDB container configuration + // TODO: Add test data setup methods + // TODO: Add integration test methods +} diff --git a/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java b/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java new file mode 100644 index 0000000..ed57f3b --- /dev/null +++ b/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java @@ -0,0 +1,425 @@ +package com.mongodb.samplemflix.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.result.UpdateResult; +import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.model.Movie; +import com.mongodb.samplemflix.model.dto.BatchInsertResponse; +import com.mongodb.samplemflix.model.dto.CreateMovieRequest; +import com.mongodb.samplemflix.model.dto.DeleteResponse; +import com.mongodb.samplemflix.model.dto.MovieSearchQuery; +import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; +import com.mongodb.samplemflix.repository.MovieRepository; +import java.util.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Unit tests for MovieServiceImpl using Spring Data MongoDB. + * + * These tests verify the business logic of the service layer + * by mocking the repository and MongoTemplate dependencies. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("MovieService Unit Tests") +class MovieServiceTest { + + @Mock + private MovieRepository movieRepository; + + @Mock + private MongoTemplate mongoTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private MovieServiceImpl movieService; + + private ObjectId testId; + private Movie testMovie; + private CreateMovieRequest createRequest; + private UpdateMovieRequest updateRequest; + + @BeforeEach + void setUp() { + testId = new ObjectId(); + + testMovie = Movie.builder() + .id(testId) + .title("Test Movie") + .year(2024) + .plot("A test plot") + .genres(Arrays.asList("Action", "Drama")) + .build(); + + createRequest = CreateMovieRequest.builder() + .title("New Movie") + .year(2024) + .plot("A new movie plot") + .build(); + + updateRequest = UpdateMovieRequest.builder() + .title("Updated Title") + .year(2025) + .build(); + } + + // ==================== GET ALL MOVIES TESTS ==================== + + @Test + @DisplayName("Should get all movies with default pagination") + void testGetAllMovies_WithDefaults() { + // Arrange + MovieSearchQuery query = MovieSearchQuery.builder().build(); + List expectedMovies = Arrays.asList(testMovie); + + when(mongoTemplate.find(any(Query.class), eq(Movie.class))) + .thenReturn(expectedMovies); + + // Act + List result = movieService.getAllMovies(query); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(testMovie.getTitle(), result.get(0).getTitle()); + verify(mongoTemplate).find(any(Query.class), eq(Movie.class)); + } + + @Test + @DisplayName("Should get all movies with custom pagination") + void testGetAllMovies_WithCustomPagination() { + // Arrange + MovieSearchQuery query = MovieSearchQuery.builder() + .limit(50) + .skip(10) + .build(); + List expectedMovies = Arrays.asList(testMovie); + + when(mongoTemplate.find(any(Query.class), eq(Movie.class))) + .thenReturn(expectedMovies); + + // Act + List result = movieService.getAllMovies(query); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + verify(mongoTemplate).find(any(Query.class), eq(Movie.class)); + } + + @Test + @DisplayName("Should enforce maximum limit of 100") + void testGetAllMovies_EnforcesMaxLimit() { + // Arrange + MovieSearchQuery query = MovieSearchQuery.builder() + .limit(200) + .build(); + + when(mongoTemplate.find(any(Query.class), eq(Movie.class))) + .thenReturn(Collections.emptyList()); + + // Act + movieService.getAllMovies(query); + + // Assert + verify(mongoTemplate).find(any(Query.class), eq(Movie.class)); + } + + @Test + @DisplayName("Should enforce minimum limit of 1") + void testGetAllMovies_EnforcesMinLimit() { + // Arrange + MovieSearchQuery query = MovieSearchQuery.builder() + .limit(0) + .build(); + + when(mongoTemplate.find(any(Query.class), eq(Movie.class))) + .thenReturn(Collections.emptyList()); + + // Act + movieService.getAllMovies(query); + + // Assert + verify(mongoTemplate).find(any(Query.class), eq(Movie.class)); + } + + // ==================== GET MOVIE BY ID TESTS ==================== + + @Test + @DisplayName("Should get movie by valid ID") + void testGetMovieById_ValidId() { + // Arrange + String validId = testId.toHexString(); + when(movieRepository.findById(testId)).thenReturn(Optional.of(testMovie)); + + // Act + Movie result = movieService.getMovieById(validId); + + // Assert + assertNotNull(result); + assertEquals(testMovie.getTitle(), result.getTitle()); + verify(movieRepository).findById(testId); + } + + @Test + @DisplayName("Should throw ValidationException for invalid ID format") + void testGetMovieById_InvalidIdFormat() { + // Arrange + String invalidId = "invalid-id"; + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.getMovieById(invalidId)); + verify(movieRepository, never()).findById(any()); + } + + @Test + @DisplayName("Should throw ResourceNotFoundException when movie not found") + void testGetMovieById_NotFound() { + // Arrange + String validId = testId.toHexString(); + when(movieRepository.findById(testId)).thenReturn(Optional.empty()); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> movieService.getMovieById(validId)); + verify(movieRepository).findById(testId); + } + + // ==================== CREATE MOVIE TESTS ==================== + + @Test + @DisplayName("Should create movie successfully") + void testCreateMovie_Success() { + // Arrange + when(movieRepository.save(any(Movie.class))).thenReturn(testMovie); + + // Act + Movie result = movieService.createMovie(createRequest); + + // Assert + assertNotNull(result); + verify(movieRepository).save(any(Movie.class)); + } + + @Test + @DisplayName("Should throw ValidationException when title is null") + void testCreateMovie_NullTitle() { + // Arrange + CreateMovieRequest invalidRequest = CreateMovieRequest.builder() + .title(null) + .year(2024) + .build(); + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.createMovie(invalidRequest)); + verify(movieRepository, never()).save(any()); + } + + @Test + @DisplayName("Should throw ValidationException when title is empty") + void testCreateMovie_EmptyTitle() { + // Arrange + CreateMovieRequest invalidRequest = CreateMovieRequest.builder() + .title(" ") + .year(2024) + .build(); + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.createMovie(invalidRequest)); + verify(movieRepository, never()).save(any()); + } + + // ==================== CREATE MOVIES BATCH TESTS ==================== + + @Test + @DisplayName("Should create movies batch successfully") + void testCreateMoviesBatch_Success() { + // Arrange + List requests = Arrays.asList(createRequest, createRequest); + List savedMovies = Arrays.asList(testMovie, testMovie); + + when(movieRepository.saveAll(anyList())).thenReturn(savedMovies); + + // Act + BatchInsertResponse result = movieService.createMoviesBatch(requests); + + // Assert + assertNotNull(result); + assertEquals(2, result.getInsertedCount()); + assertNotNull(result.getInsertedIds()); + verify(movieRepository).saveAll(anyList()); + } + + // ==================== UPDATE MOVIE TESTS ==================== + + @Test + @DisplayName("Should update movie successfully") + void testUpdateMovie_Success() { + // Arrange + String validId = testId.toHexString(); + Map requestMap = new HashMap<>(); + requestMap.put("title", "Updated Title"); + requestMap.put("year", 2025); + + when(objectMapper.convertValue(updateRequest, Map.class)).thenReturn(requestMap); + + UpdateResult updateResult = mock(UpdateResult.class); + when(updateResult.getMatchedCount()).thenReturn(1L); + when(mongoTemplate.updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class))) + .thenReturn(updateResult); + when(movieRepository.findById(testId)).thenReturn(Optional.of(testMovie)); + + // Act + Movie result = movieService.updateMovie(validId, updateRequest); + + // Assert + assertNotNull(result); + verify(mongoTemplate).updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class)); + verify(movieRepository).findById(testId); + } + + @Test + @DisplayName("Should throw ValidationException for invalid ID in update") + void testUpdateMovie_InvalidId() { + // Arrange + String invalidId = "invalid-id"; + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.updateMovie(invalidId, updateRequest)); + verify(mongoTemplate, never()).updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class)); + } + + @Test + @DisplayName("Should throw ValidationException when update request is empty") + void testUpdateMovie_EmptyRequest() { + // Arrange + String validId = testId.toHexString(); + UpdateMovieRequest emptyRequest = UpdateMovieRequest.builder().build(); + Map emptyMap = new HashMap<>(); + + when(objectMapper.convertValue(emptyRequest, Map.class)).thenReturn(emptyMap); + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.updateMovie(validId, emptyRequest)); + verify(mongoTemplate, never()).updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class)); + } + + @Test + @DisplayName("Should throw ResourceNotFoundException when movie to update not found") + void testUpdateMovie_NotFound() { + // Arrange + String validId = testId.toHexString(); + Map requestMap = new HashMap<>(); + requestMap.put("title", "Updated Title"); + + when(objectMapper.convertValue(updateRequest, Map.class)).thenReturn(requestMap); + + UpdateResult updateResult = mock(UpdateResult.class); + when(updateResult.getMatchedCount()).thenReturn(0L); + when(mongoTemplate.updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class))) + .thenReturn(updateResult); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> movieService.updateMovie(validId, updateRequest)); + verify(mongoTemplate).updateFirst(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), any(Class.class)); + verify(movieRepository, never()).findById(any()); + } + + // ==================== DELETE MOVIE TESTS ==================== + + @Test + @DisplayName("Should delete movie successfully") + void testDeleteMovie_Success() { + // Arrange + String validId = testId.toHexString(); + when(movieRepository.existsById(testId)).thenReturn(true); + + // Act + DeleteResponse result = movieService.deleteMovie(validId); + + // Assert + assertNotNull(result); + assertEquals(1L, result.getDeletedCount()); + verify(movieRepository).existsById(testId); + verify(movieRepository).deleteById(testId); + } + + @Test + @DisplayName("Should throw ValidationException for invalid ID in delete") + void testDeleteMovie_InvalidId() { + // Arrange + String invalidId = "invalid-id"; + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.deleteMovie(invalidId)); + verify(movieRepository, never()).deleteById(any()); + } + + @Test + @DisplayName("Should throw ResourceNotFoundException when movie to delete not found") + void testDeleteMovie_NotFound() { + // Arrange + String validId = testId.toHexString(); + when(movieRepository.existsById(testId)).thenReturn(false); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> movieService.deleteMovie(validId)); + verify(movieRepository).existsById(testId); + verify(movieRepository, never()).deleteById(any()); + } + + // ==================== FIND AND DELETE MOVIE TESTS ==================== + + @Test + @DisplayName("Should find and delete movie successfully") + void testFindAndDeleteMovie_Success() { + // Arrange + String validId = testId.toHexString(); + when(mongoTemplate.findAndRemove(any(Query.class), eq(Movie.class))).thenReturn(testMovie); + + // Act + Movie result = movieService.findAndDeleteMovie(validId); + + // Assert + assertNotNull(result); + assertEquals(testMovie.getTitle(), result.getTitle()); + verify(mongoTemplate).findAndRemove(any(Query.class), eq(Movie.class)); + } + + @Test + @DisplayName("Should throw ValidationException for invalid ID in find and delete") + void testFindAndDeleteMovie_InvalidId() { + // Arrange + String invalidId = "invalid-id"; + + // Act & Assert + assertThrows(ValidationException.class, () -> movieService.findAndDeleteMovie(invalidId)); + verify(mongoTemplate, never()).findAndRemove(any(), any()); + } + + @Test + @DisplayName("Should throw ResourceNotFoundException when movie to find and delete not found") + void testFindAndDeleteMovie_NotFound() { + // Arrange + String validId = testId.toHexString(); + when(mongoTemplate.findAndRemove(any(Query.class), eq(Movie.class))).thenReturn(null); + + // Act & Assert + assertThrows(ResourceNotFoundException.class, () -> movieService.findAndDeleteMovie(validId)); + verify(mongoTemplate).findAndRemove(any(Query.class), eq(Movie.class)); + } +}