Skip to content

Commit 3e74fcc

Browse files
add external book search
1 parent a0cc497 commit 3e74fcc

File tree

7 files changed

+321
-21
lines changed

7 files changed

+321
-21
lines changed

internal/clients/openlibrary.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package clients
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"time"
9+
10+
"github.com/nevzattalhaozcan/forgotten/internal/models"
11+
)
12+
13+
type OpenLibraryClient struct {
14+
BaseURL string
15+
Client *http.Client
16+
}
17+
18+
func NewOpenLibraryClient() *OpenLibraryClient {
19+
return &OpenLibraryClient{
20+
BaseURL: "https://openlibrary.org",
21+
Client: &http.Client{Timeout: 10 * time.Second},
22+
}
23+
}
24+
25+
type olSearchResponse struct {
26+
Docs []struct {
27+
Key string `json:"key"`
28+
Title string `json:"title"`
29+
AuthorName []string `json:"author_name"`
30+
FirstPublishYear *int `json:"first_publish_year"`
31+
ISBN []string `json:"isbn"`
32+
CoverI *int `json:"cover_i"`
33+
} `json:"docs"`
34+
}
35+
36+
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)
38+
resp, err := c.Client.Get(u)
39+
if err != nil {
40+
return nil, err
41+
}
42+
defer resp.Body.Close()
43+
44+
var data olSearchResponse
45+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
46+
return nil, err
47+
}
48+
49+
var out []*models.ExternalBook
50+
for _, d := range data.Docs {
51+
var author *string
52+
if len(d.AuthorName) > 0 {
53+
a := d.AuthorName[0]
54+
author = &a
55+
}
56+
var isbn *string
57+
if len(d.ISBN) > 0 {
58+
i := d.ISBN[0]
59+
isbn = &i
60+
}
61+
var pubYear *int
62+
if d.FirstPublishYear != nil {
63+
pubYear = d.FirstPublishYear
64+
}
65+
var coverURL *string
66+
if d.CoverI != nil {
67+
u := fmt.Sprintf("https://covers.openlibrary.org/b/id/%d-L.jpg", *d.CoverI)
68+
coverURL = &u
69+
}
70+
71+
eb := &models.ExternalBook{
72+
ExternalID: fmt.Sprintf("OL%s", d.Key),
73+
Source: "openlibrary",
74+
Title: d.Title,
75+
Author: author,
76+
CoverURL: coverURL,
77+
PublishedYear: pubYear,
78+
ISBN: isbn,
79+
}
80+
out = append(out, eb)
81+
}
82+
return out, nil
83+
}

internal/handlers/book.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,22 @@ func (h *BookHandler) ListBooks(c *gin.Context) {
177177
}
178178

179179
c.JSON(http.StatusOK, gin.H{"books": books})
180+
}
181+
182+
func (h *BookHandler) Search(c *gin.Context) {
183+
query := c.Query("q")
184+
if query == "" {
185+
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
186+
return
187+
}
188+
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
189+
source := c.DefaultQuery("source", "all")
190+
191+
results, err := h.bookService.SearchBooks(query, limit, source)
192+
if err != nil {
193+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
194+
return
195+
}
196+
197+
c.JSON(http.StatusOK, gin.H{"books": results})
180198
}

