diff --git a/README.md b/README.md index f4cb016..8ca11f4 100644 --- a/README.md +++ b/README.md @@ -1,311 +1,561 @@ -# spring_boot_java_random_user +# Spring Boot Java Random User API -Spring Boot REST API that consumes the public [Random User](https://randomuser.me/) API and persists data in a PostgreSQL database. -Interactive API documentation is available via **Swagger UI / OpenAPI**. +
+ +[![Java](https://img.shields.io/badge/Java-25-blue?logo=java)](https://www.oracle.com/java/) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.3-green?logo=spring)](https://spring.io/projects/spring-boot) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue?logo=postgresql)](https://www.postgresql.org/) +[![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE) + +A modern Spring Boot REST API that consumes the public [Random User](https://randomuser.me/) API and persists data in a PostgreSQL database. Includes interactive API documentation via **Swagger UI / OpenAPI**. + +[Features](#-features) โ€ข [Quick Start](#-quick-start) โ€ข [API Endpoints](#-api-endpoints) โ€ข [Testing](#-testing) โ€ข [Architecture](#-architecture) + +
+ +--- + +## ๐Ÿ“‹ Table of Contents + +1. [Features](#-features) +2. [Tech Stack](#-tech-stack) +3. [Prerequisites](#-prerequisites) +4. [Quick Start](#-quick-start) +5. [Configuration](#-configuration) +6. [Running the Project](#-running-the-project) +7. [API Documentation](#-api-documentation) +8. [Testing](#-testing) +9. [Architecture](#-architecture) +10. [Database Schema](#-database-schema) +11. [Monitoring](#-monitoring) +12. [Quality & CI/CD](#-quality--cicd) +13. [Troubleshooting](#-troubleshooting) +14. [Todo](#-todo) + +--- + +## โœจ Features + +- โœ… **RESTful API** - Fetch and manage random users +- โœ… **PostgreSQL Integration** - Persist data with Spring Data JDBC +- โœ… **OpenAPI/Swagger** - Interactive API documentation at `/api` +- โœ… **Docker Support** - Easy setup with Docker Compose +- โœ… **Health Monitoring** - Spring Actuator endpoints +- โœ… **Code Quality** - SonarQube integration via GitHub Actions +- โœ… **Test Coverage** - JUnit 5 + Mockito + JaCoCo +- โœ… **BDD Testing** - Cucumber support (in progress) +- โœ… **Environment Management** - dotenv-java for secure configuration --- ## ๐Ÿ› ๏ธ Tech Stack -| Technology | Version | -|---|---| -| Java | 25 | -| Spring Boot | 4.0.3 | -| Spring Web MVC | (managed by Boot) | -| Spring Data JDBC | (managed by Boot) | -| Spring Actuator | (managed by Boot) | -| springdoc-openapi | 3.0.1 | -| PostgreSQL | 15 | -| dotenv-java | 5.2.2 | -| Docker / Docker Compose | - | -| Maven | Wrapper included | +| Component | Version | Purpose | +|-----------|---------|---------| +| **Java** | 25 | Language | +| **Spring Boot** | 4.0.3 | Framework | +| **Spring Web MVC** | (auto) | REST API | +| **Spring Data JDBC** | (auto) | Database access | +| **Spring Actuator** | (auto) | Monitoring | +| **PostgreSQL** | 15 | Database | +| **H2** | (test) | In-memory DB for tests | +| **springdoc-openapi** | 3.0.1 | OpenAPI/Swagger | +| **dotenv-java** | 5.2.2 | Environment config | +| **Docker Compose** | - | Local infrastructure | +| **Maven** | Wrapper | Build tool | +| **JUnit 5** | (auto) | Testing framework | +| **Mockito** | (auto) | Mocking | +| **JaCoCo** | 0.8.14 | Code coverage | --- -## ๐Ÿ“‹ Prerequisites +## ๐Ÿ“ฆ Prerequisites -- **Java 25+** -- **Maven** (or use the included `./mvnw` wrapper) -- **Docker Desktop** +- **Java 25+** (or compatible JDK) +- **Maven 3.8+** (included wrapper available) +- **Docker Desktop** (for PostgreSQL) +- **Git** (for version control) --- -## โš™๏ธ Configuration +## ๐Ÿš€ Quick Start + +### 1. Clone the repository -### 1. Environment variables +```bash +git clone https://github.com/XPEHO/spring_boot_java_random_user.git +cd spring_boot_java_random_user +``` -Copy the template and fill in the values: +### 2. Set up environment variables ```bash cp .env.template .env +# Edit .env with your PostgreSQL credentials ``` -`.env` content: +### 3. Start PostgreSQL -```env -POSTGRES_USER={POSTGRES_USER} -POSTGRES_PASSWORD={POSTGRES_PASSWORD} -POSTGRES_DB={POSTGRES_DB} -POSTGRES_PORT={POSTGRES_PORT} +```bash +docker-compose up -d ``` -> โš ๏ธ The `.env` file is git-ignored. Never commit it. - -### 2. Test properties +### 4. Run the application ```bash -cp src/test/resources/application-test.properties.template src/test/resources/application-test.properties +./mvnw spring-boot:run ``` -> โš ๏ธ This file is also git-ignored. -But for credentials and local overrides, use: +### 5. Access the API -``` -src/main/resources/application-local.properties -``` +- **Swagger UI**: http://localhost:8080/api +- **Health Check**: http://localhost:8080/actuator/health -### Example for application.properties +--- -```properties -spring.application.name=spring_boot_java_random_user -springdoc.swagger-ui.path=/api -spring.datasource.url=jdbc:postgresql://localhost:5432/ -spring.datasource.driver-class-name=org.postgresql.Driver +## โš™๏ธ Configuration + +### Environment Setup + +#### .env file (git-ignored) + +```bash +cp .env.template .env ``` -### Local credentials & security +**Content:** +```env +POSTGRES_USER=your_user +POSTGRES_PASSWORD=your_password +POSTGRES_DB=your_database +POSTGRES_PORT=5432 +``` -To avoid exposing database credentials in source code, create a `.env` file at the project root. +#### Local Profile (application-local.properties) -Then, in `src/main/resources/application-local.properties`: +For local development with different credentials: ```properties spring.datasource.username=${DB_USER} spring.datasource.password=${DB_PASSWORD} ``` -Both `.env` and `application-local.properties` are in `.gitignore` (already set). +Set `SPRING_PROFILES_ACTIVE=local` in your IDE run configuration. -To activate the local profile in IntelliJ or VS Code, add this environment variable to your run configuration: +#### Test Profile (application-test.properties) -``` -SPRING_PROFILES_ACTIVE=local -``` +H2 in-memory database for unit tests (auto-configured, no setup needed). -This way, each developer can use their own credentials without risk of leaking them to GitHub. +### Default Configuration +**application.properties** includes: -> โš ๏ธ **Common startup error**: -> ``` -> Failed to configure a DataSource: 'url' attribute is not specified -> and no embedded datasource could be configured. -> Reason: Failed to determine a suitable driver class -> ``` -> This error occurs because the PostgreSQL driver is present in the dependencies but the datasource URL is not configured. -> **Fix**: either add the `spring.datasource.*` properties above, or exclude the DataSource auto-configuration if no database is needed: -> ```properties -> spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration -> ``` +```properties +spring.application.name=spring_boot_java_random_user +spring.datasource.url=jdbc:postgresql://localhost:${POSTGRES_PORT}/${POSTGRES_DB} +spring.datasource.driver-class-name=org.postgresql.Driver +springdoc.swagger-ui.path=/api +randomuser.api.base-url=https://dummyjson.com/ +``` --- -## ๐Ÿš€ Running the project - -### Start the database - -```bash -docker-compose up -d -``` +## ๐Ÿƒ Running the Project -### With the Maven Wrapper (recommended) +### Development Mode ```bash +# Using Maven wrapper (recommended) ./mvnw spring-boot:run -``` - -### With Maven installed -```bash +# Or with Maven installed mvn spring-boot:run ``` -### Build and run the JAR +### Production Mode ```bash +# Build JAR ./mvnw clean package + +# Run JAR java -jar target/spring_boot_java_random_user-0.0.1-SNAPSHOT.jar ``` -The application will be available at: http://localhost:8080 +### Docker Mode + +```bash +# Build Docker image +docker build -t xpeho/spring-boot-random-user . + +# Run container +docker run -p 8080:8080 --env-file .env xpeho/spring-boot-random-user +``` + +Application will be available at: **http://localhost:8080** --- -## ๐Ÿ“– API Documentation (Swagger UI) +## ๐Ÿ“– API Documentation + +### Interactive Swagger UI ``` http://localhost:8080/api ``` -Raw OpenAPI specification (JSON): +### OpenAPI JSON Specification ``` http://localhost:8080/v3/api-docs ``` -### Available endpoints +### Available Endpoints -#### Generate and persist random users +#### ๐Ÿ”ต GET /random-users + +Fetch and persist random users from the external API. ```http GET /random-users?count=500 ``` -#### Update an existing user +**Parameters:** +- `count` (optional): Number of users to fetch (default: 500, max: 5000) + +**Response:** `200 OK` +```json +[ + { + "id": 1, + "gender": "male", + "firstname": "John", + "lastname": "Doe", + "civility": "Mr", + "email": "john.doe@example.com", + "phone": "+1 234 567 8900", + "picture": "https://example.com/pic.jpg", + "nat": "US" + } +] +``` + +#### ๐ŸŸข GET /random-users/{id} + +Retrieve a specific user by ID. ```http -PUT /random-users/{id} -Content-Type: application/json +GET /random-users/1 ``` -Example body: +**Response:** `200 OK` or `404 Not Found` +#### ๐ŸŸก PUT /random-users/{id} + +Update an existing user's information. + +```http +PUT /random-users/1 +Content-Type: application/json +``` + +**Request Body:** ```json { "gender": "female", - "firstname": "Albert", - "lastname": "Bing", - "civility": "Mrs", - "email": "albert.bing@example.com", - "phone": "123456789", + "firstname": "Jane", + "lastname": "Smith", + "civility": "Ms", + "email": "jane.smith@example.com", + "phone": "+1 987 654 3210", "picture": "pic.jpg", "nat": "FR" } ``` -Responses: -- `200` if the user was updated successfully -- `404` if the user id does not exist +**Responses:** +- `200 OK` - User updated successfully +- `404 Not Found` - User not found --- -## ๐Ÿ” Monitoring (Actuator) +## ๐Ÿงช Testing + +### Run All Tests +```bash +./mvnw verify ``` -http://localhost:8080/actuator -http://localhost:8080/actuator/health + +Test execution lifecycle: +1. **pre-integration-test** โ†’ Docker Compose starts PostgreSQL +2. **integration-test** โ†’ Unit and integration tests run +3. **post-integration-test** โ†’ Docker Compose stops + +### Run Specific Test Suite + +```bash +# Unit tests only +./mvnw test + +# Integration tests only +./mvnw failsafe:integration-test + +# Tests with specific tag (Cucumber) +./mvnw test -Dcucumber.filter.tags="@smoke" +``` + +### Test Coverage Report + +```bash +./mvnw clean verify ``` +Generated reports: +- **HTML Report**: `target/site/jacoco/index.html` +- **XML Report**: `target/site/jacoco/jacoco.xml` (used by SonarQube) + +### Test Framework Details + +- **Framework**: JUnit 5 +- **Mocking**: Mockito +- **BDD**: Cucumber (planned) +- **Coverage**: JaCoCo + --- -## ๐Ÿงช Tests +## ๐Ÿ—๏ธ Architecture -The Maven plugin starts and stops Docker Compose automatically during tests: +### Project Structure -```bash -./mvnw verify +``` +spring_boot_java_random_user/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ main/ +โ”‚ โ”‚ โ”œโ”€โ”€ java/com/xpeho/spring_boot_java_random_user/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ SpringBootJavaRandomUserApplication.java โ† Entry point +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config/ โ† Spring configuration +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ data/ โ† Data layer +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ converters/ โ† DTO/Entity conversion +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ models/ โ† Data models +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ services/ โ† Data services +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ sources/ โ† API & DB sources +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ domain/ โ† Business logic +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ entities/ โ† Domain entities +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ exceptions/ โ† Custom exceptions +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ services/ โ† Business services +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ usecases/ โ† Use cases +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ presentation/ โ† API layer +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controllers/ โ† REST controllers +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ handlers/ โ† Exception handlers +โ”‚ โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ”‚ โ”œโ”€โ”€ application.properties โ† Main config +โ”‚ โ”‚ โ”œโ”€โ”€ application-local.properties โ† Local overrides +โ”‚ โ”‚ โ”œโ”€โ”€ schema.sql โ† DB schema +โ”‚ โ”‚ โ””โ”€โ”€ static/ โ† Static assets +โ”‚ โ””โ”€โ”€ test/ +โ”‚ โ”œโ”€โ”€ java/ โ† Test code +โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ”œโ”€โ”€ application-test.properties โ† Test config +โ”‚ โ””โ”€โ”€ features/ โ† Cucumber features +โ”œโ”€โ”€ .github/ +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ”œโ”€โ”€ sonar.yaml โ† SonarQube CI/CD +โ”‚ โ””โ”€โ”€ ISSUE_TEMPLATE/ โ† GitHub templates +โ”œโ”€โ”€ docker-compose.yml โ† Local infrastructure +โ”œโ”€โ”€ pom.xml โ† Maven config +โ”œโ”€โ”€ .env.template โ† Env variables template +โ”œโ”€โ”€ .gitignore โ† Git ignore rules +โ””โ”€โ”€ README.md โ† This file ``` -Execution cycle: -1. `pre-integration-test` โ†’ `docker-compose up` (PostgreSQL starts) -2. `integration-test` โ†’ tests run -3. `post-integration-test` โ†’ `docker-compose down` (PostgreSQL stops) +### Layered Architecture -> **Prerequisite**: Docker must be installed and running. +``` +Presentation Layer (Controllers) + โ†“ +Domain Layer (Use Cases, Services) + โ†“ +Data Layer (Repositories, Converters) + โ†“ +Database (PostgreSQL) +``` --- -## ๐Ÿ”Ž Code Quality (SonarQube) +## ๐Ÿ—„๏ธ Database Schema + +### Users Table + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | SERIAL | PRIMARY KEY | Auto-incremented identifier | +| `gender` | VARCHAR(20) | - | Gender | +| `firstname` | VARCHAR(100) | - | First name | +| `lastname` | VARCHAR(100) | - | Last name | +| `civility` | VARCHAR(20) | - | Title (Mr, Ms, Mrs, etc.) | +| `email` | VARCHAR(255) | - | Email address | +| `phone` | VARCHAR(50) | - | Phone number | +| `picture` | VARCHAR(500) | - | Avatar/picture URL | +| `nat` | VARCHAR(10) | - | Nationality code | + +### DDL + +Auto-generated on startup via `schema.sql`: + +```sql +DROP TABLE IF EXISTS "users"; + +CREATE TABLE IF NOT EXISTS "users" ( + id SERIAL PRIMARY KEY, + gender VARCHAR(20), + firstname VARCHAR(100), + lastname VARCHAR(100), + civility VARCHAR(20), + email VARCHAR(255), + phone VARCHAR(50), + picture VARCHAR(500), + nationality VARCHAR(10) +); +``` -A GitHub Actions workflow is configured in `.github/workflows/sonar.yaml`. +--- -### Workflow triggers +## ๐Ÿ“Š Monitoring -- `push` on all branches -- `pull_request` +### Health Check -### What it runs +``` +http://localhost:8080/actuator/health +``` -- Java 25 setup (Temurin) -- Maven build + tests + SonarQube analysis: +### Actuator Endpoints -```bash -./mvnw clean verify sonar:sonar -Dsonar.qualitygate.wait=true ``` +http://localhost:8080/actuator +http://localhost:8080/actuator/health +http://localhost:8080/actuator/metrics +http://localhost:8080/actuator/beans +``` + +### Metrics -### Required GitHub Secrets +- JVM metrics +- HTTP metrics +- Database connection pool metrics -| Secret | Description | -| `APPLICATION_TEST_PROPERTIES` (Base64-encoded `application-test.properties` content) -| `SONAR_TOKEN` | SonarQube authentication token | -| `SONAR_HOST_URL` | SonarQube instance URL | -| `POSTGRES_USER` | PostgreSQL user | -| `POSTGRES_PASSWORD` | PostgreSQL password | -| `POSTGRES_DB` | Database name | -| `POSTGRES_PORT` | PostgreSQL port | +--- -> `GITHUB_TOKEN` is provided automatically by GitHub Actions. +## ๐Ÿ” Quality & CI/CD -### JaCoCo coverage report (local) +### Local SonarQube Analysis ```bash -./mvnw clean verify +./mvnw clean verify sonar:sonar ``` -Generated reports: -- HTML: `target/site/jacoco/index.html` -- XML (used by SonarQube): `target/site/jacoco/jacoco.xml` +### GitHub Actions Workflows + +#### ๐ŸŸฆ SonarQube Analysis (.github/workflows/sonar.yaml) + +**Triggers:** +- Push to any branch +- Pull requests + +**What it does:** +1. Sets up Java 25 +2. Runs Maven tests with code coverage +3. Uploads results to SonarQube +4. Waits for quality gate + +**Required Secrets:** +- `SONAR_TOKEN` - SonarQube authentication +- `SONAR_HOST_URL` - SonarQube instance URL +- `POSTGRES_USER` - DB user +- `POSTGRES_PASSWORD` - DB password +- `POSTGRES_DB` - DB name +- `POSTGRES_PORT` - DB port --- -## ๐Ÿ“ Project structure +## โŒ Troubleshooting + +### DataSource Configuration Error +**Error:** ``` -src/ -โ”œโ”€โ”€ main/ -โ”‚ โ”œโ”€โ”€ java/com/xpeho/spring_boot_java_random_user/ -โ”‚ โ”‚ โ””โ”€โ”€ SpringBootJavaRandomUserApplication.java โ† Entry point, loads .env -โ”‚ โ””โ”€โ”€ resources/ -โ”‚ โ”œโ”€โ”€ application.properties โ† Spring configuration -โ”‚ โ””โ”€โ”€ schema.sql โ† Database schema -โ””โ”€โ”€ test/ - โ”œโ”€โ”€ java/com/xpeho/spring_boot_java_random_user/ - โ”‚ โ””โ”€โ”€ SpringBootJavaRandomUserApplicationTests.java - โ””โ”€โ”€ resources/ - โ”œโ”€โ”€ application-test.properties โ† Test config (git-ignored) - โ””โ”€โ”€ application-test.properties.template โ† Template to copy -.env โ† Environment variables (git-ignored) -.env.template โ† Template to copy -docker-compose.yml โ† Local PostgreSQL +Failed to configure a DataSource: 'url' attribute is not specified +and no embedded datasource could be configured. ``` ---- +**Solution:** +Ensure `spring.datasource.url` is set in `application.properties` or environment variables. + +### PostgreSQL Connection Failed + +**Error:** +``` +org.postgresql.util.PSQLException: Connection to localhost:5432 refused +``` + +**Solution:** +```bash +# Check if Docker is running +docker ps + +# Start PostgreSQL +docker-compose up -d + +# Verify connection +psql -U postgres -h localhost +``` + +### Tests Fail with H2 + +**Error:** +``` +Circular placeholder reference in application-test.properties +``` -## ๐Ÿ—„๏ธ Database +**Solution:** +Use the corrected `application-test.properties` with H2 configuration: +```properties +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driver-class-name=org.h2.Driver +``` -### Schema +### Java Version Not Supported -The `user` table is created automatically on startup via `schema.sql`: +**Error:** +``` +error: release version 25 not supported +``` -| Column | Type | Description | -|---|---|---| -| `id` | SERIAL PK | Auto-incremented identifier | -| `gender` | VARCHAR(20) | Gender | -| `firstname` | VARCHAR(100) | First name (`name.first`) | -| `lastname` | VARCHAR(100) | Last name (`name.last`) | -| `civility` | VARCHAR(20) | Title (`name.title`) | -| `email` | VARCHAR(255) | Email address | -| `phone` | VARCHAR(50) | Phone number | -| `picture` | VARCHAR(500) | Medium picture URL | -| `nationality` | VARCHAR(10) | Nationality | +**Solution:** +- Install Java 25 or later +- Update Maven compiler plugin version in `pom.xml` --- ## ๐ŸŒ External API -This project consumes the public **Random User Generator** API: +This project integrates with **Random User Generator API**: -- **URL**: [https://randomuser.me/api/](https://randomuser.me/api/) -- **Documentation**: [https://randomuser.me/documentation](https://randomuser.me/documentation) +- **URL**: https://randomuser.me/api/ +- **Documentation**: https://randomuser.me/documentation +- **Features**: + - Generate random users + - Filter by nationality + - Customize response fields + +--- + +## ๐Ÿ“ˆ Future Enhancements + +- [ ] [Delete endpoint - DELETE /random-users/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/10) +- [ ] [Create endpoint - POST /random-users](https://github.com/XPEHO/spring_boot_java_random_user/issues/11) +- [ ] [Liquibase database migrations](setup-docs/liquibase-guide.md) +- [ ] [Cucumber BDD testing suite](setup-docs/cucumber-guide-english.md) --- @@ -313,14 +563,53 @@ This project consumes the public **Random User Generator** API: - [x] [Add Sonarqube in the project](https://github.com/XPEHO/spring_boot_java_random_user/issues/2) - [x] [Add PostgreSQL database with docker](https://github.com/XPEHO/spring_boot_java_random_user/issues/6) -- [X] [Add this endpoint get /user/random](https://github.com/XPEHO/spring_boot_java_random_user/issues/5) -- [ ] [Add this endpoint get /user/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/8) -- [X] [Add this endpoint put /user/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/9) +- [x] [Add this endpoint get /user/random](https://github.com/XPEHO/spring_boot_java_random_user/issues/5) +- [x] [Add this endpoint get /user/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/8) +- [x] [Add this endpoint put /user/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/9) - [ ] [Add this endpoint delete /user/{id}](https://github.com/XPEHO/spring_boot_java_random_user/issues/10) - [ ] [Add this endpoint post /user](https://github.com/XPEHO/spring_boot_java_random_user/issues/11) --- +## ๐Ÿ‘ฅ Contributing + +Contributions are welcome! Please follow these steps: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit changes (`git commit -m 'Add amazing feature'`) +4. Push to branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +--- + +## ๐Ÿ“ License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +--- + ## ๐Ÿ‘ค Author -Project developed by **XPEHO**. +**XPEHO** - [GitHub Organization](https://github.com/XPEHO) + +--- + +## ๐Ÿ”— Useful Links + +- [Spring Boot Documentation](https://spring.io/projects/spring-boot) +- [Random User API Docs](https://randomuser.me/documentation) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) +- [Docker Documentation](https://docs.docker.com/) +- [SonarQube Documentation](https://docs.sonarqube.org/) + +--- + +
+ +**Made with โค๏ธ by XPEHO** + +โญ If you found this helpful, please consider starring the repository! + +
+ diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCase.java b/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCase.java new file mode 100644 index 0000000..4f92988 --- /dev/null +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCase.java @@ -0,0 +1,20 @@ +package com.xpeho.spring_boot_java_random_user.domain.usecases; + +import com.xpeho.spring_boot_java_random_user.domain.entities.UserEntity; +import com.xpeho.spring_boot_java_random_user.domain.exceptions.UserNotFoundException; +import com.xpeho.spring_boot_java_random_user.domain.services.UserService; +import org.springframework.stereotype.Service; + +@Service +public class GetUserByIdUseCase { + private final UserService userService; + + public GetUserByIdUseCase(UserService userService) { + this.userService = userService; + } + + public UserEntity execute(long id) { + return userService.getById(id) + .orElseThrow(() -> new UserNotFoundException(id)); + } +} diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/UpdateRandomUserUseCase.java b/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/UpdateRandomUserUseCase.java index 26a73f5..df84352 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/UpdateRandomUserUseCase.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/domain/usecases/UpdateRandomUserUseCase.java @@ -16,18 +16,18 @@ public UpdateRandomUserUseCase(UserService userService) { public UserEntity execute(int id, UserRequest user) { UserEntity existingUser = userService.getById(id) - .orElseThrow(() -> new UserNotFoundException(id)); + .orElseThrow(() -> new UserNotFoundException(id)); UserEntity updatedUser = new UserEntity( - existingUser.id(), - user.gender(), - user.firstname(), - user.lastname(), - user.civility(), - user.email(), - user.phone(), - user.picture(), - user.nat() + existingUser.id(), + user.gender(), + user.firstname(), + user.lastname(), + user.civility(), + user.email(), + user.phone(), + user.picture(), + user.nat() ); return userService.save(updatedUser); diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/controllers/UserController.java b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/controllers/UserController.java index 622f434..c8ad32a 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/controllers/UserController.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/controllers/UserController.java @@ -41,6 +41,23 @@ ResponseEntity> getRandomUsers( @Max(5000) int count ); + + @GetMapping("/{id}") + @Operation( + summary = "Get user by id", + description = "Given a user by id, return the user if it exists in the database", + parameters = { + @Parameter(name = "id", description = "id of the requested user") + } + ) + @ApiResponse(responseCode = "200", description = "User successfully found and returned") + @ApiResponse(responseCode = "404", description = "The requested user does not exist") + @ApiResponse(responseCode = "500", description = "Internal server error") + ResponseEntity getUserById( + @PathVariable + int id + ); + @PutMapping("/{id}") @Operation( summary = "Modify a random user", diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java index 49a6203..6bb260f 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java @@ -4,6 +4,7 @@ import com.xpeho.spring_boot_java_random_user.domain.entities.UserRequest; import com.xpeho.spring_boot_java_random_user.domain.exceptions.UserNotFoundException; import com.xpeho.spring_boot_java_random_user.domain.usecases.FetchAndSaveRandomUsersUseCase; +import com.xpeho.spring_boot_java_random_user.domain.usecases.GetUserByIdUseCase; import com.xpeho.spring_boot_java_random_user.domain.usecases.UpdateRandomUserUseCase; import com.xpeho.spring_boot_java_random_user.presentation.controllers.UserController; import org.slf4j.Logger; @@ -24,13 +25,16 @@ public class UserHandler implements UserController { private final FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase; private final UpdateRandomUserUseCase updateRandomUserUseCase; + private final GetUserByIdUseCase getUserByIdUseCase; public UserHandler( FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase, - UpdateRandomUserUseCase updateRandomUserUseCase + UpdateRandomUserUseCase updateRandomUserUseCase, + GetUserByIdUseCase getUserByIdUseCase ) { this.fetchAndSaveRandomUsersUseCase = fetchAndSaveRandomUsersUseCase; this.updateRandomUserUseCase = updateRandomUserUseCase; + this.getUserByIdUseCase = getUserByIdUseCase; } @Override @@ -55,4 +59,15 @@ public ResponseEntity updateRandomUser(int id, UserRequest user) { } } + @Override + public ResponseEntity getUserById(int id) { + try { + UserEntity user = getUserByIdUseCase.execute(id); + return ResponseEntity.ok(user); + } catch (UserNotFoundException e) { + logger.warn("warning: the requested user does not exist : {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } + } diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCaseTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCaseTest.java new file mode 100644 index 0000000..f22956c --- /dev/null +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/domain/usecases/GetUserByIdUseCaseTest.java @@ -0,0 +1,73 @@ +package com.xpeho.spring_boot_java_random_user.domain.usecases; + +import com.xpeho.spring_boot_java_random_user.domain.entities.UserEntity; +import com.xpeho.spring_boot_java_random_user.domain.exceptions.UserNotFoundException; +import com.xpeho.spring_boot_java_random_user.domain.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GetUserByIdUseCaseTest { + private UserService userService; + private GetUserByIdUseCase useCase; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + useCase = new GetUserByIdUseCase(userService); + } + + @Test + @DisplayName("Should return user when user exists") + void shouldReturnUserWhenFound() { + UserEntity expected = new UserEntity(1L, "male", "John", "Doe", "Mr", "john@example.com", "0600000000", "http://pic.jpg", "FR"); + when(userService.getById(1L)).thenReturn(Optional.of(expected)); + + UserEntity result = useCase.execute(1L); + + assertEquals(expected, result); + verify(userService).getById(1L); + } + + @Test + @DisplayName("Should throw UserNotFoundException when user does not exist") + void shouldThrowUserNotFoundExceptionWhenUserDoesNotExist() { + when(userService.getById(99L)).thenReturn(Optional.empty()); + + UserNotFoundException exception = assertThrows( + UserNotFoundException.class, + () -> useCase.execute(99L) + ); + + assertTrue(exception.getMessage().contains("99")); + verify(userService).getById(99L); + } + + @Test + @DisplayName("Should call userService exactly once with the given id") + void shouldCallUserServiceOnce() { + UserEntity user = new UserEntity(5L, "female", "Alice", "Smith", "Ms", "alice@example.com", "0611111111", "http://pic2.jpg", "US"); + when(userService.getById(5L)).thenReturn(Optional.of(user)); + + useCase.execute(5L); + + verify(userService, times(1)).getById(5L); + verifyNoMoreInteractions(userService); + } + + @Test + @DisplayName("Should not call userService with a different id") + void shouldNotCallUserServiceWithDifferentId() { + UserEntity user = new UserEntity(3L, "male", "Bob", "Brown", "Mr", "bob@example.com", "0622222222", "http://pic3.jpg", "DE"); + when(userService.getById(3L)).thenReturn(Optional.of(user)); + + useCase.execute(3L); + + verify(userService, never()).getById(42L); + } +} diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserHandlerTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserHandlerTest.java new file mode 100644 index 0000000..8debcad --- /dev/null +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserHandlerTest.java @@ -0,0 +1,120 @@ +package com.xpeho.spring_boot_java_random_user.presentation; + +import com.xpeho.spring_boot_java_random_user.domain.entities.UserEntity; +import com.xpeho.spring_boot_java_random_user.domain.entities.UserRequest; +import com.xpeho.spring_boot_java_random_user.domain.exceptions.UserNotFoundException; +import com.xpeho.spring_boot_java_random_user.domain.usecases.FetchAndSaveRandomUsersUseCase; +import com.xpeho.spring_boot_java_random_user.domain.usecases.GetUserByIdUseCase; +import com.xpeho.spring_boot_java_random_user.domain.usecases.UpdateRandomUserUseCase; +import com.xpeho.spring_boot_java_random_user.presentation.handlers.UserHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class UserHandlerTest { + + private FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase; + private UpdateRandomUserUseCase updateRandomUserUseCase; + private GetUserByIdUseCase getUserByIdUseCase; + private UserHandler userHandler; + + @BeforeEach + void setUp() { + fetchAndSaveRandomUsersUseCase = mock(FetchAndSaveRandomUsersUseCase.class); + updateRandomUserUseCase = mock(UpdateRandomUserUseCase.class); + getUserByIdUseCase = mock(GetUserByIdUseCase.class); + userHandler = new UserHandler(fetchAndSaveRandomUsersUseCase, updateRandomUserUseCase, getUserByIdUseCase); + } + + @Test + @DisplayName("Should return 200 and users when getRandomUsers succeeds") + void shouldReturnOkWhenGetRandomUsersSucceeds() throws IOException { + List users = List.of( + new UserEntity(1L, "male", "John", "Doe", "Mr", "john@example.com", "0600000000", "pic.jpg", "FR") + ); + when(fetchAndSaveRandomUsersUseCase.execute(2)).thenReturn(users); + + ResponseEntity> response = userHandler.getRandomUsers(2); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(users, response.getBody()); + verify(fetchAndSaveRandomUsersUseCase, times(1)).execute(2); + } + + @Test + @DisplayName("Should return 500 and empty list when getRandomUsers throws IOException") + void shouldReturnInternalServerErrorWhenGetRandomUsersFails() throws IOException { + when(fetchAndSaveRandomUsersUseCase.execute(5)).thenThrow(new IOException("downstream unavailable")); + + ResponseEntity> response = userHandler.getRandomUsers(5); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertTrue(response.getBody() != null && response.getBody().isEmpty()); + verify(fetchAndSaveRandomUsersUseCase, times(1)).execute(5); + } + + @Test + @DisplayName("Should return 200 and user when getUserById succeeds") + void shouldReturnOkWhenGetUserByIdSucceeds() { + UserEntity user = new UserEntity(42L, "female", "Alice", "Smith", "Ms", "alice@example.com", "0611111111", "alice.jpg", "US"); + when(getUserByIdUseCase.execute(42)).thenReturn(user); + + ResponseEntity response = userHandler.getUserById(42); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(user, response.getBody()); + verify(getUserByIdUseCase, times(1)).execute(42); + } + + @Test + @DisplayName("Should return 404 when getUserById throws UserNotFoundException") + void shouldReturnNotFoundWhenGetUserByIdFails() { + when(getUserByIdUseCase.execute(99)).thenThrow(new UserNotFoundException(99)); + + ResponseEntity response = userHandler.getUserById(99); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertNull(response.getBody()); + verify(getUserByIdUseCase, times(1)).execute(99); + } + + @Test + @DisplayName("Should return 200 and updated user when updateRandomUser succeeds") + void shouldReturnOkWhenUpdateRandomUserSucceeds() { + UserRequest request = new UserRequest("female", "Jane", "Doe", "Ms", "jane@example.com", "0622222222", "jane.jpg", "FR"); + UserEntity updated = new UserEntity(7L, "female", "Jane", "Doe", "Ms", "jane@example.com", "0622222222", "jane.jpg", "FR"); + when(updateRandomUserUseCase.execute(7, request)).thenReturn(updated); + + ResponseEntity response = userHandler.updateRandomUser(7, request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(updated, response.getBody()); + verify(updateRandomUserUseCase, times(1)).execute(7, request); + } + + @Test + @DisplayName("Should return 404 when updateRandomUser throws UserNotFoundException") + void shouldReturnNotFoundWhenUpdateRandomUserFails() { + UserRequest request = new UserRequest("male", "Bob", "Brown", "Mr", "bob@example.com", "0633333333", "bob.jpg", "DE"); + when(updateRandomUserUseCase.execute(123, request)).thenThrow(new UserNotFoundException(123)); + + ResponseEntity response = userHandler.updateRandomUser(123, request); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertNull(response.getBody()); + verify(updateRandomUserUseCase, times(1)).execute(123, request); + } +} diff --git a/src/test/resources/application-test.properties.template b/src/test/resources/application-test.properties.template index 7b598cc..5b6f5b1 100644 --- a/src/test/resources/application-test.properties.template +++ b/src/test/resources/application-test.properties.template @@ -1,6 +1,6 @@ POSTGRES_USER=your_postgres_user POSTGRES_PASSWORD=your_postgres_password POSTGRES_DB=your_postgres_db -POSTGRES_PORT=5433 +POSTGRES_PORT=your_postgres_port