Skip to content

Commit f1b0237

Browse files
add multi source search for books
1 parent 586735b commit f1b0237

File tree

7 files changed

+348
-19
lines changed

7 files changed

+348
-19
lines changed

internal/clients/bookapi.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package clients
2+
3+
import (
4+
"fmt"
5+
"log"
6+
7+
"github.com/nevzattalhaozcan/forgotten/internal/models"
8+
)
9+
10+
type BookAPIClient interface {
11+
Search(query string, limit int) ([]*models.ExternalBook, error)
12+
SearchMerged(query string, limit int) ([]*models.ExternalBook, error)
13+
}
14+
15+
type MultiSourceClient struct {
16+
googleBooks *GoogleBooksClient
17+
openLibrary *OpenLibraryClient
18+
preferredSource string
19+
}
20+
21+
func NewMultiSourceClient(googleAPIKey string, preferredSource string) *MultiSourceClient {
22+
var googleClient *GoogleBooksClient
23+
if googleAPIKey != "" {
24+
googleClient = NewGoogleBooksClient(googleAPIKey)
25+
}
26+
return &MultiSourceClient{
27+
googleBooks: googleClient,
28+
openLibrary: NewOpenLibraryClient(),
29+
preferredSource: preferredSource,
30+
}
31+
}
32+
33+
func (c *MultiSourceClient) Search(query string, limit int) ([]*models.ExternalBook, error) {
34+
var books []*models.ExternalBook
35+
var err error
36+
37+
if c.preferredSource == "google" || c.preferredSource == "all" {
38+
if c.googleBooks != nil {
39+
books, err = c.googleBooks.Search(query, limit, true)
40+
if err == nil && len(books) > 0 {
41+
return books, nil
42+
}
43+
if err != nil {
44+
fmt.Printf("Google Books search error: %v, falling back to Open Library\n", err)
45+
}
46+
}
47+
}
48+
49+
books, err = c.openLibrary.Search(query, limit)
50+
if err != nil {
51+
return nil, fmt.Errorf("all sources failed: %w", err)
52+
}
53+
log.Printf("Found %d books from Open Library\n", len(books))
54+
return books, nil
55+
}
56+
57+
func (c *MultiSourceClient) SearchMerged(query string, limit int) ([]*models.ExternalBook, error) {
58+
var allBooks []*models.ExternalBook
59+
seen := make(map[string]bool)
60+
61+
if c.googleBooks != nil {
62+
gbBooks, err := c.googleBooks.SearchMultiLang(query, limit)
63+
if err == nil {
64+
for _, b := range gbBooks {
65+
key := getExternalBookKey(b)
66+
if !seen[key] {
67+
seen[key] = true
68+
allBooks = append(allBooks, b)
69+
}
70+
}
71+
log.Printf("Found %d books from Google Books\n", len(gbBooks))
72+
} else {
73+
fmt.Printf("Google Books search error: %v\n", err)
74+
}
75+
}
76+
77+
if len(allBooks) < limit {
78+
remaining := limit - len(allBooks)
79+
olBooks, err := c.openLibrary.Search(query, remaining*2)
80+
if err == nil {
81+
for _, b := range olBooks {
82+
if len(allBooks) >= limit {
83+
break
84+
}
85+
key := getExternalBookKey(b)
86+
if !seen[key] {
87+
seen[key] = true
88+
allBooks = append(allBooks, b)
89+
}
90+
}
91+
log.Printf("Found %d books from Open Library\n", len(olBooks))
92+
}
93+
}
94+
95+
if len(allBooks) == 0 {
96+
return nil, fmt.Errorf("no books found from any source")
97+
}
98+
99+
return allBooks, nil
100+
}
101+
102+
func getExternalBookKey(eb *models.ExternalBook) string {
103+
if eb.ISBN != nil && *eb.ISBN != "" {
104+
return "isbn:" + *eb.ISBN
105+
}
106+
return "ext:" + eb.ExternalID
107+
}