internal/handlers/server.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/gin-contrib/cors"
1616
"github.com/gin-gonic/gin"
17+
"github.com/nevzattalhaozcan/forgotten/internal/clients"
1718
"github.com/nevzattalhaozcan/forgotten/internal/config"
1819
"github.com/nevzattalhaozcan/forgotten/internal/middleware"
1920
"github.com/nevzattalhaozcan/forgotten/internal/repository"
@@ -58,6 +59,7 @@ func (s *Server) setupRoutes() {
5859
var clubRepo repository.ClubRepository = repository.NewClubRepository(s.db)
5960
var eventRepo repository.EventRepository = repository.NewEventRepository(s.db)
6061
var bookRepo repository.BookRepository = repository.NewBookRepository(s.db)
62+
var olClient clients.OpenLibraryClient = *clients.NewOpenLibraryClient()
6163
var postRepo repository.PostRepository = repository.NewPostRepository(s.db)
6264
var commentRepo repository.CommentRepository = repository.NewCommentRepository(s.db)
6365
var readingRepo repository.ReadingRepository = repository.NewReadingRepository(s.db)
@@ -93,7 +95,7 @@ func (s *Server) setupRoutes() {
9395
eventService := services.NewEventService(eventRepo, clubRepo, s.config)
9496
eventHandler := NewEventHandler(eventService)
9597

96-
bookService := services.NewBookService(bookRepo, s.config)
98+
bookService := services.NewBookService(bookRepo, &olClient, s.config)
9799
bookHandler := NewBookHandler(bookService)
98100

99101
postService := services.NewPostService(postRepo, userRepo, clubRepo, bookRepo, s.db, s.config)
@@ -123,7 +125,7 @@ func (s *Server) setupRoutes() {
123125
api.GET("/posts/public", postHandler.ListPublicPosts)
124126
api.GET("/posts/popular", postHandler.ListPopularPublicPosts)
125127

126-
api.GET("/books", bookHandler.ListBooks)
128+
api.GET("/books", bookHandler.Search)
127129
api.GET("/books/:id", bookHandler.GetBookByID)
128130

129131
api.GET("/posts/:id/likes", postHandler.ListLikesByPostID)

internal/models/book.go

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import (
77
)
88

99
type Book struct {
10-
ID uint `json:"id" gorm:"primaryKey"`
10+
ID uint `json:"id" gorm:"primaryKey"`
11+
12+
// External API fields
13+
ExternalID *string `json:"external_id,omitempty" gorm:"index"`
14+
Source *string `json:"source,omitempty" gorm:"size:50"`
15+
16+
// Book metadata
1117
Title string `json:"title" gorm:"size:255;not null"`
12-
Author *string `json:"author,omitempty" gorm:"size:255"`
18+
Author *string `json:"author,omitempty" gorm:"size:255"`
1319
CoverURL *string `json:"cover_url,omitempty" gorm:"type:text"`
1420
Genre *string `json:"genre,omitempty" gorm:"size:100"`
1521
Pages *int `json:"pages,omitempty"`
@@ -18,25 +24,71 @@ type Book struct {
1824
Description *string `json:"description,omitempty" gorm:"type:text"`
1925
Rating *float32 `json:"rating,omitempty" gorm:"type:decimal(2,1)" validate:"omitempty,gte=0,lte=5"`
2026

27+
// Platform-specific analytics (simplified)
28+
ReadCount int `json:"read_count" gorm:"default:0"`
29+
LocalRating *float32 `json:"local_rating,omitempty" gorm:"type:decimal(2,1)"`
30+
RatingCount int `json:"rating_count" gorm:"default:0"`
31+
IsClubFavorite bool `json:"is_club_favorite" gorm:"default:false"`
32+
IsTrending bool `json:"is_trending" gorm:"default:false"`
33+
34+
// Cache management
35+
CachedAt *time.Time `json:"cached_at,omitempty"`
36+
LastAccessed *time.Time `json:"last_accessed,omitempty"`
37+
2138
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
2239
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
2340
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
2441
}
2542

43+
type ExternalBook struct {
44+
ExternalID string `json:"external_id"`
45+
Source string `json:"source"`
46+
Title string `json:"title"`
47+
Author *string `json:"author,omitempty"`
48+
CoverURL *string `json:"cover_url,omitempty"`
49+
Genre *string `json:"genre,omitempty"`
50+
Pages *int `json:"pages,omitempty"`
51+
PublishedYear *int `json:"published_year,omitempty"`
52+
ISBN *string `json:"isbn,omitempty"`
53+
Description *string `json:"description,omitempty"`
54+
Rating *float32 `json:"rating,omitempty"`
55+
}
56+
57+
func (eb *ExternalBook) ToBook() *Book {
58+
now := time.Now()
59+
return &Book{
60+
ExternalID: &eb.ExternalID,
61+
Source: &eb.Source,
62+
Title: eb.Title,
63+
Author: eb.Author,
64+
CoverURL: eb.CoverURL,
65+
Genre: eb.Genre,
66+
Pages: eb.Pages,
67+
PublishedYear: eb.PublishedYear,
68+
ISBN: eb.ISBN,
69+
Description: eb.Description,
70+
Rating: eb.Rating,
71+
CachedAt: &now,
72+
LastAccessed: &now,
73+
}
74+
}
75+
2676
type CreateBookRequest struct {
2777
Title string `json:"title" validate:"required,min=1,max=255"`
28-
Author *string `json:"author,omitempty" validate:"omitempty,min=1,max=255"`
78+
Author *string `json:"author,omitempty" validate:"omitempty,min=1,max=255"`
2979
CoverURL *string `json:"cover_url,omitempty" validate:"omitempty,url"`
3080
Genre *string `json:"genre,omitempty" validate:"omitempty,min=1,max=100"`
3181
Pages *int `json:"pages,omitempty" validate:"omitempty,gte=1"`
3282
PublishedYear *int `json:"published_year,omitempty" validate:"omitempty,gte=0,lte=2100"`
3383
ISBN *string `json:"isbn,omitempty" validate:"omitempty,isbn"`
3484
Description *string `json:"description,omitempty" validate:"omitempty,min=1"`
85+
ExternalID *string `json:"external_id,omitempty"`
86+
Source *string `json:"source,omitempty"`
3587
}
3688

3789
type UpdateBookRequest struct {
3890
Title *string `json:"title,omitempty" validate:"omitempty,min=1,max=255"`
39-
Author *string `json:"author,omitempty" validate:"omitempty,min=1,max=255"`
91+
Author *string `json:"author,omitempty" validate:"omitempty,min=1,max=255"`
4092
CoverURL *string `json:"cover_url,omitempty" validate:"omitempty,url"`
4193
Genre *string `json:"genre,omitempty" validate:"omitempty,min=1,max=100"`
4294
Pages *int `json:"pages,omitempty" validate:"omitempty,gte=1"`
@@ -46,35 +98,61 @@ type UpdateBookRequest struct {
4698
Rating *float32 `json:"rating,omitempty" validate:"omitempty,gte=0,lte=5"`
4799
}
48100

101+
type BookSearchRequest struct {
102+
Query string `json:"query" form:"q" validate:"required,min=1"`
103+
Limit int `json:"limit" form:"limit" validate:"omitempty,gte=1,lte=100"`
104+
Source string `json:"source" form:"source" validate:"omitempty,oneof=local external all"`
105+
}
106+
49107
type BookResponse struct {
50108
ID uint `json:"id"`
109+
ExternalID *string `json:"external_id,omitempty"`
110+
Source *string `json:"source,omitempty"`
51111
Title string `json:"title"`
52-
Author *string `json:"author,omitempty"`
112+
Author *string `json:"author,omitempty"`
53113
CoverURL *string `json:"cover_url,omitempty"`
54114
Genre *string `json:"genre,omitempty"`
55115
Pages *int `json:"pages,omitempty"`
56116
PublishedYear *int `json:"published_year,omitempty"`
57117
ISBN *string `json:"isbn,omitempty"`
58118
Description *string `json:"description,omitempty"`
59119
Rating *float32 `json:"rating,omitempty"`
120+
LocalRating *float32 `json:"local_rating,omitempty"`
121+
122+
ReadCount int `json:"read_count"`
123+
RatingCount int `json:"rating_count"`
124+
IsClubFavorite bool `json:"is_club_favorite"`
125+
IsTrending bool `json:"is_trending"`
126+
127+
ClubCount *int `json:"club_count,omitempty"`
128+
129+
UserRating *float32 `json:"user_rating,omitempty"`
130+
ReadingStatus *string `json:"reading_status,omitempty"`
60131

61132
CreatedAt time.Time `json:"created_at"`
62133
UpdatedAt time.Time `json:"updated_at"`
63134
}
64135

65136
func (b *Book) ToResponse() BookResponse {
66137
return BookResponse{
67-
ID: b.ID,
68-
Title: b.Title,
69-
Author: b.Author,
70-
CoverURL: b.CoverURL,
71-
Genre: b.Genre,
72-
Pages: b.Pages,
73-
PublishedYear: b.PublishedYear,
74-
ISBN: b.ISBN,
75-
Description: b.Description,
76-
Rating: b.Rating,
77-
CreatedAt: b.CreatedAt,
78-
UpdatedAt: b.UpdatedAt,
138+
ID: b.ID,
139+
ExternalID: b.ExternalID,
140+
Source: b.Source,
141+
Title: b.Title,
142+
Author: b.Author,
143+
CoverURL: b.CoverURL,
144+
Genre: b.Genre,
145+
Pages: b.Pages,
146+
PublishedYear: b.PublishedYear,
147+
ISBN: b.ISBN,
148+
Description: b.Description,
149+
Rating: b.Rating,
150+
LocalRating: b.LocalRating,
151+
ReadCount: b.ReadCount,
152+
RatingCount: b.RatingCount,
153+
IsClubFavorite: b.IsClubFavorite,
154+
IsTrending: b.IsTrending,
155+
CreatedAt: b.CreatedAt,
156+
UpdatedAt: b.UpdatedAt,
79157
}
80-
}
158+
}

internal/repository/book.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package repository
22

33
import (
4+
"strings"
5+
46
"github.com/nevzattalhaozcan/forgotten/internal/models"
57
"gorm.io/gorm"
68
)
@@ -41,4 +43,59 @@ func (r *bookRepository) List(limit, offset int) ([]*models.Book, error) {
4143
return nil, err
4244
}
4345
return books, nil
46+
}
47+
48+
func (r *bookRepository) GetByExternalID(source, externalID string) (*models.Book, error) {
49+
var book models.Book
50+
if err := r.db.Where("source = ? AND external_id = ?", source, externalID).First(&book).Error; err != nil {
51+
return nil, err
52+
}
53+
return &book, nil
54+
}
55+
56+
func (r *bookRepository) GetByISBN(isbn string) (*models.Book, error) {
57+
var book models.Book
58+
if err := r.db.Where("isbn = ?", isbn).First(&book).Error; err != nil {
59+
return nil, err
60+
}
61+
return &book, nil
62+
}
63+
64+
func (r *bookRepository) UpsertByExternalID(book *models.Book) error {
65+
if book.ExternalID == nil || book.Source == nil {
66+
return r.db.Save(book).Error
67+
}
68+
69+
var existing models.Book
70+
err := r.db.Where("source = ? AND external_id = ?", *book.Source, *book.ExternalID).First(&existing).Error
71+
if err != nil {
72+
if err == gorm.ErrRecordNotFound {
73+
return r.db.Create(book).Error
74+
}
75+
return err
76+
}
77+
78+
existing.Title = book.Title
79+
existing.Author = book.Author
80+
existing.CoverURL = book.CoverURL
81+
existing.Genre = book.Genre
82+
existing.Pages = book.Pages
83+
existing.PublishedYear = book.PublishedYear
84+
existing.ISBN = book.ISBN
85+
existing.Description = book.Description
86+
existing.Rating = book.Rating
87+
now := book.LastAccessed
88+
if now != nil {
89+
existing.LastAccessed = book.LastAccessed
90+
}
91+
return r.db.Save(&existing).Error
92+
}
93+
94+
func (r *bookRepository) SearchLocal(query string, limit int) ([]*models.Book, error) {
95+
q := "%" + strings.ToLower(query) + "%"
96+
var books []*models.Book
97+
if err := r.db.Where("LOWER(title) LIKE ? OR LOWER(author) LIKE ?", q, q).Limit(limit).Find(&books).Error; err != nil {
98+
return nil, err
99+
}
100+
return books, nil
44101
}

internal/repository/interfaces.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ type BookRepository interface {
5555
Update(book *models.Book) error
5656
Delete(id uint) error
5757
List(limit, offset int) ([]*models.Book, error)
58+
GetByExternalID(source, externalID string) (*models.Book, error)
59+
GetByISBN(isbn string) (*models.Book, error)
60+
UpsertByExternalID(book *models.Book) error
61+
SearchLocal(query string, limit int) ([]*models.Book, error)
5862
}
5963

6064
type PostRepository interface {

0 commit comments

Comments
 (0)