diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..448ad18 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Environment variables for development +# Copy this file to .env and update with your values + +# Database Configuration +DB_URL=jdbc:postgresql://localhost:5432/mtgsearch +DB_USER=postgres +DB_PASSWORD=postgres +DB_MAX_POOL_SIZE=10 + +# JWT Configuration +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRATION=86400 + +# Application Configuration +APP_NAME=mtg-search +APP_VERSION=0.1.0 +APP_ENVIRONMENT=development + +# Server Configuration +SERVER_PORT=8080 +SERVER_SERVLET_CONTEXT_PATH=/ + +# Frontend Configuration +VITE_API_URL=http://localhost:8080 +VITE_APP_TITLE=MTG Search + +# Logging +LOG_LEVEL=DEBUG +LOG_DIR=logs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e026966 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +*.class +*.jar +*.war +*.nar +*.zip +*.tar.gz +*.rar +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +.DS_Store +*.log +logs/ +target/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ +*.sublime-workspace +*.sublime-project +coverage/ +.nyc_output/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..544fa8a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,139 @@ +stages: + - build + - test + - docker + - deploy + +variables: + DOCKER_IMAGE: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} + DOCKER_IMAGE_LATEST: ${CI_REGISTRY_IMAGE}:latest + DOCKER_DRIVER: overlay2 + DOCKER_TLS_CERTDIR: "" + +# Build stage - compile Java and frontend +build:java: + stage: build + image: eclipse-temurin:21-jdk + before_script: + - apt-get update && apt-get install -y curl + - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + - apt-get install -y nodejs + script: + - chmod +x ./gradlew + - ./gradlew clean build -x test --build-cache + artifacts: + paths: + - backend/build/libs/ + - frontend/dist/ + expire_in: 1 day + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .gradle/ + - frontend/node_modules/ + +# Test stage - run unit and integration tests +test:unit: + stage: test + image: eclipse-temurin:21-jdk + needs: + - build:java + script: + - chmod +x ./gradlew + - ./gradlew test --build-cache + artifacts: + reports: + junit: backend/build/test-results/test/**/*.xml + paths: + - backend/build/reports/ + expire_in: 30 days + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .gradle/ + +# Integration tests with Postgres +test:integration: + stage: test + image: eclipse-temurin:21-jdk + services: + - postgres:16-alpine + variables: + POSTGRES_DB: mtgsearch_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mtgsearch_test + script: + - chmod +x ./gradlew + - ./gradlew integrationTest --build-cache + only: + - main + - merge_requests + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .gradle/ + +# Code quality checks +test:quality: + stage: test + image: eclipse-temurin:21-jdk + script: + - chmod +x ./gradlew + - ./gradlew check -x test + allow_failure: true + only: + - merge_requests + +# Docker build and push +docker:build: + stage: docker + image: docker:latest + services: + - docker:dind + needs: + - build:java + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + script: + - docker build -t $DOCKER_IMAGE . + - docker push $DOCKER_IMAGE + - docker tag $DOCKER_IMAGE $DOCKER_IMAGE_LATEST + - docker push $DOCKER_IMAGE_LATEST + only: + - main + - develop + - tags + +# Deploy to staging (example - configure for your environment) +deploy:staging: + stage: deploy + image: alpine:latest + before_script: + - apk add --no-cache curl jq + script: + - echo "Deploying to staging environment..." + - curl -X POST ${STAGING_DEPLOY_WEBHOOK} -d "{\"image\": \"$DOCKER_IMAGE\"}" + environment: + name: staging + url: https://staging-mtgsearch.example.com + only: + - develop + when: manual + +# Deploy to production (example - configure for your environment) +deploy:production: + stage: deploy + image: alpine:latest + before_script: + - apk add --no-cache curl jq + script: + - echo "Deploying to production..." + - curl -X POST ${PROD_DEPLOY_WEBHOOK} -d "{\"image\": \"$DOCKER_IMAGE\"}" + environment: + name: production + url: https://mtgsearch.example.com + only: + - tags + - main + when: manual diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..50b4567 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,470 @@ +# Development Guide for MTG Search + +## Local Development Environment Setup + +### Prerequisites Installation + +#### macOS +```bash +# Install Homebrew if not already installed +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install Java 21 +brew install --cask java + +# Install Node.js +brew install node@20 + +# Install PostgreSQL +brew install postgresql + +# Install Docker +brew install --cask docker +``` + +#### Ubuntu/Debian +```bash +# Install Java 21 +sudo apt-get update +sudo apt-get install -y openjdk-21-jdk + +# Install Node.js +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Install PostgreSQL +sudo apt-get install -y postgresql postgresql-contrib + +# Install Docker +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +``` + +#### Windows +1. Install Java 21 from [Oracle](https://www.oracle.com/java/technologies/downloads/) +2. Install Node.js from [nodejs.org](https://nodejs.org) +3. Install PostgreSQL from [postgresql.org](https://www.postgresql.org/download/windows/) +4. Install Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop) + +### Quick Start with Startup Script + +```bash +# Make script executable +chmod +x startup.sh + +# Run the startup script +./startup.sh +``` + +### Manual Setup + +#### Step 1: Start PostgreSQL + +**Using Docker (Recommended):** +```bash +docker run --name mtgsearch-postgres \ + -e POSTGRES_DB=mtgsearch \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + -d postgres:16-alpine +``` + +**Using Local PostgreSQL:** +```bash +# macOS +brew services start postgresql + +# Linux +sudo systemctl start postgresql + +# Windows +# Start from Services or GUI +``` + +#### Step 2: Verify Database Connection + +```bash +psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;" +``` + +#### Step 3: Build and Run Backend + +```bash +cd backend + +# Build with Gradle +../gradlew build + +# Run the application +../gradlew bootRun + +# The backend will start on http://localhost:8080 +``` + +#### Step 4: Set Up and Run Frontend + +```bash +cd frontend + +# Install dependencies +npm install + +# Start development server +npm run dev + +# The frontend will start on http://localhost:5173 +``` + +## Development Workflow + +### Making Changes + +#### Backend Changes +1. Edit Java files in `backend/src/main/java` +2. Spring Boot DevTools will auto-reload +3. Check logs: `tail -f logs/mtg-search.log` + +#### Frontend Changes +1. Edit Vue files in `frontend/src` +2. Vite will hot-reload automatically +3. Check browser console for errors + +### Testing + +#### Backend Tests +```bash +cd backend + +# Run all tests +../gradlew test + +# Run specific test class +../gradlew test --tests AuthServiceTest + +# Run with coverage +../gradlew test jacocoTestReport +``` + +#### Frontend Tests +```bash +cd frontend + +# Run tests +npm run test + +# Run with coverage +npm run test -- --coverage + +# Watch mode +npm run test -- --watch +``` + +### Code Quality + +#### Backend +```bash +cd backend + +# Run checks (spotless, checkstyle, etc.) +../gradlew check + +# Format code +../gradlew spotlessApply +``` + +#### Frontend +```bash +cd frontend + +# Run linter +npm run lint + +# Fix linting issues +npm run lint --fix +``` + +## Database Management + +### Running Flyway Migrations + +Migrations run automatically when the application starts. To manage migrations manually: + +```bash +cd backend + +# Show migration status +../gradlew flywayInfo + +# Validate migrations +../gradlew flywayValidate + +# Repair (use with caution) +../gradlew flywayRepair + +# Clean schema (WARNING: deletes all data) +../gradlew flywayClean +``` + +### Creating New Migration + +1. Create file: `backend/src/main/resources/db/migration/V{NUMBER}__Description.sql` + +Example: +```sql +-- V2__Add_search_history_table.sql +CREATE TABLE IF NOT EXISTS search_history ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + search_query VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_search_history_user_id ON search_history(user_id); +``` + +## API Endpoints Testing + +### Using curl + +```bash +# Register a new user +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "email": "test@example.com", + "password": "SecurePassword123!" + }' + +# Login +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "password": "SecurePassword123!" + }' + +# Use token in requests +TOKEN= +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/api/v1/protected-endpoint +``` + +### Using Swagger UI + +Navigate to: http://localhost:8080/swagger-ui.html + +## Debugging + +### Backend Debugging + +#### In IDE (IntelliJ IDEA) +1. Set breakpoints in your code +2. Run → Debug 'MtgSearchApplication' +3. Step through code + +#### Remote Debugging +```bash +cd backend +../gradlew bootRun --args='--debug' + +# Then connect remote debugger on port 5005 +``` + +### Frontend Debugging + +1. Open browser DevTools (F12 or Cmd+Option+I) +2. Check Console, Network, and Application tabs +3. Use Vue DevTools browser extension + +### Logs + +```bash +# Backend logs +tail -f logs/mtg-search.log + +# Backend errors +tail -f logs/mtg-search-error.log + +# Frontend browser console +# Open DevTools → Console tab +``` + +## Environment Variables + +Create `.env` file in project root: + +```env +# Database +DB_URL=jdbc:postgresql://localhost:5432/mtgsearch +DB_USER=postgres +DB_PASSWORD=postgres + +# JWT +JWT_SECRET=development-secret-key +JWT_EXPIRATION=86400 + +# Application +SPRING_PROFILE=dev + +# Frontend +VITE_API_URL=http://localhost:8080 +``` + +## IDE Configuration + +### IntelliJ IDEA +1. Open project root folder +2. File → Project Structure → Modules +3. Select backend module +4. Set Java 21 as SDK +5. Install Spring Boot plugin +6. Install Lombok plugin + +### VS Code +1. Install extensions: + - Extension Pack for Java + - Spring Boot Extension Pack + - Vue 3 Extension + - TypeScript Vue Plugin +2. Configure Java home in settings +3. Install Node modules + +## Useful Gradle Commands + +```bash +# Build tasks +./gradlew build # Full build +./gradlew clean build # Clean and build +./gradlew build -x test # Build without tests + +# Test tasks +./gradlew test # Run tests +./gradlew test --watch # Watch mode + +# Development tasks +./gradlew bootRun # Run application +./gradlew bootRun --debug # Debug mode + +# Code generation +./gradlew openApiGenerate # Generate from OpenAPI spec +./gradlew generateJooqCode # Generate jOOQ entities + +# Database tasks +./gradlew flywayInfo # Show migration status +./gradlew flywayMigrate # Run migrations +``` + +## Useful npm Commands + +```bash +# Development +npm run dev # Start dev server +npm run build # Build for production +npm run preview # Preview production build + +# Testing +npm run test # Run tests +npm run test:ui # Run tests with UI +npm run test -- --watch # Watch mode + +# Quality +npm run lint # Run linter +npm run type-check # Check TypeScript types +``` + +## Troubleshooting Common Issues + +### "Address already in use" port 8080 +```bash +# Find process using port 8080 +lsof -i :8080 + +# Kill the process +kill -9 +``` + +### "Address already in use" port 5173 +```bash +# Find process using port 5173 +lsof -i :5173 + +# Kill the process +kill -9 +``` + +### PostgreSQL connection refused +```bash +# Check if PostgreSQL is running +psql --version + +# Try to connect +psql -U postgres -h localhost + +# If using Docker, check container +docker ps | grep postgres +docker logs mtgsearch-postgres +``` + +### Gradle build fails +```bash +# Clear gradle cache +rm -rf ~/.gradle/caches + +# Clean and rebuild +./gradlew clean build --refresh-dependencies +``` + +### Frontend build fails +```bash +# Clear node_modules +rm -rf frontend/node_modules package-lock.json + +# Reinstall dependencies +cd frontend && npm install +``` + +### Flyway migration fails +```bash +# Check migration status +cd backend && ../gradlew flywayInfo + +# Validate migrations +../gradlew flywayValidate + +# Start fresh (DELETES ALL DATA) +../gradlew flywayClean +../gradlew flywayMigrate +``` + +## Performance Profiling + +### Backend +```bash +# Generate JFR recording +cd backend +../gradlew bootRun --jvmArgs="-XX:StartFlightRecording=filename=recording.jfr" + +# Analyze with Java Flight Recorder +jmc recording.jfr +``` + +### Frontend +```bash +# Build with source maps +cd frontend +npm run build -- --sourcemap + +# Use Chrome DevTools Performance tab +``` + +## Additional Resources + +- [Spring Boot Documentation](https://spring.io/projects/spring-boot) +- [Vue 3 Documentation](https://vuejs.org) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [Gradle Documentation](https://gradle.org/guides/) +- [Docker Documentation](https://docs.docker.com/) +- [GitLab CI/CD Documentation](https://docs.gitlab.com/ee/ci/) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d84d026 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# Multi-stage build for MTG Search +# Stage 1: Build backend and frontend +FROM eclipse-temurin:21-jdk as builder + +WORKDIR /build + +# Install Node.js +RUN apt-get update && apt-get install -y curl && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# Copy gradle files +COPY gradle gradle +COPY gradlew . +COPY build.gradle.kts . +COPY settings.gradle.kts . +COPY gradle.properties . + +# Copy backend source +COPY backend backend + +# Copy frontend source +COPY frontend frontend + +# Build backend +WORKDIR /build/backend +RUN ../gradlew build -x test + +# Build frontend +WORKDIR /build/frontend +RUN npm ci && npm run build + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre + +WORKDIR /app + +# Install curl for health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Copy built backend jar +COPY --from=builder /build/backend/build/libs/*.jar app.jar + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/api/v1/auth/health || exit 1 + +# Run application +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8af07e4 --- /dev/null +++ b/Makefile @@ -0,0 +1,117 @@ +# MTG Search - Makefile for common development tasks + +.PHONY: help build test run clean docker-build docker-run docker-stop frontend-install frontend-build backend-build + +help: + @echo "MTG Search - Available Commands" + @echo "================================" + @echo "make build - Build both backend and frontend" + @echo "make backend-build - Build backend only" + @echo "make frontend-build - Build frontend only" + @echo "make test - Run all tests" + @echo "make backend-test - Run backend tests" + @echo "make frontend-test - Run frontend tests" + @echo "make run - Run backend server (requires frontend built)" + @echo "make dev - Run frontend dev server and backend" + @echo "make clean - Clean all build directories" + @echo "make docker-build - Build Docker image" + @echo "make docker-run - Start services with docker-compose" + @echo "make docker-stop - Stop all docker-compose services" + @echo "make docker-clean - Remove all containers and volumes" + @echo "make lint - Run code quality checks" + @echo "make format - Format code" + @echo "make db-init - Initialize database" + +build: backend-build frontend-build + @echo "✓ Build complete" + +backend-build: + @echo "Building backend..." + cd backend && ../gradlew clean build -x test + @echo "✓ Backend build complete" + +frontend-build: + @echo "Building frontend..." + cd frontend && npm install && npm run build + @echo "✓ Frontend build complete" + +frontend-install: + @echo "Installing frontend dependencies..." + cd frontend && npm install + @echo "✓ Frontend dependencies installed" + +test: backend-test + @echo "✓ All tests passed" + +backend-test: + @echo "Running backend tests..." + cd backend && ../gradlew test + +frontend-test: + @echo "Running frontend tests..." + cd frontend && npm run test + +run: + @echo "Starting backend server..." + cd backend && ../gradlew bootRun + +dev: frontend-install + @echo "Starting development environment..." + @echo "Frontend: http://localhost:5173" + @echo "Backend: http://localhost:8080" + @echo "" + @trap 'kill %1 %2' SIGINT; \ + (cd frontend && npm run dev) & \ + (cd backend && ../gradlew bootRun) & \ + wait + +clean: + @echo "Cleaning build directories..." + cd backend && ../gradlew clean + cd frontend && rm -rf node_modules dist + rm -rf logs + @echo "✓ Clean complete" + +docker-build: + @echo "Building Docker image..." + docker build -t mtgsearch:latest . + @echo "✓ Docker build complete" + +docker-run: + @echo "Starting Docker services..." + docker-compose up --build + @echo "✓ Services started" + +docker-stop: + @echo "Stopping Docker services..." + docker-compose down + @echo "✓ Services stopped" + +docker-clean: + @echo "Cleaning Docker resources..." + docker-compose down -v + docker image rm mtgsearch:latest + @echo "✓ Docker cleanup complete" + +lint: + @echo "Running linters..." + cd backend && ../gradlew check -x test + cd frontend && npm run lint + @echo "✓ Linting complete" + +format: + @echo "Formatting code..." + cd frontend && npm run lint + @echo "✓ Formatting complete" + +db-init: + @echo "Initializing database..." + docker run --name mtgsearch-postgres \ + -e POSTGRES_DB=mtgsearch \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + -d postgres:16-alpine + @echo "✓ Database initialized on localhost:5432" + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md index ca4c998..c9946f2 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,513 @@ -# MTG Search +# MTG Search - Magic The Gathering Card Search Platform +A full-stack web application for searching and exploring Magic The Gathering cards with user authentication, built with Spring Boot, Vue.js, PostgreSQL, and deployed with Docker. - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files -* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: +## Architecture Overview ``` -cd existing_repo -git remote add origin https://gitlab.moustos.net/dionmoustos/mtg-search.git -git branch -M main -git push -uf origin main +├── Backend (Java 21, Spring Boot 3.2.5) +│ ├── REST API with OpenAPI/Swagger documentation +│ ├── JWT-based authentication +│ ├── PostgreSQL database with jOOQ ORM +│ ├── Flyway database migrations +│ ├── Comprehensive logging with Logback +│ └── JUnit 5 and integration tests +│ +├── Frontend (Vue 3, TypeScript, Vite) +│ ├── Single Page Application (SPA) +│ ├── Responsive UI with CSS Grid/Flexbox +│ ├── Pinia state management +│ ├── Vue Router with auth guards +│ └── Axios HTTP client with JWT interceptors +│ +├── Database (PostgreSQL 16) +│ ├── Schema managed by Flyway migrations +│ ├── jOOQ-generated DTOs and repositories +│ └── Indexed queries for performance +│ +└── Deployment + ├── Docker multi-stage build + ├── Docker Compose for local dev + └── GitLab CI/CD pipeline ``` -## Integrate with your tools +## Prerequisites -* [Set up project integrations](https://gitlab.moustos.net/dionmoustos/mtg-search/-/settings/integrations) +- **Java 21** or later +- **Node.js 20+** and npm 10+ +- **PostgreSQL 16+** +- **Docker & Docker Compose** (for containerized setup) +- **Git** -## Collaborate with your team +## Quick Start -* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) -* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) -* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) -* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) -* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +### Using Docker Compose (Recommended) -## Test and Deploy +```bash +# Clone the repository +git clone +cd mtg-search -Use the built-in continuous integration in GitLab. +# Start all services (PostgreSQL, Backend, built Frontend) +docker-compose up --build -* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) -* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) -* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) -* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) -* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/) +# Backend will be available at: http://localhost:8080 +# Frontend will be served by the backend +``` -*** +### Local Development Setup -# Editing this README +#### 1. Database Setup -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +```bash +# Start PostgreSQL (using Docker) +docker run --name mtgsearch-postgres \ + -e POSTGRES_DB=mtgsearch \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + -d postgres:16-alpine -## Suggestions for a good README +# Or use your local PostgreSQL installation +``` -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +#### 2. Backend Setup -## Name -Choose a self-explaining name for your project. +```bash +cd backend -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +# Build the project +../gradlew build -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +# Run the application +../gradlew bootRun -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +# Backend starts on http://localhost:8080 +# Swagger UI available at: http://localhost:8080/swagger-ui.html +# API Docs available at: http://localhost:8080/v3/api-docs +``` -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +#### 3. Frontend Setup -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +```bash +cd frontend -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +# Install dependencies +npm install -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +# Start development server +npm run dev + +# Frontend starts on http://localhost:5173 +``` + +## API Endpoints + +### Authentication + +- **POST** `/api/v1/auth/register` - Register a new user + ```json + { + "username": "john_doe", + "email": "john@example.com", + "password": "SecurePassword123!" + } + ``` + +- **POST** `/api/v1/auth/login` - Login and get JWT token + ```json + { + "username": "john_doe", + "password": "SecurePassword123!" + } + ``` + Returns: + ```json + { + "token": "eyJhbGc...", + "id": 1, + "username": "john_doe", + "email": "john@example.com" + } + ``` + +- **GET** `/api/v1/auth/health` - Health check (no auth required) + +### Protected Endpoints + +All protected endpoints require JWT token in Authorization header: +``` +Authorization: Bearer +``` + +## Building + +### Build Backend + +```bash +./gradlew clean build + +# Build outputs to: backend/build/libs/ +``` + +### Build Frontend + +```bash +cd frontend +npm run build + +# Build outputs to: backend/src/main/resources/static/ +``` + +### Build Full Docker Image + +```bash +docker build -t mtgsearch:latest . +``` + +## Testing + +### Backend Unit Tests + +```bash +./gradlew test +``` + +### Backend Integration Tests + +```bash +./gradlew integrationTest +``` + +### Frontend Tests + +```bash +cd frontend +npm run test +npm run test:ui # Opens UI dashboard +``` + +### All Tests with Coverage + +```bash +./gradlew test jacocoTestReport +``` + +## Configuration + +### Environment Variables + +Create a `.env` file in the project root: + +```env +# Database +DB_URL=jdbc:postgresql://localhost:5432/mtgsearch +DB_USER=postgres +DB_PASSWORD=postgres + +# JWT +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRATION=86400 + +# Frontend +VITE_API_URL=http://localhost:8080 +``` + +### Application Properties + +Backend configuration in `backend/src/main/resources/application.yml`: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/mtgsearch + username: postgres + password: postgres + + jpa: + hibernate: + ddl-auto: validate + +app: + jwt: + secret: ${JWT_SECRET:your-secret-key} + expiration: ${JWT_EXPIRATION:86400} +``` + +## Database Migrations + +Migrations are managed by Flyway and located in `backend/src/main/resources/db/migration/` + +### Adding a New Migration + +1. Create a new file: `V2__Add_new_table.sql` +2. Write SQL migration +3. Run application - Flyway will auto-apply migrations + +Example migration: +```sql +-- V2__Add_cards_table.sql +CREATE TABLE IF NOT EXISTS cards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +## Logging + +Logs are configured in `backend/src/main/resources/logback-spring.xml` + +- **Log Location**: `logs/mtg-search.log` +- **Error Logs**: `logs/mtg-search-error.log` +- **Max File Size**: 100MB +- **Retention**: 30 days +- **Compression**: Automatic gzip compression + +View logs: +```bash +tail -f logs/mtg-search.log +tail -f logs/mtg-search-error.log +``` + +## Development Workflow + +### Code Generation + +The project uses OpenAPI Generator and jOOQ for code generation: + +```bash +# Generate server stubs from OpenAPI spec +./gradlew openApiGenerate + +# Generate jOOQ entities from database schema +./gradlew generateJooqCode +``` + +### Code Quality + +```bash +# Format code +./gradlew spotlessApply + +# Run static analysis +./gradlew check + +# Frontend linting +cd frontend +npm run lint +``` + +## Deployment + +### Staging Deployment + +```bash +git checkout develop +git merge feature-branch +git push origin develop + +# GitLab CI will automatically build and deploy to staging +``` + +### Production Deployment + +```bash +git tag v0.2.0 +git push origin v0.2.0 + +# GitLab CI will build, test, and deploy to production +``` + +### Manual Deployment + +```bash +# Build Docker image +docker build -t mtgsearch:v0.1.0 . +docker tag mtgsearch:v0.1.0 your-registry/mtgsearch:v0.1.0 + +# Push to registry +docker push your-registry/mtgsearch:v0.1.0 + +# Pull and run on server +docker pull your-registry/mtgsearch:v0.1.0 +docker run -d \ + -e SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/mtgsearch \ + -e SPRING_DATASOURCE_USERNAME=postgres \ + -e SPRING_DATASOURCE_PASSWORD=secure-password \ + -e JWT_SECRET=your-production-secret \ + -p 8080:8080 \ + --name mtgsearch \ + your-registry/mtgsearch:v0.1.0 +``` + +## CI/CD Pipeline + +The project includes a comprehensive GitLab CI/CD pipeline (`.gitlab-ci.yml`): + +1. **Build Stage** + - Compiles Java backend + - Builds Vue.js frontend + - Caches dependencies + +2. **Test Stage** + - Unit tests + - Integration tests with PostgreSQL + - Code quality checks + +3. **Docker Stage** + - Builds multi-stage Docker image + - Pushes to registry + +4. **Deploy Stage** + - Deploys to staging with manual trigger + - Deploys to production on version tags + +## Project Structure + +``` +mtg-search/ +├── backend/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/net/moustos/mtgsearch/ +│ │ │ │ ├── MtgSearchApplication.java +│ │ │ │ ├── config/ +│ │ │ │ ├── controller/ +│ │ │ │ ├── service/ +│ │ │ │ ├── repository/ +│ │ │ │ ├── model/ +│ │ │ │ └── security/ +│ │ │ └── resources/ +│ │ │ ├── application.yml +│ │ │ ├── logback-spring.xml +│ │ │ └── db/migration/ +│ │ └── test/ +│ │ └── java/ +│ └── build.gradle.kts +│ +├── frontend/ +│ ├── src/ +│ │ ├── pages/ +│ │ ├── components/ +│ │ ├── services/ +│ │ ├── stores/ +│ │ ├── router/ +│ │ ├── types/ +│ │ ├── main.ts +│ │ └── App.vue +│ ├── public/ +│ ├── index.html +│ ├── package.json +│ ├── vite.config.ts +│ └── tsconfig.json +│ +├── gradle/ +├── build.gradle.kts +├── settings.gradle.kts +├── gradle.properties +├── gradlew +├── gradlew.bat +│ +├── Dockerfile +├── docker-compose.yml +├── .gitlab-ci.yml +├── .gitignore +└── README.md +``` + +## Troubleshooting + +### Backend won't start +```bash +# Check if port 8080 is already in use +lsof -i :8080 + +# Check PostgreSQL is running +psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;" + +# View logs +tail -f logs/mtg-search.log +``` + +### Frontend connection issues +- Check backend is running on 8080 +- Check browser console for errors +- Verify CORS settings in SecurityConfig.java + +### Database migration failures +```bash +# Check migration status +./gradlew flywayInfo + +# Repair migrations (use with caution) +./gradlew flywayRepair + +# Clean database (WARNING: deletes all data) +./gradlew flywayClean +``` + +### Docker build fails +```bash +# Clear Docker cache +docker system prune -a + +# Rebuild with no cache +docker build --no-cache -t mtgsearch:latest . +``` + +## Performance Optimization + +### Backend +- Connection pooling: HikariCP configured with 10 max connections +- Database indexing on frequently queried columns +- Flyway async migration setup +- Request caching headers + +### Frontend +- Code splitting with Vite +- Lazy route loading +- Production build minification +- Gzip compression + +### Database +- Query result caching +- Indexed columns for search operations +- Batch operations optimization + +## Security + +- **Password Hashing**: BCrypt with strength 12 +- **JWT Tokens**: Auth0 library with HMAC256 +- **CORS**: Configured for development (update for production) +- **HTTPS**: Required for production deployment +- **SQL Injection**: Protected via parameterized queries with jOOQ +- **CSRF**: Disabled for API (JWT-based auth) ## Contributing -State if you are open to contributions and what your requirements are for accepting them. -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +1. Create a feature branch: `git checkout -b feature/amazing-feature` +2. Commit changes: `git commit -m 'Add amazing feature'` +3. Push to branch: `git push origin feature/amazing-feature` +4. Open a Pull Request ## License -For open source projects, say how it is licensed. -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +MIT License - see LICENSE file for details + +## Support + +For issues and questions: +- Open an issue on GitLab +- Check existing documentation +- Review API Swagger docs at `/swagger-ui.html` + +## Roadmap + +- [ ] Card search functionality with filters +- [ ] Deck building feature +- [ ] Social features (friends, sharing) +- [ ] Mobile app +- [ ] Advanced analytics +- [ ] Magic Online API integration +- [ ] Performance tuning for large datasets diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..0d9faa3 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,162 @@ +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jooq.meta.jaxb.Property + +plugins { + id("org.springframework.boot") version "3.2.5" + id("io.spring.dependency-management") version "1.1.4" + id("org.openapi.generator") version "7.3.0" + id("nu.studer.jooq") version "9.0" + id("java") +} + +group = "net.moustos" +version = "0.1.0" + +repositories { + mavenCentral() +} + +dependencies { + // Spring Boot + implementation("org.springframework.boot:spring-boot-starter-web:3.2.5") + implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.5") + implementation("org.springframework.boot:spring-boot-starter-security:3.2.5") + implementation("org.springframework.boot:spring-boot-starter-validation:3.2.5") + developmentOnly("org.springframework.boot:spring-boot-devtools:3.2.5") + + // Database + implementation("org.postgresql:postgresql:42.7.3") + implementation("org.flywaydb:flyway-core:9.22.3") + implementation("org.flywaydb:flyway-database-postgresql:9.22.3") + + // jOOQ + implementation("org.jooq:jooq:3.19.8") + implementation("org.jooq:jooq-meta:3.19.8") + implementation("org.jooq:jooq-codegen:3.19.8") + jooqGenerator("org.postgresql:postgresql:42.7.3") + + // Security & JWT + implementation("com.auth0:java-jwt:4.4.0") + implementation("at.fageorgetown:jbcrypt:0.9.1") + implementation("org.springframework.security:spring-security-crypto:6.2.3") + + // OpenAPI & Swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0") + implementation("jakarta.annotation:jakarta.annotation-api:2.1.1") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1") + + // Logging + implementation("ch.qos.logback:logback-core:1.5.0") + implementation("ch.qos.logback:logback-classic:1.5.0") + implementation("net.logstash.logback:logstash-logback-encoder:7.4") + + // Utilities + implementation("org.projectlombok:lombok:1.18.30") + annotationProcessor("org.projectlombok:lombok:1.18.30") + implementation("org.apache.commons:commons-lang3:3.14.0") + implementation("com.google.guava:guava:33.1.0-jre") + + // Testing + testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.5") + testImplementation("org.springframework.security:spring-security-test:6.2.3") + testImplementation("org.testcontainers:testcontainers:1.19.7") + testImplementation("org.testcontainers:postgresql:1.19.7") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testImplementation("org.mockito:mockito-core:5.7.1") + testImplementation("org.mockito:mockito-junit-jupiter:5.7.1") + testImplementation("io.rest-assured:rest-assured:5.4.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +application { + mainClass.set("net.moustos.mtgsearch.MtgSearchApplication") +} + +springBoot { + buildInfo() +} + +// OpenAPI Generator Configuration +openApiGenerate { + generatorName.set("spring") + inputSpec.set("$projectDir/openapi/api.yaml") + outputDir.set("$projectDir/build/generated") + apiPackage.set("net.moustos.mtgsearch.api") + modelPackage.set("net.moustos.mtgsearch.model.api") + globalProperties.set(mapOf( + "apis" to "true", + "models" to "true" + )) + configOptions.set(mapOf( + "delegatePattern" to "true", + "title" to "MTG Search API", + "interfaceOnly" to "false", + "skipDefaultInterface" to "false", + "useSpringBoot3" to "true", + "useJakartaEe" to "true" + )) +} + +// jOOQ Configuration +jooq { + configurations { + create("main") { + jooqConfiguration.apply { + jdbc.apply { + driver = "org.postgresql.Driver" + url = "jdbc:postgresql://localhost:5432/mtgsearch" + user = "postgres" + password = "postgres" + } + generator.apply { + name = "org.jooq.codegen.JavaGenerator" + database.apply { + name = "org.jooq.meta.postgres.PostgresDatabase" + inputSchema = "public" + } + target.apply { + packageName = "net.moustos.mtgsearch.jooq.generated" + directory = "$projectDir/build/generated/jooq" + } + } + } + } + } +} + +tasks.register("generateJooqCode") { + dependsOn("jooqCodegen") +} + +sourceSets { + main { + java { + srcDirs( + "src/main/java", + "$buildDir/generated/src/main/java", + "$buildDir/generated/jooq" + ) + } + } +} + +tasks.compileJava { + dependsOn("openApiGenerate", "generateJooqCode") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } +} + +tasks.named("build") { + dependsOn("openApiGenerate", "generateJooqCode") +} diff --git a/backend/openapi/api.yaml b/backend/openapi/api.yaml new file mode 100644 index 0000000..6b5ee38 --- /dev/null +++ b/backend/openapi/api.yaml @@ -0,0 +1,162 @@ +openapi: 3.0.0 +info: + title: MTG Search API + version: 0.1.0 + description: Magic The Gathering Card Search REST API + contact: + name: API Support + url: https://github.com/example/mtg-search + license: + name: MIT + +servers: + - url: http://localhost:8080 + description: Development server + - url: https://api.mtgsearch.example.com + description: Production server + +paths: + /api/v1/auth/register: + post: + tags: + - Authentication + summary: Register a new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '201': + description: User registered successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '400': + description: Invalid input + '409': + description: User already exists + + /api/v1/auth/login: + post: + tags: + - Authentication + summary: Login user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login successful + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: Invalid credentials + '500': + description: Server error + + /api/v1/auth/health: + get: + tags: + - Health + summary: Health check + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + +components: + schemas: + RegisterRequest: + type: object + required: + - username + - email + - password + properties: + username: + type: string + minLength: 3 + maxLength: 100 + example: john_doe + email: + type: string + format: email + example: john@example.com + password: + type: string + format: password + minLength: 8 + example: SecurePassword123! + + LoginRequest: + type: object + required: + - username + - password + properties: + username: + type: string + example: john_doe + password: + type: string + format: password + example: SecurePassword123! + + UserResponse: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + email: + type: string + message: + type: string + error: + type: string + + LoginResponse: + type: object + properties: + token: + type: string + description: JWT token for authentication + id: + type: integer + format: int64 + username: + type: string + email: + type: string + error: + type: string + + HealthResponse: + type: object + properties: + status: + type: string + enum: + - ok + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + +security: + - bearerAuth: [] diff --git a/backend/src/main/java/net/moustos/mtgsearch/MtgSearchApplication.java b/backend/src/main/java/net/moustos/mtgsearch/MtgSearchApplication.java new file mode 100644 index 0000000..6a2cce2 --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/MtgSearchApplication.java @@ -0,0 +1,17 @@ +package net.moustos.mtgsearch; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +/** + * Main Spring Boot Application entry point for MTG Search + */ +@SpringBootApplication +@ComponentScan(basePackages = {"net.moustos.mtgsearch"}) +public class MtgSearchApplication { + + public static void main(String[] args) { + SpringApplication.run(MtgSearchApplication.class, args); + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/config/AppConfig.java b/backend/src/main/java/net/moustos/mtgsearch/config/AppConfig.java new file mode 100644 index 0000000..963200e --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/config/AppConfig.java @@ -0,0 +1,37 @@ +package net.moustos.mtgsearch.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * General application configuration + */ +@Configuration +public class AppConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/config/SecurityConfig.java b/backend/src/main/java/net/moustos/mtgsearch/config/SecurityConfig.java new file mode 100644 index 0000000..51a3f1e --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/config/SecurityConfig.java @@ -0,0 +1,69 @@ +package net.moustos.mtgsearch.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import net.moustos.mtgsearch.security.JwtAuthenticationFilter; +import net.moustos.mtgsearch.service.UserDetailsServiceImpl; + +/** + * Spring Security configuration + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final UserDetailsServiceImpl userDetailsService; + private final PasswordEncoder passwordEncoder; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder, + JwtAuthenticationFilter jwtAuthenticationFilter) { + this.userDetailsService = userDetailsService; + this.passwordEncoder = passwordEncoder; + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder + .userDetailsService(userDetailsService) + .passwordEncoder(passwordEncoder); + return authenticationManagerBuilder.build(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.disable()) + .csrf(csrf -> csrf.disable()) + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Unauthorized\"}"); + }) + ) + .sessionManagement(sessionManagement -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api/v1/public/**").permitAll() + .requestMatchers("/swagger-ui/**", "/javadoc/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/health", "/health/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/config/WebMvcConfig.java b/backend/src/main/java/net/moustos/mtgsearch/config/WebMvcConfig.java new file mode 100644 index 0000000..fd1f47e --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package net.moustos.mtgsearch.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC configuration for serving static resources + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .setCacheControl(org.springframework.http.CacheControl.maxAge(365, java.util.concurrent.TimeUnit.DAYS)); + + registry.addResourceHandler("/index.html") + .addResourceLocations("classpath:/static/index.html"); + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/controller/AuthController.java b/backend/src/main/java/net/moustos/mtgsearch/controller/AuthController.java new file mode 100644 index 0000000..8dba68c --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/controller/AuthController.java @@ -0,0 +1,130 @@ +package net.moustos.mtgsearch.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import net.moustos.mtgsearch.model.User; +import net.moustos.mtgsearch.service.AuthService; + +import java.util.Map; + +/** + * Authentication controller for login and registration endpoints + */ +@RestController +@RequestMapping("/api/v1/auth") +@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"}) +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + /** + * Register a new user + */ + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + try { + User user = authService.register(request.getUsername(), request.getEmail(), request.getPassword()); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of( + "id", user.getId(), + "username", user.getUsername(), + "email", user.getEmail(), + "message", "User registered successfully" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of( + "error", e.getMessage() + )); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( + "error", "Registration failed" + )); + } + } + + /** + * Login user and return JWT token + */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + try { + String token = authService.authenticate(request.getUsername(), request.getPassword()); + User user = authService.getUserByUsername(request.getUsername()).orElseThrow(); + return ResponseEntity.ok(Map.of( + "token", token, + "id", user.getId(), + "username", user.getUsername(), + "email", user.getEmail() + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of( + "error", e.getMessage() + )); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( + "error", "Login failed" + )); + } + } + + /** + * Health check endpoint + */ + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "ok")); + } + + // DTOs + public static class RegisterRequest { + public String username; + public String email; + public String password; + + public RegisterRequest() { + } + + public RegisterRequest(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + } + + public static class LoginRequest { + public String username; + public String password; + + public LoginRequest() { + } + + public LoginRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/model/User.java b/backend/src/main/java/net/moustos/mtgsearch/model/User.java new file mode 100644 index 0000000..89f70e0 --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/model/User.java @@ -0,0 +1,55 @@ +package net.moustos.mtgsearch.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * User entity for authentication + */ +@Entity +@Table(name = "users") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String username; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false, length = 255) + private String password; + + @Column(nullable = false) + private Boolean active; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + active = true; + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/repository/UserRepository.java b/backend/src/main/java/net/moustos/mtgsearch/repository/UserRepository.java new file mode 100644 index 0000000..4bb9abc --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/repository/UserRepository.java @@ -0,0 +1,18 @@ +package net.moustos.mtgsearch.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import net.moustos.mtgsearch.model.User; + +import java.util.Optional; + +/** + * User repository for database operations + */ +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/security/JwtAuthenticationFilter.java b/backend/src/main/java/net/moustos/mtgsearch/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1e59772 --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/security/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package net.moustos.mtgsearch.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import net.moustos.mtgsearch.service.UserDetailsServiceImpl; + +import java.io.IOException; + +/** + * JWT Authentication filter to validate tokens on each request + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsServiceImpl userDetailsService; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsServiceImpl userDetailsService) { + this.jwtTokenProvider = jwtTokenProvider; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = extractJwtFromRequest(request); + + if (jwt != null && jwtTokenProvider.validateToken(jwt)) { + String username = jwtTokenProvider.validateTokenAndGetUsername(jwt); + var userDetails = userDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + logger.error("Could not set user authentication in security context", ex); + } + + filterChain.doFilter(request, response); + } + + private String extractJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/security/JwtTokenProvider.java b/backend/src/main/java/net/moustos/mtgsearch/security/JwtTokenProvider.java new file mode 100644 index 0000000..b471d5a --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/security/JwtTokenProvider.java @@ -0,0 +1,83 @@ +package net.moustos.mtgsearch.security; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * JWT Token provider for generating and validating JWT tokens + */ +@Component +public class JwtTokenProvider { + + @Value("${app.jwt.secret:your-secret-key-change-in-production}") + private String jwtSecret; + + @Value("${app.jwt.expiration:86400}") + private long jwtExpiration; + + private static final String CLAIM_USER_ID = "userId"; + private static final String CLAIM_USERNAME = "username"; + private static final String CLAIM_EMAIL = "email"; + + /** + * Generate a JWT token for a user + */ + public String generateToken(long userId, String username, String email) { + Instant now = Instant.now(); + Instant expiresAt = now.plus(jwtExpiration, ChronoUnit.SECONDS); + + return JWT.create() + .withClaim(CLAIM_USER_ID, userId) + .withClaim(CLAIM_USERNAME, username) + .withClaim(CLAIM_EMAIL, email) + .withIssuedAt(now) + .withExpiresAt(expiresAt) + .withIssuer("mtg-search") + .sign(Algorithm.HMAC256(jwtSecret)); + } + + /** + * Validate JWT token and extract user ID + */ + public long validateTokenAndGetUserId(String token) throws JWTVerificationException { + return JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer("mtg-search") + .build() + .verify(token) + .getClaim(CLAIM_USER_ID) + .asLong(); + } + + /** + * Validate JWT token and extract username + */ + public String validateTokenAndGetUsername(String token) throws JWTVerificationException { + return JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer("mtg-search") + .build() + .verify(token) + .getClaim(CLAIM_USERNAME) + .asString(); + } + + /** + * Validate JWT token + */ + public boolean validateToken(String token) { + try { + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer("mtg-search") + .build() + .verify(token); + return true; + } catch (JWTVerificationException e) { + return false; + } + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/service/AuthService.java b/backend/src/main/java/net/moustos/mtgsearch/service/AuthService.java new file mode 100644 index 0000000..3cceefd --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/service/AuthService.java @@ -0,0 +1,86 @@ +package net.moustos.mtgsearch.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import net.moustos.mtgsearch.model.User; +import net.moustos.mtgsearch.repository.UserRepository; +import net.moustos.mtgsearch.security.JwtTokenProvider; + +import java.util.Optional; + +/** + * Authentication service for user login and registration + */ +@Service +@Transactional +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtTokenProvider = jwtTokenProvider; + } + + /** + * Register a new user + */ + public User register(String username, String email, String password) { + if (userRepository.existsByUsername(username)) { + throw new IllegalArgumentException("Username already exists"); + } + if (userRepository.existsByEmail(email)) { + throw new IllegalArgumentException("Email already exists"); + } + + User user = User.builder() + .username(username) + .email(email) + .password(passwordEncoder.encode(password)) + .active(true) + .build(); + + return userRepository.save(user); + } + + /** + * Authenticate user and return JWT token + */ + public String authenticate(String username, String password) { + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + throw new IllegalArgumentException("Invalid username or password"); + } + + User user = userOpt.get(); + if (!user.getActive()) { + throw new IllegalArgumentException("User account is inactive"); + } + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new IllegalArgumentException("Invalid username or password"); + } + + return jwtTokenProvider.generateToken(user.getId(), user.getUsername(), user.getEmail()); + } + + /** + * Get user by ID + */ + @Transactional(readOnly = true) + public Optional getUserById(Long userId) { + return userRepository.findById(userId); + } + + /** + * Get user by username + */ + @Transactional(readOnly = true) + public Optional getUserByUsername(String username) { + return userRepository.findByUsername(username); + } +} diff --git a/backend/src/main/java/net/moustos/mtgsearch/service/UserDetailsServiceImpl.java b/backend/src/main/java/net/moustos/mtgsearch/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..e22a4ba --- /dev/null +++ b/backend/src/main/java/net/moustos/mtgsearch/service/UserDetailsServiceImpl.java @@ -0,0 +1,40 @@ +package net.moustos.mtgsearch.service; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import net.moustos.mtgsearch.repository.UserRepository; + +import java.util.Collections; + +/** + * User details service for Spring Security + */ +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))) + .accountNonLocked(user.getActive()) + .accountNonExpired(true) + .credentialsNonExpired(true) + .enabled(user.getActive()) + .build(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..e0db217 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,72 @@ +spring: + application: + name: mtg-search + version: 0.1.0 + + datasource: + url: jdbc:postgresql://localhost:5432/mtgsearch + username: postgres + password: postgres + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + idle-timeout: 300000 + max-lifetime: 1200000 + auto-commit: true + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate.format_sql: true + hibernate.jdbc.batch_size: 20 + hibernate.order_inserts: true + hibernate.order_updates: true + + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + sql-migration-prefix: V + sql-migration-suffix: .sql + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +app: + jwt: + secret: ${JWT_SECRET:your-secret-key-change-in-production} + expiration: ${JWT_EXPIRATION:86400} + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + enabled: true + operations-sorter: method + tags-sorter: alpha + +server: + port: 8080 + servlet: + context-path: / + compression: + enabled: true + min-response-size: 1024 + +logging: + level: + root: INFO + net.moustos.mtgsearch: DEBUG + org.springframework.web: INFO + org.springframework.security: DEBUG + config: classpath:logback-spring.xml + file: + name: logs/mtg-search.log + max-size: 100MB + max-history: 30 diff --git a/backend/src/main/resources/db/migration/V1__Initial_Schema.sql b/backend/src/main/resources/db/migration/V1__Initial_Schema.sql new file mode 100644 index 0000000..452e256 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__Initial_Schema.sql @@ -0,0 +1,16 @@ +-- Initial schema setup for MTG Search +-- Users table for authentication +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for common queries +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_active ON users(active); diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..d3ad43b --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,60 @@ + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_DIR}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + + ${LOG_DIR}/${LOG_FILE_NAME}-error.log + + ERROR + ACCEPT + DENY + + + ${LOG_PATTERN} + + + ${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 3GB + + + + + + + + + + + + + + + + + diff --git a/backend/src/test/java/net/moustos/mtgsearch/AuthControllerIntegrationTest.java b/backend/src/test/java/net/moustos/mtgsearch/AuthControllerIntegrationTest.java new file mode 100644 index 0000000..9276eb9 --- /dev/null +++ b/backend/src/test/java/net/moustos/mtgsearch/AuthControllerIntegrationTest.java @@ -0,0 +1,119 @@ +package net.moustos.mtgsearch; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +/** + * Integration tests for authentication API + */ +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(locations = "classpath:application-test.yml") +public class AuthControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @BeforeEach + public void setUp() { + // Clear database before each test + } + + @Test + public void testUserRegistration() throws Exception { + String registerPayload = """ + { + "username": "testuser", + "email": "test@example.com", + "password": "SecurePassword123!" + } + """; + + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerPayload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")); + } + + @Test + public void testUserLogin() throws Exception { + // First register + String registerPayload = """ + { + "username": "testuser", + "email": "test@example.com", + "password": "SecurePassword123!" + } + """; + + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerPayload)) + .andExpect(status().isCreated()); + + // Then login + String loginPayload = """ + { + "username": "testuser", + "password": "SecurePassword123!" + } + """; + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").exists()) + .andExpect(jsonPath("$.username").value("testuser")); + } + + @Test + public void testLoginWithInvalidCredentials() throws Exception { + String loginPayload = """ + { + "username": "nonexistent", + "password": "WrongPassword123!" + } + """; + + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginPayload)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void testDuplicateUsernameRegistration() throws Exception { + String registerPayload = """ + { + "username": "testuser", + "email": "test@example.com", + "password": "SecurePassword123!" + } + """; + + // Register first user + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerPayload)) + .andExpect(status().isCreated()); + + // Try to register with same username + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(registerPayload)) + .andExpect(status().isBadRequest()); + } +} diff --git a/backend/src/test/java/net/moustos/mtgsearch/AuthServiceTest.java b/backend/src/test/java/net/moustos/mtgsearch/AuthServiceTest.java new file mode 100644 index 0000000..4ae0ef8 --- /dev/null +++ b/backend/src/test/java/net/moustos/mtgsearch/AuthServiceTest.java @@ -0,0 +1,108 @@ +package net.moustos.mtgsearch.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import net.moustos.mtgsearch.model.User; +import net.moustos.mtgsearch.repository.UserRepository; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AuthService + */ +@SpringBootTest +@TestPropertySource(locations = "classpath:application-test.yml") +@DisplayName("AuthService Tests") +public class AuthServiceTest { + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + public void setUp() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("Should register a new user successfully") + public void testRegisterUser() { + User user = authService.register("testuser", "test@example.com", "SecurePassword123!"); + assertNotNull(user.getId()); + assertEquals("testuser", user.getUsername()); + assertEquals("test@example.com", user.getEmail()); + assertTrue(user.getActive()); + } + + @Test + @DisplayName("Should throw exception for duplicate username") + public void testRegisterDuplicateUsername() { + authService.register("testuser", "test@example.com", "SecurePassword123!"); + assertThrows(IllegalArgumentException.class, () -> + authService.register("testuser", "another@example.com", "SecurePassword123!") + ); + } + + @Test + @DisplayName("Should throw exception for duplicate email") + public void testRegisterDuplicateEmail() { + authService.register("testuser", "test@example.com", "SecurePassword123!"); + assertThrows(IllegalArgumentException.class, () -> + authService.register("anotheruser", "test@example.com", "SecurePassword123!") + ); + } + + @Test + @DisplayName("Should authenticate user successfully") + public void testAuthenticateUser() { + authService.register("testuser", "test@example.com", "SecurePassword123!"); + String token = authService.authenticate("testuser", "SecurePassword123!"); + assertNotNull(token); + assertFalse(token.isEmpty()); + } + + @Test + @DisplayName("Should throw exception for invalid username") + public void testAuthenticateWithInvalidUsername() { + assertThrows(IllegalArgumentException.class, () -> + authService.authenticate("nonexistent", "SomePassword123!") + ); + } + + @Test + @DisplayName("Should throw exception for invalid password") + public void testAuthenticateWithInvalidPassword() { + authService.register("testuser", "test@example.com", "SecurePassword123!"); + assertThrows(IllegalArgumentException.class, () -> + authService.authenticate("testuser", "WrongPassword123!") + ); + } + + @Test + @DisplayName("Should retrieve user by ID") + public void testGetUserById() { + User registeredUser = authService.register("testuser", "test@example.com", "SecurePassword123!"); + var retrievedUser = authService.getUserById(registeredUser.getId()); + assertTrue(retrievedUser.isPresent()); + assertEquals(registeredUser.getId(), retrievedUser.get().getId()); + } + + @Test + @DisplayName("Should retrieve user by username") + public void testGetUserByUsername() { + authService.register("testuser", "test@example.com", "SecurePassword123!"); + var retrievedUser = authService.getUserByUsername("testuser"); + assertTrue(retrievedUser.isPresent()); + assertEquals("testuser", retrievedUser.get().getUsername()); + } +} diff --git a/backend/src/test/java/net/moustos/mtgsearch/UserRepositoryTest.java b/backend/src/test/java/net/moustos/mtgsearch/UserRepositoryTest.java new file mode 100644 index 0000000..007e7f1 --- /dev/null +++ b/backend/src/test/java/net/moustos/mtgsearch/UserRepositoryTest.java @@ -0,0 +1,88 @@ +package net.moustos.mtgsearch.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import net.moustos.mtgsearch.model.User; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for UserRepository + */ +@SpringBootTest +@TestPropertySource(locations = "classpath:application-test.yml") +public class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @BeforeEach + public void setUp() { + userRepository.deleteAll(); + } + + @Test + public void testFindByUsername() { + User user = User.builder() + .username("testuser") + .email("test@example.com") + .password("hashedpassword") + .active(true) + .build(); + + userRepository.save(user); + + var found = userRepository.findByUsername("testuser"); + assertTrue(found.isPresent()); + assertEquals("testuser", found.get().getUsername()); + } + + @Test + public void testFindByEmail() { + User user = User.builder() + .username("testuser") + .email("test@example.com") + .password("hashedpassword") + .active(true) + .build(); + + userRepository.save(user); + + var found = userRepository.findByEmail("test@example.com"); + assertTrue(found.isPresent()); + assertEquals("test@example.com", found.get().getEmail()); + } + + @Test + public void testExistsByUsername() { + User user = User.builder() + .username("testuser") + .email("test@example.com") + .password("hashedpassword") + .active(true) + .build(); + + userRepository.save(user); + + assertTrue(userRepository.existsByUsername("testuser")); + assertFalse(userRepository.existsByUsername("nonexistent")); + } + + @Test + public void testExistsByEmail() { + User user = User.builder() + .username("testuser") + .email("test@example.com") + .password("hashedpassword") + .active(true) + .build(); + + userRepository.save(user); + + assertTrue(userRepository.existsByEmail("test@example.com")); + assertFalse(userRepository.existsByEmail("nonexistent@example.com")); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..134611d --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,30 @@ +spring: + profiles: + active: test + datasource: + url: jdbc:postgresql://localhost:5432/mtgsearch_test + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 5 + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: create-drop + show-sql: false + + flyway: + enabled: true + baseline-on-migrate: true + +app: + jwt: + secret: test-secret-key + expiration: 3600 + +logging: + level: + root: WARN + net.moustos.mtgsearch: DEBUG diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..47d4252 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,42 @@ +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + id("java") + id("org.springframework.boot") version "3.2.5" apply false + id("io.spring.dependency-management") version "1.1.4" +} + +group = "net.moustos" +version = "0.1.0" + +allprojects { + repositories { + mavenCentral() + maven { + url = uri("https://repo.maven.apache.org/maven2/") + } + } +} + +subprojects { + apply(plugin = "java") + + java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") + } + + tasks.withType { + useJUnitPlatform() + testLogging { + events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = false + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..59bdd01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: mtgsearch-db + environment: + POSTGRES_DB: mtgsearch + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: mtgsearch-backend + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mtgsearch + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + SPRING_JPA_HIBERNATE_DDL_AUTO: validate + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production} + JWT_EXPIRATION: 86400 + ports: + - "8080:8080" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/auth/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + postgres_data: + driver: local + +networks: + default: + name: mtgsearch-network diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..8d0fff6 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,34 @@ +# Frontend ESLint configuration +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:vue/vue3-recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "vue-eslint-parser", + "parserOptions": { + "ecmaVersion": "latest", + "parser": "@typescript-eslint/parser", + "sourceType": "module" + }, + "plugins": [ + "vue", + "@typescript-eslint" + ], + "rules": { + "vue/multi-word-component-names": "off", + "vue/no-setup-props-destructure": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "no-console": "warn" + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..fa9f3fe --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env.local +.DS_Store +*.log +npm-debug.log* diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..86e4021 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +frontend/.eslintrc.json +frontend/.prettierrc +frontend/.gitignore diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..7dfdfee --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,11 @@ +; Frontend Prettier configuration +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "vueIndentScriptAndStyle": true +} diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 0000000..95ee9df --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1,6 @@ +/// +declare module '*.vue' { + import { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8d7a75e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MTG Search + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..667cf35 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "mtg-search-frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.2.0", + "axios": "^1.7.7", + "pinia-plugin-persistedstate": "^3.2.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "vite": "^5.3.1", + "typescript": "^5.4.5", + "vue-tsc": "^1.8.27", + "@vue/test-utils": "^2.4.6", + "vitest": "^2.0.0", + "eslint": "^9.0.0", + "eslint-plugin-vue": "^9.26.0", + "@typescript-eslint/eslint-plugin": "^7.8.0", + "@typescript-eslint/parser": "^7.8.0" + } +} diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..377ccd3 --- /dev/null +++ b/frontend/public/.gitkeep @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b357972 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..8a99580 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,15 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import { createPinia } from 'pinia' +import createPersistedState from 'pinia-plugin-persistedstate' + +const app = createApp(App) + +const pinia = createPinia() +pinia.use(createPersistedState()) + +app.use(pinia) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..ee353af --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/pages/Home.vue b/frontend/src/pages/Home.vue new file mode 100644 index 0000000..dcdf6bf --- /dev/null +++ b/frontend/src/pages/Home.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue new file mode 100644 index 0000000..96f7735 --- /dev/null +++ b/frontend/src/pages/Login.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/frontend/src/pages/Register.vue b/frontend/src/pages/Register.vue new file mode 100644 index 0000000..9cc235c --- /dev/null +++ b/frontend/src/pages/Register.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..2e44617 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,49 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import Home from '@/pages/Home.vue' +import Login from '@/pages/Login.vue' +import Register from '@/pages/Register.vue' +import Dashboard from '@/pages/Dashboard.vue' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'Home', + component: Home, + }, + { + path: '/login', + name: 'Login', + component: Login, + }, + { + path: '/register', + name: 'Register', + component: Register, + }, + { + path: '/dashboard', + name: 'Dashboard', + component: Dashboard, + meta: { requiresAuth: true }, + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + } else if ((to.path === '/login' || to.path === '/register') && authStore.isAuthenticated) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..be11749 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,11 @@ +import axios from 'axios' + +const apiClient = axios.create({ + baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +export default apiClient diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..1f5c9cd --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,109 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import apiClient from '@/services/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(null) + const user = ref(null) + + const isAuthenticated = computed(() => !!token.value) + + const login = async (username: string, password: string) => { + try { + const response = await apiClient.post('/api/v1/auth/login', { + username, + password, + }) + + token.value = response.data.token + user.value = { + id: response.data.id, + username: response.data.username, + email: response.data.email, + } + + localStorage.setItem('token', token.value) + localStorage.setItem('user', JSON.stringify(user.value)) + + return response.data + } catch (error) { + token.value = null + user.value = null + localStorage.removeItem('token') + localStorage.removeItem('user') + throw error + } + } + + const register = async (username: string, email: string, password: string) => { + try { + const response = await apiClient.post('/api/v1/auth/register', { + username, + email, + password, + }) + return response.data + } catch (error) { + throw error + } + } + + const logout = () => { + token.value = null + user.value = null + localStorage.removeItem('token') + localStorage.removeItem('user') + } + + const setupAuthInterceptors = () => { + apiClient.interceptors.request.use((config) => { + if (token.value) { + config.headers.Authorization = `Bearer ${token.value}` + } + return config + }) + + apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + logout() + } + return Promise.reject(error) + } + ) + } + + const loadSavedAuth = () => { + const savedToken = localStorage.getItem('token') + const savedUser = localStorage.getItem('user') + + if (savedToken) { + token.value = savedToken + } + if (savedUser) { + user.value = JSON.parse(savedUser) + } + } + + return { + token, + user, + isAuthenticated, + login, + register, + logout, + setupAuthInterceptors, + loadSavedAuth, + } +}, { + persist: { + enabled: true, + strategies: [ + { + key: 'auth', + storage: localStorage, + }, + ], + }, +}) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..4b614fb --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,35 @@ +/** + * API Response types + */ +export interface ApiResponse { + data?: T + error?: string + message?: string + status?: number +} + +export interface User { + id: number + username: string + email: string + createdAt?: string + updatedAt?: string +} + +export interface AuthResponse { + token: string + id: number + username: string + email: string +} + +export interface RegisterRequest { + username: string + email: string + password: string +} + +export interface LoginRequest { + username: string + password: string +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1021089 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + + /* Bundler mode */ + "moduleResolution": "node", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..9e00a41 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..dd237e4 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + strictPort: false, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: resolve(__dirname, '../backend/src/main/resources/static'), + emptyOutDir: true, + sourcemap: false, + minify: 'terser', + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..2da5984 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'jsdom', + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, +}) diff --git a/frontend/vitest.ui.ts b/frontend/vitest.ui.ts new file mode 100644 index 0000000..b9b39b7 --- /dev/null +++ b/frontend/vitest.ui.ts @@ -0,0 +1,2 @@ + +export {} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b2a9a8b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +# Gradle Configuration +org.gradle.jvmargs=-Xmx2g +org.gradle.parallel=true +org.gradle.caching=true + +# Project Versions +javaVersion=21 +springBootVersion=3.2.5 +springCloudVersion=2023.0.1 +openApiGeneratorVersion=7.3.0 +jooqVersion=3.19.8 +postgresqlVersion=42.7.3 +flywayVersion=9.22.3 +logbackVersion=1.5.0 +auth0-jwtVersion=4.4.0 +bcryptVersion=0.9.1 + +# Frontend +nodeVersion=20.11.0 +npmVersion=10.2.4 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7ac3afb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1 @@ +gradleVersion=8.9 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..60001fb --- /dev/null +++ b/gradlew @@ -0,0 +1,46 @@ +#!/bin/sh +# +# Copyright 2015 the original author or authors. +# +# Licensed 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 +# +# https://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. +# + +############################################################################## +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell available, you may +# specify it by setting the SHELL variable when running this script. +# For example: SHELL=/bin/bash ./gradlew +# +# (2) Springboot 3 requires Java 17+ +# +############################################################################## + +set -e + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +APP_HOME=$( cd "$SCRIPT_DIR" && cd "$(dirname "$0")" && pwd -P || echo "$SCRIPT_DIR" ) +APP_NAME="mtg-search" +APP_JAR_NAME="mtg-search-0.1.0.jar" + +case "$(uname)" in + *CYGWIN*) APP_HOME=`(cd "$APP_HOME" && pwd -W)` ;; +esac + +DEFAULT_JVM_OPTS='"-Xmx2g" "-Xms256m"' +CLASSPATH=$APP_HOME/backend/build/libs/$APP_JAR_NAME + +java $DEFAULT_JVM_OPTS -jar "$CLASSPATH" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5929405 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,86 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx2g" "-Xms256m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >nul 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo the location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo the location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\backend\build\libs\mtg-search-0.1.0.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% -jar "%CLASSPATH%" %* + +:end +@endlocal & set ERROR_CODE=%ERRORLEVEL% + +if not "%ERRORLEVEL%"=="0" goto fail + +:fail +exit /b %ERRORLEVEL% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +exit /b %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7162a68 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +rootProject.name = "mtg-search" + +include(":backend") +include(":frontend") diff --git a/startup.sh b/startup.sh new file mode 100644 index 0000000..876f71d --- /dev/null +++ b/startup.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Startup script for MTG Search Project +# This script helps set up and run the project + +set -e + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${BLUE}=== MTG Search Project Startup ===${NC}" + +# Check prerequisites +check_prerequisites() { + echo -e "\n${BLUE}Checking prerequisites...${NC}" + + # Check Java + if ! command -v java &> /dev/null; then + echo -e "${RED}✗ Java not found. Please install Java 21+${NC}" + exit 1 + fi + JAVA_VERSION=$(java -version 2>&1 | grep -oP 'version "\K[0-9.]+') + echo -e "${GREEN}✓ Java $JAVA_VERSION found${NC}" + + # Check Node.js + if ! command -v node &> /dev/null; then + echo -e "${RED}✗ Node.js not found. Please install Node.js 20+${NC}" + exit 1 + fi + NODE_VERSION=$(node -v) + echo -e "${GREEN}✓ Node.js $NODE_VERSION found${NC}" + + # Check PostgreSQL + if ! command -v psql &> /dev/null; then + if ! command -v docker &> /dev/null; then + echo -e "${RED}✗ PostgreSQL and Docker not found. Please install one of them${NC}" + exit 1 + fi + echo -e "${GREEN}✓ Docker found (will use for PostgreSQL)${NC}" + else + PG_VERSION=$(psql --version) + echo -e "${GREEN}✓ $PG_VERSION found${NC}" + fi +} + +# Setup database +setup_database() { + echo -e "\n${BLUE}Setting up database...${NC}" + + # Check if PostgreSQL is running + if ! psql -U postgres -h localhost -d postgres -c "SELECT 1" &> /dev/null; then + echo -e "${BLUE}Starting PostgreSQL with Docker...${NC}" + + if docker ps -a --format '{{.Names}}' | grep -q mtgsearch-postgres; then + docker start mtgsearch-postgres + echo -e "${GREEN}✓ PostgreSQL container started${NC}" + else + docker run --name mtgsearch-postgres \ + -e POSTGRES_DB=mtgsearch \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + -d postgres:16-alpine + + echo -e "${BLUE}Waiting for PostgreSQL to be ready...${NC}" + sleep 10 + echo -e "${GREEN}✓ PostgreSQL container created and started${NC}" + fi + else + echo -e "${GREEN}✓ PostgreSQL is already running${NC}" + fi + + # Create database if it doesn't exist + psql -U postgres -h localhost -c "CREATE DATABASE mtgsearch;" 2>/dev/null || true + echo -e "${GREEN}✓ Database ready${NC}" +} + +# Build backend +build_backend() { + echo -e "\n${BLUE}Building backend...${NC}" + cd backend + ../gradlew clean build -x test + cd .. + echo -e "${GREEN}✓ Backend built successfully${NC}" +} + +# Install frontend dependencies +install_frontend() { + echo -e "\n${BLUE}Installing frontend dependencies...${NC}" + cd frontend + npm install + cd .. + echo -e "${GREEN}✓ Frontend dependencies installed${NC}" +} + +# Build frontend +build_frontend() { + echo -e "\n${BLUE}Building frontend...${NC}" + cd frontend + npm run build + cd .. + echo -e "${GREEN}✓ Frontend built successfully${NC}" +} + +# Display startup options +show_menu() { + echo -e "\n${BLUE}=== Startup Options ===${NC}" + echo "1. Full Setup (database + build everything)" + echo "2. Setup Database Only" + echo "3. Build Backend Only" + echo "4. Build Frontend Only" + echo "5. Run Backend (requires database)" + echo "6. Run Frontend Dev Server" + echo "7. Run Both (Backend + Frontend)" + echo "8. Docker Compose (recommended)" + echo "9. Exit" + echo "" +} + +# Main menu +main_menu() { + while true; do + show_menu + read -p "Select option (1-9): " choice + + case $choice in + 1) + check_prerequisites + setup_database + build_backend + install_frontend + build_frontend + echo -e "\n${GREEN}✓ Setup complete!${NC}" + echo -e "${BLUE}Run 'make run' to start the backend${NC}" + echo -e "${BLUE}In another terminal, run 'cd frontend && npm run dev' for frontend${NC}" + ;; + 2) + check_prerequisites + setup_database + ;; + 3) + build_backend + ;; + 4) + install_frontend + build_frontend + ;; + 5) + check_prerequisites + echo -e "\n${BLUE}Starting backend on http://localhost:8080${NC}" + cd backend + ../gradlew bootRun + ;; + 6) + echo -e "\n${BLUE}Starting frontend dev server on http://localhost:5173${NC}" + cd frontend + npm run dev + ;; + 7) + echo -e "\n${BLUE}Starting Backend + Frontend${NC}" + echo -e "${BLUE}Frontend: http://localhost:5173${NC}" + echo -e "${BLUE}Backend: http://localhost:8080${NC}" + (cd backend && ../gradlew bootRun) & + (cd frontend && npm run dev) & + wait + ;; + 8) + echo -e "\n${BLUE}Starting with Docker Compose${NC}" + docker-compose up --build + ;; + 9) + echo -e "${GREEN}Goodbye!${NC}" + exit 0 + ;; + *) + echo -e "${RED}Invalid option. Please try again.${NC}" + ;; + esac + done +} + +# Run main menu +main_menu