Skip to content
Open
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
53 changes: 53 additions & 0 deletions booklibrary-spring/README.md
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 36 additions & 0 deletions booklibrary-spring/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions booklibrary-spring/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "booklibrary-spring"
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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(); }
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> trigger() { return Map.of("processed", downloaderService.processOne()); }
}
Original file line number Diff line number Diff line change
@@ -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<String, String> ping() { return Map.of("status", "ok"); }
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<Collection> 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<Collection> 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<Collection> collections) { this.collections = collections; }
}
Original file line number Diff line number Diff line change
@@ -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<Book> books = new HashSet<>();

protected Collection() { }

public Collection(String name) {
this.name = name;
}

public Long getId() { return id; }
public String getName() { return name; }
public Set<Book> getBooks() { return books; }

public void setName(String name) { this.name = name; }
}
Loading