diff --git a/booklibrary-spring/README.md b/booklibrary-spring/README.md new file mode 100644 index 0000000..52621a8 --- /dev/null +++ b/booklibrary-spring/README.md @@ -0,0 +1,53 @@ +# booklibrary-spring + +Spring Boot (Java) port of the Flask booklibrary app (domain subset). + +## Quick start (PostgreSQL) +1. Ensure PostgreSQL database and user: +``` +CREATE DATABASE booklibrary; +CREATE USER booklibrary WITH PASSWORD 'booklibrary'; +GRANT ALL PRIVILEGES ON DATABASE booklibrary TO booklibrary; +``` +2. From repository root: +``` +cd booklibrary-spring +../gradlew bootRun # or ./gradlew bootRun if wrapper copied +``` +3. Test: +``` +curl http://localhost:8080/ping/ +``` +Should return: +``` +{"status":"ok"} +``` + +## H2 Dev Profile +``` +./gradlew bootRun --args='--spring.profiles.active=dev' +``` + +## Sample Book Creation +Insert an author first (psql): +``` +INSERT INTO authors(name, surname, surname_first) VALUES ('Isaac', 'Asimov', 'Asimov, Isaac'); +``` +Create a book: +``` +curl -X POST http://localhost:8080/books/api \ + -H "Content-Type: application/json" \ + -d '{"title":"The Foundation","authorId":1,"status":"to read","tags":"sci-fi"}' +``` + +Search: +``` +curl 'http://localhost:8080/books/api/search?q=foundation' +``` + +## Future Work +- Implement real cover downloader +- Provider-based recommendations +- Pagination + sorting parameters +- OpenAPI / Swagger docs +- Additional fields (genres, language, year) diff --git a/booklibrary-spring/build.gradle.kts b/booklibrary-spring/build.gradle.kts new file mode 100644 index 0000000..10249f4 --- /dev/null +++ b/booklibrary-spring/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("java") + id("org.springframework.boot") version "3.3.3" + id("io.spring.dependency-management") version "1.1.5" +} + +group = "com.jborza.booklibrary" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } +} + +repositories { mavenCentral() } + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.flywaydb:flyway-core") + implementation("org.springframework.boot:spring-boot-starter-cache") + // spring-boot-starter-web already pulls in jackson + + runtimeOnly("org.postgresql:postgresql") + runtimeOnly("com.h2database:h2") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +tasks.test { useJUnitPlatform() } + +// Quick start: +// 1. Ensure Postgres running (db: booklibrary / user: booklibrary / pass: booklibrary) +// 2. ./gradlew bootRun +// 3. curl http://localhost:8080/ping/ \ No newline at end of file diff --git a/booklibrary-spring/settings.gradle.kts b/booklibrary-spring/settings.gradle.kts new file mode 100644 index 0000000..0e36b20 --- /dev/null +++ b/booklibrary-spring/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "booklibrary-spring" diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/BookLibraryApplication.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/BookLibraryApplication.java new file mode 100644 index 0000000..763a454 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/BookLibraryApplication.java @@ -0,0 +1,13 @@ +package com.jborza.booklibrary; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class BookLibraryApplication { + public static void main(String[] args) { + SpringApplication.run(BookLibraryApplication.class, args); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/AuthorController.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/AuthorController.java new file mode 100644 index 0000000..590189f --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/AuthorController.java @@ -0,0 +1,19 @@ +package com.jborza.booklibrary.controller; + +import com.jborza.booklibrary.repository.AuthorRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/authors/api") +public class AuthorController { + private final AuthorRepository authorRepository; + + public AuthorController(AuthorRepository authorRepository) { + this.authorRepository = authorRepository; + } + + @GetMapping + public Object list() { return authorRepository.findAll(); } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/BookController.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/BookController.java new file mode 100644 index 0000000..1d09156 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/BookController.java @@ -0,0 +1,29 @@ +package com.jborza.booklibrary.controller; + +import com.jborza.booklibrary.dto.BookRequest; +import com.jborza.booklibrary.service.BookService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/books/api") +public class BookController { + private final BookService bookService; + + public BookController(BookService bookService) { + this.bookService = bookService; + } + + @PostMapping + public Object create(@Valid @RequestBody BookRequest req) { return bookService.create(req); } + + @GetMapping("/{id}") + public Object get(@PathVariable Long id) { return bookService.get(id); } + + @GetMapping("/search") + public Object search(@RequestParam(required = false) String q, + @RequestParam(required = false) String status, + @RequestParam(required = false) String author) { + return bookService.search(q, status, author); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/DownloaderController.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/DownloaderController.java new file mode 100644 index 0000000..2b589d4 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/DownloaderController.java @@ -0,0 +1,20 @@ +package com.jborza.booklibrary.controller; + +import com.jborza.booklibrary.service.DownloaderService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + +@RestController +@RequestMapping("/download_book_covers") +public class DownloaderController { + private final DownloaderService downloaderService; + + public DownloaderController(DownloaderService downloaderService) { + this.downloaderService = downloaderService; + } + + @PostMapping + public Map trigger() { return Map.of("processed", downloaderService.processOne()); } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/PingController.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/PingController.java new file mode 100644 index 0000000..47d0348 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/PingController.java @@ -0,0 +1,11 @@ +package com.jborza.booklibrary.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + +@RestController +public class PingController { + @GetMapping("/ping/") + public Map ping() { return Map.of("status", "ok"); } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/RecommendationController.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/RecommendationController.java new file mode 100644 index 0000000..161c615 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/controller/RecommendationController.java @@ -0,0 +1,22 @@ +package com.jborza.booklibrary.controller; + +import com.jborza.booklibrary.service.RecommendationService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/recommendations") +public class RecommendationController { + private final RecommendationService recommendationService; + + public RecommendationController(RecommendationService recommendationService) { + this.recommendationService = recommendationService; + } + + @GetMapping("/{bookId}") + public Object recommend(@PathVariable Long bookId) { + return recommendationService.recommendationsFor(bookId, 5); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Author.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Author.java new file mode 100644 index 0000000..fe45bf6 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Author.java @@ -0,0 +1,38 @@ +package com.jborza.booklibrary.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name = "authors") +public class Author { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String surname; + + @Column(name = "surname_first", nullable = false) + private String surnameFirst; + + protected Author() { } + + public Author(String name, String surname, String surnameFirst) { + this.name = name; + this.surname = surname; + this.surnameFirst = surnameFirst; + } + + public Long getId() { return id; } + public String getName() { return name; } + public String getSurname() { return surname; } + public String getSurnameFirst() { return surnameFirst; } + + public void setName(String name) { this.name = name; } + public void setSurname(String surname) { this.surname = surname; } + public void setSurnameFirst(String surnameFirst) { this.surnameFirst = surnameFirst; } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Book.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Book.java new file mode 100644 index 0000000..4f563d7 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Book.java @@ -0,0 +1,95 @@ +package com.jborza.booklibrary.domain; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "books", indexes = { + @Index(name = "idx_books_author", columnList = "author_id"), + @Index(name = "idx_books_sortable_title", columnList = "sortable_title"), + @Index(name = "idx_books_remote_image_url", columnList = "remote_image_url") +}) +public class Book { + + private static final List ARTICLES = List.of("a ", "an ", "the "); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Author author; + + @Column(name = "status") + private String status; + + @Column(name = "tags") + private String tags; + + @Column(name = "remote_image_url") + private String remoteImageUrl; + + @Column(name = "sortable_title", nullable = false) + private String sortableTitle; + + @ManyToMany + @JoinTable(name = "book_collection", + joinColumns = @JoinColumn(name = "book_id"), + inverseJoinColumns = @JoinColumn(name = "collection_id")) + private Set collections = new HashSet<>(); + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + protected Book() { } + + public Book(String title, Author author, String status, String tags, String remoteImageUrl) { + this.title = title; + this.author = author; + this.status = status; + this.tags = tags; + this.remoteImageUrl = remoteImageUrl; + this.sortableTitle = computeSortableTitle(title); + } + + @PrePersist + @PreUpdate + public void updateSortableTitle() { + this.sortableTitle = computeSortableTitle(this.title); + } + + public static String computeSortableTitle(String original) { + if (original == null || original.isBlank()) return ""; + String lower = original.toLowerCase(); + for (String a : ARTICLES) { + if (lower.startsWith(a)) { + return original.substring(a.length()).trim(); + } + } + return original.trim(); + } + + public Long getId() { return id; } + public String getTitle() { return title; } + public Author getAuthor() { return author; } + public String getStatus() { return status; } + public String getTags() { return tags; } + public String getRemoteImageUrl() { return remoteImageUrl; } + public String getSortableTitle() { return sortableTitle; } + public Set getCollections() { return collections; } + public LocalDateTime getCreatedAt() { return createdAt; } + + public void setTitle(String title) { this.title = title; } + public void setAuthor(Author author) { this.author = author; } + public void setStatus(String status) { this.status = status; } + public void setTags(String tags) { this.tags = tags; } + public void setRemoteImageUrl(String remoteImageUrl) { this.remoteImageUrl = remoteImageUrl; } + public void setCollections(Set collections) { this.collections = collections; } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Collection.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Collection.java new file mode 100644 index 0000000..79879c2 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/Collection.java @@ -0,0 +1,32 @@ +package com.jborza.booklibrary.domain; + +import jakarta.persistence.*; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "collections") +public class Collection { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + @ManyToMany(mappedBy = "collections") + private Set books = new HashSet<>(); + + protected Collection() { } + + public Collection(String name) { + this.name = name; + } + + public Long getId() { return id; } + public String getName() { return name; } + public Set getBooks() { return books; } + + public void setName(String name) { this.name = name; } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/OtherBook.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/OtherBook.java new file mode 100644 index 0000000..4fb20f0 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/domain/OtherBook.java @@ -0,0 +1,38 @@ +package com.jborza.booklibrary.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name = "other_books") +public class OtherBook { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String author; + + @Column + private String source; + + protected OtherBook() { } + + public OtherBook(String title, String author, String source) { + this.title = title; + this.author = author; + this.source = source; + } + + public Long getId() { return id; } + public String getTitle() { return title; } + public String getAuthor() { return author; } + public String getSource() { return source; } + + public void setTitle(String title) { this.title = title; } + public void setAuthor(String author) { this.author = author; } + public void setSource(String source) { this.source = source; } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookRequest.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookRequest.java new file mode 100644 index 0000000..b8d7c0a --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookRequest.java @@ -0,0 +1,12 @@ +package com.jborza.booklibrary.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BookRequest( + @NotBlank String title, + @NotNull Long authorId, + String status, + String tags, + String remoteImageUrl +) { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookResponse.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookResponse.java new file mode 100644 index 0000000..506a62c --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/dto/BookResponse.java @@ -0,0 +1,12 @@ +package com.jborza.booklibrary.dto; + +public record BookResponse( + Long id, + String title, + String sortableTitle, + Long authorId, + String authorName, + String status, + String tags, + String remoteImageUrl +) { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/GlobalExceptionHandler.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ca37cc2 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package com.jborza.booklibrary.exception; + +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.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.time.Instant; +import java.util.Map; +import java.util.stream.Collectors; + +@ControllerAdvice +public class GlobalExceptionHandler { + + public record ErrorResponse(Instant timestamp, int code, String error, String message, Map details) { } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + new ErrorResponse(Instant.now(), 404, "NOT_FOUND", ex.getMessage(), null) + ); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body( + new ErrorResponse(Instant.now(), 400, "BAD_REQUEST", ex.getMessage(), null) + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + Map details = ex.getBindingResult().getAllErrors().stream() + .filter(e -> e instanceof FieldError) + .map(e -> (FieldError) e) + .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage)); + return ResponseEntity.badRequest().body( + new ErrorResponse(Instant.now(), 400, "VALIDATION_ERROR", "Validation failed", details) + ); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + new ErrorResponse(Instant.now(), 500, "INTERNAL_ERROR", ex.getMessage(), null) + ); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/NotFoundException.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/NotFoundException.java new file mode 100644 index 0000000..1e6de3b --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/exception/NotFoundException.java @@ -0,0 +1,5 @@ +package com.jborza.booklibrary.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { super(message); } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/mapper/BookMapper.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/mapper/BookMapper.java new file mode 100644 index 0000000..8f62080 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/mapper/BookMapper.java @@ -0,0 +1,21 @@ +package com.jborza.booklibrary.mapper; + +import com.jborza.booklibrary.domain.Book; +import com.jborza.booklibrary.dto.BookResponse; +import org.springframework.stereotype.Component; + +@Component +public class BookMapper { + public BookResponse toResponse(Book book) { + return new BookResponse( + book.getId(), + book.getTitle(), + book.getSortableTitle(), + book.getAuthor().getId(), + book.getAuthor().getName() + " " + book.getAuthor().getSurname(), + book.getStatus(), + book.getTags(), + book.getRemoteImageUrl() + ); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/AuthorRepository.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/AuthorRepository.java new file mode 100644 index 0000000..19d5fa0 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/AuthorRepository.java @@ -0,0 +1,6 @@ +package com.jborza.booklibrary.repository; + +import com.jborza.booklibrary.domain.Author; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthorRepository extends JpaRepository { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/BookRepository.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/BookRepository.java new file mode 100644 index 0000000..ff133c1 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/BookRepository.java @@ -0,0 +1,7 @@ +package com.jborza.booklibrary.repository; + +import com.jborza.booklibrary.domain.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface BookRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/CollectionRepository.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/CollectionRepository.java new file mode 100644 index 0000000..9c22355 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/CollectionRepository.java @@ -0,0 +1,6 @@ +package com.jborza.booklibrary.repository; + +import com.jborza.booklibrary.domain.Collection; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CollectionRepository extends JpaRepository { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/OtherBookRepository.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/OtherBookRepository.java new file mode 100644 index 0000000..0a63096 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/repository/OtherBookRepository.java @@ -0,0 +1,6 @@ +package com.jborza.booklibrary.repository; + +import com.jborza.booklibrary.domain.OtherBook; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OtherBookRepository extends JpaRepository { } diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/scheduler/CoverDownloadScheduler.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/scheduler/CoverDownloadScheduler.java new file mode 100644 index 0000000..394e5cd --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/scheduler/CoverDownloadScheduler.java @@ -0,0 +1,25 @@ +package com.jborza.booklibrary.scheduler; + +import com.jborza.booklibrary.service.DownloaderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class CoverDownloadScheduler { + private static final Logger log = LoggerFactory.getLogger(CoverDownloadScheduler.class); + private final DownloaderService downloaderService; + + public CoverDownloadScheduler(DownloaderService downloaderService) { + this.downloaderService = downloaderService; + } + + @Scheduled(fixedDelayString = "${covers.download.interval-ms:5000}") + public void run() { + boolean processed = downloaderService.processOne(); + if (processed) { + log.debug("Processed one cover download task"); + } + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/BookService.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/BookService.java new file mode 100644 index 0000000..6be3a31 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/BookService.java @@ -0,0 +1,56 @@ +package com.jborza.booklibrary.service; + +import com.jborza.booklibrary.domain.Book; +import com.jborza.booklibrary.dto.BookRequest; +import com.jborza.booklibrary.exception.NotFoundException; +import com.jborza.booklibrary.mapper.BookMapper; +import com.jborza.booklibrary.repository.AuthorRepository; +import com.jborza.booklibrary.repository.BookRepository; +import com.jborza.booklibrary.spec.BookSpecifications; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class BookService { + private final BookRepository bookRepository; + private final AuthorRepository authorRepository; + private final BookMapper mapper; + + public BookService(BookRepository bookRepository, AuthorRepository authorRepository, BookMapper mapper) { + this.bookRepository = bookRepository; + this.authorRepository = authorRepository; + this.mapper = mapper; + } + + @Transactional + public Object create(BookRequest req) { + var author = authorRepository.findById(req.authorId()) + .orElseThrow(() -> new NotFoundException("Author " + req.authorId() + " not found")); + Book book = new Book(req.title(), author, req.status(), req.tags(), req.remoteImageUrl()); + bookRepository.save(book); + return mapper.toResponse(book); + } + + public Object get(Long id) { + var book = bookRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Book " + id + " not found")); + return mapper.toResponse(book); + } + + public List search(String q, String status, String author) { + Specification spec = BookSpecifications.all(List.of( + BookSpecifications.titleOrTagsLike(q), + BookSpecifications.statusEquals(status), + BookSpecifications.authorNameLike(author) + )); + return bookRepository.findAll(spec, Sort.by("sortableTitle").ascending()) + .stream() + .map(mapper::toResponse) + .map(o -> (Object) o) + .toList(); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/DownloaderService.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/DownloaderService.java new file mode 100644 index 0000000..7daad40 --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/DownloaderService.java @@ -0,0 +1,15 @@ +package com.jborza.booklibrary.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class DownloaderService { + private static final Logger log = LoggerFactory.getLogger(DownloaderService.class); + + public boolean processOne() { + log.debug("DownloaderService.processOne placeholder invoked"); + return false; // placeholder + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/FixService.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/FixService.java new file mode 100644 index 0000000..87f331a --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/FixService.java @@ -0,0 +1,9 @@ +package com.jborza.booklibrary.service; + +import org.springframework.stereotype.Service; +import java.util.List; + +@Service +public class FixService { + public List listPendingGenreFixes() { return List.of(); } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/RecommendationService.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/RecommendationService.java new file mode 100644 index 0000000..119bdfd --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/service/RecommendationService.java @@ -0,0 +1,19 @@ +package com.jborza.booklibrary.service; + +import com.jborza.booklibrary.repository.OtherBookRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class RecommendationService { + private final OtherBookRepository otherBookRepository; + + public RecommendationService(OtherBookRepository otherBookRepository) { + this.otherBookRepository = otherBookRepository; + } + + public List recommendationsFor(Long bookId, int limit) { + return otherBookRepository.findAll().stream().limit(limit).toList(); + } +} diff --git a/booklibrary-spring/src/main/java/com/jborza/booklibrary/spec/BookSpecifications.java b/booklibrary-spring/src/main/java/com/jborza/booklibrary/spec/BookSpecifications.java new file mode 100644 index 0000000..04842be --- /dev/null +++ b/booklibrary-spring/src/main/java/com/jborza/booklibrary/spec/BookSpecifications.java @@ -0,0 +1,53 @@ +package com.jborza.booklibrary.spec; + +import com.jborza.booklibrary.domain.Book; +import org.springframework.data.jpa.domain.Specification; +import jakarta.persistence.criteria.Join; + +import java.util.ArrayList; +import java.util.List; + +public final class BookSpecifications { + private BookSpecifications() { } + + public static Specification titleOrTagsLike(String query) { + return (root, cq, cb) -> { + if (query == null || query.isBlank()) return null; + String like = "%" + query.toLowerCase() + "%"; + return cb.or( + cb.like(cb.lower(root.get("title")), like), + cb.like(cb.lower(root.get("tags")), like) + ); + }; + } + + public static Specification statusEquals(String status) { + return (root, cq, cb) -> { + if (status == null || status.isBlank()) return null; + return cb.equal(root.get("status"), status); + }; + } + + public static Specification authorNameLike(String author) { + return (root, cq, cb) -> { + if (author == null || author.isBlank()) return null; + String pattern = "%" + author.toLowerCase() + "%"; + Join joinAuthor = root.join("author"); + return cb.or( + cb.like(cb.lower(joinAuthor.get("name")), pattern), + cb.like(cb.lower(joinAuthor.get("surname")), pattern) + ); + }; + } + + public static Specification all(List> specs) { + List> nonNull = new ArrayList<>(); + for (Specification s : specs) if (s != null) nonNull.add(s); + if (nonNull.isEmpty()) return Specification.where(null); + Specification result = nonNull.get(0); + for (int i = 1; i < nonNull.size(); i++) { + result = result.and(nonNull.get(i)); + } + return result; + } +} diff --git a/booklibrary-spring/src/main/resources/application.yml b/booklibrary-spring/src/main/resources/application.yml new file mode 100644 index 0000000..873b18f --- /dev/null +++ b/booklibrary-spring/src/main/resources/application.yml @@ -0,0 +1,41 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/booklibrary + username: booklibrary + password: booklibrary + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + format_sql: true + flyway: + enabled: true + +logging: + level: + root: INFO + com.jborza.booklibrary: DEBUG + +--- +spring: + config: + activate: + on-profile: dev + datasource: + url: jdbc:h2:mem:booklibrary;MODE=PostgreSQL;DATABASE_TO_UPPER=false + username: sa + password: + driverClassName: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: false +logging: + level: + root: INFO diff --git a/booklibrary-spring/src/main/resources/db/migration/V1__baseline.sql b/booklibrary-spring/src/main/resources/db/migration/V1__baseline.sql new file mode 100644 index 0000000..963cff8 --- /dev/null +++ b/booklibrary-spring/src/main/resources/db/migration/V1__baseline.sql @@ -0,0 +1,39 @@ +CREATE TABLE authors ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + surname VARCHAR(255) NOT NULL, + surname_first VARCHAR(255) NOT NULL +); + +CREATE TABLE collections ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE books ( + id SERIAL PRIMARY KEY, + author_id INTEGER NOT NULL REFERENCES authors(id), + title VARCHAR(1024) NOT NULL, + sortable_title VARCHAR(1024) NOT NULL, + status VARCHAR(100), + tags VARCHAR(1024), + remote_image_url VARCHAR(1024), + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE other_books ( + id SERIAL PRIMARY KEY, + title VARCHAR(1024) NOT NULL, + author VARCHAR(512) NOT NULL, + source VARCHAR(255) +); + +CREATE TABLE book_collection ( + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + PRIMARY KEY (book_id, collection_id) +); + +CREATE INDEX idx_books_author ON books(author_id); +CREATE INDEX idx_books_sortable_title ON books(sortable_title); +CREATE INDEX idx_books_remote_image_url ON books(remote_image_url);