diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index e63c9cd..da988aa 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/README.md b/README.md
index b5cab37..c18869a 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,10 @@ A library management system built with a layered architecture, focusing on data
* [Key Features](#key-features)
* [Testing](#testing)
* [Setup Instructions](#setup-instructions)
+ * [Configuration](#configuration)
* [Option 1: Quick Run (Docker only)](#option-1-quick-run-docker-only)
* [Option 2: Development Mode (Hybrid)](#option-2-development-mode-hybrid)
+ * [Access API Documentation](#access-api-documentation)
* [Cleaning up](#cleaning-up)
* [Roadmap](#roadmap)
@@ -79,6 +81,7 @@ erDiagram
LocalDateTime returnDate
}
```
+> **Note:** Entities `Author`, `Book`, `Loan`, `Reader`, and `User` track creation and modification timestamps (`createdAt`, `modifiedAt`) via JPA Auditing. These fields are omitted from the diagram for clarity.
## Key Features
- **Authentication & Authorization**: REST API secured with JSON Web Tokens (JWT). Role-based access control (RBAC) separating regular users and administrators.
@@ -86,9 +89,9 @@ erDiagram
- **JPA Auditing**: Automated tracking of creation and modification timestamps for every resource using `@CreatedDate` and `@LastModifiedDate`.
- **Global Exception Handling**: Centralized error management using `@RestControllerAdvice` to ensure consistent JSON error responses across the API.
- **Validation**: Input data is strictly validated using Hibernate Validator annotations (e.g., `@NotBlank`, `@Size`) to maintain data quality.
-- **Clean Architecture**: Strict separation between database entities and API response models using the DTO (Data Transfer Object) pattern.
+- **API-Entity Decoupling**: Strict separation between database entities and API response models (DTOs) to ensure data security and interface stability.
- **Database Versioning**: Full schema control and versioning using Liquibase.
-- **Optimized Persistence**: Utilization of JPA EntityGraphs to eliminate N+1 query problems during profile aggregation to improve performance by reducing database round-trips..
+- **Optimized Persistence**: Utilization of JPA EntityGraphs to eliminate N+1 query problems during data retrieval, improving performance by reducing database round-trips.
- **Externalized Configuration**: Business rules (loan limits, duration) are managed via YAML profiles.
## Testing
@@ -96,13 +99,33 @@ The project maintains a high standard of quality through different testing layer
- **Unit Tests**: Focused on business logic within the Service layer, utilizing Mockito for dependency isolation.
- **Web Layer Tests**: Utilizing MockMvc to verify REST endpoints, HTTP status codes, JSON serialization, and validation logic without starting the full server.
- **Persistence Tests**: `@DataJpaTest` used to verify complex JPQL queries and relationship mapping.
+- **Code Coverage**: Automated code coverage analysis using **JaCoCo**, with reports generated for every build.
- **CI/CD Integration**: Automated test execution via GitHub Actions on every push.
## Setup Instructions
-> [!NOTE]
-> The application uses `SPRING_PROFILES_ACTIVE` environment variable to determine the active profile.
-> Default is `prod`. To verify setup with sample data, use `dev` profile.
+### Configuration
+The application is configured using environment variables and `application.yaml` properties.
+
+**Infrastructure & Security**
+
+| Variable / Property | Description | Default |
+|-----------------------------|----------------------------------------|--------------------------------|
+| `BD_INITIAL_ADMIN_EMAIL` | Email for the initial admin account | `root@biblionode.com` |
+| `BD_INITIAL_ADMIN_PASSWORD` | Password for the initial admin account | `root1234` |
+| `RSA_PUBLIC_KEY` | Path to RSA public key for JWT | `file:./certs/public_key.pem` |
+| `RSA_PRIVATE_KEY` | Path to RSA private key for JWT | `file:./certs/private_key.pem` |
+| `SPRING_PROFILES_ACTIVE` | Active Spring profile (`dev`, `prod`) | `prod` |
+
+**Business Rules**
+Can be adjusted in `application.yaml` or overridden via environment variables (e.g. `app.loan.max-active-loans` -> `APP_LOAN_MAX_ACTIVE_LOANS`).
+
+| Property Key | Default | Description |
+|-------------------------------------|---------|---------------------------------|
+| `app.loan.max-active-loans` | `5` | Maximum active loans per reader |
+| `app.loan.default-loan-days` | `14` | Loan duration in days |
+| `app.security.jwt-expiration-hours` | `1` | JWT token validity (hours) |
+| `app.pagination.default-page-size` | `20` | Default page size for lists |
### Option 1: Quick Run (Docker only)
Best for quick preview. No Java/Gradle installation required.
@@ -112,33 +135,22 @@ Best for quick preview. No Java/Gradle installation required.
git clone https://github.com/mgrablo/BiblioNode.git
cd BiblioNode
```
-2. **Setup database & Run App**:
- You can specify the profile inline:
+2. **Start the application**:
```bash
- SPRING_PROFILES_ACTIVE=dev docker-compose up -d
+ # Run with default 'prod' profile
+ docker-compose up -d
```
- Or just run with default `prod` profile:
+ *Alternatively, force the 'dev' profile:*
```bash
- docker-compose up -d
+ SPRING_PROFILES_ACTIVE=dev docker-compose up -d
```
- **Default Admin Credentials:**
- - Email: `root@biblionode.com`
- - Password: `root1234`
-
- *(You can change these via `BD_INITIAL_ADMIN_EMAIL` and `BD_INITIAL_ADMIN_PASSWORD` environment variables or update them later via API)*
-
+ **Default Credentials**: See [Configuration](#configuration).
> [!TIP]
- > You can also create a `.env` file in the root directory to set these variables.
- > Check `.env.example` for reference.
-
-3. **Access API documentation**:
-
- Once the server is running, navigate to:
- `http://localhost:8080/swagger-ui/index.html`
+ > You can also create a `.env` file in the root directory to set variables. Check `.env.example`.
### Option 2: Development Mode (Hybrid)
-Best for making changes to the code with fast feedback
+Best for making changes to the code with fast feedback.
1. **Start only database**:
```bash
@@ -146,10 +158,8 @@ Best for making changes to the code with fast feedback
```
2. **Start the app locally**:
> [!NOTE]
- > Make sure you have JDK 21 and Gradle installed locally to run the app in development mode. The app will connect to the database running in Docker.
+ > Requires JDK 21 and Gradle locally. The app will connect to the database running in Docker. Keys are generated automatically in `certs/` on first run (`dev` profile).
- - The app will automatically generate RSA keys in `certs/` folder on first run when the `dev` profile is active.
- - Run via IDE or:
```bash
# Linux/Mac
SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun
@@ -157,13 +167,13 @@ Best for making changes to the code with fast feedback
# Windows (PowerShell)
$env:SPRING_PROFILES_ACTIVE="dev"; ./gradlew bootRun
```
-3. **Access API documentation**:
- Once the server is running, navigate to:
- `http://localhost:8080/swagger-ui/index.html`
+### Access API Documentation
+Once the server is running, navigate to:
+`http://localhost:8080/swagger-ui.html`
### Cleaning up
-If you want to easily reset the database use:
+To stop containers and remove volumes (resets database):
```bash
docker-compose down -v
```
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/BiblioNodeApplication.java b/src/main/java/io/github/mgrablo/BiblioNode/BiblioNodeApplication.java
index 15c430c..064bd30 100644
--- a/src/main/java/io/github/mgrablo/BiblioNode/BiblioNodeApplication.java
+++ b/src/main/java/io/github/mgrablo/BiblioNode/BiblioNodeApplication.java
@@ -2,8 +2,14 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+import io.github.mgrablo.BiblioNode.config.LoanProperties;
+import io.github.mgrablo.BiblioNode.config.PaginationProperties;
+import io.github.mgrablo.BiblioNode.config.SecurityProperties;
@SpringBootApplication
+@EnableConfigurationProperties({LoanProperties.class, SecurityProperties.class, PaginationProperties.class})
public class BiblioNodeApplication {
public static void main(String[] args) {
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/config/LoanProperties.java b/src/main/java/io/github/mgrablo/BiblioNode/config/LoanProperties.java
index 76cb793..db033cd 100644
--- a/src/main/java/io/github/mgrablo/BiblioNode/config/LoanProperties.java
+++ b/src/main/java/io/github/mgrablo/BiblioNode/config/LoanProperties.java
@@ -1,15 +1,10 @@
package io.github.mgrablo.BiblioNode.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Configuration;
-import lombok.Getter;
-import lombok.Setter;
-
-@Configuration
@ConfigurationProperties(prefix = "app.loan")
-@Getter @Setter
-public class LoanProperties {
- private int maxActiveLoans = 5;
- private int defaultLoanDays = 14;
+public record LoanProperties(
+ int maxActiveLoans,
+ int defaultLoanDays
+) {
}
\ No newline at end of file
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationConfig.java b/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationConfig.java
new file mode 100644
index 0000000..501822d
--- /dev/null
+++ b/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationConfig.java
@@ -0,0 +1,45 @@
+package io.github.mgrablo.BiblioNode.config;
+
+import org.springdoc.core.customizers.OperationCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.util.List;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@RequiredArgsConstructor
+public class PaginationConfig implements WebMvcConfigurer {
+ private final PaginationProperties paginationProperties;
+
+ @Override
+ public void addArgumentResolvers(List resolvers) {
+ PageableHandlerMethodArgumentResolver pageableResolver = new PageableHandlerMethodArgumentResolver();
+
+ pageableResolver.setFallbackPageable(PageRequest.of(0, paginationProperties.defaultPageSize()));
+ pageableResolver.setMaxPageSize(paginationProperties.maxPageSize());
+
+ resolvers.add(pageableResolver);
+ }
+
+ @Bean
+ public OperationCustomizer customizePagination() {
+ return (operation, handlerMethod) -> {
+ if (operation.getParameters() != null) {
+ operation.getParameters().stream()
+ .filter(p -> "size".equals(p.getName()))
+ .forEach(p -> {
+ if (p.getSchema() != null) {
+ p.getSchema().setDefault(paginationProperties.defaultPageSize());
+ }
+ });
+ }
+ return operation;
+ };
+ }
+}
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationProperties.java b/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationProperties.java
new file mode 100644
index 0000000..e89ebb4
--- /dev/null
+++ b/src/main/java/io/github/mgrablo/BiblioNode/config/PaginationProperties.java
@@ -0,0 +1,9 @@
+package io.github.mgrablo.BiblioNode.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app.pagination")
+public record PaginationProperties(
+ int defaultPageSize,
+ int maxPageSize
+) { }
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/config/SecurityProperties.java b/src/main/java/io/github/mgrablo/BiblioNode/config/SecurityProperties.java
new file mode 100644
index 0000000..e538558
--- /dev/null
+++ b/src/main/java/io/github/mgrablo/BiblioNode/config/SecurityProperties.java
@@ -0,0 +1,8 @@
+package io.github.mgrablo.BiblioNode.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app.security")
+public record SecurityProperties(
+ int jwtExpirationHours
+) { }
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/service/AuthServiceImpl.java b/src/main/java/io/github/mgrablo/BiblioNode/service/AuthServiceImpl.java
index c669954..bad8eab 100644
--- a/src/main/java/io/github/mgrablo/BiblioNode/service/AuthServiceImpl.java
+++ b/src/main/java/io/github/mgrablo/BiblioNode/service/AuthServiceImpl.java
@@ -12,6 +12,7 @@
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;
+import io.github.mgrablo.BiblioNode.config.SecurityProperties;
import io.github.mgrablo.BiblioNode.dto.*;
import io.github.mgrablo.BiblioNode.model.User;
import io.github.mgrablo.BiblioNode.repository.UserRepository;
@@ -26,6 +27,7 @@ public class AuthServiceImpl implements AuthService {
private final ReaderService readerService;
private final PasswordEncoder passwordEncoder;
private final JwtEncoder jwtEncoder;
+ private final SecurityProperties securityProperties;
@Override
public ReaderResponse register(RegisterRequest request) {
@@ -52,7 +54,7 @@ public LoginResponse login(LoginRequest request) {
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("BiblioNode")
.issuedAt(now)
- .expiresAt(now.plus(1, ChronoUnit.HOURS))
+ .expiresAt(now.plus(securityProperties.jwtExpirationHours(), ChronoUnit.HOURS))
.subject(user.getEmail())
.claim("roles", scope)
.build();
diff --git a/src/main/java/io/github/mgrablo/BiblioNode/service/LoanServiceImpl.java b/src/main/java/io/github/mgrablo/BiblioNode/service/LoanServiceImpl.java
index 8d3a677..a20b859 100644
--- a/src/main/java/io/github/mgrablo/BiblioNode/service/LoanServiceImpl.java
+++ b/src/main/java/io/github/mgrablo/BiblioNode/service/LoanServiceImpl.java
@@ -50,8 +50,8 @@ public LoanResponse borrowBook(BorrowRequest request, String email) {
.orElseThrow(() -> new ResourceNotFoundException("Reader not found"));
Long activeLoansCount = loanRepository.countByReaderIdAndReturnDateIsNull(reader.getId());
- if (activeLoansCount >= loanProperties.getMaxActiveLoans()) {
- throw new LoanLimitExceededException("Reader has exceeded the maximum number of active loans (" + loanProperties.getMaxActiveLoans() + ")");
+ if (activeLoansCount >= loanProperties.maxActiveLoans()) {
+ throw new LoanLimitExceededException("Reader has exceeded the maximum number of active loans (" + loanProperties.maxActiveLoans() + ")");
}
book.setAvailable(false);
@@ -59,7 +59,7 @@ public LoanResponse borrowBook(BorrowRequest request, String email) {
LocalDateTime now = LocalDateTime.now(clock);
Loan loan = new Loan();
loan.setLoanDate(now);
- loan.setDueDate(now.plusDays(loanProperties.getDefaultLoanDays()));
+ loan.setDueDate(now.plusDays(loanProperties.defaultLoanDays()));
loan.setBook(book);
loan.setReader(reader);
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
index 930978c..3fd66a7 100644
--- a/src/main/resources/application-dev.yaml
+++ b/src/main/resources/application-dev.yaml
@@ -8,6 +8,11 @@ app:
loan:
max-active-loans: 5
default-loan-days: 21
+ security:
+ jwt-expiration-hours: 24
+ pagination:
+ default-page-size: 15
+ max-page-size: 100
spring:
application:
@@ -39,6 +44,8 @@ springdoc:
logging:
level:
com.biblionode: DEBUG
+ org.springdoc: DEBUG
+ org.springframework.web: DEBUG
server:
port: 8080
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 354489f..1783af0 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -8,6 +8,11 @@ app:
loan:
max-active-loans: 5
default-loan-days: 14
+ security:
+ jwt-expiration-hours: 1
+ pagination:
+ default-page-size: 20
+ max-page-size: 100
spring:
application:
diff --git a/src/test/java/io/github/mgrablo/BiblioNode/config/PaginationIntegrationTest.java b/src/test/java/io/github/mgrablo/BiblioNode/config/PaginationIntegrationTest.java
new file mode 100644
index 0000000..4e0edff
--- /dev/null
+++ b/src/test/java/io/github/mgrablo/BiblioNode/config/PaginationIntegrationTest.java
@@ -0,0 +1,69 @@
+package io.github.mgrablo.BiblioNode.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import io.github.mgrablo.BiblioNode.exception.GlobalExceptionHandler;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import({SecurityConfiguration.class, GlobalExceptionHandler.class})
+public class PaginationIntegrationTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private PaginationProperties paginationProperties;
+
+ @MockitoBean
+ private JwtDecoder jwtDecoder;
+ @MockitoBean
+ private JwtEncoder jwtEncoder;
+ @MockitoBean
+ private RsaKeyConfig rsaKeyConfig;
+
+ @Test
+ void shouldApplyDefaultPageSize() throws Exception {
+ int expectedPageSize = paginationProperties.defaultPageSize();
+ mockMvc.perform(get("/api/books")
+ .with(readerUser())
+ ).andExpect(status().isOk())
+ .andExpect(jsonPath("$.pageable.pageSize").value(expectedPageSize))
+ .andExpect(jsonPath("$.size").value(expectedPageSize));
+ }
+
+ @Test
+ void shouldNotExceedMaxPageSize() throws Exception {
+ int maxPageSize = paginationProperties.maxPageSize();
+
+ mockMvc.perform(get("/api/books").with(readerUser())
+ .param("size", "10000")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.pageable.pageSize").value(maxPageSize))
+ .andExpect(jsonPath("$.size").value(maxPageSize));
+ }
+
+
+ private SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor readerUser() {
+ return jwt().authorities(new SimpleGrantedAuthority("ROLE_READER"))
+ .jwt(j -> j.subject("test@email.com"));
+ }
+}
diff --git a/src/test/java/io/github/mgrablo/BiblioNode/service/AuthServiceImplTest.java b/src/test/java/io/github/mgrablo/BiblioNode/service/AuthServiceImplTest.java
index 0be5896..62d4120 100644
--- a/src/test/java/io/github/mgrablo/BiblioNode/service/AuthServiceImplTest.java
+++ b/src/test/java/io/github/mgrablo/BiblioNode/service/AuthServiceImplTest.java
@@ -5,6 +5,7 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -20,6 +21,7 @@
import java.util.Optional;
import java.util.Set;
+import io.github.mgrablo.BiblioNode.config.SecurityProperties;
import io.github.mgrablo.BiblioNode.dto.*;
import io.github.mgrablo.BiblioNode.exception.DataIntegrityException;
import io.github.mgrablo.BiblioNode.model.Role;
@@ -43,6 +45,20 @@ class AuthServiceImplTest {
@InjectMocks
private AuthServiceImpl authService;
+ @BeforeEach
+ void setup() {
+ SecurityProperties securityProperties = new SecurityProperties(1);
+
+ authService = new AuthServiceImpl(
+ userService,
+ userRepository,
+ readerService,
+ passwordEncoder,
+ jwtEncoder,
+ securityProperties
+ );
+ }
+
@Test
void register_ShouldCoordinateUserAndProfileCreation() {
RegisterRequest request = new RegisterRequest("test@email.com", "password", "Test User");
diff --git a/src/test/java/io/github/mgrablo/BiblioNode/service/LoanServiceImplTest.java b/src/test/java/io/github/mgrablo/BiblioNode/service/LoanServiceImplTest.java
index 019f843..7ead18b 100644
--- a/src/test/java/io/github/mgrablo/BiblioNode/service/LoanServiceImplTest.java
+++ b/src/test/java/io/github/mgrablo/BiblioNode/service/LoanServiceImplTest.java
@@ -59,9 +59,7 @@ public class LoanServiceImplTest {
@BeforeEach
void setup() {
- LoanProperties loanProperties = new LoanProperties();
- loanProperties.setMaxActiveLoans(5);
- loanProperties.setDefaultLoanDays(14);
+ LoanProperties loanProperties = new LoanProperties(5, 14);
fixedClock = Clock.fixed(fixedInstant, zoneId);
loanService = new LoanServiceImpl(loanRepository,
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml
index f42d691..80d8f40 100644
--- a/src/test/resources/application-test.yaml
+++ b/src/test/resources/application-test.yaml
@@ -8,6 +8,11 @@ app:
loan:
max-active-loans: 2
default-loan-days: 14
+ security:
+ jwt-expiration-hours: 24
+ pagination:
+ default-page-size: 7
+ max-page-size: 23
spring:
datasource: