Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 41 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<!-- TOC -->
Expand Down Expand Up @@ -79,30 +81,51 @@ 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.
- **Advanced Loan System**: Full lifecycle of book borrowing and returns with automated availability management and overdue tracking.
- **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
The project maintains a high standard of quality through different testing layers:
- **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.
Expand All @@ -112,58 +135,45 @@ 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
docker-compose up -d db
```
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

# 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
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<HandlerMethodArgumentResolver> 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;
};
}
}
Original file line number Diff line number Diff line change
@@ -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
) { }
Original file line number Diff line number Diff line change
@@ -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
) { }
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,16 @@ 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);

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);

Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -39,6 +44,8 @@ springdoc:
logging:
level:
com.biblionode: DEBUG
org.springdoc: DEBUG
org.springframework.web: DEBUG

server:
port: 8080
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading