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