diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 665b40e..891394d 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -5,14 +5,14 @@ # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at -# +# # http://www.apache.org/licenses/LICENSE-2.0 -# +# # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.2/apache-maven-3.6.2-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar \ No newline at end of file diff --git a/CICD/Pipelines/Jenkinsfile b/CICD/Pipelines/Jenkinsfile new file mode 100644 index 0000000..0711681 --- /dev/null +++ b/CICD/Pipelines/Jenkinsfile @@ -0,0 +1,477 @@ +#!/usr/bin/env groovy + +/** + * Jenkinsfile for inventory-quarkus + * + * This pipeline builds, tests, and deploys the inventory-quarkus microservice. + * It supports both development and production deployments. + * + * Required Jenkins Plugins: + * - Pipeline + * - Git + * - Docker Pipeline + * - Kubernetes CLI (kubectl) + * - Credentials Binding + * - SonarQube Scanner (optional) + * + * Required Credentials: + * - docker-registry-credentials: Docker registry username/password + * - sonarqube-token: SonarQube authentication token (optional) + * - kubeconfig: Kubernetes configuration for deployment + */ + +pipeline { + agent { + label 'maven' // Agent with Maven, Java 17, and Docker installed + } + + environment { + // Project configuration + PROJECT_NAME = 'inventory-quarkus' + PROJECT_VERSION = "${env.BUILD_NUMBER}" + JAVA_HOME = tool name: 'JDK-17', type: 'jdk' + + // Docker configuration + DOCKER_REGISTRY = credentials('docker-registry-url') ?: 'docker.io' + DOCKER_IMAGE = "${DOCKER_REGISTRY}/${PROJECT_NAME}" + DOCKER_TAG = "${env.BRANCH_NAME ?: 'latest'}-${env.BUILD_NUMBER}" + + // SonarQube configuration (optional) + SONARQUBE_ENABLED = false + SONARQUBE_SCANNER = tool name: 'SonarQube', type: 'hudson.plugins.sonar.SonarRunnerInstallation' + + // Kubernetes configuration + KUBERNETES_NAMESPACE = 'inventory' + KUBERNETES_DEPLOYMENT = 'inventory-quarkus' + + // Maven options + MAVEN_OPTS = '-Dmaven.repo.local=$WORKSPACE/.m2/repository -Xmx1024m' + } + + tools { + maven 'Maven-3.9' + jdk 'JDK-17' + } + + options { + timeout(time: 30, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10')) + disableConcurrentBuilds() + timestamps() + ansiColor('xterm') + } + + stages { + stage('Checkout') { + steps { + echo "Checking out source code from ${env.GIT_URL ?: 'repository'}..." + checkout scm + + script { + env.GIT_COMMIT_SHORT = sh( + script: 'git rev-parse --short HEAD', + returnStdout: true + ).trim() + env.GIT_BRANCH_NAME = sh( + script: 'git rev-parse --abbrev-ref HEAD', + returnStdout: true + ).trim() + } + } + post { + success { + echo "Checkout completed successfully. Commit: ${env.GIT_COMMIT_SHORT}" + } + } + } + + stage('Validate') { + parallel { + stage('Code Style Check') { + steps { + echo "Running code style validation..." + sh ''' + mvn checkstyle:check -Dcheckstyle.failOnViolation=true || true + ''' + } + } + + stage('Dependency Check') { + steps { + echo "Checking for vulnerable dependencies..." + sh ''' + mvn dependency:analyze -DfailOnWarning=false || true + ''' + } + } + } + } + + stage('Build') { + steps { + echo "Building ${PROJECT_NAME}..." + sh ''' + mvn clean compile \ + -DskipTests \ + -Dproject.build.sourceEncoding=UTF-8 \ + -Dmaven.compiler.source=17 \ + -Dmaven.compiler.target=17 + ''' + } + post { + success { + archiveArtifacts artifacts: 'target/classes/**/*', fingerprint: true, allowEmptyArchive: true + } + } + } + + stage('Unit Tests') { + steps { + echo "Running unit tests..." + sh ''' + mvn test \ + -Dmaven.test.failure.ignore=false \ + -Djava.util.logging.manager=org.jboss.logmanager.LogManager + ''' + } + post { + always { + junit testResults: 'target/surefire-reports/*.xml', allowEmptyResults: true + publishHTML(target: [ + allowMissing: true, + alwaysLinkToLastBuild: true, + keepAll: true, + reportDir: 'target/site/jacoco', + reportFiles: 'index.html', + reportName: 'JaCoCo Coverage' + ]) + } + failure { + emailext( + subject: "Unit Tests Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}", + body: "Unit tests have failed. Please check the build logs.\n\nBuild URL: ${env.BUILD_URL}", + to: "${env.CHANGE_AUTHOR_EMAIL ?: 'team@example.com'}" + ) + } + } + } + + stage('SonarQube Analysis') { + when { + expression { return env.SONARQUBE_ENABLED == 'true' } + } + steps { + echo "Running SonarQube analysis..." + withSonarQubeEnv('SonarQube') { + sh ''' + mvn sonar:sonar \ + -Dsonar.projectKey=${PROJECT_NAME} \ + -Dsonar.projectName="${PROJECT_NAME}" \ + -Dsonar.projectVersion=${PROJECT_VERSION} \ + -Dsonar.sources=src/main/java \ + -Dsonar.tests=src/test/java \ + -Dsonar.java.binaries=target/classes \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + ''' + } + } + } + + stage('Quality Gate') { + when { + expression { return env.SONARQUBE_ENABLED == 'true' } + } + steps { + echo "Waiting for SonarQube Quality Gate..." + timeout(time: 5, unit: 'MINUTES') { + script { + def qg = waitForQualityGate() + if (qg.status != 'OK') { + error "Quality Gate failed: ${qg.status}" + } + } + } + } + } + + stage('Package') { + steps { + echo "Packaging application..." + sh ''' + mvn package -DskipTests + + # List generated artifacts + ls -la target/ + ''' + } + post { + success { + archiveArtifacts artifacts: 'target/*.jar', fingerprint: true + } + } + } + + stage('Build Docker Image') { + steps { + echo "Building Docker image: ${DOCKER_IMAGE}:${DOCKER_TAG}" + script { + docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') { + def customImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}", + "--build-arg JAVA_HOME=${JAVA_HOME} " + + "--build-arg BUILD_NUMBER=${env.BUILD_NUMBER} " + + "--build-arg GIT_COMMIT=${env.GIT_COMMIT_SHORT} " + + ".") + + // Push with build tag + customImage.push() + + // Push with branch tag + if (env.BRANCH_NAME) { + customImage.push("${env.BRANCH_NAME}") + } + + // Push latest tag for main branch + if (env.BRANCH_NAME == 'main' || env.BRANCH_NAME == 'master') { + customImage.push('latest') + } + } + } + } + } + + stage('Integration Tests') { + when { + anyOf { + branch 'main' + branch 'master' + branch 'develop' + } + } + steps { + echo "Running integration tests..." + sh ''' + mvn verify -DskipUnitTests -DskipITs=false || true + ''' + } + post { + always { + junit testResults: 'target/failsafe-reports/*.xml', allowEmptyResults: true + } + } + } + + stage('Security Scan') { + when { + anyOf { + branch 'main' + branch 'master' + branch 'develop' + } + } + steps { + echo "Running security scan on Docker image..." + sh ''' + # Run Trivy security scan (if available) + trivy image --exit-code 1 --severity HIGH,CRITICAL ${DOCKER_IMAGE}:${DOCKER_TAG} || true + ''' + } + } + + stage('Deploy to Development') { + when { + branch 'develop' + } + environment { + KUBECONFIG = credentials('kubeconfig-dev') + } + steps { + echo "Deploying to Development environment..." + script { + sh ''' + kubectl config use-context development + + # Update deployment image + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + # Wait for rollout + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=300s + + # Verify deployment + kubectl get pods -n ${KUBERNETES_NAMESPACE} -l app=${KUBERNETES_DEPLOYMENT} + ''' + } + } + post { + failure { + script { + sh ''' + kubectl rollout undo deployment/${KUBERNETES_DEPLOYMENT} -n ${KUBERNETES_NAMESPACE} + ''' + } + emailext( + subject: "Deployment Failed (Development): ${env.JOB_NAME}", + body: "Deployment to development environment failed. Rollback initiated.\n\nBuild URL: ${env.BUILD_URL}", + to: "${env.CHANGE_AUTHOR_EMAIL ?: 'team@example.com'}" + ) + } + } + } + + stage('Deploy to Staging') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + environment { + KUBECONFIG = credentials('kubeconfig-staging') + } + steps { + echo "Deploying to Staging environment..." + input message: 'Deploy to Staging?', ok: 'Deploy' + + script { + sh ''' + kubectl config use-context staging + + # Apply Kubernetes manifests + kubectl apply -f kubernetes/ -n ${KUBERNETES_NAMESPACE} + + # Update deployment image + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + # Wait for rollout + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=300s + ''' + } + } + } + + stage('Smoke Tests') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + steps { + echo "Running smoke tests against staging..." + script { + sh ''' + # Wait for service to be ready + sleep 30 + + # Basic health check + curl -f https://staging.example.com/q/health/ready || exit 1 + + # API smoke test + curl -f https://staging.example.com/api/inventory/count || exit 1 + ''' + } + } + } + + stage('Deploy to Production') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + environment { + KUBECONFIG = credentials('kubeconfig-prod') + } + steps { + echo "Deploying to Production environment..." + input message: 'Deploy to Production?', ok: 'Deploy', submitter: 'admin,release-manager' + + script { + sh ''' + kubectl config use-context production + + # Blue-Green deployment strategy + kubectl apply -f kubernetes/deployment.yaml -n ${KUBERNETES_NAMESPACE} + + kubectl set image deployment/${KUBERNETES_DEPLOYMENT} \ + ${KUBERNETES_DEPLOYMENT}=${DOCKER_IMAGE}:${DOCKER_TAG} \ + -n ${KUBERNETES_NAMESPACE} + + kubectl rollout status deployment/${KUBERNETES_DEPLOYMENT} \ + -n ${KUBERNETES_NAMESPACE} \ + --timeout=600s + ''' + } + } + post { + success { + emailext( + subject: "Production Deployment Successful: ${env.JOB_NAME}", + body: """ + Production deployment completed successfully! + + Project: ${PROJECT_NAME} + Version: ${DOCKER_TAG} + Build: #${env.BUILD_NUMBER} + Commit: ${env.GIT_COMMIT_SHORT} + + Build URL: ${env.BUILD_URL} + """, + to: 'release-team@example.com' + ) + } + failure { + script { + sh ''' + kubectl rollout undo deployment/${KUBERNETES_DEPLOYMENT} -n ${KUBERNETES_NAMESPACE} + ''' + } + emailext( + subject: "URGENT: Production Deployment Failed: ${env.JOB_NAME}", + body: "Production deployment failed! Rollback has been initiated.\n\nBuild URL: ${env.BUILD_URL}", + to: 'oncall@example.com' + ) + } + } + } + } + + post { + always { + echo 'Cleaning up workspace...' + cleanWs() + } + + success { + echo "Pipeline completed successfully for ${PROJECT_NAME}!" + slackSend( + color: 'good', + message: "✅ Build Successful: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}\nCommit: ${env.GIT_COMMIT_SHORT}" + ) + } + + failure { + echo "Pipeline failed for ${PROJECT_NAME}!" + slackSend( + color: 'danger', + message: "❌ Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}\nCommit: ${env.GIT_COMMIT_SHORT}\nBuild URL: ${env.BUILD_URL}" + ) + } + + unstable { + echo "Pipeline is unstable for ${PROJECT_NAME}!" + slackSend( + color: 'warning', + message: "⚠️ Build Unstable: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nBranch: ${env.BRANCH_NAME}" + ) + } + } +} \ No newline at end of file diff --git a/CICD/README.md b/CICD/README.md new file mode 100644 index 0000000..aa63760 --- /dev/null +++ b/CICD/README.md @@ -0,0 +1,118 @@ +# CI/CD Pipelines + +This folder contains the CI/CD pipeline configurations for the inventory-quarkus project. + +## Structure + +``` +CICD/ +├── Pipelines/ +│ └── Jenkinsfile # Main Jenkins pipeline definition +└── README.md # This file +``` + +## Jenkins Pipeline + +The main Jenkinsfile (`Pipelines/Jenkinsfile`) defines a complete CI/CD pipeline with the following stages: + +### Pipeline Stages + +| Stage | Description | +|-------|-------------| +| **Checkout** | Clone source code from repository | +| **Validate** | Code style check & dependency analysis (parallel) | +| **Build** | Compile the application with Maven | +| **Unit Tests** | Run unit tests and generate coverage reports | +| **SonarQube Analysis** | Static code analysis (optional) | +| **Quality Gate** | Enforce code quality standards | +| **Package** | Build JAR artifacts | +| **Build Docker Image** | Create and push Docker images | +| **Integration Tests** | Run integration tests on main branches | +| **Security Scan** | Scan Docker image for vulnerabilities | +| **Deploy to Development** | Auto-deploy on develop branch | +| **Deploy to Staging** | Manual approval required for staging | +| **Smoke Tests** | Basic health checks after deployment | +| **Deploy to Production** | Manual approval required for production | + +### Required Jenkins Plugins + +- Pipeline +- Git +- Docker Pipeline +- Kubernetes CLI (kubectl) +- Credentials Binding +- SonarQube Scanner (optional) +- Email Extension +- Slack Notification +- AnsiColor +- Timestamper + +### Required Credentials + +Configure these credentials in Jenkins: + +| Credential ID | Type | Description | +|--------------|------|-------------| +| `docker-registry-url` | Secret text | Docker registry URL | +| `docker-registry-credentials` | Username/Password | Docker registry login | +| `kubeconfig-dev` | Secret file | Kubernetes config for dev | +| `kubeconfig-staging` | Secret file | Kubernetes config for staging | +| `kubeconfig-prod` | Secret file | Kubernetes config for production | +| `sonarqube-token` | Secret text | SonarQube authentication (optional) | + +### Branch Strategy + +- **develop** → Auto-deploy to Development +- **main/master** → Deploy to Staging (manual approval) → Production (manual approval) +- **feature/*** → Build and test only + +### Environment Variables + +Key environment variables used in the pipeline: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PROJECT_NAME` | Project identifier | `inventory-quarkus` | +| `KUBERNETES_NAMESPACE` | Kubernetes namespace | `inventory` | +| `DOCKER_REGISTRY` | Docker registry URL | `docker.io` | +| `SONARQUBE_ENABLED` | Enable SonarQube | `false` | + +### Usage + +1. **Create Jenkins Job:** + - New Item → Pipeline + - Pipeline script from SCM + - Point to your Git repository + - Script path: `CICD/Pipelines/Jenkinsfile` + +2. **Configure Webhook:** + - Add webhook in Git repository to trigger Jenkins on push events + +3. **Run Pipeline:** + - Manual trigger or webhook-triggered on push + +### Notifications + +The pipeline sends notifications via: +- **Email**: On test failures, deployment failures, and production deployments +- **Slack**: On build success, failure, or unstable status + +### Rollback + +Automatic rollback is configured for: +- Development deployment failures +- Production deployment failures + +Manual rollback command: +```bash +kubectl rollout undo deployment/inventory-quarkus -n inventory +``` + +## Future Enhancements + +Consider adding: +- GitHub Actions workflow (alternative to Jenkins) +- GitLab CI configuration +- Azure DevOps pipeline +- ArgoCD for GitOps deployments +- Helm charts for Kubernetes deployments \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e88c2bd --- /dev/null +++ b/README.md @@ -0,0 +1,574 @@ +# Inventory Quarkus API + +A RESTful inventory management microservice built with Quarkus, providing CRUD operations for inventory items with pagination, validation, security, metrics, resilience, and OpenAPI documentation. + +## Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Running the Application](#running-the-application) +- [API Documentation](#api-documentation) +- [API Endpoints](#api-endpoints) +- [API Versioning](#api-versioning) +- [Security](#security) +- [Metrics](#metrics) +- [Resilience](#resilience) +- [Data Model](#data-model) +- [Error Handling](#error-handling) +- [Testing](#testing) +- [Technology Stack](#technology-stack) +- [Configuration](#configuration) +- [CI/CD](#cicd) + +## Features + +- ✅ Full CRUD operations for inventory items +- ✅ **API Versioning (v1 endpoints with enhanced features)** +- ✅ **Metrics with Micrometer/Prometheus** +- ✅ **Resilience patterns (Circuit Breaker, Retry, Timeout)** +- ✅ Paginated list endpoint with metadata +- ✅ Search inventory by product ID +- ✅ Bean Validation for input data +- ✅ OpenAPI/Swagger UI documentation +- ✅ Health checks (liveness and readiness) +- ✅ Comprehensive error handling with consistent error responses +- ✅ Caching with Caffeine for improved performance +- ✅ JWT-based authentication with role-based access control +- ✅ PostgreSQL support for production with Flyway migrations +- ✅ Structured JSON logging for production +- ✅ Audit fields (createdAt, updatedAt) +- ✅ H2 in-memory database for development +- ✅ Native image compilation support +- ✅ **CI/CD Pipeline with Jenkins** + +## Prerequisites + +- Java 17+ +- Maven 3.8+ +- PostgreSQL (for production) +- (Optional) GraalVM for native compilation + +## Running the Application + +### Development Mode + +```bash +./mvnw quarkus:dev +``` + +The application will start at `http://localhost:8080`. + +Authentication is disabled in development mode for easier testing. + +### Production Mode + +```bash +# Build the application +./mvnw clean package + +# Run with PostgreSQL +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +export POSTGRES_DB=inventory +export POSTGRES_USER=inventory +export POSTGRES_PASSWORD=inventory +export JWT_ISSUER=https://your-issuer.com +export JWT_PUBLIC_KEY_URL=/path/to/publicKey.pem + +java -jar target/quarkus-app/quarkus-run.jar +``` + +### Native Mode + +```bash +./mvnw package -Dnative +./target/inventory-quarkus-1.0.0-SNAPSHOT-runner +``` + +### Docker + +```bash +# Build Docker image +docker build -t inventory-quarkus . + +# Run with Docker +docker run -i --rm -p 8080:8080 \ + -e POSTGRES_HOST=host.docker.internal \ + -e POSTGRES_USER=inventory \ + -e POSTGRES_PASSWORD=inventory \ + inventory-quarkus +``` + +## API Documentation + +Once the application is running, access the interactive API documentation: + +- **Swagger UI**: http://localhost:8080/q/swagger-ui +- **OpenAPI Spec (YAML)**: http://localhost:8080/q/openapi +- **OpenAPI Spec (JSON)**: http://localhost:8080/q/openapi?format=json + +## API Endpoints + +### Original API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/inventory` | List inventory (paginated) | +| GET | `/api/inventory/all` | List all inventory | +| GET | `/api/inventory/count` | Count inventory items | +| GET | `/api/inventory/{id}` | Get by ID | +| GET | `/api/inventory/product/{id}` | Get by product ID | +| POST | `/api/inventory` | Create item | +| PUT | `/api/inventory/{id}` | Update item | +| PATCH | `/api/inventory/{id}/quantity` | Update quantity | +| DELETE | `/api/inventory/{id}` | Delete item | +| DELETE | `/api/inventory/cache` | Clear caches | + +### List Inventory Items (Paginated) + +```http +GET /api/inventory?page=0&size=20 +``` + +**Response:** +```json +{ + "data": [ + { + "id": 100000, + "productId": 1001, + "quantity": 0, + "createdAt": "2026-02-09T00:00:00Z", + "updatedAt": "2026-02-09T00:00:00Z" + } + ], + "total": 8, + "page": 0, + "size": 20, + "totalPages": 1, + "hasNext": false, + "hasPrevious": false +} +``` + +## API Versioning + +This API uses URI path versioning. Two versions are available: + +### Version 1 (Enhanced) - `/api/v1/inventory` + +The v1 endpoints include additional features: + +| Feature | Description | +|---------|-------------| +| **Metrics** | All endpoints are instrumented with `@Counted` and `@Timed` annotations | +| **Timeout** | 2-5 second timeouts on read operations | +| **Circuit Breaker** | Opens after 50% failure rate (10 requests window) | +| **Retry** | 3 retries with 100ms delay for get operations | + +### V1 Endpoints + +```http +GET /api/v1/inventory # List (Circuit Breaker + Metrics) +GET /api/v1/inventory/{id} # Get by ID (Retry + Timeout + Cache) +GET /api/v1/inventory/product/{id} # Get by product (Retry + Cache) +POST /api/v1/inventory # Create (Metrics) +PUT /api/v1/inventory/{id} # Update (Metrics) +PATCH /api/v1/inventory/{id}/quantity # Update quantity (Metrics) +DELETE /api/v1/inventory/{id} # Delete (Metrics) +``` + +### Version Compatibility + +| Version | Status | Features | +|---------|--------|----------| +| v0 (unversioned) | Stable | Basic CRUD, caching | +| v1 | Current | Metrics, resilience patterns, caching | + +## Security + +### JWT Authentication + +This API uses JWT (JSON Web Token) authentication for production. The authentication is disabled in development mode. + +### Roles + +| Role | Permissions | +|------|-------------| +| `admin` | Full access: Create, Read, Update, Delete, Clear Cache | +| `inventory-manager` | Create, Read, Update | +| `inventory-viewer` | Read, Update Quantity only | + +### Endpoint Security Matrix + +| Endpoint | Method | Required Role | +|----------|--------|---------------| +| `/api/inventory` | GET | Public (No auth) | +| `/api/inventory/all` | GET | Public (No auth) | +| `/api/inventory/count` | GET | Public (No auth) | +| `/api/inventory/{id}` | GET | Public (No auth) | +| `/api/inventory/product/{id}` | GET | Public (No auth) | +| `/api/inventory` | POST | `admin`, `inventory-manager` | +| `/api/inventory/{id}` | PUT | `admin`, `inventory-manager` | +| `/api/inventory/{id}/quantity` | PATCH | `admin`, `inventory-manager`, `inventory-viewer` | +| `/api/inventory/{id}` | DELETE | `admin` only | +| `/api/inventory/cache` | DELETE | `admin` only | +| `/q/health/*` | GET | Public (No auth) | + +### JWT Token Configuration + +Configure JWT in production with environment variables: + +```bash +export JWT_ISSUER=https://your-identity-provider.com +export JWT_PUBLIC_KEY_URL=https://your-identity-provider.com/.well-known/jwks.json +``` + +## Metrics + +### Prometheus Metrics + +The application exposes Prometheus-compatible metrics at `/q/metrics`. + +#### Custom Metrics + +| Metric Name | Type | Description | +|-------------|------|-------------| +| `inventory.list.count` | Counter | Total list requests | +| `inventory.list.timer` | Timer | List request duration | +| `inventory.get.by.id.count` | Counter | Get by ID requests | +| `inventory.get.by.id.timer` | Timer | Get by ID duration | +| `inventory.get.by.product.count` | Counter | Get by product requests | +| `inventory.create.count` | Counter | Create operations | +| `inventory.create.timer` | Timer | Create duration | +| `inventory.update.count` | Counter | Update operations | +| `inventory.delete.count` | Counter | Delete operations | +| `inventory.total.items` | Gauge | Total inventory items | + +#### Example Metrics Output + +``` +# HELP inventory_list_count_total Total list requests +# TYPE inventory_list_count_total counter +inventory_list_count_total 10.0 + +# HELP inventory_list_timer_seconds List request duration +# TYPE inventory_list_timer_seconds summary +inventory_list_timer_seconds{quantile="0.5"} 0.015 +inventory_list_timer_seconds{quantile="0.95"} 0.025 +inventory_list_timer_seconds{quantile="0.99"} 0.030 +inventory_list_timer_seconds_count 10.0 +``` + +#### Accessing Metrics + +```bash +curl http://localhost:8080/q/metrics +``` + +### Grafana Dashboard + +Use the Prometheus metrics with Grafana for visualization. Key panels: +- Request rate (requests/second) +- Response time percentiles (p50, p95, p99) +- Error rate +- Circuit breaker status + +## Resilience + +### Circuit Breaker + +Protects against cascading failures by opening when failure threshold is reached. + +**Configuration:** +- Request Volume Threshold: 10 requests +- Failure Ratio: 50% +- Delay: 5 seconds +- Success Threshold: 3 successful calls + +**Applied to:** `GET /api/v1/inventory` + +```java +@CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000, successThreshold = 3) +``` + +### Timeout + +Prevents hanging requests by timing out after specified duration. + +| Endpoint | Timeout | +|----------|---------| +| List inventory | 5 seconds | +| List all | 3 seconds | +| Get by ID | 2 seconds | +| Get by product | 2 seconds | + +### Retry + +Automatically retries failed operations. + +**Configuration:** +- Max Retries: 3 +- Delay: 100ms + +**Applied to:** Get by ID, Get by Product + +```java +@Retry(maxRetries = 3, delay = 100) +``` + +## Data Model + +### Inventory + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| id | Long | Auto-generated | Unique identifier | +| productId | Long | Required, Unique | Associated product ID | +| quantity | int | Required, Min 0 | Available quantity | +| createdAt | Instant | Auto-set | Creation timestamp | +| updatedAt | Instant | Auto-updated | Last update timestamp | + +### Example JSON + +```json +{ + "id": 329299, + "productId": 1002, + "quantity": 35, + "createdAt": "2026-02-09T00:00:00.000Z", + "updatedAt": "2026-02-09T00:00:00.000Z" +} +``` + +## Error Handling + +All errors return a consistent JSON structure: + +```json +{ + "status": 404, + "error": "Not Found", + "message": "Inventory item not found with id: 999999", + "path": "/api/inventory/999999", + "timestamp": "2026-02-09T00:00:00.000Z" +} +``` + +### HTTP Status Codes + +| Status | Description | +|--------|-------------| +| 200 | Success (GET, PUT, PATCH) | +| 201 | Created (POST) | +| 204 | No Content (DELETE) | +| 400 | Bad Request - Validation error | +| 401 | Unauthorized - Missing or invalid JWT | +| 403 | Forbidden - Insufficient permissions | +| 404 | Not Found - Resource doesn't exist | +| 415 | Unsupported Media Type | +| 500 | Internal Server Error | +| 503 | Service Unavailable - Circuit breaker open | + +## Testing + +### Run All Tests + +```bash +./mvnw test +``` + +### Run Integration Tests + +```bash +./mvnw verify -Dnative +``` + +### Test Categories + +| Test Class | Count | Description | +|------------|-------|-------------| +| `InventoryResourceTest.java` | 30 | Original API tests | +| `InventoryResourceV1Test.java` | 21 | V1 API tests with metrics | +| `NativeInventoryResourceIT.java` | - | Native image tests | + +## Technology Stack + +| Category | Technology | +|----------|------------| +| Framework | Quarkus 3.8.4 | +| Language | Java 17 | +| JAX-RS | RESTEasy Reactive | +| ORM | Hibernate ORM with Panache | +| Database | H2 (dev), PostgreSQL (prod) | +| Migrations | Flyway | +| Validation | Hibernate Validator | +| Security | SmallRye JWT | +| Documentation | SmallRye OpenAPI with Swagger UI | +| Health | SmallRye Health | +| Caching | Quarkus Cache with Caffeine | +| **Metrics** | **Micrometer with Prometheus** | +| **Resilience** | **SmallRye Fault Tolerance** | +| Logging | Quarkus Logging JSON | +| Testing | JUnit 5, Rest Assured | + +## Project Structure + +``` +src/ +├── main/ +│ ├── java/com/redhat/cloudnative/ +│ │ ├── Inventory.java # Entity class +│ │ ├── InventoryResource.java # REST endpoints (unversioned) +│ │ ├── InventoryResourceV1.java # REST endpoints v1 (metrics + resilience) +│ │ ├── PaginatedResponse.java # Pagination wrapper +│ │ ├── QuantityUpdateRequest.java # DTO for PATCH +│ │ ├── ErrorResponse.java # Error response DTO +│ │ └── ...ExceptionMappers.java # Exception handlers +│ └── resources/ +│ ├── application.properties # Configuration +│ ├── import.sql # Seed data (dev) +│ └── db/migration/ # Flyway migrations +│ └── V1.0.0__Initial_schema.sql +├── test/ +│ └── java/com/redhat/cloudnative/ +│ ├── InventoryResourceTest.java # Original API tests +│ └── InventoryResourceV1Test.java # V1 API tests +└── CICD/ + └── Pipelines/ + └── Jenkinsfile # CI/CD Pipeline +``` + +## Caching + +This application uses Quarkus Cache with Caffeine backend for improved performance on read operations. + +### Cached Endpoints + +| Endpoint | Cache Name | Description | +|----------|------------|-------------| +| `GET /api/inventory/{itemId}` | `inventory-cache` | Cached by inventory ID | +| `GET /api/inventory/product/{productId}` | `inventory-product-cache` | Cached by product ID | + +### Cache Configuration + +```properties +# Cache expires after 5 minutes +quarkus.cache.caffeine.inventory-cache.expire-after-write=5m +quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_HOST` | localhost | PostgreSQL host | +| `POSTGRES_PORT` | 5432 | PostgreSQL port | +| `POSTGRES_DB` | inventory | Database name | +| `POSTGRES_USER` | inventory | Database user | +| `POSTGRES_PASSWORD` | inventory | Database password | +| `JWT_ISSUER` | - | JWT token issuer URL | +| `JWT_PUBLIC_KEY_URL` | - | URL to JWT public key | + +### Development Configuration + +```properties +# H2 in-memory database +quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory +quarkus.datasource.db-kind=h2 + +# Security disabled for development +%dev.quarkus.smallrye-jwt.enabled=false + +# Metrics enabled +quarkus.micrometer.enabled=true +``` + +### Production Configuration + +```properties +# PostgreSQL database +%prod.quarkus.datasource.db-kind=postgresql + +# Flyway migrations +%prod.quarkus.flyway.migrate-at-start=true + +# JWT Security +%prod.mp.jwt.verify.issuer=${JWT_ISSUER} +%prod.quarkus.smallrye-jwt.enabled=true + +# JSON Logging +%prod.quarkus.log.console.json=true + +# Metrics +quarkus.micrometer.export.prometheus.enabled=true +``` + +## Observability Endpoints + +| Endpoint | Description | +|----------|-------------| +| `/q/health` | Overall health status | +| `/q/health/ready` | Readiness probe | +| `/q/health/live` | Liveness probe | +| `/q/metrics` | Prometheus metrics | +| `/q/swagger-ui` | API documentation | +| `/q/openapi` | OpenAPI specification | + +## CI/CD + +A complete Jenkins pipeline is provided in `CICD/Pipelines/Jenkinsfile`. + +### Pipeline Stages + +1. **Checkout** - Clone source code +2. **Validate** - Code style & dependency checks +3. **Build** - Compile with Maven +4. **Unit Tests** - Run tests with coverage +5. **SonarQube** - Static code analysis (optional) +6. **Package** - Build JAR artifacts +7. **Docker Build** - Create container image +8. **Security Scan** - Trivy vulnerability scan +9. **Deploy Dev** - Auto-deploy on develop branch +10. **Deploy Staging** - Manual approval required +11. **Deploy Production** - Manual approval required + +### Branch Strategy + +| Branch | Deployment | +|--------|------------| +| `develop` | Development environment | +| `main/master` | Staging → Production | +| `feature/*` | Build and test only | + +### Required Jenkins Plugins + +- Pipeline, Git, Docker Pipeline +- Kubernetes CLI, Credentials Binding +- SonarQube Scanner, Email Extension +- Slack Notification + +See `CICD/README.md` for detailed configuration. + +## Database Migrations + +This project uses Flyway for database migrations in production. + +### Creating a New Migration + +1. Create a file in `src/main/resources/db/migration/` +2. Name it with version pattern: `V1.0.1__Description.sql` +3. Write your SQL migration + +### Example Migration + +```sql +-- V1.0.1__Add_low_stock_flag.sql +ALTER TABLE INVENTORY ADD COLUMN low_stock_threshold INTEGER DEFAULT 10; +``` + +## License + +This project is licensed under the Apache License 2.0. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3cf5266..85f8f9f 100644 --- a/pom.xml +++ b/pom.xml @@ -7,67 +7,118 @@ 1.0.0-SNAPSHOT - 2.1.4.Final + 3.8.4 quarkus-bom io.quarkus - 2.1.4.Final - uber-jar - 3.8.1 - 3.0.0-M5 + 3.8.4 + uber-jar + 3.11.0 + 3.2.5 UTF-8 - 11 - 11 - true - 4.12.0 + 17 + 17 + true + 6.12.0 - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + - io.quarkus - quarkus-resteasy + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-test-security + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-h2 + + + io.fabric8 + tekton-client + ${tekton-client.version} + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-cache + + + + io.quarkus + quarkus-security - io.quarkus - quarkus-junit5 - test + io.quarkus + quarkus-smallrye-jwt + - io.rest-assured - rest-assured - test + io.quarkus + quarkus-jdbc-postgresql + - io.quarkus - quarkus-resteasy-jsonb + io.quarkus + quarkus-flyway + - io.quarkus - quarkus-hibernate-orm-panache + io.quarkus + quarkus-logging-json + - io.quarkus - quarkus-jdbc-h2 - - - io.fabric8 - tekton-client - ${tekton-client.version} + io.quarkus + quarkus-micrometer-registry-prometheus - - io.quarkus - quarkus-smallrye-health + + + io.quarkus + quarkus-smallrye-fault-tolerance @@ -102,7 +153,6 @@ - native @@ -151,4 +201,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java b/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java new file mode 100644 index 0000000..a4a8a06 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/ConstraintViolationExceptionMapper.java @@ -0,0 +1,42 @@ +package com.redhat.cloudnative; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +import java.util.stream.Collectors; + +/** + * Exception mapper that handles Bean Validation constraint violations. + * This catches validation errors from @Valid annotations and returns + * a consistent error response format. + */ +@Provider +public class ConstraintViolationExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(ConstraintViolationException exception) { + String violations = exception.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.BAD_REQUEST.getStatusCode()) + .error("Validation Failed") + .message(violations) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorResponse) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/ErrorResponse.java b/src/main/java/com/redhat/cloudnative/ErrorResponse.java new file mode 100644 index 0000000..92965c5 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/ErrorResponse.java @@ -0,0 +1,99 @@ +package com.redhat.cloudnative; + +import java.time.Instant; + +public class ErrorResponse { + + private int status; + private String error; + private String message; + private String path; + private Instant timestamp; + + public ErrorResponse() { + this.timestamp = Instant.now(); + } + + public ErrorResponse(int status, String error, String message, String path) { + this.status = status; + this.error = error; + this.message = message; + this.path = path; + this.timestamp = Instant.now(); + } + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private int status; + private String error; + private String message; + private String path; + + public Builder status(int status) { + this.status = status; + return this; + } + + public Builder error(String error) { + this.error = error; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder path(String path) { + this.path = path; + return this; + } + + public ErrorResponse build() { + return new ErrorResponse(status, error, message, path); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java b/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java new file mode 100644 index 0000000..70b83eb --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InvalidInventoryException.java @@ -0,0 +1,8 @@ +package com.redhat.cloudnative; + +public class InvalidInventoryException extends RuntimeException { + + public InvalidInventoryException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java new file mode 100644 index 0000000..def5786 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InvalidInventoryExceptionMapper.java @@ -0,0 +1,29 @@ +package com.redhat.cloudnative; + +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class InvalidInventoryExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(InvalidInventoryException exception) { + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.BAD_REQUEST.getStatusCode()) + .error("Bad Request") + .message(exception.getMessage()) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.BAD_REQUEST) + .entity(errorResponse) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/Inventory.java b/src/main/java/com/redhat/cloudnative/Inventory.java index 3080822..c6baf47 100644 --- a/src/main/java/com/redhat/cloudnative/Inventory.java +++ b/src/main/java/com/redhat/cloudnative/Inventory.java @@ -1,22 +1,77 @@ package com.redhat.cloudnative; -import javax.persistence.Entity; -import javax.persistence.Table; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Column; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import io.quarkus.hibernate.orm.panache.PanacheEntity; +import org.eclipse.microprofile.openapi.annotations.media.Schema; -import javax.persistence.Column; +import java.time.Instant; -@Entity -@Table(name = "INVENTORY") -public class Inventory extends PanacheEntity{ +@Entity +@Table(name = "INVENTORY") +public class Inventory extends PanacheEntity { - @Column + @Column(name = "product_id", unique = true) + @NotNull(message = "Product ID is required") + @Schema(description = "Associated product ID", required = true, example = "1001") + public Long productId; + + @Column(name = "quantity") + @NotNull(message = "Quantity is required") + @Min(value = 0, message = "Quantity cannot be negative") + @Schema(description = "Current stock quantity", required = true, example = "50") public int quantity; + @Column(name = "created_at", updatable = false) + @Schema(description = "Creation timestamp", readOnly = true) + public Instant createdAt; + + @Column(name = "updated_at") + @Schema(description = "Last update timestamp", readOnly = true) + public Instant updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } @Override public String toString() { - return "Inventory [Id='" + id + '\'' + ", quantity=" + quantity + ']'; + return "Inventory [Id='" + id + '\'' + ", productId=" + productId + ", quantity=" + quantity + + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + ']'; + } + + /** + * Get the ID (for OpenAPI documentation) + */ + @Schema(description = "Inventory ID (auto-generated, read-only)", readOnly = true) + public Long getId() { + return id; + } + + /** + * Find inventory by product ID + */ + public static Inventory findByProductId(Long productId) { + return find("productId", productId).firstResult(); + } + + /** + * Check if inventory exists for a product + */ + public static boolean existsByProductId(Long productId) { + return count("productId", productId) > 0; } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java b/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java new file mode 100644 index 0000000..ff60adc --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryNotFoundException.java @@ -0,0 +1,15 @@ +package com.redhat.cloudnative; + +public class InventoryNotFoundException extends RuntimeException { + + private final Long itemId; + + public InventoryNotFoundException(Long itemId) { + super("Inventory item not found with id: " + itemId); + this.itemId = itemId; + } + + public Long getItemId() { + return itemId; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java new file mode 100644 index 0000000..d7cdcb7 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryNotFoundExceptionMapper.java @@ -0,0 +1,29 @@ +package com.redhat.cloudnative; + +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class InventoryNotFoundExceptionMapper implements ExceptionMapper { + + @Context + UriInfo uriInfo; + + @Override + public Response toResponse(InventoryNotFoundException exception) { + ErrorResponse errorResponse = ErrorResponse.builder() + .status(Response.Status.NOT_FOUND.getStatusCode()) + .error("Not Found") + .message(exception.getMessage()) + .path(uriInfo.getRequestUri().getPath()) + .build(); + + return Response.status(Response.Status.NOT_FOUND) + .entity(errorResponse) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/InventoryResource.java b/src/main/java/com/redhat/cloudnative/InventoryResource.java index ad2f09c..5d8d2ce 100644 --- a/src/main/java/com/redhat/cloudnative/InventoryResource.java +++ b/src/main/java/com/redhat/cloudnative/InventoryResource.java @@ -1,22 +1,257 @@ package com.redhat.cloudnative; -import javax.enterprise.context.ApplicationScoped; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheName; +import io.quarkus.cache.CacheResult; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.util.List; @Path("/api/inventory") @ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Inventory", description = "Inventory management operations") public class InventoryResource { + private static final Logger LOG = Logger.getLogger(InventoryResource.class); + + @Inject + @CacheName("inventory-cache") + Cache inventoryCache; + + @GET + @Operation(summary = "List all inventory items", description = "Returns a paginated list of inventory items with metadata") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Paginated list of inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = PaginatedResponse.class))) + }) + public PaginatedResponse listAll( + @Parameter(description = "Page number (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Page size (max 100)") @QueryParam("size") @DefaultValue("20") int size) { + LOG.debugf("Listing inventory items - page: %d, size: %d", page, size); + // Limit page size to prevent performance issues + int effectiveSize = Math.min(size, 100); + List items = Inventory.findAll() + .page(page, effectiveSize) + .list(); + long total = Inventory.count(); + LOG.debugf("Found %d items out of %d total", items.size(), total); + return PaginatedResponse.of(items, total, page, effectiveSize); + } + + @GET + @Path("/all") + @Operation(summary = "List all inventory items without pagination", description = "Returns a simple list of all inventory items (use with caution for large datasets)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "List of all inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))) + }) + public List listAllWithoutPagination() { + LOG.debug("Listing all inventory items without pagination"); + return Inventory.listAll(); + } + + @GET + @Path("/count") + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Count inventory items", description = "Returns the total number of inventory items") + @APIResponse(responseCode = "200", description = "Total count of inventory items") + public Long count() { + Long count = Inventory.count(); + LOG.debugf("Inventory count: %d", count); + return count; + } @GET @Path("/{itemId}") - @Produces(MediaType.APPLICATION_JSON) - public Inventory getAvailability(@PathParam("itemId") long itemId) { + @Operation(summary = "Get inventory by ID", description = "Returns a single inventory item by its ID (cached)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheResult(cacheName = "inventory-cache") + public Inventory getAvailability( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.debugf("Getting inventory by ID: %d", itemId); Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } return inventory; } + + @GET + @Path("/product/{productId}") + @Operation(summary = "Get inventory by product ID", description = "Returns the inventory item for a specific product (cached)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found for the product", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheResult(cacheName = "inventory-product-cache") + public Inventory getByProductId( + @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { + LOG.debugf("Getting inventory by product ID: %d", productId); + Inventory inventory = Inventory.findByProductId(productId); + if (inventory == null) { + LOG.warnf("Inventory not found for product ID: %d", productId); + throw new InventoryNotFoundException(productId); + } + return inventory; + } + + @POST + @Transactional + @Operation(summary = "Create inventory item", description = "Creates a new inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions") + }) + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response create( + @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { + LOG.infof("Creating inventory item for product ID: %d with quantity: %d", inventory.productId, + inventory.quantity); + // Clear any provided ID to let the database auto-generate it + inventory.id = null; + inventory.persist(); + LOG.infof("Created inventory item with ID: %d", inventory.id); + return Response.created(URI.create("/api/inventory/" + inventory.id)) + .entity(inventory) + .build(); + } + + @PUT + @Path("/{itemId}") + @Transactional + @Operation(summary = "Update inventory item", description = "Updates an existing inventory item completely") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory update( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { + LOG.infof("Updating inventory item ID: %d with quantity: %d", itemId, updatedInventory.quantity); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = updatedInventory.quantity; + inventory.persist(); + LOG.infof("Updated inventory item ID: %d", itemId); + return inventory; + } + + @PATCH + @Path("/{itemId}/quantity") + @Transactional + @Operation(summary = "Update inventory quantity", description = "Updates only the quantity of an inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Quantity updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Insufficient permissions"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory updateQuantity( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { + LOG.infof("Updating quantity for inventory ID: %d to %d", itemId, request.getQuantity()); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for quantity update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = request.getQuantity(); + inventory.persist(); + LOG.infof("Updated quantity for inventory ID: %d", itemId); + return inventory; + } + + @DELETE + @Path("/{itemId}") + @Transactional + @Operation(summary = "Delete inventory item", description = "Deletes an inventory item by its ID") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Inventory item deleted"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Admin role required"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response delete( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.infof("Deleting inventory item ID: %d", itemId); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for deletion with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.delete(); + LOG.infof("Deleted inventory item ID: %d", itemId); + return Response.noContent().build(); + } + + /** + * Clear all caches - useful for administrative purposes + */ + @DELETE + @Path("/cache") + @Operation(summary = "Clear all inventory caches", description = "Clears all cached inventory data") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Caches cleared"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden - Admin role required") + }) + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response clearCaches() { + LOG.info("Clearing all inventory caches"); + return Response.noContent().build(); + } + } diff --git a/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java b/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java new file mode 100644 index 0000000..21a119d --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/InventoryResourceV1.java @@ -0,0 +1,296 @@ +package com.redhat.cloudnative; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheName; +import io.quarkus.cache.CacheResult; + +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Timeout; +import org.eclipse.microprofile.faulttolerance.Retry; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import io.micrometer.core.instrument.MeterRegistry; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.util.List; + +/** + * Inventory API v1 - Versioned endpoint with metrics and resilience patterns + * + * API Versioning Strategy: URI Path versioning (/api/v1/inventory) + */ +@Path("/api/v1/inventory") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Inventory v1", description = "Inventory management operations (v1)") +public class InventoryResourceV1 { + + private static final Logger LOG = Logger.getLogger(InventoryResourceV1.class); + + @Inject + @CacheName("inventory-cache") + Cache inventoryCache; + + @Inject + MeterRegistry meterRegistry; + + // ==================== GET ENDPOINTS (with metrics & resilience) + // ==================== + + @GET + @Timeout(5000) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000, successThreshold = 3) + @Counted(value = "inventory.list.count", description = "How many times inventory list has been requested") + @Timed(value = "inventory.list.timer", description = "Time taken to list inventory items", percentiles = { 0.5, + 0.95, 0.99 }) + @Operation(summary = "List all inventory items (v1)", description = "Returns a paginated list of inventory items with metadata") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Paginated list of inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = PaginatedResponse.class))), + @APIResponse(responseCode = "503", description = "Service unavailable - Circuit breaker open") + }) + public PaginatedResponse listAll( + @Parameter(description = "Page number (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Page size (max 100)") @QueryParam("size") @DefaultValue("20") int size) { + LOG.debugf("Listing inventory items - page: %d, size: %d", page, size); + int effectiveSize = Math.min(size, 100); + List items = Inventory.findAll() + .page(page, effectiveSize) + .list(); + long total = Inventory.count(); + // Record gauge metric + meterRegistry.gauge("inventory.total.items", total); + LOG.debugf("Found %d items out of %d total", items.size(), total); + return PaginatedResponse.of(items, total, page, effectiveSize); + } + + @GET + @Path("/all") + @Timeout(3000) + @Counted(value = "inventory.list.all.count", description = "How many times all inventory has been requested") + @Timed(value = "inventory.list.all.timer", description = "Time taken to list all inventory items") + @Operation(summary = "List all inventory items without pagination (v1)", description = "Returns a simple list of all inventory items") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "List of all inventory items", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))) + }) + public List listAllWithoutPagination() { + LOG.debug("Listing all inventory items without pagination"); + return Inventory.listAll(); + } + + @GET + @Path("/count") + @Produces(MediaType.TEXT_PLAIN) + @Counted(value = "inventory.count.requests", description = "How many times count has been requested") + @Operation(summary = "Count inventory items (v1)", description = "Returns the total number of inventory items") + @APIResponse(responseCode = "200", description = "Total count of inventory items") + public Long count() { + Long count = Inventory.count(); + LOG.debugf("Inventory count: %d", count); + return count; + } + + @GET + @Path("/{itemId}") + @Timeout(2000) + @Retry(maxRetries = 3, delay = 100) + @CacheResult(cacheName = "inventory-cache") + @Counted(value = "inventory.get.by.id.count", description = "How many times get by ID has been requested") + @Timed(value = "inventory.get.by.id.timer", description = "Time taken to get inventory by ID") + @Operation(summary = "Get inventory by ID (v1)", description = "Returns a single inventory item by its ID (cached)") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getAvailability( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.debugf("Getting inventory by ID: %d", itemId); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + return inventory; + } + + @GET + @Path("/product/{productId}") + @Timeout(2000) + @Retry(maxRetries = 3, delay = 100) + @CacheResult(cacheName = "inventory-product-cache") + @Counted(value = "inventory.get.by.product.count", description = "How many times get by product ID has been requested") + @Timed(value = "inventory.get.by.product.timer", description = "Time taken to get inventory by product ID") + @Operation(summary = "Get inventory by product ID (v1)", description = "Returns the inventory item for a specific product") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found for the product", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + public Inventory getByProductId( + @Parameter(description = "Product ID", required = true) @PathParam("productId") Long productId) { + LOG.debugf("Getting inventory by product ID: %d", productId); + Inventory inventory = Inventory.findByProductId(productId); + if (inventory == null) { + LOG.warnf("Inventory not found for product ID: %d", productId); + throw new InventoryNotFoundException(productId); + } + return inventory; + } + + // ==================== POST ENDPOINT ==================== + + @POST + @Transactional + @Counted(value = "inventory.create.count", description = "How many inventory items have been created") + @Timed(value = "inventory.create.timer", description = "Time taken to create inventory item") + @Operation(summary = "Create inventory item (v1)", description = "Creates a new inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "201", description = "Inventory item created", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response create( + @RequestBody(description = "Inventory item to create", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory inventory) { + LOG.infof("Creating inventory item for product ID: %d with quantity: %d", inventory.productId, + inventory.quantity); + inventory.id = null; + inventory.persist(); + LOG.infof("Created inventory item with ID: %d", inventory.id); + return Response.created(URI.create("/api/v1/inventory/" + inventory.id)) + .entity(inventory) + .build(); + } + + // ==================== PUT ENDPOINT ==================== + + @PUT + @Path("/{itemId}") + @Transactional + @Counted(value = "inventory.update.count", description = "How many inventory items have been updated") + @Timed(value = "inventory.update.timer", description = "Time taken to update inventory item") + @Operation(summary = "Update inventory item (v1)", description = "Updates an existing inventory item completely") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Inventory item updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid inventory data", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory update( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "Updated inventory data", required = true, content = @Content(schema = @Schema(implementation = Inventory.class))) @Valid Inventory updatedInventory) { + LOG.infof("Updating inventory item ID: %d with quantity: %d", itemId, updatedInventory.quantity); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = updatedInventory.quantity; + inventory.persist(); + LOG.infof("Updated inventory item ID: %d", itemId); + return inventory; + } + + // ==================== PATCH ENDPOINT ==================== + + @PATCH + @Path("/{itemId}/quantity") + @Transactional + @Counted(value = "inventory.quantity.update.count", description = "How many quantity updates have been performed") + @Timed(value = "inventory.quantity.update.timer", description = "Time taken to update quantity") + @Operation(summary = "Update inventory quantity (v1)", description = "Updates only the quantity of an inventory item") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Quantity updated", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = Inventory.class))), + @APIResponse(responseCode = "400", description = "Invalid quantity value", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Inventory updateQuantity( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId, + @RequestBody(description = "New quantity value", required = true, content = @Content(schema = @Schema(implementation = QuantityUpdateRequest.class))) @Valid QuantityUpdateRequest request) { + LOG.infof("Updating quantity for inventory ID: %d to %d", itemId, request.getQuantity()); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for quantity update with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.quantity = request.getQuantity(); + inventory.persist(); + LOG.infof("Updated quantity for inventory ID: %d", itemId); + return inventory; + } + + // ==================== DELETE ENDPOINTS ==================== + + @DELETE + @Path("/{itemId}") + @Transactional + @Counted(value = "inventory.delete.count", description = "How many inventory items have been deleted") + @Operation(summary = "Delete inventory item (v1)", description = "Deletes an inventory item by its ID") + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "Inventory item deleted"), + @APIResponse(responseCode = "404", description = "Inventory item not found", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = ErrorResponse.class))) + }) + @CacheInvalidate(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response delete( + @Parameter(description = "Inventory item ID", required = true) @PathParam("itemId") Long itemId) { + LOG.infof("Deleting inventory item ID: %d", itemId); + Inventory inventory = Inventory.findById(itemId); + if (inventory == null) { + LOG.warnf("Inventory item not found for deletion with ID: %d", itemId); + throw new InventoryNotFoundException(itemId); + } + inventory.delete(); + LOG.infof("Deleted inventory item ID: %d", itemId); + return Response.noContent().build(); + } + + /** + * Clear all caches + */ + @DELETE + @Path("/cache") + @Counted(value = "cache.clear.count", description = "How many times cache has been cleared") + @Operation(summary = "Clear all inventory caches (v1)", description = "Clears all cached inventory data") + @APIResponse(responseCode = "204", description = "Caches cleared") + @CacheInvalidateAll(cacheName = "inventory-cache") + @CacheInvalidateAll(cacheName = "inventory-product-cache") + public Response clearCaches() { + LOG.info("Clearing all inventory caches"); + return Response.noContent().build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/PaginatedResponse.java b/src/main/java/com/redhat/cloudnative/PaginatedResponse.java new file mode 100644 index 0000000..88865eb --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/PaginatedResponse.java @@ -0,0 +1,119 @@ +package com.redhat.cloudnative; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.List; + +/** + * Generic paginated response wrapper that includes metadata about pagination. + * + * @param The type of data in the response + */ +@Schema(description = "Paginated response with metadata") +public class PaginatedResponse { + + @Schema(description = "List of items in the current page") + private List data; + + @Schema(description = "Total number of items across all pages", example = "100") + private long total; + + @Schema(description = "Current page number (0-based)", example = "0") + private int page; + + @Schema(description = "Number of items per page", example = "20") + private int size; + + @Schema(description = "Total number of pages", example = "5") + private int totalPages; + + @Schema(description = "Whether there is a next page", example = "true") + private boolean hasNext; + + @Schema(description = "Whether there is a previous page", example = "false") + private boolean hasPrevious; + + public PaginatedResponse() { + } + + public PaginatedResponse(List data, long total, int page, int size) { + this.data = data; + this.total = total; + this.page = page; + this.size = size; + this.totalPages = calculateTotalPages(total, size); + this.hasNext = page < totalPages - 1; + this.hasPrevious = page > 0; + } + + private int calculateTotalPages(long total, int size) { + if (size <= 0) { + return 0; + } + return (int) Math.ceil((double) total / size); + } + + // Getters and Setters + public List getData() { + return data; + } + + public void setData(List data) { + this.data = data; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public int getTotalPages() { + return totalPages; + } + + public void setTotalPages(int totalPages) { + this.totalPages = totalPages; + } + + public boolean isHasNext() { + return hasNext; + } + + public void setHasNext(boolean hasNext) { + this.hasNext = hasNext; + } + + public boolean isHasPrevious() { + return hasPrevious; + } + + public void setHasPrevious(boolean hasPrevious) { + this.hasPrevious = hasPrevious; + } + + /** + * Builder method to create a PaginatedResponse from a Panache query result + */ + public static PaginatedResponse of(List data, long total, int page, int size) { + return new PaginatedResponse<>(data, total, page, size); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java b/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java new file mode 100644 index 0000000..d8bbee3 --- /dev/null +++ b/src/main/java/com/redhat/cloudnative/QuantityUpdateRequest.java @@ -0,0 +1,30 @@ +package com.redhat.cloudnative; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * DTO for quantity update requests. + * Provides proper validation for PATCH operations. + */ +public class QuantityUpdateRequest { + + @NotNull(message = "Quantity is required") + @Min(value = 0, message = "Quantity cannot be negative") + private Integer quantity; + + public QuantityUpdateRequest() { + } + + public QuantityUpdateRequest(Integer quantity) { + this.quantity = quantity; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index edc16e4..117af31 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,94 @@ +# =========================================== +# Development Configuration (H2 in-memory) +# =========================================== quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1 quarkus.datasource.db-kind=h2 quarkus.hibernate-orm.database.generation=drop-and-create quarkus.hibernate-orm.log.sql=true quarkus.hibernate-orm.sql-load-script=import.sql -%prod.quarkus.package.uber-jar=true + +# =========================================== +# Production Configuration (PostgreSQL) +# =========================================== +%prod.quarkus.datasource.db-kind=postgresql +%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:inventory} +%prod.quarkus.datasource.username=${POSTGRES_USER:inventory} +%prod.quarkus.datasource.password=${POSTGRES_PASSWORD:inventory} +%prod.quarkus.datasource.jdbc.max-size=20 +%prod.quarkus.datasource.jdbc.min-size=5 +%prod.quarkus.hibernate-orm.database.generation=none +%prod.quarkus.hibernate-orm.sql-load-script= +%prod.quarkus.flyway.migrate-at-start=true +%prod.quarkus.package.uber-jar=true + +# Flyway Configuration +quarkus.flyway.locations=db/migration +%dev.quarkus.flyway.migrate-at-start=false + +# =========================================== +# Cache Configuration (Caffeine backend) +# =========================================== +quarkus.cache.caffeine.inventory-cache.expire-after-write=5m +quarkus.cache.caffeine.inventory-product-cache.expire-after-write=5m + +# =========================================== +# Security - JWT Authentication +# =========================================== +# JWT configuration for production +%prod.mp.jwt.verify.publickey.location=${JWT_PUBLIC_KEY_URL:/publicKey.pem} +%prod.mp.jwt.verify.issuer=${JWT_ISSUER:https://your-issuer.com} +%prod.quarkus.smallrye-jwt.enabled=true + +# Disable auth for development +%dev.quarkus.smallrye-jwt.enabled=false +%test.quarkus.smallrye-jwt.enabled=false + +# Security roles mapping (production only) +%prod.quarkus.http.auth.permission.roles1.paths=/api/inventory/* +%prod.quarkus.http.auth.permission.roles1.policy=authenticated +# Allow health endpoints without authentication +quarkus.http.auth.permission.public.paths=/q/health/*,/q/health +quarkus.http.auth.permission.public.policy=permit + +# Disable security for dev and test modes +%dev.quarkus.http.auth.permission.deny.paths=/* +%dev.quarkus.http.auth.permission.deny.policy=permit +%test.quarkus.http.auth.permission.deny.paths=/* +%test.quarkus.http.auth.permission.deny.policy=permit + +# =========================================== +# Logging Configuration +# =========================================== +# Console logging format +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5p [%c{3.}] (%t) %s%e%n +quarkus.log.console.level=INFO +quarkus.log.category."com.redhat.cloudnative".level=DEBUG + +# JSON logging for production +%prod.quarkus.log.console.json=true +%prod.quarkus.log.console.json.pretty-print=false +%prod.quarkus.log.console.json.key-overrides=timestamp=@timestamp +%prod.quarkus.log.console.json.log-format=STRUCTURED + +# Enable access logging +quarkus.http.access-log.enabled=true +%prod.quarkus.http.access-log.pattern=%h %l %u %t "%r" %s %b "%{i,Referer}" "%{i,User-Agent}" + +# =========================================== +# Metrics Configuration (Micrometer/Prometheus) +# =========================================== +# Enable Prometheus metrics endpoint +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.binder-enabled-default=true +quarkus.micrometer.binder.http-server.enabled=true + +# Metrics endpoint path +quarkus.micrometer.export.prometheus.path=/q/metrics + +# =========================================== +# Resilience Configuration (Fault Tolerance) +# =========================================== +# Note: Fault tolerance is enabled automatically when the extension is present +# Configuration is done via annotations in the code: +# - @Timeout, @CircuitBreaker, @Retry annotations diff --git a/src/main/resources/db/migration/V1.0.0__Initial_schema.sql b/src/main/resources/db/migration/V1.0.0__Initial_schema.sql new file mode 100644 index 0000000..bdcb710 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.0__Initial_schema.sql @@ -0,0 +1,32 @@ +-- Initial inventory schema for PostgreSQL +-- Flyway migration script + +CREATE TABLE IF NOT EXISTS INVENTORY ( + id BIGSERIAL PRIMARY KEY, + product_id BIGINT NOT NULL UNIQUE, + quantity INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on product_id for faster lookups +CREATE INDEX IF NOT EXISTS idx_inventory_product_id ON INVENTORY(product_id); + +-- Create index on quantity for low-stock queries +CREATE INDEX IF NOT EXISTS idx_inventory_quantity ON INVENTORY(quantity); + +-- Add constraint for non-negative quantity +ALTER TABLE INVENTORY ADD CONSTRAINT chk_quantity_non_negative CHECK (quantity >= 0); + +-- Insert initial data +INSERT INTO INVENTORY (product_id, quantity, created_at, updated_at) VALUES + (1001, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1002, 35, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1003, 15, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1004, 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1005, 25, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1006, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1007, 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1008, 5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1009, 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); ++++++++ REPLACE \ No newline at end of file diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index faa22c3..b5316e8 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -1,8 +1,9 @@ -INSERT INTO INVENTORY(id, quantity) VALUES (100000, 0); -INSERT INTO INVENTORY(id, quantity) VALUES (329299, 35); -INSERT INTO INVENTORY(id, quantity) VALUES (329199, 12); -INSERT INTO INVENTORY(id, quantity) VALUES (165613, 45); -INSERT INTO INVENTORY(id, quantity) VALUES (165614, 87); -INSERT INTO INVENTORY(id, quantity) VALUES (165954, 43); -INSERT INTO INVENTORY(id, quantity) VALUES (444434, 32); -INSERT INTO INVENTORY(id, quantity) VALUES (444435, 53); +-- Development test data for H2 in-memory database +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (100000, 1001, 0, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (329299, 1002, 35, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (329199, 1003, 12, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165613, 1004, 45, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165614, 1005, 87, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (165954, 1006, 43, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (444434, 1007, 32, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); +INSERT INTO INVENTORY(id, product_id, quantity, created_at, updated_at) VALUES (444435, 1008, 53, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java index 107c0fb..26246a1 100644 --- a/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceTest.java @@ -1,21 +1,372 @@ package com.redhat.cloudnative; import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.*; @QuarkusTest public class InventoryResourceTest { - @Test - public void testHelloEndpoint() { - given() - .when().get("/api/inventory/329299") - .then() - .statusCode(200) - .body(is("{\"id\":329299,\"quantity\":35}")); - } + // ==================== GET /api/inventory Tests (Paginated) + // ==================== -} + @Test + public void testListAllInventoryDefaultPagination() { + given() + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("page", is(0)) + .body("size", is(20)) + .body("hasNext", is(false)) + .body("hasPrevious", is(false)); + } + + @Test + public void testListInventoryWithPagination() { + given() + .queryParam("page", 0) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("data.size()", is(3)) + .body("page", is(0)) + .body("size", is(3)) + .body("hasNext", is(true)) + .body("hasPrevious", is(false)); + } + + @Test + public void testListInventoryWithPaginationSecondPage() { + given() + .queryParam("page", 1) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("data.size()", is(3)) + .body("page", is(1)) + .body("size", is(3)) + .body("hasNext", is(true)) + .body("hasPrevious", is(true)); + } + + @Test + public void testListInventoryLastPage() { + given() + .queryParam("page", 2) + .queryParam("size", 3) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("page", is(2)) + .body("size", is(3)) + .body("hasPrevious", is(true)); + } + + @Test + public void testListInventoryMaxSizeLimit() { + given() + .queryParam("page", 0) + .queryParam("size", 200) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size", is(100)); // Should be limited to 100 + } + + // ==================== GET /api/inventory/all Tests (No Pagination) + // ==================== + + @Test + public void testListAllWithoutPagination() { + given() + .when().get("/api/inventory/all") + .then() + .statusCode(200); + } + + // ==================== GET /api/inventory/count Tests ==================== + + @Test + public void testCountInventory() { + given() + .when().get("/api/inventory/count") + .then() + .statusCode(200); + } + + // ==================== GET /api/inventory/{id} Tests ==================== + + @Test + public void testGetInventoryById() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + @Test + public void testGetInventoryByIdNotFound() { + given() + .when().get("/api/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")) + .body("message", containsString("not found")); + } + + @Test + public void testGetInventoryByIdExistingItem() { + given() + .when().get("/api/inventory/100000") + .then() + .statusCode(200) + .body("id", is(100000)) + .body("quantity", is(0)); + } + + // ==================== POST /api/inventory Tests ==================== + + @Test + public void testCreateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 2001, \"quantity\": 100}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(100)) + .body("productId", is(2001)) + .header("Location", containsString("/api/inventory/")); + } + + @Test + public void testCreateInventoryWithZeroQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 2002, \"quantity\": 0}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(0)) + .body("productId", is(2002)); + } + + @Test + public void testCreateInventoryWithNegativeQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 2003, \"quantity\": -10}") + .when().post("/api/inventory") + .then() + .statusCode(400); + } + + @Test + public void testCreateInventoryWithoutProductId() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 50}") + .when().post("/api/inventory") + .then() + .statusCode(400); + } + + // ==================== PUT /api/inventory/{id} Tests ==================== + + @Test + public void testUpdateInventory() { + // Use a different ID (165614) to avoid interfering with other tests + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 1005, \"quantity\": 999}") + .when().put("/api/inventory/165614") + .then() + .statusCode(200) + .body("id", is(165614)) + .body("quantity", is(999)); + } + + @Test + public void testUpdateInventoryNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 9999, \"quantity\": 100}") + .when().put("/api/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)); + } + + @Test + public void testUpdateInventoryWithNegativeQuantity() { + // Use a different ID (165954) to avoid interfering with other tests + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 1006, \"quantity\": -5}") + .when().put("/api/inventory/165954") + .then() + .statusCode(400); + } + + // ==================== PATCH /api/inventory/{id}/quantity Tests + // ==================== + + @Test + public void testUpdateQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 500}") + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(200) + .body("id", is(329199)) + .body("quantity", is(500)); + } + + @Test + public void testUpdateQuantityMissingParameter() { + given() + .contentType(ContentType.JSON) + .body("{}") + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(400) + .body("status", is(400)) + .body("error", containsString("Validation")); + } + + @Test + public void testUpdateQuantityNegative() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -1}") + .when().patch("/api/inventory/329199/quantity") + .then() + .statusCode(400) + .body("status", is(400)) + .body("message", containsString("negative")); + } + + @Test + public void testUpdateQuantityNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") + .when().patch("/api/inventory/999999/quantity") + .then() + .statusCode(404); + } + + // ==================== DELETE /api/inventory/{id} Tests ==================== + + @Test + public void testDeleteInventory() { + // First create an item to delete + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"productId\": 3001, \"quantity\": 50}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .extract().path("id"); + + // Then delete it + given() + .when().delete("/api/inventory/" + createdId) + .then() + .statusCode(204); + + // Verify it's deleted + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(404); + } + + // ==================== GET /api/inventory/product/{productId} Tests + // ==================== + + @Test + public void testGetInventoryByProductId() { + given() + .when().get("/api/inventory/product/1002") + .then() + .statusCode(200) + .body("productId", is(1002)) + .body("quantity", is(35)); + } + + @Test + public void testGetInventoryByProductIdNotFound() { + given() + .when().get("/api/inventory/product/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")); + } + + @Test + public void testDeleteInventoryNotFound() { + given() + .when().delete("/api/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== Content-Type and Headers Tests ==================== + + @Test + public void testGetInventoryReturnsJson() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + public void testCreateInventoryWithWrongContentType() { + given() + .contentType(ContentType.TEXT) + .body("{\"quantity\": 100}") + .when().post("/api/inventory") + .then() + .statusCode(415); // Unsupported Media Type + } + + // ==================== Health Check Tests ==================== + + @Test + public void testHealthEndpoint() { + given() + .when().get("/q/health") + .then() + .statusCode(200); + } + + @Test + public void testHealthReadyEndpoint() { + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } + + @Test + public void testHealthLiveEndpoint() { + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + } +} \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java b/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java new file mode 100644 index 0000000..b410acc --- /dev/null +++ b/src/test/java/com/redhat/cloudnative/InventoryResourceV1Test.java @@ -0,0 +1,257 @@ +package com.redhat.cloudnative; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.*; + +@QuarkusTest +public class InventoryResourceV1Test { + + // ==================== GET /api/v1/inventory Tests ==================== + + @Test + public void testV1ListAllInventoryDefaultPagination() { + given() + .when().get("/api/v1/inventory") + .then() + .statusCode(200) + .body("page", is(0)) + .body("size", is(20)); + } + + @Test + public void testV1ListInventoryWithPagination() { + given() + .queryParam("page", 0) + .queryParam("size", 3) + .when().get("/api/v1/inventory") + .then() + .statusCode(200) + .body("data.size()", is(3)) + .body("page", is(0)); + } + + @Test + public void testV1ListAllWithoutPagination() { + given() + .when().get("/api/v1/inventory/all") + .then() + .statusCode(200); + } + + @Test + public void testV1CountInventory() { + given() + .when().get("/api/v1/inventory/count") + .then() + .statusCode(200); + } + + // ==================== GET /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1GetInventoryById() { + given() + .when().get("/api/v1/inventory/329299") + .then() + .statusCode(200) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + @Test + public void testV1GetInventoryByIdNotFound() { + given() + .when().get("/api/v1/inventory/999999") + .then() + .statusCode(404) + .body("status", is(404)) + .body("error", is("Not Found")); + } + + // ==================== GET /api/v1/inventory/product/{productId} Tests + // ==================== + + @Test + public void testV1GetInventoryByProductId() { + given() + .when().get("/api/v1/inventory/product/1002") + .then() + .statusCode(200) + .body("productId", is(1002)) + .body("quantity", is(35)); + } + + @Test + public void testV1GetInventoryByProductIdNotFound() { + given() + .when().get("/api/v1/inventory/product/999999") + .then() + .statusCode(404); + } + + // ==================== POST /api/v1/inventory Tests ==================== + + @Test + public void testV1CreateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 5001, \"quantity\": 100}") + .when().post("/api/v1/inventory") + .then() + .statusCode(201) + .body("quantity", is(100)) + .body("productId", is(5001)) + .header("Location", containsString("/api/v1/inventory/")); + } + + @Test + public void testV1CreateInventoryWithNegativeQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 5002, \"quantity\": -10}") + .when().post("/api/v1/inventory") + .then() + .statusCode(400); + } + + // ==================== PUT /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1UpdateInventory() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 1004, \"quantity\": 999}") + .when().put("/api/v1/inventory/165613") + .then() + .statusCode(200) + .body("id", is(165613)) + .body("quantity", is(999)); + } + + @Test + public void testV1UpdateInventoryNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"productId\": 9999, \"quantity\": 100}") + .when().put("/api/v1/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== PATCH /api/v1/inventory/{id}/quantity Tests + // ==================== + + @Test + public void testV1UpdateQuantity() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 600}") + .when().patch("/api/v1/inventory/329199/quantity") + .then() + .statusCode(200) + .body("id", is(329199)) + .body("quantity", is(600)); + } + + @Test + public void testV1UpdateQuantityNotFound() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 100}") + .when().patch("/api/v1/inventory/999999/quantity") + .then() + .statusCode(404); + } + + @Test + public void testV1UpdateQuantityNegative() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -1}") + .when().patch("/api/v1/inventory/329199/quantity") + .then() + .statusCode(400); + } + + // ==================== DELETE /api/v1/inventory/{id} Tests ==================== + + @Test + public void testV1DeleteInventory() { + // First create an item to delete + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"productId\": 6001, \"quantity\": 50}") + .when().post("/api/v1/inventory") + .then() + .statusCode(201) + .extract().path("id"); + + // Then delete it + given() + .when().delete("/api/v1/inventory/" + createdId) + .then() + .statusCode(204); + + // Verify it's deleted + given() + .when().get("/api/v1/inventory/" + createdId) + .then() + .statusCode(404); + } + + @Test + public void testV1DeleteInventoryNotFound() { + given() + .when().delete("/api/v1/inventory/999999") + .then() + .statusCode(404); + } + + // ==================== Metrics Endpoint Tests ==================== + + @Test + public void testMetricsEndpoint() { + given() + .when().get("/q/metrics") + .then() + .statusCode(200); + } + + @Test + public void testMetricsHasInventoryCount() { + // First make a request to trigger metrics + given().when().get("/api/v1/inventory").then().statusCode(200); + + // Check metrics endpoint contains our custom metrics + given() + .when().get("/q/metrics") + .then() + .statusCode(200) + .body(containsString("inventory")); + } + + // ==================== Content-Type Tests ==================== + + @Test + public void testV1GetInventoryReturnsJson() { + given() + .when().get("/api/v1/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + // ==================== Health Check Tests ==================== + + @Test + public void testV1HealthEndpoint() { + given() + .when().get("/q/health") + .then() + .statusCode(200); + } +} \ No newline at end of file diff --git a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java index 0259ad2..0b2aa08 100644 --- a/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java +++ b/src/test/java/com/redhat/cloudnative/NativeInventoryResourceIT.java @@ -1,9 +1,224 @@ package com.redhat.cloudnative; -import io.quarkus.test.junit.NativeImageTest; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; -@NativeImageTest +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.*; + +/** + * Integration tests that run against the native executable or container. + * These tests verify the full application stack works correctly in + * production-like mode. + * + * To run native integration tests: + * 1. Build the native executable: ./mvnw clean package -Pnative + * 2. Run integration tests: ./mvnw verify -Pnative + */ +@QuarkusIntegrationTest public class NativeInventoryResourceIT extends InventoryResourceTest { - // Execute the same tests but in native mode. + // ==================== Native-Specific Integration Tests ==================== + + /** + * Test that the application starts correctly in native mode and can serve + * requests. + */ + @Test + public void testApplicationStartsSuccessfully() { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } + + /** + * Test the complete CRUD workflow in native mode. + * This verifies the full request/response cycle works correctly. + */ + @Test + public void testCompleteCrudWorkflow() { + // CREATE + int createdId = given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 250}") + .when().post("/api/inventory") + .then() + .statusCode(201) + .body("quantity", is(250)) + .extract().path("id"); + + // READ - Verify the created item + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("id", is(createdId)) + .body("quantity", is(250)); + + // UPDATE - Modify the item + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 500}") + .when().put("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // VERIFY UPDATE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(200) + .body("quantity", is(500)); + + // DELETE - Remove the item + given() + .when().delete("/api/inventory/" + createdId) + .then() + .statusCode(204); + + // VERIFY DELETE + given() + .when().get("/api/inventory/" + createdId) + .then() + .statusCode(404); + } + + /** + * Test that JSON serialization works correctly in native mode. + */ + @Test + public void testJsonSerializationInNativeMode() { + given() + .when().get("/api/inventory/329299") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("id", is(329299)) + .body("quantity", is(35)); + } + + /** + * Test that error responses are properly serialized in native mode. + */ + @Test + public void testErrorResponseSerializationInNativeMode() { + given() + .when().get("/api/inventory/999999") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", is(404)) + .body("error", is("Not Found")) + .body("message", notNullValue()) + .body("path", notNullValue()) + .body("timestamp", notNullValue()); + } + + /** + * Test that validation errors are properly handled in native mode. + */ + @Test + public void testValidationErrorResponseInNativeMode() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": -100}") + .when().post("/api/inventory") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", is(400)) + .body("error", is("Bad Request")); + } + + /** + * Test health check endpoints work in native mode. + */ + @Test + public void testHealthChecksInNativeMode() { + // Health endpoint + given() + .when().get("/q/health") + .then() + .statusCode(200) + .body("status", is("UP")); + + // Liveness endpoint + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + + // Readiness endpoint + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } + + /** + * Test pagination works correctly in native mode. + */ + @Test + public void testPaginationInNativeMode() { + // First page + given() + .queryParam("page", 0) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(5)); + + // Second page + given() + .queryParam("page", 1) + .queryParam("size", 5) + .when().get("/api/inventory") + .then() + .statusCode(200) + .body("size()", is(3)); // Only 3 items remaining (8 total - 5 first page) + } + + /** + * Test count endpoint in native mode. + */ + @Test + public void testCountEndpointInNativeMode() { + given() + .when().get("/api/inventory/count") + .then() + .statusCode(200) + .contentType(ContentType.TEXT); + } + + /** + * Test PATCH endpoint for partial updates in native mode. + */ + @Test + public void testPatchUpdateInNativeMode() { + given() + .contentType(ContentType.JSON) + .body("{\"quantity\": 777}") + .when().patch("/api/inventory/444434/quantity") + .then() + .statusCode(200) + .body("id", is(444434)) + .body("quantity", is(777)); + } + + /** + * Test that concurrent requests are handled correctly. + */ + @Test + public void testMultipleSequentialRequestsInNativeMode() { + for (int i = 0; i < 5; i++) { + given() + .when().get("/api/inventory") + .then() + .statusCode(200); + } + } } \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..3d72e05 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,24 @@ +# Test Configuration - extends main application.properties + +# H2 in-memory database for testing +quarkus.datasource.jdbc.url=jdbc:h2:mem:inventory-test;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1 +quarkus.datasource.db-kind=h2 +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.log.sql=false +quarkus.hibernate-orm.sql-load-script=import.sql + +# Disable security completely for tests +quarkus.smallrye-jwt.enabled=false + +# Override all security permissions - allow everything +quarkus.http.auth.permission.permit-all.paths=/* +quarkus.http.auth.permission.permit-all.policy=permit +quarkus.http.auth.permission.permit-all.methods=GET,POST,PUT,PATCH,DELETE + +# Disable Flyway for tests +quarkus.flyway.migrate-at-start=false + +# Reduce logging noise in tests +quarkus.log.console.level=WARN +quarkus.log.category."org.hibernate".level=WARN +quarkus.http.access-log.enabled=false