Compare commits
15 Commits
13f14caaa3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e5a1ba2b | |||
| f115787ef6 | |||
| 80d0502608 | |||
| 1682fda5e7 | |||
| 3a9687fac4 | |||
| 523ba6e10f | |||
| a700422b4f | |||
| 4e072f2f96 | |||
| 29e8a32864 | |||
| 914521f376 | |||
| 28022c3210 | |||
| 648d67cb29 | |||
| 9e966f1b1a | |||
| 2d03f3a7f4 | |||
| 86b6d6c0b9 |
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
+31
-43
@@ -1,46 +1,34 @@
|
||||
node_modules
|
||||
HELP.md
|
||||
.gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.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/
|
||||
|
||||
### Vaadin Ignore
|
||||
src/main/frontend/generated/
|
||||
|
||||
### mtg json
|
||||
/AllPrintings/
|
||||
/AllPrintings.psql.zip
|
||||
/AllPrintings.psql.zip.sha256
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
coverage/
|
||||
.nyc_output/
|
||||
dev.properties
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
+470
@@ -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:17-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
@@ -0,0 +1,53 @@
|
||||
# Multi-stage build for MTG Search
|
||||
# Stage 1: Build backend and frontend
|
||||
FROM eclipse-temurin:21.0.2-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.0.2-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"]
|
||||
Vendored
+189
@@ -0,0 +1,189 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
timeout(time: 1, unit: 'HOURS')
|
||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(name: 'DEPLOY_TO_STAGING', defaultValue: false, description: 'Deploy to staging')
|
||||
booleanParam(name: 'DEPLOY_TO_PRODUCTION', defaultValue: false, description: 'Deploy to production')
|
||||
}
|
||||
|
||||
environment {
|
||||
// Docker registry configuration
|
||||
REGISTRY = credentials('docker-registry-url')
|
||||
REGISTRY_USER = credentials('docker-registry-user')
|
||||
REGISTRY_PASSWORD = credentials('docker-registry-password')
|
||||
DOCKER_IMAGE = "${REGISTRY}/${GIT_COMMIT.take(8)}"
|
||||
DOCKER_IMAGE_LATEST = "${REGISTRY}:latest"
|
||||
|
||||
// Database configuration for builds
|
||||
DB_URL = "jdbc:postgresql://postgres:5432/mtgsearch_build"
|
||||
DB_USER = "postgres"
|
||||
DB_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "mtgsearch_build"
|
||||
|
||||
// SonarQube configuration
|
||||
SONAR_USER_HOME = "${WORKSPACE}/.sonar"
|
||||
GIT_DEPTH = "0"
|
||||
|
||||
// Build cache
|
||||
GRADLE_USER_HOME = "${WORKSPACE}/.gradle"
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Build Java & Frontend') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
echo "Building Java and Frontend..."
|
||||
}
|
||||
sh '''
|
||||
chmod +x ./gradlew
|
||||
./gradlew clean build -x test --build-cache
|
||||
'''
|
||||
}
|
||||
post {
|
||||
success {
|
||||
archiveArtifacts artifacts: 'backend/build/libs/**/*.jar,frontend/dist/**/*', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Unit Tests') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
echo "Running unit tests..."
|
||||
}
|
||||
sh '''
|
||||
chmod +x ./gradlew
|
||||
./gradlew test --build-cache
|
||||
'''
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit 'backend/build/test-results/test/**/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Integration Tests') {
|
||||
agent {
|
||||
docker {
|
||||
image 'eclipse-temurin:21-jdk'
|
||||
args '--network=host'
|
||||
}
|
||||
}
|
||||
when {
|
||||
anyOf {
|
||||
branch 'main'
|
||||
branch 'develop'
|
||||
any echo "Running integration tests..."
|
||||
}
|
||||
sh '''
|
||||
chmod +x ./gradlew
|
||||
./gradlew integrationTest --build-cache
|
||||
'''
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit 'backend/build/test-results/integrationTest/**/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Code Quality Checks') {
|
||||
agent {
|
||||
docker {
|
||||
image 'eclipse-temurin:21-jdk'
|
||||
args '--network=host'
|
||||
}
|
||||
}any script {
|
||||
echo "Running code quality checks..."
|
||||
}
|
||||
sh '''
|
||||
chmod +x ./gradlew
|
||||
./gradlew check -x test
|
||||
'''
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo "Quality checks failed - continuing pipeline with warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube Analysis') {
|
||||
withSonarQubeEnv() {
|
||||
sh "./gradlew sonar -Dsonar.projectKey=MTGSearch -Dsonar.projectName='MTGSearch' -Dsonar.host.url=https://sonarqube.moustos.net -Dsonar.token=sqp_452a7bbe843fafe78b1a45efaf7ffb86ab6ba881"
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy to Staging') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'develop'
|
||||
expression { params.DEPLOY_TO_STAGING == true }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "Deploying to staging environment..."
|
||||
input(id: 'DeployToStaging', message: 'Deploy to staging?', ok: 'Deploy')
|
||||
}
|
||||
sh '''
|
||||
curl -X POST "${STAGING_DEPLOY_WEBHOOK}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"${DOCKER_IMAGE}"}'
|
||||
echo "Deployment to staging triggered"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy to Production') {
|
||||
when {
|
||||
anyOf {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.DEPLOY_TO_PRODUCTION == true }
|
||||
}
|
||||
buildingTag()
|
||||
}
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "Deploying to production environment..."
|
||||
input(id: 'DeployToProd', message: 'Deploy to production?', ok: 'Deploy')
|
||||
}
|
||||
sh '''
|
||||
curl -X POST "${PROD_DEPLOY_WEBHOOK}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"${DOCKER_IMAGE}"}'
|
||||
echo "Deployment to production triggered"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
echo "Pipeline failed! Check logs for details."
|
||||
// You can add notification logic here
|
||||
}
|
||||
}
|
||||
success {
|
||||
script {
|
||||
echo "Pipeline completed successfully!"
|
||||
// You can add notification logic here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:17-alpine
|
||||
@echo "✓ Database initialized on localhost:5432"
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -0,0 +1,513 @@
|
||||
# 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.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Java 21** or later
|
||||
- **Node.js 20+** and npm 10+
|
||||
- **PostgreSQL 16+**
|
||||
- **Docker & Docker Compose** (for containerized setup)
|
||||
- **Git**
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd mtg-search
|
||||
|
||||
# Start all services (PostgreSQL, Backend, built Frontend)
|
||||
docker-compose up --build
|
||||
|
||||
# Backend will be available at: http://localhost:8080
|
||||
# Frontend will be served by the backend
|
||||
```
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
#### 1. Database Setup
|
||||
|
||||
```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:17-alpine
|
||||
|
||||
# Or use your local PostgreSQL installation
|
||||
```
|
||||
|
||||
#### 2. Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Build the project
|
||||
../gradlew build
|
||||
|
||||
# Run the application
|
||||
../gradlew bootRun
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
#### 3. Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,225 @@
|
||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import java.sql.DriverManager
|
||||
|
||||
|
||||
|
||||
// Version strings for libraries
|
||||
val postgresqlVersion: String = "42.7.10"
|
||||
val flywayVersion: String = "12.4.0"
|
||||
val jooqVersion: String = "3.21.0"
|
||||
val logbackVersion: String = "1.5.0"
|
||||
val springSecurityVersion: String = "6.3.1"
|
||||
val springdocOpenApiVersion: String = "2.5.0"
|
||||
val jacksonVersion: String = "2.18.0"
|
||||
val commonsLang3Version: String = "3.14.0"
|
||||
val guavaVersion: String = "33.1.0-jre"
|
||||
val lombokVersion: String = "1.18.30"
|
||||
val junitVersion: String = "5.10.2"
|
||||
val mockitoVersion: String = "5.11.0"
|
||||
val testcontainersVersion: String = "1.20.0"
|
||||
val h2Version: String = "2.3.232"
|
||||
val restAssuredVersion: String = "5.4.0"
|
||||
|
||||
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath("org.postgresql:postgresql:42.7.10")
|
||||
classpath("org.flywaydb:flyway-database-postgresql:12.4.0")
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.springframework.boot") version "4.0.+"
|
||||
id("io.spring.dependency-management") version "1.1.6"
|
||||
id("org.openapi.generator") version "7.6.0"
|
||||
id("nu.studer.jooq") version "9.0"
|
||||
id("org.flywaydb.flyway") version "12.4.0"
|
||||
id("application")
|
||||
id("java")
|
||||
}
|
||||
|
||||
group = "net.moustos"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||
implementation("org.flywaydb:flyway-core:$flywayVersion")
|
||||
implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion")
|
||||
|
||||
implementation("org.jooq:jooq:$jooqVersion")
|
||||
implementation("org.jooq:jooq-meta:$jooqVersion")
|
||||
implementation("org.jooq:jooq-codegen:$jooqVersion")
|
||||
jooqGenerator("org.postgresql:postgresql:$postgresqlVersion")
|
||||
jooqGenerator("org.jooq:jooq:$jooqVersion")
|
||||
jooqGenerator("org.jooq:jooq-meta:$jooqVersion")
|
||||
jooqGenerator("org.jooq:jooq-codegen:$jooqVersion")
|
||||
|
||||
implementation("com.auth0:java-jwt:4.4.0")
|
||||
implementation("org.springframework.security:spring-security-crypto:$springSecurityVersion")
|
||||
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocOpenApiVersion")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
|
||||
|
||||
implementation("ch.qos.logback:logback-core:$logbackVersion")
|
||||
implementation("ch.qos.logback:logback-classic:$logbackVersion")
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
|
||||
|
||||
compileOnly("org.projectlombok:lombok:$lombokVersion")
|
||||
annotationProcessor("org.projectlombok:lombok:$lombokVersion")
|
||||
|
||||
implementation("org.apache.commons:commons-lang3:$commonsLang3Version")
|
||||
implementation("com.google.guava:guava:$guavaVersion")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("org.testcontainers:testcontainers:$testcontainersVersion")
|
||||
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
|
||||
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
|
||||
testImplementation("com.h2database:h2:$h2Version")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion")
|
||||
testImplementation("org.mockito:mockito-core:$mockitoVersion")
|
||||
testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion")
|
||||
testImplementation("io.rest-assured:rest-assured:$restAssuredVersion")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("net.moustos.mtgsearch.MtgSearchApplication")
|
||||
}
|
||||
|
||||
springBoot {
|
||||
buildInfo()
|
||||
}
|
||||
|
||||
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"
|
||||
))
|
||||
}
|
||||
|
||||
flyway {
|
||||
url = project.property("db.url") as String
|
||||
user = project.property("db.user") as String
|
||||
password = project.property("db.password") as String
|
||||
}
|
||||
|
||||
jooq {
|
||||
version.set("$jooqVersion")
|
||||
configurations {
|
||||
create("main") {
|
||||
jooqConfiguration.apply {
|
||||
jdbc.apply {
|
||||
driver = "org.postgresql.Driver"
|
||||
url = project.property("db.url") as String
|
||||
user = project.property("db.user") as String
|
||||
password = project.property("db.password") as String
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isPostgresAvailable(): Boolean {
|
||||
val dbUrl = project.property("db.url") as String
|
||||
val dbUser = project.property("db.user") as String
|
||||
logger.lifecycle("Checking PostgreSQL availability at $dbUrl with user $dbUser")
|
||||
return try {
|
||||
// Load the PostgreSQL driver
|
||||
Class.forName("org.postgresql.Driver")
|
||||
DriverManager.setLoginTimeout(10)
|
||||
DriverManager.getConnection(dbUrl, dbUser, project.property("db.password") as String).use { connection ->
|
||||
logger.lifecycle("Successfully connected to PostgreSQL database.")
|
||||
true
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
logger.error("Failed to connect to PostgreSQL at $dbUrl with user $dbUser: ${ex.message}")
|
||||
logger.error("Exception details: ", ex)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("generateJooq") {
|
||||
dependsOn("flywayMigrate")
|
||||
onlyIf {
|
||||
isPostgresAvailable()
|
||||
}
|
||||
doFirst {
|
||||
logger.lifecycle("Starting jOOQ code generation...")
|
||||
}
|
||||
doLast {
|
||||
logger.lifecycle("Finished jOOQ task.")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("flywayMigrate") {
|
||||
onlyIf {
|
||||
isPostgresAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("compileJava") {
|
||||
dependsOn("openApiGenerate")
|
||||
}
|
||||
|
||||
sourceSets["main"].java {
|
||||
srcDir("$buildDir/generated/src/main/java")
|
||||
srcDir("$buildDir/generated/jooq")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("build") {
|
||||
dependsOn("openApiGenerate")
|
||||
}
|
||||
@@ -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,42 @@
|
||||
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));
|
||||
|
||||
boolean active = Boolean.TRUE.equals(user.getActive());
|
||||
|
||||
return User.builder()
|
||||
.username(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")))
|
||||
.accountLocked(!active)
|
||||
.accountExpired(false)
|
||||
.credentialsExpired(false)
|
||||
.disabled(!active)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
spring:
|
||||
application:
|
||||
name: ${app.name:mtg-search}
|
||||
version: ${app.version:0.1.0}
|
||||
|
||||
datasource:
|
||||
url: ${db.url:jdbc:postgresql://localhost:5432/mtgsearch}
|
||||
username: ${db.user:postgres}
|
||||
password: ${db.password:postgres}
|
||||
hikari:
|
||||
maximum-pool-size: ${db.maxPoolSize: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
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 1024
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: ${jwt.secret:your-secret-key-change-in-production}
|
||||
expiration: ${jwt.expiration:86400}
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: ${log.level:INFO}
|
||||
net.moustos.mtgsearch: DEBUG
|
||||
org.springframework.web: INFO
|
||||
org.springframework.security: DEBUG
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
name: ${log.dir:logs}/app.log
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
enabled: true
|
||||
operations-sorter: method
|
||||
tags-sorter: alpha
|
||||
@@ -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,124 @@
|
||||
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;
|
||||
import net.moustos.mtgsearch.repository.UserRepository;
|
||||
|
||||
/**
|
||||
* Integration tests for authentication API
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(locations = "classpath:application-test.yml")
|
||||
public class AuthControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// Clear database before each test
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@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,128 @@
|
||||
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.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
import net.moustos.mtgsearch.repository.UserRepository;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
|
||||
/**
|
||||
* Unit tests for AuthService
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@Disabled("Requires Docker for Testcontainers")
|
||||
@DisplayName("AuthService Tests")
|
||||
public class AuthServiceTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
|
||||
.withDatabaseName("mtgsearch_test")
|
||||
.withUsername("postgres")
|
||||
.withPassword("postgres");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
}
|
||||
|
||||
@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,108 @@
|
||||
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.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
|
||||
/**
|
||||
* Tests for UserRepository
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@Disabled("Requires Docker for Testcontainers")
|
||||
public class UserRepositoryTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
|
||||
.withDatabaseName("mtgsearch_test")
|
||||
.withUsername("postgres")
|
||||
.withPassword("postgres");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
}
|
||||
|
||||
@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,22 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: test
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb
|
||||
driver-class-name: org.h2.Driver
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.H2Dialect
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
flyway:
|
||||
enabled: false
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: test-secret-key
|
||||
expiration: 3600
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
net.moustos.mtgsearch: DEBUG
|
||||
@@ -1,43 +0,0 @@
|
||||
plugins {
|
||||
id 'MTGDBPlugin'
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.6'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
id 'com.vaadin' version '24.9.3'
|
||||
}
|
||||
|
||||
group = 'com.example'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
description = 'Demo project for Spring Boot'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
ext {
|
||||
set('vaadinVersion', "24.9.3")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.vaadin:vaadin-spring-boot-starter'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "com.vaadin:vaadin-bom:${vaadinVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
import java.util.Properties
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.springframework.boot") version "4.0.+" apply false
|
||||
id("io.spring.dependency-management") version "1.1.6"
|
||||
id("org.sonarqube") version "7.2.3.7755"
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "MTGSearch")
|
||||
property("sonar.projectName", "MTGSearch")
|
||||
property("sonar.qualitygate.wait", true)
|
||||
}
|
||||
}
|
||||
|
||||
group = "net.moustos"
|
||||
version = "0.1.0"
|
||||
|
||||
// Load dev.properties if it exists
|
||||
val devPropsFile = File("dev.properties")
|
||||
val devProps = Properties().apply {
|
||||
if (devPropsFile.exists()) {
|
||||
load(devPropsFile.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
// Define properties with defaults, preferring env vars over dev.properties
|
||||
val propertiesMap = mapOf(
|
||||
"db.url" to (System.getenv("DB_URL") ?: devProps.getProperty("db.url", "jdbc:postgresql://localhost:5432/mtgsearch")),
|
||||
"db.user" to (System.getenv("DB_USER") ?: devProps.getProperty("db.user", "postgres")),
|
||||
"db.password" to (System.getenv("DB_PASSWORD") ?: devProps.getProperty("db.password", "postgres")),
|
||||
"db.maxPoolSize" to (System.getenv("DB_MAX_POOL_SIZE") ?: devProps.getProperty("db.maxPoolSize", "10")),
|
||||
"jwt.secret" to (System.getenv("JWT_SECRET") ?: devProps.getProperty("jwt.secret", "your-secret-key-change-in-production")),
|
||||
"jwt.expiration" to (System.getenv("JWT_EXPIRATION") ?: devProps.getProperty("jwt.expiration", "86400")),
|
||||
"app.name" to (System.getenv("APP_NAME") ?: devProps.getProperty("app.name", "mtg-search")),
|
||||
"app.version" to (System.getenv("APP_VERSION") ?: devProps.getProperty("app.version", "0.1.0")),
|
||||
"app.environment" to (System.getenv("APP_ENVIRONMENT") ?: devProps.getProperty("app.environment", "development")),
|
||||
"server.port" to (System.getenv("SERVER_PORT") ?: devProps.getProperty("server.port", "8080")),
|
||||
"server.servlet.contextPath" to (System.getenv("SERVER_SERVLET_CONTEXT_PATH") ?: devProps.getProperty("server.servlet.contextPath", "/")),
|
||||
"vite.apiUrl" to (System.getenv("VITE_API_URL") ?: devProps.getProperty("vite.apiUrl", "http://localhost:8080")),
|
||||
"vite.appTitle" to (System.getenv("VITE_APP_TITLE") ?: devProps.getProperty("vite.appTitle", "MTG Search")),
|
||||
"log.level" to (System.getenv("LOG_LEVEL") ?: devProps.getProperty("log.level", "DEBUG")),
|
||||
"log.dir" to (System.getenv("LOG_DIR") ?: devProps.getProperty("log.dir", "logs"))
|
||||
)
|
||||
|
||||
// Set system properties for Spring Boot
|
||||
propertiesMap.forEach { (key, value) ->
|
||||
System.setProperty(key, value)
|
||||
}
|
||||
|
||||
// Also set as project extra for Gradle use
|
||||
propertiesMap.forEach { (key, value) ->
|
||||
extra.set(key, value)
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("https://repo.maven.apache.org/maven2/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configure(subprojects.filter { it.name == "backend" }) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Developer helper tasks
|
||||
tasks.register("createDevProperties") {
|
||||
group = "developer"
|
||||
description = "Creates dev.properties file with default values if it doesn't exist"
|
||||
|
||||
doLast {
|
||||
val devPropsFile = File("dev.properties")
|
||||
if (!devPropsFile.exists()) {
|
||||
val defaultContent = """
|
||||
# Development properties
|
||||
# This file contains configuration values for build and runtime.
|
||||
# Do not commit this file to the repository.
|
||||
|
||||
# Database Configuration
|
||||
db.url=jdbc:postgresql://localhost:5432/mtgsearch
|
||||
db.user=postgres
|
||||
db.password=postgres
|
||||
db.maxPoolSize=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.contextPath=/
|
||||
|
||||
# Frontend Configuration
|
||||
vite.apiUrl=http://localhost:8080
|
||||
vite.appTitle=MTG Search
|
||||
|
||||
# Logging
|
||||
log.level=DEBUG
|
||||
log.dir=logs
|
||||
""".trimIndent()
|
||||
devPropsFile.writeText(defaultContent)
|
||||
println("Created dev.properties with default values")
|
||||
} else {
|
||||
println("dev.properties already exists")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Exec>("startPostgres") {
|
||||
group = "developer"
|
||||
description = "Starts the PostgreSQL container using docker-compose"
|
||||
|
||||
doFirst {
|
||||
// Check if Docker is available
|
||||
val dockerCheck = providers.exec {
|
||||
commandLine("docker", "info")
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
if (dockerCheck.result.get().exitValue != 0) {
|
||||
throw GradleException("""
|
||||
Docker is not available or not running. Please ensure:
|
||||
1. Docker daemon is running
|
||||
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
|
||||
3. Docker Compose is installed
|
||||
""".trimIndent())
|
||||
}
|
||||
println("Starting PostgreSQL container...")
|
||||
}
|
||||
|
||||
commandLine("docker", "compose", "up", "-d", "postgres")
|
||||
workingDir = project.rootDir
|
||||
|
||||
doLast {
|
||||
println("PostgreSQL container started. Waiting for it to be healthy...")
|
||||
// Optional: wait for health check
|
||||
Thread.sleep(5000) // Simple wait, could be improved
|
||||
println("PostgreSQL should be ready now.")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Exec>("stopPostgres") {
|
||||
group = "developer"
|
||||
description = "Stops the PostgreSQL container using docker-compose"
|
||||
|
||||
doFirst {
|
||||
// Check if Docker is available
|
||||
val dockerCheck = providers.exec {
|
||||
commandLine("docker", "info")
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
if (dockerCheck.result.get().exitValue != 0) {
|
||||
throw GradleException("""
|
||||
Docker is not available or not running. Please ensure:
|
||||
1. Docker daemon is running
|
||||
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
|
||||
3. Docker Compose is installed
|
||||
""".trimIndent())
|
||||
}
|
||||
println("Stopping PostgreSQL container...")
|
||||
}
|
||||
|
||||
commandLine("docker", "compose", "down")
|
||||
workingDir = project.rootDir
|
||||
|
||||
doLast {
|
||||
println("PostgreSQL container stopped.")
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
plugins {
|
||||
id 'groovy-base'
|
||||
id 'java-library'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation gradleApi()
|
||||
implementation localGroovy()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import org.gradle.api.Plugin;
|
||||
import org.gradle.api.Project;
|
||||
import org.gradle.api.Task;
|
||||
|
||||
public class MTGDBPlugin implements Plugin<Project> {
|
||||
|
||||
@Override
|
||||
public void apply(Project target) {
|
||||
Task javaTask = target.task("javaTask");
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
implementation-class=MTGDBPlugin
|
||||
@@ -1,9 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: 'postgres:latest'
|
||||
environment:
|
||||
- 'POSTGRES_DB=mtg'
|
||||
- 'POSTGRES_PASSWORD=password'
|
||||
- 'POSTGRES_USER=username'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'com.google.cloud.tools.jib' version '3.4.2'
|
||||
id 'application'
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'com.example.DbSetup'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation 'org.postgresql:postgresql:42.7.3'
|
||||
}
|
||||
|
||||
def containername = "mtg-search-db"
|
||||
def dbName = "mtgsearch"
|
||||
def dbUser = "dbuser"
|
||||
def dbPass = "dbpass"
|
||||
def psqlZipFile = new File("$rootDir\\AllPrintings.psql.zip")
|
||||
def psqlFile = new File("$rootDir\\AllPrintings\\AllPrintings.psql")
|
||||
|
||||
jib {
|
||||
from {
|
||||
image = 'postgres:17' // base image
|
||||
}
|
||||
to {
|
||||
image = "hub.docker.com/$containername:latest"
|
||||
auth {
|
||||
username = System.getenv("DOCKER_USER")?:""
|
||||
password = System.getenv("DOCKER_PASS")?:""
|
||||
}
|
||||
}
|
||||
container {
|
||||
// Jib runs as rootless by default; PostgreSQL needs a few tweaks
|
||||
entrypoint = ['docker-entrypoint.sh']
|
||||
args = ['postgres']
|
||||
environment = [
|
||||
POSTGRES_USER: dbUser,
|
||||
POSTGRES_PASSWORD: dbPass,
|
||||
POSTGRES_DB: dbName
|
||||
]
|
||||
ports = ['5432']
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Gradle task to run database setup logic
|
||||
tasks.register('runDbSetup', JavaExec) {
|
||||
dependsOn classes
|
||||
mainClass.set('com.example.DbSetup')
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
environment 'POSTGRES_USER', dbUser
|
||||
environment 'POSTGRES_PASSWORD', dbPass
|
||||
environment 'POSTGRES_DB', dbName
|
||||
environment 'PSQL_FILE', psqlFile.canonicalPath
|
||||
|
||||
}
|
||||
|
||||
// Ensure setup runs before building the Docker image
|
||||
tasks.named('jib') {
|
||||
dependsOn('runDbSetup')
|
||||
}
|
||||
|
||||
tasks.register('startPostgresContainer', Exec) {
|
||||
commandLine 'docker', 'run', '-d',
|
||||
'--name', "$containername",
|
||||
'-e', "POSTGRES_USER=$dbUser",
|
||||
'-e', "POSTGRES_PASSWORD=$dbPass",
|
||||
'-e', "POSTGRES_DB=$dbName",
|
||||
'-p', '5432:5432',
|
||||
'postgres:17'
|
||||
}
|
||||
|
||||
tasks.register('stopPostgresContainer', Exec) {
|
||||
commandLine 'docker', 'rm', '-f', containername
|
||||
}
|
||||
|
||||
// Hook the lifecycle
|
||||
tasks.named('runDbSetup') {
|
||||
dependsOn('startPostgresContainer')
|
||||
finalizedBy('stopPostgresContainer')
|
||||
}
|
||||
|
||||
tasks.register('getLatestMTGJson') {
|
||||
outputs.files files("AllPrintings.psql.zip.sha256","AllPrintings.psql.zip")
|
||||
outputs.upToDateWhen {
|
||||
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
|
||||
def urlHash = url.text // Groovy shortcut to read text from URL
|
||||
def file = new File('AllPrintings.psql.zip.sha256')
|
||||
if(file.exists()) {
|
||||
def fileHash = file.text
|
||||
def equal = urlHash == fileHash
|
||||
if(!equal){
|
||||
println("AllPrintings.psql.zip.sha256 out of date")
|
||||
}
|
||||
return equal
|
||||
}
|
||||
println("AllPrintings.psql.zip.sha256 not found")
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
doLast {
|
||||
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip")
|
||||
|
||||
println("Downloading $url to $psqlZipFile")
|
||||
psqlZipFile.withOutputStream { out ->
|
||||
out << url.openStream()
|
||||
}
|
||||
|
||||
url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
|
||||
def file = new File("AllPrintings.psql.zip.sha256")
|
||||
println("Downloading $url to $file")
|
||||
file.withOutputStream { out ->
|
||||
out << url.openStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("unzipAllPrintings"){
|
||||
dependsOn("getLatestMTGJson")
|
||||
doLast {
|
||||
def destinationDir = new File("AllPrintings")
|
||||
|
||||
// Ensure the destination directory exists
|
||||
destinationDir.mkdirs()
|
||||
|
||||
psqlZipFile.withInputStream { fis ->
|
||||
new ZipInputStream(fis).with { zis ->
|
||||
ZipEntry entry
|
||||
while ((entry = zis.nextEntry) != null) {
|
||||
def outputFile = new File(destinationDir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
outputFile.mkdirs()
|
||||
} else {
|
||||
outputFile.parentFile.mkdirs() // Ensure parent directories exist
|
||||
outputFile.withOutputStream { fos ->
|
||||
fos << zis // Copy the entry's content
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
println "Successfully unzipped ${psqlZipFile.name} to ${destinationDir.absolutePath}"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
FROM postgres:17
|
||||
COPY ./init.sql /docker-entrypoint-initdb.d/
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.example;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
import java.util.Objects;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
public class DbSetup {
|
||||
public static void main(String[] args) throws Exception {
|
||||
|
||||
// ========================
|
||||
//todo replace with better way to wait for container to be ready
|
||||
Thread.sleep(5000);
|
||||
// ========================
|
||||
String dbuser = Objects.requireNonNullElse(System.getenv("POSTGRES_USER"),"dbuser");
|
||||
String dbpass = Objects.requireNonNullElse(System.getenv("POSTGRES_PASSWORD"),"dbpass");
|
||||
String dbname = Objects.requireNonNullElse(System.getenv("POSTGRES_DB"),"mtgsearch");
|
||||
String psqlFilePath = Objects.requireNonNullElse(System.getenv("PSQL_FILE"),"AllPrintings/AllPrintings.psql");
|
||||
String url = "jdbc:postgresql://localhost:5432/" + dbname;
|
||||
|
||||
|
||||
//Load the MTGJson Allprintings PSQL File
|
||||
File psqlFile = new File(psqlFilePath);
|
||||
|
||||
String[] commands = {"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe","-U",dbuser,"-d",dbname,"-h","localhost","-p","5432","-f",psqlFile.getCanonicalPath()};
|
||||
String[] envVars = { "PGPASSWORD="+dbpass };
|
||||
Runtime rt = Runtime.getRuntime();
|
||||
System.out.println(String.join(" ",commands));
|
||||
Process proc = rt.exec(commands,envVars);
|
||||
System.out.println("<OUTPUT>");
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println("Output: " + line);
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("</OUTPUT>");
|
||||
System.out.println("<ERROR>");
|
||||
|
||||
try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(proc.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = errorReader.readLine()) != null) {
|
||||
System.err.println("Error: " + line);
|
||||
}
|
||||
}
|
||||
System.out.println("</ERROR>");
|
||||
int exitVal = proc.waitFor();
|
||||
System.out.println("Process exitValue: " + exitVal);
|
||||
|
||||
|
||||
// try (Connection conn = DriverManager.getConnection(url, dbuser, dbpass);
|
||||
// Statement stmt = conn.createStatement()) {
|
||||
// stmt.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)");
|
||||
// stmt.execute("INSERT INTO users (name) VALUES ('Alice'), ('Bob')");
|
||||
// System.out.println("✅ Database setup complete!");
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,3 @@
|
||||
frontend/.eslintrc.json
|
||||
frontend/.prettierrc
|
||||
frontend/.gitignore
|
||||
@@ -0,0 +1,11 @@
|
||||
; Frontend Prettier configuration
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"vueIndentScriptAndStyle": true
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitkeep
|
||||
@@ -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>© 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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__dirname, '../backend/src/main/resources/static'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
},
|
||||
})
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- Vitest Configuration UI -->
|
||||
export {}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Gradle Configuration
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
# Project Versions - All compatible with JDK 21
|
||||
javaVersion=21
|
||||
springBootVersion=4.0.+
|
||||
springDependencyManagementVersion=1.1.6
|
||||
springCloudVersion=2024.0.0
|
||||
springSecurityVersion=6.3.1
|
||||
springdocOpenApiVersion=2.5.0
|
||||
openApiGeneratorVersion=7.6.0
|
||||
jooqVersion=3.21.0
|
||||
jooqPluginVersion=9.0
|
||||
postgresqlVersion=42.7.3
|
||||
flywayVersion=10.8.1
|
||||
logbackVersion=1.5.0
|
||||
logstashLogbackVersion=8.0
|
||||
auth0-jwtVersion=4.4.0
|
||||
jacksonVersion=2.18.0
|
||||
commonsLang3Version=3.14.0
|
||||
guavaVersion=33.1.0-jre
|
||||
lombokVersion=1.18.30
|
||||
junitVersion=5.10.2
|
||||
mockitoVersion=5.11.0
|
||||
testcontainersVersion=1.20.0
|
||||
h2Version=2.3.232
|
||||
restAssuredVersion=5.4.0
|
||||
|
||||
# Frontend
|
||||
nodeVersion=20.11.0
|
||||
npmVersion=10.2.4
|
||||
|
||||
# Docker Images
|
||||
postgresDockerVersion=17-alpine
|
||||
alpineVersion=3.20
|
||||
Vendored
BIN
Binary file not shown.
+1
-3
@@ -1,7 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,129 +1,78 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original 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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# 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 such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@@ -132,120 +81,96 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
Vendored
+84
-94
@@ -1,94 +1,84 @@
|
||||
@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
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@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=.
|
||||
@rem This is normally unused
|
||||
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="-Xmx64m" "-Xms64m"
|
||||
|
||||
@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. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@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 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=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
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 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 init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven{ url 'https://mvnrepository.com/'}
|
||||
|
||||
gradlePluginPortal()
|
||||
// If you have other custom repositories, add them here
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'demo'
|
||||
|
||||
|
||||
include 'db'
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
rootProject.name = "mtg-search"
|
||||
|
||||
include(":backend")
|
||||
include(":frontend")
|
||||
@@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
This file is auto-generated by Vaadin.
|
||||
-->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<style>
|
||||
html, body, #outlet {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- This outlet div is where the views are rendered -->
|
||||
<div id="outlet"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.example.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
vaadin.launch-browser=true
|
||||
spring.application.name=demo
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.example.demo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class DemoApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
+185
@@ -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:17-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
|
||||
Reference in New Issue
Block a user