Lab: Adding Data Persistence to the 'Book Catalog' Application
In this lab, we’ll extend our Book Catalog REST API to store book data in a database using Spring Data JPA. We’ll first use H2 as an in-memory database for development and then switch to PostgreSQL for a more production-like setup.
Objectives:
- Configure Spring Data JPA with H2 database
- Create JPA entities and repositories
- Modify the service layer to use JPA repositories
- Add data validation
- Implement pagination and sorting
- Add database transaction management
- Configure a PostgreSQL database
- Implement database migrations with Flyway
Prerequisites:
- Completed “Book Catalog” REST API from Module 3
- Java Development Kit (JDK) 17 or later
- Maven
- PostgreSQL (for the second part of the lab)
Step 1: Add Spring Data JPA and H2 Database Dependencies
- Open your
pom.xml
file and add the following dependencies:
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Step 2: Configure H2 Database in application.properties
- Open or create
src/main/resources/application.properties
and add the following configuration:
# Server port
server.port=8081
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Enable H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# JPA/Hibernate properties
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings
# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0
Step 3: Update the Book Model as a JPA Entity
- Modify the
Book
class to make it a JPA entity:
package com.example.bookcatalog.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(max = 100, message = "Title cannot exceed 100 characters")
@Column(nullable = false, length = 100)
private String title;
@Size(max = 200, message = "Author name cannot exceed 200 characters")
@Column(length = 200)
private String author;
@Pattern(regexp = "^\\d{10}|\\d{13}$", message = "ISBN must be 10 or 13 digits")
@Column(length = 20, unique = true)
private String isbn;
@Past(message = "Publication date must be in the past")
@Column(name = "publication_date")
private LocalDate publicationDate;
@Column(columnDefinition = "TEXT")
private String description;
@Min(value = 1, message = "Page count must be at least 1")
@Column(name = "page_count")
private Integer pageCount;
// Default constructor (required by JPA)
public Book() {
}
// Constructor with fields
public Book(String title, String author, String isbn, LocalDate publicationDate,
String description, Integer pageCount) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publicationDate = publicationDate;
this.description = description;
this.pageCount = pageCount;
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public LocalDate getPublicationDate() {
return publicationDate;
}
public void setPublicationDate(LocalDate publicationDate) {
this.publicationDate = publicationDate;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getPageCount() {
return pageCount;
}
public void setPageCount(Integer pageCount) {
this.pageCount = pageCount;
}
// equals, hashCode, toString methods
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Book book = (Book) o;
return Objects.equals(id, book.id) &&
Objects.equals(isbn, book.isbn);
}
@Override
public int hashCode() {
return Objects.hash(id, isbn);
}
@Override
public String toString() {
return "Book{" +
"id=" + id +
", title='" + title + '\'' +
", author='" + author + '\'' +
", isbn='" + isbn + '\'' +
", publicationDate=" + publicationDate +
", pageCount=" + pageCount +
'}';
}
}
- Don’t forget to add the necessary import for
java.util.Objects
:
import java.util.Objects;
Step 4: Create a Book Repository Interface
- Create a new interface
BookRepository
in therepository
package:
package com.example.bookcatalog.repository;
import com.example.bookcatalog.model.Book;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
// Find book by ISBN
Optional<Book> findByIsbn(String isbn);
// Find books by title containing a string (case-insensitive)
List<Book> findByTitleContainingIgnoreCase(String title);
// Find books by author containing a string (case-insensitive)
List<Book> findByAuthorContainingIgnoreCase(String author);
// Find books published after a certain date
List<Book> findByPublicationDateAfter(LocalDate date);
// Find books with page count greater than a value
List<Book> findByPageCountGreaterThan(Integer pageCount);
// Custom query to search books by title or author
@Query("SELECT b FROM Book b WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(b.author) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Book> searchBooks(@Param("keyword") String keyword);
// Custom query to find books published in a specific year
@Query("SELECT b FROM Book b WHERE YEAR(b.publicationDate) = :year")
List<Book> findBooksByPublicationYear(@Param("year") int year);
// Paginated search
Page<Book> findByTitleContainingIgnoreCaseOrAuthorContainingIgnoreCase(
String title, String author, Pageable pageable);
}
Step 5: Update the Book Service to Use JPA Repository
- Update the
BookService
interface:
package com.example.bookcatalog.service;
import com.example.bookcatalog.model.Book;
import org.springframework.data.domain.Page;
import java.util.List;
public interface BookService {
List<Book> findAllBooks();
Book findBookById(Long id);
Book findBookByIsbn(String isbn);
Book createBook(Book book);
Book updateBook(Long id, Book bookDetails);
void deleteBook(Long id);
List<Book> searchBooks(String keyword);
Page<Book> findBooksPaginated(int page, int size, String sortBy, String direction);
List<Book> findBooksByYear(int year);
}
- Update the
BookServiceImpl
class:
package com.example.bookcatalog.service.impl;
import com.example.bookcatalog.exception.ResourceNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import com.example.bookcatalog.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class BookServiceImpl implements BookService {
private final BookRepository bookRepository;
@Autowired
public BookServiceImpl(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Override
@Transactional(readOnly = true)
public List<Book> findAllBooks() {
return bookRepository.findAll();
}
@Override
@Transactional(readOnly = true)
public Book findBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id));
}
@Override
@Transactional(readOnly = true)
public Book findBookByIsbn(String isbn) {
return bookRepository.findByIsbn(isbn)
.orElseThrow(() -> new ResourceNotFoundException("Book not found with ISBN: " + isbn));
}
@Override
@Transactional
public Book createBook(Book book) {
// Check if a book with the same ISBN already exists
bookRepository.findByIsbn(book.getIsbn()).ifPresent(existingBook -> {
throw new IllegalArgumentException("Book with ISBN " + book.getIsbn() + " already exists");
});
return bookRepository.save(book);
}
@Override
@Transactional
public Book updateBook(Long id, Book bookDetails) {
Book book = findBookById(id);
// If ISBN is being changed, check if the new ISBN already exists on another book
if (!book.getIsbn().equals(bookDetails.getIsbn())) {
bookRepository.findByIsbn(bookDetails.getIsbn()).ifPresent(existingBook -> {
if (!existingBook.getId().equals(id)) {
throw new IllegalArgumentException("Book with ISBN " + bookDetails.getIsbn() + " already exists");
}
});
}
book.setTitle(bookDetails.getTitle());
book.setAuthor(bookDetails.getAuthor());
book.setIsbn(bookDetails.getIsbn());
book.setPublicationDate(bookDetails.getPublicationDate());
book.setDescription(bookDetails.getDescription());
book.setPageCount(bookDetails.getPageCount());
return bookRepository.save(book);
}
@Override
@Transactional
public void deleteBook(Long id) {
Book book = findBookById(id);
bookRepository.delete(book);
}
@Override
@Transactional(readOnly = true)
public List<Book> searchBooks(String keyword) {
return bookRepository.searchBooks(keyword);
}
@Override
@Transactional(readOnly = true)
public Page<Book> findBooksPaginated(int page, int size, String sortBy, String direction) {
Sort sort = direction.equalsIgnoreCase(Sort.Direction.ASC.name()) ?
Sort.by(sortBy).ascending() : Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
return bookRepository.findAll(pageable);
}
@Override
@Transactional(readOnly = true)
public List<Book> findBooksByYear(int year) {
return bookRepository.findBooksByPublicationYear(year);
}
}
Step 6: Create Exception Handling Classes
- Create or update
ResourceNotFoundException
class:
package com.example.bookcatalog.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
- Create a global exception handler:
package com.example.bookcatalog.exception;
import jakarta.validation.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<?> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {
ErrorDetails errorDetails = new ErrorDetails(
LocalDateTime.now(),
ex.getMessage(),
request.getDescription(false));
return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgumentException(
IllegalArgumentException ex, WebRequest request) {
ErrorDetails errorDetails = new ErrorDetails(
LocalDateTime.now(),
ex.getMessage(),
request.getDescription(false));
return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
ValidationErrorDetails errorDetails = new ValidationErrorDetails(
LocalDateTime.now(),
"Validation failed",
errors);
return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<?> handleConstraintViolationException(
ConstraintViolationException ex, WebRequest request) {
ErrorDetails errorDetails = new ErrorDetails(
LocalDateTime.now(),
ex.getMessage(),
request.getDescription(false));
return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<?> handleDataIntegrityViolationException(
DataIntegrityViolationException ex, WebRequest request) {
ErrorDetails errorDetails = new ErrorDetails(
LocalDateTime.now(),
"Database integrity constraint violated: " + ex.getMostSpecificCause().getMessage(),
request.getDescription(false));
return new ResponseEntity<>(errorDetails, HttpStatus.CONFLICT);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleGlobalException(
Exception ex, WebRequest request) {
ErrorDetails errorDetails = new ErrorDetails(
LocalDateTime.now(),
ex.getMessage(),
request.getDescription(false));
return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
- Create error response classes:
package com.example.bookcatalog.exception;
import java.time.LocalDateTime;
public class ErrorDetails {
private LocalDateTime timestamp;
private String message;
private String details;
public ErrorDetails(LocalDateTime timestamp, String message, String details) {
this.timestamp = timestamp;
this.message = message;
this.details = details;
}
// Getters
public LocalDateTime getTimestamp() {
return timestamp;
}
public String getMessage() {
return message;
}
public String getDetails() {
return details;
}
}
package com.example.bookcatalog.exception;
import java.time.LocalDateTime;
import java.util.Map;
public class ValidationErrorDetails {
private LocalDateTime timestamp;
private String message;
private Map<String, String> errors;
public ValidationErrorDetails(LocalDateTime timestamp, String message, Map<String, String> errors) {
this.timestamp = timestamp;
this.message = message;
this.errors = errors;
}
// Getters
public LocalDateTime getTimestamp() {
return timestamp;
}
public String getMessage() {
return message;
}
public Map<String, String> getErrors() {
return errors;
}
}
Step 7: Update Book Controller to Support Pagination
package com.example.bookcatalog.controller;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book Controller", description = "Operations pertaining to books in the catalog")
public class BookController {
private final BookService bookService;
@Autowired
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Operation(summary = "Get all books", description = "Returns a list of all books in the catalog")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved books",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) })
})
@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
List<Book> books = bookService.findAllBooks();
return ResponseEntity.ok(books);
}
@Operation(summary = "Get paginated books", description = "Returns a paginated list of books with sorting")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved paginated books",
content = { @Content(mediaType = "application/json") })
})
@GetMapping("/paginated")
public ResponseEntity<Page<Book>> getPaginatedBooks(
@Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "10") int size,
@Parameter(description = "Sort field") @RequestParam(defaultValue = "id") String sort,
@Parameter(description = "Sort direction") @RequestParam(defaultValue = "asc") String direction) {
Page<Book> books = bookService.findBooksPaginated(page, size, sort, direction);
return ResponseEntity.ok(books);
}
@Operation(summary = "Get a book by ID", description = "Returns a single book identified by its ID")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved book",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) }),
@ApiResponse(responseCode = "404", description = "Book not found",
content = @Content)
})
@GetMapping("/{id}")
public ResponseEntity<Book> getBookById(
@Parameter(description = "ID of the book to be retrieved") @PathVariable Long id) {
Book book = bookService.findBookById(id);
return ResponseEntity.ok(book);
}
@Operation(summary = "Get a book by ISBN", description = "Returns a single book identified by its ISBN")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved book",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) }),
@ApiResponse(responseCode = "404", description = "Book not found",
content = @Content)
})
@GetMapping("/isbn/{isbn}")
public ResponseEntity<Book> getBookByIsbn(
@Parameter(description = "ISBN of the book to be retrieved") @PathVariable String isbn) {
Book book = bookService.findBookByIsbn(isbn);
return ResponseEntity.ok(book);
}
@Operation(summary = "Search books", description = "Returns books matching the search keyword in title or author")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved books",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) })
})
@GetMapping("/search")
public ResponseEntity<List<Book>> searchBooks(
@Parameter(description = "Search keyword") @RequestParam String keyword) {
List<Book> books = bookService.searchBooks(keyword);
return ResponseEntity.ok(books);
}
@Operation(summary = "Find books by publication year", description = "Returns books published in a specific year")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved books",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) })
})
@GetMapping("/year/{year}")
public ResponseEntity<List<Book>> getBooksByYear(
@Parameter(description = "Publication year") @PathVariable int year) {
List<Book> books = bookService.findBooksByYear(year);
return ResponseEntity.ok(books);
}
@Operation(summary = "Create a new book", description = "Adds a new book to the catalog")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Book successfully created",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) }),
@ApiResponse(responseCode = "400", description = "Invalid input",
content = @Content)
})
@PostMapping
public ResponseEntity<Book> createBook(
@Parameter(description = "Book object to be added") @Valid @RequestBody Book book) {
Book createdBook = bookService.createBook(book);
return new ResponseEntity<>(createdBook, HttpStatus.CREATED);
}
@Operation(summary = "Update a book", description = "Updates an existing book in the catalog")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Book successfully updated",
content = { @Content(mediaType = "application/json",
schema = @Schema(implementation = Book.class)) }),
@ApiResponse(responseCode = "400", description = "Invalid input",
content = @Content),
@ApiResponse(responseCode = "404", description = "Book not found",
content = @Content)
})
@PutMapping("/{id}")
public ResponseEntity<Book> updateBook(
@Parameter(description = "ID of the book to be updated") @PathVariable Long id,
@Parameter(description = "Updated book object") @Valid @RequestBody Book book) {
Book updatedBook = bookService.updateBook(id, book);
return ResponseEntity.ok(updatedBook);
}
@Operation(summary = "Delete a book", description = "Removes a book from the catalog")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Book successfully deleted",
content = @Content),
@ApiResponse(responseCode = "404", description = "Book not found",
content = @Content)
})
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(
@Parameter(description = "ID of the book to be deleted") @PathVariable Long id) {
bookService.deleteBook(id);
return ResponseEntity.noContent().build();
}
}
Step 8: Create a Data Initializer
Create a class to load initial data when the application starts:
package com.example.bookcatalog.config;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.time.LocalDate;
@Configuration
public class DataInitializer {
@Bean
@Profile("!prod") // This bean will not be loaded in production
public CommandLineRunner loadData(BookRepository bookRepository) {
return args -> {
// Only initialize if the database is empty
if (bookRepository.count() == 0) {
bookRepository.save(new Book(
"To Kill a Mockingbird",
"Harper Lee",
"9780061120084",
LocalDate.of(1960, 7, 11),
"A novel about racial injustice in a small Alabama town during the Great Depression.",
281
));
bookRepository.save(new Book(
"1984",
"George Orwell",
"9780451524935",
LocalDate.of(1949, 6, 8),
"A dystopian novel set in a totalitarian society ruled by the Party, which has total control over every action and thought of the people.",
328
));
bookRepository.save(new Book(
"The Great Gatsby",
"F. Scott Fitzgerald",
"9780743273565",
LocalDate.of(1925, 4, 10),
"A novel that explores themes of decadence, idealism, resistance to change, social upheaval, and excess.",
180
));
bookRepository.save(new Book(
"The Hobbit",
"J.R.R. Tolkien",
"9780345339683",
LocalDate.of(1937, 9, 21),
"A fantasy novel about the adventures of the hobbit Bilbo Baggins.",
310
));
bookRepository.save(new Book(
"Pride and Prejudice",
"Jane Austen",
"9780141439518",
LocalDate.of(1813, 1, 28),
"A romantic novel of manners that follows the character development of Elizabeth Bennet.",
432
));
System.out.println("Sample books loaded into database!");
}
};
}
}
Step 9: Create Application Profiles
- Create a file
application-dev.properties
insrc/main/resources/
:
# Development profile configuration
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
# Show SQL
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Hibernate auto DDL
spring.jpa.hibernate.ddl-auto=update
# Logging
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
- Create a file
application-prod.properties
insrc/main/resources/
:
# Production profile configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/bookdb
spring.datasource.username=postgres
spring.datasource.password=password
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# Disable automatic schema creation to use migrations instead
spring.jpa.hibernate.ddl-auto=validate
# Disable showing SQL
spring.jpa.show-sql=false
# Disable H2 console
spring.h2.console.enabled=false
# Logging level
logging.level.root=INFO
logging.level.com.example.bookcatalog=WARN
- Update the main
application.properties
to use the dev profile by default:
# Active profile
spring.profiles.active=dev
# Common configuration
server.port=8081
# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0
# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings
# SpringDoc OpenAPI configuration
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method
Step 10: Add Database Migration with Flyway
- Add Flyway dependency to your
pom.xml
:
<!-- Flyway for database migrations -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
- Create a migration script for PostgreSQL. Create a file
V1__Create_books_table.sql
insrc/main/resources/db/migration/
:
CREATE TABLE IF NOT EXISTS books (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
author VARCHAR(200),
isbn VARCHAR(20) UNIQUE,
publication_date DATE,
description TEXT,
page_count INTEGER
);
-- Insert initial data
INSERT INTO books (title, author, isbn, publication_date, description, page_count)
VALUES
('To Kill a Mockingbird', 'Harper Lee', '9780061120084', '1960-07-11', 'A novel about racial injustice in a small Alabama town during the Great Depression.', 281),
('1984', 'George Orwell', '9780451524935', '1949-06-08', 'A dystopian novel set in a totalitarian society ruled by the Party, which has total control over every action and thought of the people.', 328),
('The Great Gatsby', 'F. Scott Fitzgerald', '9780743273565', '1925-04-10', 'A novel that explores themes of decadence, idealism, resistance to change, social upheaval, and excess.', 180),
('The Hobbit', 'J.R.R. Tolkien', '9780345339683', '1937-09-21', 'A fantasy novel about the adventures of the hobbit Bilbo Baggins.', 310),
('Pride and Prejudice', 'Jane Austen', '9780141439518', '1813-01-28', 'A romantic novel of manners that follows the character development of Elizabeth Bennet.', 432);
- Add Flyway configuration to
application-prod.properties
:
# Flyway configuration
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration
Step 11: Create a Custom Health Indicator
package com.example.bookcatalog.health;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class BookCatalogHealthIndicator implements HealthIndicator {
private final BookRepository bookRepository;
@Autowired
public BookCatalogHealthIndicator(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Override
public Health health() {
try {
long bookCount = bookRepository.count();
return Health.up()
.withDetail("book_count", bookCount)
.withDetail("message", "Book catalog is operational")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.withDetail("message", "Book catalog is not operational")
.build();
}
}
}
Step 12: Create a Database Connection Configuration for PostgreSQL
- Create a PostgreSQL database configuration class:
package com.example.bookcatalog.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import javax.sql.DataSource;
@Configuration
@Profile("prod")
public class PostgresConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
}
Step 13: Test the Application with H2 Database
- Run the application with the dev profile:
mvn spring-boot:run -Dspring-boot.run.profiles=dev
-
Access the H2 console at http://localhost:8081/h2-console and enter the database URL, username, and password from your application-dev.properties.
-
Test the API using Swagger UI at http://localhost:8081/swagger-ui.html or with tools like Postman or curl.
Step 14: Set Up PostgreSQL Database
-
Install PostgreSQL if you haven’t already.
-
Create a new database called
bookdb
:
CREATE DATABASE bookdb;
- Run the application with the prod profile:
mvn spring-boot:run -Dspring-boot.run.profiles=prod
-
Flyway will automatically run the migrations to create the tables and insert initial data.
-
Test the API with the PostgreSQL database.
Step 15: Update Actuator Endpoint Configuration
- Add more detailed Actuator configuration in
application.properties
:
# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings,flyway,beans,env
management.endpoint.health.show-details=always
# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0
info.java.version=${java.version}
info.spring-boot.version=${spring-boot.version}
Step 16: Create Metrics
Add a metric counter to the BookController
to track how many times each endpoint is accessed:
package com.example.bookcatalog.controller;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
// Other imports...
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book Controller", description = "Operations pertaining to books in the catalog")
public class BookController {
private final BookService bookService;
private final MeterRegistry meterRegistry;
@Autowired
public BookController(BookService bookService, MeterRegistry meterRegistry) {
this.bookService = bookService;
this.meterRegistry = meterRegistry;
}
@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
meterRegistry.counter("api.requests", "endpoint", "getAllBooks").increment();
List<Book> books = bookService.findAllBooks();
return ResponseEntity.ok(books);
}
// Update other methods similarly
@GetMapping("/{id}")
public ResponseEntity<Book> getBookById(@PathVariable Long id) {
meterRegistry.counter("api.requests", "endpoint", "getBookById").increment();
Book book = bookService.findBookById(id);
return ResponseEntity.ok(book);
}
// Continue for other methods...
}
Step 17: Final Testing
- Test all endpoints using Swagger UI or Postman.
- Verify that data is properly stored in the database.
- Check the Actuator endpoints to monitor application health and metrics.
- Test different profiles to ensure proper configuration.
Summary
In Module 4, we’ve successfully added data persistence to our Book Catalog application using Spring Data JPA. Here’s what we’ve accomplished:
-
Configured Spring Data JPA: We set up Spring Data JPA with H2 database for development and PostgreSQL for production.
-
Created JPA Entities: We transformed our Book model into a JPA entity with proper annotations and validations.
-
Implemented Repositories: We created a BookRepository interface that extends JpaRepository, adding custom query methods for specific data access needs.
-
Enhanced Service Layer: We updated our service layer to use the repository for database operations, adding transaction management.
-
Added Data Validation: We implemented Bean Validation to ensure data integrity before persisting to the database.
-
Implemented Pagination and Sorting: We added support for paginated results with sorting capabilities.
-
Created Multiple Profiles: We set up development and production profiles with appropriate configurations.
-
Added Database Migration: We implemented Flyway for database schema migration and initial data loading.
-
Enhanced Exception Handling: We created a comprehensive exception handling system for database-related errors.
-
Added Monitoring: We exposed useful metrics and health indicators through Spring Boot Actuator.
By Wahid Hamdi