internal/clients/googlebooks.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package clients
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
9+
"github.com/nevzattalhaozcan/forgotten/internal/models"
10+
)
11+
12+
type GoogleBooksClient struct {
13+
APIKey string
14+
BaseURL string
15+
Client *http.Client
16+
}
17+
18+
func NewGoogleBooksClient(apiKey string) *GoogleBooksClient {
19+
return &GoogleBooksClient{
20+
APIKey: apiKey,
21+
BaseURL: "https://www.googleapis.com/books/v1",
22+
Client: &http.Client{},
23+
}
24+
}
25+
26+
type gbSearchResponse struct {
27+
Items []struct {
28+
ID string
29+
VolumeInfo struct {
30+
Title string `json:"title"`
31+
Authors []string `json:"authors"`
32+
Publisher string `json:"publisher"`
33+
PublishedDate string `json:"publishedDate"`
34+
Description string `json:"description"`
35+
PageCount int `json:"pageCount"`
36+
Categories []string `json:"categories"`
37+
AverageRating float32 `json:"averageRating"`
38+
RatingsCount int `json:"ratingsCount"`
39+
Language string `json:"language"`
40+
IndustryIdentifiers []struct {
41+
Type string `json:"type"`
42+
Identifier string `json:"identifier"`
43+
} `json:"industryIdentifiers"`
44+
ImageLinks struct {
45+
Thumbnail string `json:"thumbnail"`
46+
SmallThumbnail string `json:"smallThumbnail"`
47+
} `json:"imageLinks"`
48+
}
49+
}
50+
}
51+
52+
func (c *GoogleBooksClient) Search(query string, limit int, tr bool) ([]*models.ExternalBook, error) {
53+
params := url.Values{}
54+
params.Set("q", query)
55+
if tr {
56+
params.Set("langRestrict", "tr")
57+
}
58+
params.Set("maxResults", fmt.Sprintf("%d", limit))
59+
params.Set("key", c.APIKey)
60+
61+
u := fmt.Sprintf("%s/volumes?%s", c.BaseURL, params.Encode())
62+
resp, err := c.Client.Get(u)
63+
if err != nil {
64+
return nil, err
65+
}
66+
defer resp.Body.Close()
67+
68+
var data gbSearchResponse
69+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
70+
return nil, err
71+
}
72+
73+
var out []*models.ExternalBook
74+
for _, item := range data.Items {
75+
vi := item.VolumeInfo
76+
77+
var author *string
78+
if len(vi.Authors) > 0 {
79+
a := vi.Authors[0]
80+
author = &a
81+
}
82+
83+
var isbn *string
84+
for _, id := range vi.IndustryIdentifiers {
85+
if id.Type == "ISBN_13" {
86+
isbn = &id.Identifier
87+
break
88+
}
89+
}
90+
91+
var genre *string
92+
if len(vi.Categories) > 0 {
93+
g := vi.Categories[0]
94+
genre = &g
95+
}
96+
97+
var coverURL *string
98+
if vi.ImageLinks.Thumbnail != "" {
99+
cover := vi.ImageLinks.Thumbnail
100+
coverURL = &cover
101+
}
102+
103+
var year *int
104+
if len(vi.PublishedDate) >= 4 {
105+
y := 0
106+
fmt.Sscanf(vi.PublishedDate[:4], "%d", &y)
107+
if y > 0 {
108+
year = &y
109+
}
110+
}
111+
112+
var desc *string
113+
if vi.Description != "" {
114+
desc = &vi.Description
115+
}
116+
117+
var rating *float32
118+
if vi.AverageRating > 0 {
119+
rating = &vi.AverageRating
120+
}
121+
122+
eb := &models.ExternalBook{
123+
ExternalID: fmt.Sprintf("GB_%s", item.ID),
124+
Source: "googlebooks",
125+
Title: vi.Title,
126+
Author: author,
127+
CoverURL: coverURL,
128+
Genre: genre,
129+
Pages: &vi.PageCount,
130+
PublishedYear: year,
131+
ISBN: isbn,
132+
Description: desc,
133+
Rating: rating,
134+
}
135+
out = append(out, eb)
136+
}
137+
return out, nil
138+
}
139+
140+
func (c *GoogleBooksClient) SearchMultiLang(query string, limit int) ([]*models.ExternalBook, error) {
141+
books, err := c.Search(query, limit, true)
142+
if err == nil && len(books) > limit/2 {
143+
return books, nil
144+
}
145+
146+
return c.Search(query, limit, false)
147+
}

