This commit is contained in:
2026-04-27 22:36:44 +09:30
parent 86b6d6c0b9
commit 2d03f3a7f4
58 changed files with 4376 additions and 62 deletions
+29
View File
@@ -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
+31
View File
@@ -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/
+139
View File
@@ -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
+470
View File
@@ -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=<your-jwt-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 <PID>
```
### "Address already in use" port 5173
```bash
# Find process using port 5173
lsof -i :5173
# Kill the process
kill -9 <PID>
```
### 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/)
+53
View File
@@ -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"]
+117
View File
@@ -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
+482 -62
View File
@@ -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 <repository-url>
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 <token>
```
## 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
+162
View File
@@ -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")
}
+162
View File
@@ -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: []
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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<Map<String, String>> 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;
}
}
}
@@ -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();
}
}
@@ -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<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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<User> 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<User> getUserById(Long userId) {
return userRepository.findById(userId);
}
/**
* Get user by username
*/
@Transactional(readOnly = true)
public Optional<User> getUserByUsername(String username) {
return userRepository.findByUsername(username);
}
}
@@ -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();
}
}
@@ -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
@@ -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);
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_DIR" value="logs"/>
<property name="LOG_FILE_NAME" value="mtg-search"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- Console appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- Rolling file appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Error file appender -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<!-- Application loggers -->
<logger name="net.moustos.mtgsearch" level="DEBUG"/>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.security" level="DEBUG"/>
<logger name="org.hibernate" level="WARN"/>
<logger name="org.flywaydb" level="INFO"/>
</configuration>
@@ -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());
}
}
@@ -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());
}
}
@@ -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"));
}
}
@@ -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
+42
View File
@@ -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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStandardStreams = false
}
}
}
+53
View File
@@ -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
+34
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env.local
.DS_Store
*.log
npm-debug.log*
+3
View File
@@ -0,0 +1,3 @@
frontend/.eslintrc.json
frontend/.prettierrc
frontend/.gitignore
+11
View File
@@ -0,0 +1,11 @@
; Frontend Prettier configuration
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"vueIndentScriptAndStyle": true
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MTG Search</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+33
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitkeep
+131
View File
@@ -0,0 +1,131 @@
<template>
<div id="app" class="app">
<header class="navbar">
<div class="navbar-container">
<router-link to="/" class="navbar-brand">
<h1>MTG Search</h1>
</router-link>
<nav class="navbar-menu">
<router-link v-if="!authStore.isAuthenticated" to="/login" class="nav-link">Login</router-link>
<router-link v-if="!authStore.isAuthenticated" to="/register" class="nav-link">Register</router-link>
<router-link v-if="authStore.isAuthenticated" to="/dashboard" class="nav-link">Dashboard</router-link>
<button v-if="authStore.isAuthenticated" @click="logout" class="nav-link logout-btn">Logout</button>
</nav>
</div>
</header>
<main class="main-content">
<router-view />
</main>
<footer class="footer">
<p>&copy; 2024 MTG Search. All rights reserved.</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from './stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const logout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
background-color: #1a1a2e;
color: white;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
text-decoration: none;
color: white;
}
.navbar-brand h1 {
font-size: 1.5rem;
font-weight: 600;
}
.navbar-menu {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s ease;
cursor: pointer;
border: none;
background: transparent;
font-size: 1rem;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.logout-btn {
background-color: #e74c3c;
}
.logout-btn:hover {
background-color: #c0392b;
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
padding: 2rem 1rem;
}
.footer {
background-color: #1a1a2e;
color: white;
text-align: center;
padding: 2rem;
margin-top: 2rem;
}
</style>
+15
View File
@@ -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')
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="dashboard-page">
<div class="dashboard-container">
<h1>Welcome, {{ authStore.user?.username }}!</h1>
<div class="dashboard-grid">
<div class="card">
<h2>Profile Information</h2>
<div class="profile-info">
<p><strong>Username:</strong> {{ authStore.user?.username }}</p>
<p><strong>Email:</strong> {{ authStore.user?.email }}</p>
<p><strong>ID:</strong> {{ authStore.user?.id }}</p>
</div>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div class="actions">
<button class="btn btn-secondary">Search Cards</button>
<button class="btn btn-secondary">View Decks</button>
<button class="btn btn-secondary">Settings</button>
</div>
</div>
</div>
<div class="card">
<h2>Search Cards</h2>
<p style="color: #666; margin-bottom: 1rem;">Coming soon...</p>
<input
type="text"
placeholder="Search Magic The Gathering cards..."
class="search-input"
disabled
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.dashboard-page {
display: flex;
flex-direction: column;
gap: 2rem;
}
.dashboard-container h1 {
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: #1a1a2e;
margin-bottom: 1.5rem;
font-size: 1.4rem;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-info p {
color: #333;
line-height: 1.6;
}
.actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-input {
width: 100%;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: #1a1a2e;
}
.search-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="home-page">
<div class="hero">
<h1>Welcome to MTG Search</h1>
<p>Search and explore Magic The Gathering cards</p>
<div class="hero-buttons" v-if="!authStore.isAuthenticated">
<router-link to="/login" class="btn btn-primary">Login</router-link>
<router-link to="/register" class="btn btn-secondary">Register</router-link>
</div>
<div class="hero-buttons" v-else>
<router-link to="/dashboard" class="btn btn-primary">Go to Dashboard</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.home-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.hero {
text-align: center;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero h1 {
font-size: 3rem;
color: #1a1a2e;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.2rem;
color: #666;
margin-bottom: 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="login-page">
<div class="login-container">
<h1>Login to MTG Search</h1>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Enter your username"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Enter your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<p class="signup-link">
Don't have an account? <router-link to="/register">Register here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
password: '',
})
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
loading.value = true
error.value = ''
try {
await authStore.login(form.username, form.password)
router.push('/dashboard')
} catch (err: any) {
error.value = err.response?.data?.error || 'Login failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.signup-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.signup-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.signup-link a:hover {
text-decoration: underline;
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="register-page">
<div class="register-container">
<h1>Create Account</h1>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Choose a username"
required
minlength="3"
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
v-model="form.email"
id="email"
type="email"
placeholder="Enter your email"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Create a password"
required
minlength="8"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
v-model="form.confirmPassword"
id="confirmPassword"
type="password"
placeholder="Confirm your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<div v-if="success" class="alert alert-success">
{{ success }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Creating account...' : 'Register' }}
</button>
</form>
<p class="login-link">
Already have an account? <router-link to="/login">Login here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const handleRegister = async () => {
error.value = ''
success.value = ''
if (form.value.password !== form.value.confirmPassword) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
await authStore.register(form.value.username, form.value.email, form.value.password)
success.value = 'Account created successfully! Redirecting to login...'
setTimeout(() => {
router.push('/login')
}, 2000)
} catch (err: any) {
error.value = err.response?.data?.error || 'Registration failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 75vh;
}
.register-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.login-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.login-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.login-link a:hover {
text-decoration: underline;
}
</style>
+49
View File
@@ -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
+11
View File
@@ -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
+109
View File
@@ -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<string | null>(null)
const user = ref<any>(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,
},
],
},
})
+35
View File
@@ -0,0 +1,35 @@
/**
* API Response types
*/
export interface ApiResponse<T> {
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
}
+32
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
+28
View File
@@ -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',
},
})
+16
View File
@@ -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'),
},
},
})
+2
View File
@@ -0,0 +1,2 @@
<!-- Vitest Configuration UI -->
export {}
+20
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
gradleVersion=8.9
Vendored
+46
View File
@@ -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 <some-task>
#
# (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" "$@"
Vendored
+86
View File
@@ -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%
+4
View File
@@ -0,0 +1,4 @@
rootProject.name = "mtg-search"
include(":backend")
include(":frontend")
+185
View File
@@ -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