internal/clients/openlibrary.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ type olSearchResponse struct {
3434
}
3535

3636
func (c *OpenLibraryClient) Search(query string, limit int) ([]*models.ExternalBook, error) {
37-
u := fmt.Sprintf("%s/search.json?q=%s&limit=%d", c.BaseURL, url.QueryEscape(query), limit)
37+
u := fmt.Sprintf("%s/search.json?q=%s&language=tur&limit=%d",
38+
c.BaseURL, url.QueryEscape(query),
39+
limit)
3840
resp, err := c.Client.Get(u)
3941
if err != nil {
4042
return nil, err

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ type Config struct {
1414
JWT JWTConfig
1515
App AppConfig
1616
Redis RedisConfig
17+
BookAPIs BookAPIsConfig
18+
}
19+
20+
type BookAPIsConfig struct {
21+
GoogleBooksAPIKey string
22+
ISBNDBAPIKey string
23+
PreferredSource string // "google", "isbndb", "openlibrary"
1724
}
1825

1926
type ServerConfig struct {
@@ -88,6 +95,11 @@ func Load() *Config {
8895
TLS: getEnvAsBool("REDIS_TLS", false),
8996
CacheTTLSeconds: getEnvAsInt("REDIS_CACHE_TTL_SECONDS", 600),
9097
},
98+
BookAPIs: BookAPIsConfig{
99+
GoogleBooksAPIKey: getEnv("GOOGLE_BOOKS_API_KEY", ""),
100+
ISBNDBAPIKey: getEnv("ISBNDB_API_KEY", ""),
101+
PreferredSource: getEnv("BOOK_API_SOURCE", "google"),
102+
},
91103
}
92104
}
93105

internal/handlers/book.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,17 @@ func (h *BookHandler) ListBooks(c *gin.Context) {
179179
c.JSON(http.StatusOK, gin.H{"books": books})
180180
}
181181

182+
// @Summary Search for books
183+
// @Description Search for books using an external API
184+
// @Tags Books
185+
// @Produce json
186+
// @Param q query string true "Search query"
187+
// @Param limit query int false "Number of results to return" default(20)
188+
// @Param source query string false "Source to search (google, isbndb, openlibrary)" default(all)
189+
// @Success 200 {object} map[string]interface{} "Search results"
190+
// @Failure 400 {object} map[string]string "Bad request"
191+
// @Failure 500 {object} map[string]string "Internal server error"
192+
// @Router /api/v1/books/search [get]
182193
func (h *BookHandler) Search(c *gin.Context) {
183194
query := c.Query("q")
184195
if query == "" {

internal/handlers/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (s *Server) setupRoutes() {
5959
var clubRepo repository.ClubRepository = repository.NewClubRepository(s.db)
6060
var eventRepo repository.EventRepository = repository.NewEventRepository(s.db)
6161
var bookRepo repository.BookRepository = repository.NewBookRepository(s.db)
62-
var olClient clients.OpenLibraryClient = *clients.NewOpenLibraryClient()
62+
var bookClient clients.BookAPIClient = clients.NewMultiSourceClient(s.config.BookAPIs.GoogleBooksAPIKey, s.config.BookAPIs.PreferredSource)
6363
var postRepo repository.PostRepository = repository.NewPostRepository(s.db)
6464
var commentRepo repository.CommentRepository = repository.NewCommentRepository(s.db)
6565
var readingRepo repository.ReadingRepository = repository.NewReadingRepository(s.db)
@@ -95,7 +95,7 @@ func (s *Server) setupRoutes() {
9595
eventService := services.NewEventService(eventRepo, clubRepo, s.config)
9696
eventHandler := NewEventHandler(eventService)
9797

98-
bookService := services.NewBookService(bookRepo, &olClient, s.config)
98+
bookService := services.NewBookService(bookRepo, bookClient, s.config)
9999
bookHandler := NewBookHandler(bookService)
100100

101101
postService := services.NewPostService(postRepo, userRepo, clubRepo, bookRepo, s.db, s.config)

0 commit comments

Comments
 (0)