diff --git a/README.md b/README.md index 370016d..12067e5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # abs-tract -This is an "all-in-one" book metadata provider for AudiobookShelf that can currently pull metadata from Goodreads and Kindle Store. +This is an "all-in-one" book metadata provider for AudiobookShelf that can currently pull metadata from Goodreads, Kindle Store and BookBeat. Current metadata providers plan to be improved, and other metadata providers are on the roadmap. @@ -55,6 +55,35 @@ I'm glad you asked - It's a fun play on words. AudiobookShelf is often abbreviat - Publish Year - of edition chosen by Amazon. **Not original publish year** - ASIN +### BookBeat + +#### Pros: +- High-quality covers (2048x2048) +- Comprehensive metadata including narrators and duration +- Supports multiple markets and languages +- Format filtering (audiobook/ebook) + +#### Cons: +- Content availability varies by market (country) +- Some books may not be available in certain formats in all markets + +#### Metadata Provided: + - Title + - Subtitle + - Author(s) + - Narrator(s) + - Cover (2048x2048 might take a few seconds to appear) + - Description + - Duration (in minutes) + - ISBN + - Genres + - Language + - Series Name + - Series Position + - Publisher + - Published Year + - Tags + ## Running The best way to run abs-tract is to use Docker. To run abs-tract using Docker, use the following command: @@ -87,6 +116,14 @@ curl --request GET \ --url "http://$ADDRESS:5555/kindle/uk/search?query=The+Hobbit&author=J.R.R.+Tolkien" ``` +### BookBeat + +```bash +ADDRESS=localhost +curl --request GET \ + --url "http://$ADDRESS:5555/bookbeat/uk/audiobook/en/search?query=The+Hobbit&author=J.R.R.+Tolkien" +``` + ## Setup with AudiobookShelf You can then set up abs-tract in AudiobookShelf. @@ -124,6 +161,76 @@ Region can be one of the following: - uk - United Kingdom - us - United States +### BookBeat + +- Name: **BookBeat** +- URL: `http://:5555/bookbeat///` + - e.g. `192.168.1.100:5555/bookbeat/uk/all/en,de` +- Authorization Header Value: **Leave this unset** + +Market determines which BookBeat regional catalog to search. Different markets may have different book availability. Market can be one of the following: + +- at - Austria +- be - Belgium +- bg - Bulgaria +- hr - Croatia +- cy - Cyprus +- cz - Czech Republic +- dk - Denmark +- ee - Estonia +- fi - Finland +- fr - France +- de - Germany +- gr - Greece +- hu - Hungary +- ie - Ireland +- it - Italy +- lv - Latvia +- lt - Lithuania +- lu - Luxembourg +- mt - Malta +- nl - Netherlands +- no - Norway +- pl - Poland +- pt - Portugal +- ro - Romania +- sk - Slovakia +- si - Slovenia +- es - Spain +- se - Sweden +- ch - Switzerland +- uk - United Kingdom + +Format filters results by book type. Note that not all books are available in all formats in every market. Format can be one of the following: + +- all - Search both audiobooks and ebooks +- audiobook - Search audiobooks only +- ebook - Search ebooks only + +Language can be "all" or comma separated list of: + +- en - English +- de - German +- ar - Arabic +- eu - Basque +- ca - Catalan +- cs - Czech +- da - Danish +- nl - Dutch +- et - Estonian +- fi - Finnish +- fr - French +- hu - Hungarian +- it - Italian +- nb - Norwegian +- nn - Norwegian Nynorsk +- pl - Polish +- pt - Portuguese +- ru - Russian +- es - Spanish +- sv - Swedish +- tr - Turkish + ## FAQ ### Why is Goodreads not returning covers? diff --git a/bookbeat/book.go b/bookbeat/book.go new file mode 100644 index 0000000..95bdbd6 --- /dev/null +++ b/bookbeat/book.go @@ -0,0 +1,26 @@ +package bookbeat + +type BookSeries struct { + Series string `json:"series"` + Sequence int `json:"sequence,omitempty"` +} + +type Book struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` + Authors string `json:"authors"` + Narrators string `json:"narrators,omitempty"` + Publisher string `json:"publisher,omitempty"` + PublishedYear int `json:"publishedYear,omitempty"` + Description string `json:"description,omitempty"` + ISBN string `json:"isbn,omitempty"` + ASIN string `json:"asin,omitempty"` + Language string `json:"language,omitempty"` + Duration int `json:"duration,omitempty"` + Genres []string `json:"genres,omitempty"` + Tags []string `json:"tags,omitempty"` + Series *BookSeries `json:"series,omitempty"` + Cover string `json:"cover,omitempty"` +} diff --git a/bookbeat/book_response.go b/bookbeat/book_response.go new file mode 100644 index 0000000..5214373 --- /dev/null +++ b/bookbeat/book_response.go @@ -0,0 +1,105 @@ +package bookbeat + +import ( + "time" +) + +type RatingDistribution struct { + Num1 int `json:"1"` + Num2 int `json:"2"` + Num3 int `json:"3"` + Num4 int `json:"4"` + Num5 int `json:"5"` +} + +type Rating struct { + RatingValue float64 `json:"ratingValue"` + NumberOfRatings int `json:"numberOfRatings"` + RatingDistribution RatingDistribution `json:"ratingDistribution"` +} + +type Badge struct { + ID string `json:"id"` + TranslationKey string `json:"translationKey"` + Type string `json:"type"` + Icon string `json:"icon"` +} + +type Genre struct { + Genreid int `json:"genreid"` + Name string `json:"name"` +} + +type Series struct { + Count int `json:"count"` + Partindex int `json:"partindex"` + Prevbookid int `json:"prevbookid"` + Nextbookid int `json:"nextbookid"` + ID int `json:"id"` + Name string `json:"name"` + Partnumber int `json:"partnumber"` + URL string `json:"url"` +} + +type CopyrightOwner struct { + Year int `json:"year"` + Name string `json:"name"` +} + +type Contributor struct { + ID int `json:"id"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Displayname string `json:"displayname"` + Role string `json:"role"` + Description string `json:"description"` + Booksurl string `json:"booksurl"` +} + +type Edition struct { + ID int `json:"id"` + Isbn string `json:"isbn"` + Format string `json:"format"` + Language string `json:"language"` + Published time.Time `json:"published"` + BookBeatPublishDate time.Time `json:"bookBeatPublishDate"` + BookBeatUnpublishDate time.Time `json:"bookBeatUnpublishDate"` + Availablefrom time.Time `json:"availablefrom"` + Publisher string `json:"publisher"` + CopyrightOwners []CopyrightOwner `json:"copyrightOwners"` + Contributors []Contributor `json:"contributors"` + Previewenabled bool `json:"previewenabled"` +} + +type BookResponse struct { + ID int `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Originaltitle string `json:"originaltitle"` + Author string `json:"author"` + Shareurl string `json:"shareurl"` + Summary string `json:"summary"` + Grade float64 `json:"grade"` + Rating Rating `json:"rating"` + NarratingRating Rating `json:"narratingRating"` + Cover string `json:"cover"` + Narrator string `json:"narrator"` + Translator string `json:"translator"` + Language string `json:"language"` + Published time.Time `json:"published"` + Originalpublishyear int `json:"originalpublishyear"` + Ebooklength float64 `json:"ebooklength"` + Ebookduration float64 `json:"ebookduration"` + Audiobooklength int `json:"audiobooklength"` // in seconds + Genres []Genre `json:"genres"` + Editions []Edition `json:"editions"` + Upcomingeditions []Edition `json:"upcomingeditions"` + Markets []string `json:"markets"` + Series *Series `json:"series"` + Contenttypetags []string `json:"contenttypetags"` + Relatedreadingsurl string `json:"relatedreadingsurl"` + Nextcontenturl string `json:"nextcontenturl"` + Type int `json:"Type"` + Bookappviewurl string `json:"bookappviewurl"` + Badges []Badge `json:"badges"` +} diff --git a/bookbeat/bookbeat.go b/bookbeat/bookbeat.go new file mode 100644 index 0000000..47f584d --- /dev/null +++ b/bookbeat/bookbeat.go @@ -0,0 +1,245 @@ +// Package bookbeat provides functionality to search and retrieve book metadata from the BookBeat service. +package bookbeat + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/imroc/req/v3" +) + +// API endpoints for BookBeat services +const ( + SearchBaseURL = "https://www.bookbeat.com/api/next/search" +) + +// breakTagRegex matches HTML break tags in book descriptions +var breakTagRegex = regexp.MustCompile(``) + +// languageMap converts language codes to BookBeat language identifiers +var languageMap = map[string]string{ + "en": "english", + "de": "german", + "ar": "arabic", + "eu": "basque", + "ca": "catalan", + "cs": "czech", + "da": "danish", + "nl": "dutch", + "et": "estonian", + "fi": "finnish", + "fr": "french", + "hu": "hungarian", + "it": "italian", + "nb": "norwegian", + "nn": "norwegiannynorsk", + "pl": "polish", + "pt": "portuguese", + "ru": "russian", + "es": "spanish", + "sv": "swedish", + "tr": "turkish", +} + +// countryMap converts country codes to BookBeat market identifiers +var countryMap = map[string]int{ + "gr": 30, + "nl": 31, + "be": 32, + "fr": 33, + "es": 34, + "hu": 36, + "it": 39, + "ro": 40, + "ch": 41, + "at": 43, + "uk": 44, + "dk": 45, + "se": 46, + "no": 47, + "pl": 48, + "de": 49, + "pt": 351, + "lu": 352, + "ie": 353, + "mt": 356, + "cy": 357, + "fi": 358, + "bg": 359, + "lt": 370, + "lv": 371, + "ee": 372, + "hr": 385, + "si": 386, + "cz": 420, + "sk": 421, +} + +// Bookbeat represents a client for interacting with the BookBeat API +type Bookbeat struct { + searchBaseURL string + httpClient *req.Client + market string + formats []string + languages []string +} + +// Market returns set market +func (b *Bookbeat) Market() string { + return b.market +} + +// Languages returns set languages +func (b *Bookbeat) Languages() []string { + return b.languages +} + +// Formats returns set formats +func (b *Bookbeat) Formats() []string { + return b.formats +} + +// SearchBooks performs a search query against the BookBeat API and returns raw search results +func (b *Bookbeat) SearchBooks(ctx context.Context, query string, author *string) (SearchResponse, error) { + params := url.Values{} + params.Add("query", query) + if author != nil { + params.Add("author", *author) + } + params.Add("page", "1") + params.Add("limit", "20") + params.Add("sortby", "relevance") + params.Add("sortorder", "desc") + params.Add("includeerotic", "false") + params.Add("market", strconv.Itoa(countryMap[b.market])) + + // Add format filters + for _, format := range b.formats { + params.Add("format", format) + } + + // Add language filters + for _, language := range b.languages { + params.Add("language", language) + } + + searchURL, _ := url.Parse(b.searchBaseURL) + searchURL.RawQuery = params.Encode() + + response, err := b.httpClient.R().SetContext(ctx).Get(searchURL.String()) + if err != nil { + return SearchResponse{}, fmt.Errorf("failed to create request: %w", err) + } + if !response.IsSuccessState() { + return SearchResponse{}, + fmt.Errorf("search request failed with status %d: %s", + response.StatusCode, + response.Body) + } + + var searchResp SearchResponse + if err := json.NewDecoder(response.Body).Decode(&searchResp); err != nil { + return SearchResponse{}, fmt.Errorf("failed to decode search response: %w", err) + } + searchResp.QueryUrl = searchURL.String() + return searchResp, nil +} + +// BookMetadata retrieves detailed metadata for a specific book from the BookBeat API +func (b *Bookbeat) BookMetadata(ctx context.Context, bookURL string) (BookResponse, error) { + response, err := b.httpClient.R().SetContext(ctx).Get(bookURL) + if err != nil { + return BookResponse{}, fmt.Errorf("failed to create request: %w", err) + } + if !response.IsSuccessState() { + return BookResponse{}, + fmt.Errorf("book metadata request failed with status %d: %s", + response.StatusCode, + response.Body) + } + + var bookResp BookResponse + if err := json.NewDecoder(response.Body).Decode(&bookResp); err != nil { + return BookResponse{}, fmt.Errorf("failed to decode book metadata response: %w", err) + } + return bookResp, nil +} + +// Search performs a comprehensive search and returns structured book data +// It searches for books, retrieves detailed metadata for each result, and transforms the data into Book structs +func (b *Bookbeat) Search(ctx context.Context, query string, author *string) ([]Book, error) { + searchResp, err := b.SearchBooks(ctx, query, author) + if err != nil { + return nil, err + } + + // Pre-allocate slice with capacity for multiple editions per book + books := make([]Book, 0, len(searchResp.Embedded.Books)*2) + // Process each book from search results + for _, searchBook := range searchResp.Embedded.Books { + // Get detailed metadata for the book + bookResp, err := b.BookMetadata(ctx, searchBook.Links.Self.Href) + if err != nil { + // Skip books that fail to load metadata + continue + } + + // Extract series information if available + var series *BookSeries + if bookResp.Series != nil { + series = &BookSeries{ + Series: bookResp.Series.Name, + Sequence: bookResp.Series.Partnumber, + } + } + + // Extract genre names + genres := ExtractGenreNames(bookResp.Genres) + + // Clean up cover URL by removing query parameters + cover := SanitizeCoverURL(bookResp.Cover) + + // Process each edition of the book (audiobook, ebook, etc.) + for _, edition := range bookResp.Editions { + // Skip unwanted formats + if !slices.Contains(b.formats, strings.ToLower(edition.Format)) { + continue + } + // Separate authors and narrators from contributors + authors, narrators := ExtractContributors(edition.Contributors) + + // HACK: looks like ABS wants minutes and not seconds like its statet in the api definition + if bookResp.Audiobooklength > 0 { + bookResp.Audiobooklength /= 60 + } + + // Create book structure with all metadata + book := Book{ + ID: bookResp.ID, + Title: bookResp.Title, + Subtitle: bookResp.Subtitle, + Description: breakTagRegex.ReplaceAllString(bookResp.Summary, "\n"), + Cover: cover, + Series: series, + Language: bookResp.Language, + Duration: bookResp.Audiobooklength, + Genres: genres, + Tags: bookResp.Contenttypetags, + Type: edition.Format, + Authors: strings.Join(authors, ", "), + Narrators: strings.Join(narrators, ", "), + Publisher: edition.Publisher, + PublishedYear: edition.Published.Year(), + ISBN: edition.Isbn, + } + books = append(books, book) + } + } + return books, nil +} diff --git a/bookbeat/bookbeat_httptest_test.go b/bookbeat/bookbeat_httptest_test.go new file mode 100644 index 0000000..147c851 --- /dev/null +++ b/bookbeat/bookbeat_httptest_test.go @@ -0,0 +1,336 @@ +package bookbeat_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/ahobsonsayers/abs-tract/bookbeat" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadTestData loads JSON test data from testdata directory +func loadTestData(t *testing.T, filename string) []byte { + t.Helper() + path := filepath.Join("testdata", filename) + data, err := os.ReadFile(path) + require.NoError(t, err, "failed to read test data file: %s", filename) + return data +} + +func TestSearchBooksWithMockServer(t *testing.T) { + // Load test data from JSON file + searchResponseData := loadTestData(t, "search_response.json") + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request parameters + assert.Equal(t, "/api/next/search", r.URL.Path) + assert.Contains(t, r.URL.Query().Get("query"), "Harry Potter") + assert.Equal(t, "audiobook", r.URL.Query().Get("format")) + assert.Equal(t, "english", r.URL.Query().Get("language")) + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(searchResponseData); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + // Create client with test server URLs + client, err := bookbeat.NewClientWithURLs("ee", "audiobook", "en", server.URL+"/api/next/search") + require.NoError(t, err) + + // Test the search + result, err := client.SearchBooks(context.Background(), TestBookTitle, lo.ToPtr(TestBookAuthor)) + require.NoError(t, err) + + // Verify results match test data + assert.Equal(t, 1, result.Count) + assert.Len(t, result.Embedded.Books, 1) + assert.Equal(t, 38059, result.Embedded.Books[0].ID) + assert.Contains(t, result.QueryUrl, "query=Harry+Potter") +} + +func TestBookMetadataWithMockServer(t *testing.T) { + // Load test data from JSON file + bookMetadataData := loadTestData(t, "book_metadata.json") + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/books/372/38059", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(bookMetadataData); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + // Create client with test server URLs + client, err := bookbeat.NewClientWithURLs("ee", "all", "en", server.URL+"/api/next/search") + require.NoError(t, err) + + // Test book metadata retrieval + result, err := client.BookMetadata(context.Background(), server.URL+"/api/books/372/38059") + require.NoError(t, err) + + // Verify results match test data + assert.Equal(t, 38059, result.ID) + assert.Equal(t, TestBookTitle, result.Title) + assert.Empty(t, result.Subtitle) // null in JSON + assert.Contains(t, result.Summary, "
") + assert.Equal(t, "English", result.Language) + assert.Equal(t, 30334, result.Audiobooklength) + assert.Len(t, result.Genres, 8) + assert.Equal(t, "Fantasy", result.Genres[0].Name) + assert.NotNil(t, result.Series) + assert.Equal(t, "Harry Potter", result.Series.Name) + assert.Equal(t, 1, result.Series.Partnumber) + assert.Len(t, result.Editions, 2) // Both audiobook and ebook + assert.Equal(t, "audioBook", result.Editions[0].Format) + assert.Equal(t, "eBook", result.Editions[1].Format) +} + +func TestSearchWithMockServer(t *testing.T) { + // Load test data from JSON files + searchResponseData := loadTestData(t, "search_response.json") + bookMetadataData := loadTestData(t, "book_metadata.json") + + // Create test server that handles both endpoints + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch path := r.URL.Path; path { + case "/api/next/search": + // Modify the search response to point to our test server + var searchResp bookbeat.SearchResponse + + if err := json.Unmarshal(searchResponseData, &searchResp); err != nil { + t.Errorf("Failed to write response: %v", err) + } + searchResp.Embedded.Books[0].Links.Self.Href = server.URL + "/api/books/372/38059" + if err := json.NewEncoder(w).Encode(searchResp); err != nil { + t.Errorf("Failed to write response: %v", err) + } + case "/api/books/372/38059": + if _, err := w.Write(bookMetadataData); err != nil { + t.Errorf("Failed to write response: %v", err) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Create client with test server URLs + client, err := bookbeat.NewClientWithURLs("ee", "audiobook", "en", server.URL+"/api/next/search") + require.NoError(t, err) + + // Test full search functionality + books, err := client.Search(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + + // Verify results using real test data + require.Len(t, books, 1) // Only audiobook edition since we filtered for audiobook + book := books[0] + + assert.Equal(t, 38059, book.ID) + assert.Equal(t, TestBookTitle, book.Title) + assert.Empty(t, book.Subtitle) // null in test data + assert.Contains(t, book.Description, "Stephen Fry brings") + assert.NotContains(t, book.Description, "
") // Should be converted to \n + assert.NotContains(t, book.Cover, "?") // Query params should be removed + assert.Equal(t, "English", book.Language) + assert.Equal(t, 505, book.Duration) + assert.Contains(t, book.Genres, "Fantasy") + assert.Contains(t, book.Tags, "contentsynch") + assert.Equal(t, "audioBook", book.Type) + assert.Equal(t, TestBookAuthor, book.Authors) + assert.Equal(t, "Stephen Fry", book.Narrators) + assert.Equal(t, "Pottermore Publishing", book.Publisher) + assert.Equal(t, 2015, book.PublishedYear) + assert.Equal(t, "9781781102367", book.ISBN) +} + +func TestSearchBooksEmptyResults(t *testing.T) { + // Load empty search response test data + emptySearchData := loadTestData(t, "search_response_empty.json") + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(emptySearchData); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + // Create client with test server URLs + client, err := bookbeat.NewClientWithURLs("ee", "all", "en", server.URL+"/api/next/search") + require.NoError(t, err) + + // Test search with no results + result, err := client.SearchBooks(context.Background(), "NonexistentBook", nil) + require.NoError(t, err) + + // Verify empty results + assert.Equal(t, 0, result.Count) + assert.Empty(t, result.Embedded.Books) + assert.False(t, result.IsCapped) +} + +func TestSearchBooksErrorHandling(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + expectedError string + }{ + { + name: "HTTP 404 error", + statusCode: 404, + responseBody: "Not Found", + expectedError: "search request failed with status 404", + }, + { + name: "HTTP 500 error", + statusCode: 500, + responseBody: "Internal Server Error", + expectedError: "search request failed with status 500", + }, + { + name: "Invalid JSON response", + statusCode: 200, + responseBody: "invalid json", + expectedError: "failed to decode search response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + if _, err := w.Write([]byte(tt.responseBody)); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client, err := bookbeat.NewClientWithURLs("uk", "all", "en", server.URL) + require.NoError(t, err) + + _, err = client.SearchBooks(context.Background(), "test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +func TestBookMetadataErrorHandling(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + expectedError string + }{ + { + name: "HTTP 404 error", + statusCode: 404, + responseBody: "Not Found", + expectedError: "book metadata request failed with status 404", + }, + { + name: "Invalid JSON response", + statusCode: 200, + responseBody: "invalid json", + expectedError: "failed to decode book metadata response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + if _, err := w.Write([]byte(tt.responseBody)); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client, err := bookbeat.NewClientWithURLs("uk", "all", "en", server.URL) + require.NoError(t, err) + + _, err = client.BookMetadata(context.Background(), server.URL+"/book/123") + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +func TestSearchBooksErrors(t *testing.T) { + client, err := bookbeat.NewClientWithURLs("uk", "all", "en", ";;") + require.NoError(t, err) + _, err = client.SearchBooks(context.Background(), "test", nil) + require.Error(t, err) +} + +func TestBookMetadataErrors(t *testing.T) { + client, err := bookbeat.NewClientWithURLs("uk", "all", "en", ";;") + require.NoError(t, err) + _, err = client.BookMetadata(context.Background(), ";;") + require.Error(t, err) +} + +func TestSearchErrors(t *testing.T) { + client, err := bookbeat.NewClientWithURLs("uk", "all", "en", ";;") + require.NoError(t, err) + _, err = client.Search(context.Background(), "test", nil) + require.Error(t, err) +} + +func TestSearchWithMetadataError(t *testing.T) { + // Load test data from JSON files + searchResponseData := loadTestData(t, "search_response.json") + bookMetadataData := loadTestData(t, "book_metadata.json") + + // Create test server that handles both endpoints + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch path := r.URL.Path; path { + case "/api/next/search": + // Modify the search response to point to our test server + var searchResp bookbeat.SearchResponse + + if err := json.Unmarshal(searchResponseData, &searchResp); err != nil { + t.Errorf("Failed to write response: %v", err) + } + searchResp.Embedded.Books[0].Links.Self.Href = ";;" // Set invalid URL + if err := json.NewEncoder(w).Encode(searchResp); err != nil { + t.Errorf("Failed to write response: %v", err) + } + case "/api/books/372/38059": + if _, err := w.Write(bookMetadataData); err != nil { + t.Errorf("Failed to write response: %v", err) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Create client with test server URLs + client, err := bookbeat.NewClientWithURLs("ee", "audiobook", "en", server.URL+"/api/next/search") + require.NoError(t, err) + + books, err := client.Search(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + require.Empty(t, books) +} diff --git a/bookbeat/bookbeat_test.go b/bookbeat/bookbeat_test.go new file mode 100644 index 0000000..1817098 --- /dev/null +++ b/bookbeat/bookbeat_test.go @@ -0,0 +1,259 @@ +package bookbeat_test + +import ( + "context" + "net/url" + "os" + "strings" + "testing" + + "github.com/ahobsonsayers/abs-tract/bookbeat" + "github.com/samber/lo" + "github.com/stretchr/testify/require" +) + +const ( + TestBookTitle = "Harry Potter and the Philosopher's Stone" + TestBookAuthor = "J.K. Rowling" + BookURL = "https://edge.bookbeat.com/api/books/372/38059" +) + +func integrationTest(t *testing.T) { + t.Helper() + if os.Getenv("INTEGRATION") == "" { + t.Skip("skipping integration tests, set environment variable INTEGRATION") + } +} + +func TestSearchBooks(t *testing.T) { + integrationTest(t) + + client, err := bookbeat.NewClient("ee", "all", "en") + require.NoError(t, err) + require.NotNil(t, client) + + searchResp, err := client.SearchBooks(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + + // Verify response structure + require.NotEmpty(t, searchResp.Embedded.Books) + require.Positive(t, searchResp.Count) + + // Check first book has required fields + firstBook := searchResp.Embedded.Books[0] + require.NotZero(t, firstBook.ID) + require.NotEmpty(t, firstBook.Links.Self.Href) + + // Verify query URL is set + encodedTitle := url.QueryEscape(TestBookTitle) + require.NotEmpty(t, searchResp.QueryUrl) + require.Contains(t, searchResp.QueryUrl, encodedTitle) +} + +func TestSearchBooksWithAuthor(t *testing.T) { + integrationTest(t) + + client, err := bookbeat.NewClient("ee", "all", "en") + require.NoError(t, err) + + searchResp, err := client.SearchBooks(context.Background(), TestBookTitle, lo.ToPtr(TestBookAuthor)) + require.NoError(t, err) + + require.NotEmpty(t, searchResp.Embedded.Books) + require.Positive(t, searchResp.Count) + + // Verify query URL contains both title and author + encodedTitle := url.QueryEscape(TestBookTitle) + encodedAuthor := url.QueryEscape(TestBookAuthor) + require.Contains(t, searchResp.QueryUrl, encodedTitle) + require.Contains(t, searchResp.QueryUrl, encodedAuthor) +} + +func TestSearchBooksWithFormats(t *testing.T) { + integrationTest(t) + + tests := []struct { + name string + market string + format string + shouldBeEmpty bool + }{ + {"audiobook only (uk)", "uk", "audiobook", false}, + {"ebook only (uk)", "uk", "ebook", true}, + {"all formats (uk)", "uk", "all", false}, + {"audiobook only (ee)", "ee", "audiobook", false}, + {"ebook only (ee)", "ee", "ebook", false}, + {"all formats (ee)", "ee", "all", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := bookbeat.NewClient(tt.market, tt.format, "en") + require.NoError(t, err) + + searchResp, err := client.SearchBooks(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + if tt.shouldBeEmpty { + require.Empty(t, searchResp.Embedded.Books) + } else { + require.NotEmpty(t, searchResp.Embedded.Books) + } + + // Verify format parameter is in query URL + if tt.format != "all" { + require.Contains(t, searchResp.QueryUrl, "format="+tt.format) + require.Equal(t, 1, strings.Count(searchResp.QueryUrl, "format")) + } else { + require.Contains(t, searchResp.QueryUrl, "format=audiobook") + require.Contains(t, searchResp.QueryUrl, "format=ebook") + require.Equal(t, 2, strings.Count(searchResp.QueryUrl, "format")) + } + }) + } +} + +func TestSearchWithFormats(t *testing.T) { + integrationTest(t) + + tests := []struct { + name string + market string + format string + shouldBe int + }{ + {"audiobook only (uk)", "uk", "audiobook", 1}, + {"ebook only (uk)", "uk", "ebook", 0}, + {"all formats (uk)", "uk", "all", 1}, + {"audiobook only (ee)", "ee", "audiobook", 1}, + {"ebook only (ee)", "ee", "ebook", 1}, + {"all formats (ee)", "ee", "all", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := bookbeat.NewClient(tt.market, tt.format, "en") + require.NoError(t, err) + + searchResp, err := client.Search(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + require.Len(t, searchResp, tt.shouldBe) + }) + } +} + +func TestSearchBooksWithLanguages(t *testing.T) { + integrationTest(t) + + tests := []struct { + name string + languages string + }{ + {"english only", "en"}, + {"multiple languages", "en,de"}, + {"all languages", "all"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := bookbeat.NewClient("ee", "all", tt.languages) + require.NoError(t, err) + + searchResp, err := client.SearchBooks(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + require.NotEmpty(t, searchResp.Embedded.Books) + + // Verify language parameter is in query URL + switch languageCode := tt.languages; languageCode { + case "en": + require.Contains(t, searchResp.QueryUrl, "language=english") + require.Equal(t, 1, strings.Count(searchResp.QueryUrl, "language")) + case "en,de": + require.Contains(t, searchResp.QueryUrl, "language=english") + require.Contains(t, searchResp.QueryUrl, "language=german") + require.Equal(t, 2, strings.Count(searchResp.QueryUrl, "language")) + case "all": + require.Contains(t, searchResp.QueryUrl, "language=english") + require.Contains(t, searchResp.QueryUrl, "language=german") + require.Greater(t, strings.Count(searchResp.QueryUrl, "language"), 10) + } + }) + } +} + +func TestBookMetadata(t *testing.T) { + integrationTest(t) + + // First get a book URL from search + client, err := bookbeat.NewClient("ee", "all", "en") + require.NoError(t, err) + + searchResp, err := client.SearchBooks(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + require.NotEmpty(t, searchResp.Embedded.Books) + + bookURL := searchResp.Embedded.Books[0].Links.Self.Href + require.Equal(t, "https://edge.bookbeat.com/api/books/372/38059", bookURL) + + // Get detailed metadata + bookResp, err := client.BookMetadata(context.Background(), bookURL) + require.NoError(t, err) + + // Verify response fields + require.Equal(t, 38059, bookResp.ID) + require.Equal(t, TestBookTitle, bookResp.Title) + require.Contains(t, bookResp.Cover, "book-covers") + require.Equal(t, "English", bookResp.Language) + require.NotEmpty(t, bookResp.Editions) + + // Check first edition fields + firstEdition := bookResp.Editions[0] + require.Equal(t, 56072, firstEdition.ID) + require.Equal(t, "audioBook", firstEdition.Format) + require.Equal(t, "Pottermore Publishing", firstEdition.Publisher) + require.Len(t, firstEdition.Contributors, 2) + authors, narrators := bookbeat.ExtractContributors(firstEdition.Contributors) + require.Contains(t, authors, "J.K. Rowling") + require.Contains(t, narrators, "Stephen Fry") +} + +func TestSearch(t *testing.T) { + integrationTest(t) + + client, err := bookbeat.NewClient("ee", "all", "en") + require.NoError(t, err) + + books, err := client.Search(context.Background(), TestBookTitle, nil) + require.NoError(t, err) + require.NotEmpty(t, books) + + // Check first book has all expected fields + firstBook := books[0] + require.NotZero(t, firstBook.ID) + require.NotEmpty(t, firstBook.Title) + require.NotEmpty(t, firstBook.Type) + require.NotEmpty(t, firstBook.Authors) + require.NotEmpty(t, firstBook.Cover) + require.NotEmpty(t, firstBook.Language) + require.NotZero(t, firstBook.PublishedYear) + + // Verify description has HTML break tags converted to newlines + require.NotContains(t, firstBook.Description, "
Turning the envelope over, his hand trembling, Harry saw a purple wax seal bearing a coat of arms; a lion, an eagle, a badger and a snake surrounding a large letter 'H'.

Treat your ears to a performance so rich and captivating you'll imagine yourself in the halls of Hogwarts. Wherever you listen, the unmistakable voice of Stephen Fry is guaranteed to guide you ever more deeply into this magical story and transport you to the heart of the adventure.

Harry Potter has never even heard of Hogwarts when the letters start dropping on the doormat at number four, Privet Drive. Addressed in green ink on yellowish parchment with a purple seal, they are swiftly confiscated by his grisly aunt and uncle. Then, on Harry's eleventh birthday, a great beetle-eyed giant of a man called Rubeus Hagrid bursts in with some astonishing news: Harry Potter is a wizard, and he has a place at Hogwarts School of Witchcraft and Wizardry. An incredible adventure is about to begin!

Theme music composed by James Hannigan

Having become classics of our time, the Harry Potter stories never fail to bring comfort and escapism. With their message of hope, belonging and the enduring power of truth and love, the story of the Boy Who Lived continues to delight generations of new listeners.", + "grade": 4.8, + "rating": { + "ratingValue": 4.8, + "numberOfRatings": 18612, + "ratingDistribution": { + "1": 116, + "2": 43, + "3": 314, + "4": 2034, + "5": 16105 + } + }, + "narratingRating": { + "ratingValue": 4.9, + "numberOfRatings": 12579, + "ratingDistribution": { + "1": 74, + "2": 44, + "3": 180, + "4": 903, + "5": 11378 + } + }, + "cover": "https://prod-bb-images.akamaized.net/book-covers/erimage-9781781102367-coresourcepottermore-2022-05-31t12-04.jpg?w=400", + "narrator": "Stephen Fry", + "translator": null, + "language": "English", + "published": "2015-11-20T00:00:00+00:00", + "originalpublishyear": 2015, + "ebooklength": 281, + "ebookduration": 32997, + "audiobooklength": 30334, + "genres": [ + { + "genreid": 20, + "name": "Fantasy", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/20/books?offset=0&limit=50" + }, + { + "genreid": 84, + "name": "Books for Children", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/84/books?offset=0&limit=50" + }, + { + "genreid": 87, + "name": "Books for Children: from 6 years", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/87/books?offset=0&limit=50" + }, + { + "genreid": 94, + "name": "Adventure Stories for Children", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/94/books?offset=0&limit=50" + }, + { + "genreid": 96, + "name": "Ghosts & Supernatural for Children", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/96/books?offset=0&limit=50" + }, + { + "genreid": 106, + "name": "English", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/106/books?offset=0&limit=50" + }, + { + "genreid": 109, + "name": "Fantasy & Science fiction", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/109/books?offset=0&limit=50" + }, + { + "genreid": 111, + "name": "Children and YA", + "parentid": null, + "booksurl": "https://api.bookbeat.com/api/categories/111/books?offset=0&limit=50" + } + ], + "editions": [ + { + "id": 56072, + "isbn": "9781781102367", + "format": "audioBook", + "language": "", + "published": "2015-11-20T00:00:00+00:00", + "bookBeatPublishDate": "2015-11-20T00:00:00+00:00", + "bookBeatUnpublishDate": null, + "availablefrom": "2015-11-20T00:00:00+00:00", + "publisher": "Pottermore Publishing", + "copyrightOwners": [ + { + "year": 1997, + "name": "J.K. Rowling" + } + ], + "contributors": [ + { + "id": 366, + "firstname": "J.K.", + "lastname": "Rowling", + "displayname": "J.K. Rowling", + "role": "bb-author", + "description": null, + "booksurl": "https://api.bookbeat.com/api/search/books?offset=0&limit=50&author=J.K.%20Rowling&sortby=relevance&sortorder=desc" + }, + { + "id": 7655, + "firstname": "Stephen", + "lastname": "Fry", + "displayname": "Stephen Fry", + "role": "bb-narrator", + "description": null, + "booksurl": "https://api.bookbeat.com/api/search/books?offset=0&limit=50&narrator=Stephen%20Fry&sortby=relevance&sortorder=desc" + } + ], + "previewenabled": true, + "singlesale": null, + "accessibilityinfo": null + }, + { + "id": 262287, + "isbn": "9781781100219", + "format": "eBook", + "language": "", + "published": "2015-12-08T00:00:00+00:00", + "bookBeatPublishDate": "2015-12-08T00:00:00+00:00", + "bookBeatUnpublishDate": null, + "availablefrom": "2015-12-08T00:00:00+00:00", + "publisher": "Pottermore Publishing", + "copyrightOwners": [], + "contributors": [ + { + "id": 366, + "firstname": "J.K.", + "lastname": "Rowling", + "displayname": "J.K. Rowling", + "role": "bb-author", + "description": null, + "booksurl": "https://api.bookbeat.com/api/search/books?offset=0&limit=50&author=J.K.%20Rowling&sortby=relevance&sortorder=desc" + } + ], + "previewenabled": false, + "singlesale": null, + "accessibilityinfo": null + } + ], + "upcomingeditions": [], + "markets": [ + "Greece", + "Netherlands", + "Belgium", + "Hungary", + "Romania", + "Switzerland", + "Austria", + "Uk", + "Denmark", + "Sweden", + "Norway", + "Poland", + "Germany", + "Portugal", + "Luxembourg", + "Ireland", + "Malta", + "Cyprus", + "Finland", + "Bulgaria", + "Lithuania", + "Latvia", + "Estonia", + "Croatia", + "Slovenia", + "Czechrepublic", + "Slovakia" + ], + "series": { + "count": 7, + "partindex": 1, + "prevbookid": null, + "nextbookid": 38060, + "id": 1585, + "name": "Harry Potter", + "partnumber": 1, + "url": "https://api.bookbeat.com/api/series/1585?offset=0&limit=25" + }, + "contenttypetags": [ + "contentsynch" + ], + "relatedreadingsurl": "https://api.bookbeat.com/api/booklists/relatedreadings/Estonia/38059?offset=0&limit=25", + "nextcontenturl": "https://api.bookbeat.com/api/nextcontentviews/38059", + "Type": 0, + "bookappviewurl": "https://api.bookbeat.com/api/bookview/Estonia/38059", + "badges": [ + { + "id": "top10-category.372.20", + "translationKey": "category-title-20", + "icon": "top10", + "type": "TopList" + }, + { + "id": "top10-category.372.84", + "translationKey": "category-title-84", + "icon": "top10", + "type": "TopList" + }, + { + "id": "top100.372", + "translationKey": "badge-all-time-high", + "icon": "top100", + "type": "TopList" + } + ], + "swiftWords": [ + { + "id": 35, + "translationKey": "swift-written-wellwritten" + }, + { + "id": 16, + "translationKey": "swift-book-fantastic" + }, + { + "id": 63, + "translationKey": "swift-impact-entertaining" + } + ], + "narratingSwiftWords": [ + { + "id": 51, + "translationKey": "swift-narration-elevates-book" + }, + { + "id": 46, + "translationKey": "swift-narration-captivating" + }, + { + "id": 42, + "translationKey": "swift-narration-expressive" + } + ] +} diff --git a/bookbeat/testdata/search_response.json b/bookbeat/testdata/search_response.json new file mode 100644 index 0000000..e41fbcf --- /dev/null +++ b/bookbeat/testdata/search_response.json @@ -0,0 +1,130 @@ +{ + "iscapped": false, + "count": 1, + "sort": { + "sortby": "relevance", + "sortorder": "desc" + }, + "filter": { + "titles": [], + "languages": [ + "english" + ], + "authors": [], + "narrators": [], + "translators": [], + "formats": [], + "series": [], + "categories": [], + "contenttypetags": [], + "badgeids": [], + "bookids": [], + "iskid": false, + "includeerotic": false + }, + "explanations": [], + "filteroptions": [ + "language", + "format" + ], + "_links": { + "self": { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&sortby=relevance&sortorder=desc&offset=0&limit=50&v=14", + "method": "GET" + }, + "sort": [ + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=relevance&sortorder=desc&v=14", + "method": "GET", + "title": "relevance|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=publishdate&sortorder=desc&v=14", + "method": "GET", + "title": "publishdate|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=publishdate&sortorder=asc&v=14", + "method": "GET", + "title": "publishdate|asc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=rating&sortorder=desc&v=14", + "method": "GET", + "title": "rating|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=title&sortorder=asc&v=14", + "method": "GET", + "title": "title|asc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=Harry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&kid=false&includeerotic=false&offset=0&limit=50&sortby=author_lastname&sortorder=asc&v=14", + "method": "GET", + "title": "author_lastname|asc" + } + ] + }, + "_embedded": { + "books": [ + { + "id": 38059, + "title": "Harry Potter and the Philosopher's Stone", + "description": "Stephen Fry brings the richness of these magical stories to life in the original British recordings.

Turning the envelope over, his hand trembling, Harry saw a purple wax seal bearing a coat of arms; a lion, an eagle, a badger and a snake surrounding a large letter 'H'.

Treat your ears to a performance so rich and captivating you'll imagine yourself in the halls of Hogwarts. Wherever you listen, the unmistakable voice of Stephen Fry is guaranteed to guide you ever more deeply into this magical story and transport you to the heart of the adventure.

Harry Potter has never even heard of Hogwarts when the letters start dropping on the doormat at number four, Privet Drive. Addressed in green ink on yellowish parchment with a purple seal, they are swiftly confiscated by his grisly aunt and uncle. Then, on Harry's eleventh birthday, a great beetle-eyed giant of a man called Rubeus Hagrid bursts in with some astonishing news: Harry Potter is a wizard, and he has a place at Hogwarts School of Witchcraft and Wizardry. An incredible adventure is about to begin!

Theme music composed by James Hannigan

Having become classics of our time, the Harry Potter stories never fail to bring comfort and escapism. With their message of hope, belonging and the enduring power of truth and love, the story of the Boy Who Lived continues to delight generations of new listeners.", + "image": "https://prod-bb-images.akamaized.net/book-covers/erimage-9781781102367-coresourcepottermore-2022-05-31t12-04.jpg?w=400", + "author": "J.K. Rowling", + "shareurl": "https://www.bookbeat.com/ee/book/38059", + "grade": 4.8, + "language": "English", + "audiobookisbn": "9781781102367", + "ebookisbn": "9781781100219", + "published": "2015-11-20T00:00:00+00:00", + "contenttypetags": [ + "contentsynch", + "unabridged" + ], + "audiobooksinglesale": null, + "ebooksinglesale": null, + "series": { + "id": 1585, + "name": "Harry Potter", + "partnumber": 1 + }, + "_links": { + "self": { + "href": "https://edge.bookbeat.com/api/books/372/38059", + "method": "GET" + }, + "nextcontent": { + "href": "https://api.bookbeat.com/api/nextcontentviews/38059", + "method": "GET" + } + }, + "topbadges": [ + { + "id": "top10-category.372.20", + "translationKey": "category-title-20", + "type": "TopList", + "icon": "top10" + } + ], + "_embedded": { + "contributors": [ + { + "id": 366, + "displayname": "J.K. Rowling", + "role": "bb-author", + "booksurl": "https://search-api.bookbeat.com/api/tabsearch/books?query=*&offset=0&limit=50&market=372&author=J.K.%20Rowling&v=14" + }, + { + "id": 7655, + "displayname": "Stephen Fry", + "role": "bb-narrator", + "booksurl": "https://search-api.bookbeat.com/api/tabsearch?query=*&offset=0&limit=50&market=372&narrator=Stephen%20Fry&v=14" + } + ] + } + } + ] + } +} diff --git a/bookbeat/testdata/search_response_empty.json b/bookbeat/testdata/search_response_empty.json new file mode 100644 index 0000000..c0588f8 --- /dev/null +++ b/bookbeat/testdata/search_response_empty.json @@ -0,0 +1,74 @@ +{ + "iscapped": false, + "count": 0, + "sort": { + "sortby": "relevance", + "sortorder": "desc" + }, + "filter": { + "titles": [], + "languages": [ + "english" + ], + "authors": [], + "narrators": [], + "translators": [], + "formats": [ + "audiobook", + "ebook" + ], + "series": [], + "categories": [], + "contenttypetags": [], + "badgeids": [], + "bookids": [], + "iskid": false, + "includeerotic": false + }, + "explanations": [], + "filteroptions": [ + "language", + "format" + ], + "_links": { + "self": { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&sortby=relevance&sortorder=desc&offset=0&limit=50&v=14", + "method": "GET" + }, + "sort": [ + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=relevance&sortorder=desc&v=14", + "method": "GET", + "title": "relevance|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=publishdate&sortorder=desc&v=14", + "method": "GET", + "title": "publishdate|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=publishdate&sortorder=asc&v=14", + "method": "GET", + "title": "publishdate|asc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=rating&sortorder=desc&v=14", + "method": "GET", + "title": "rating|desc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=title&sortorder=asc&v=14", + "method": "GET", + "title": "title|asc" + }, + { + "href": "https://search-api.bookbeat.com/api/tabsearch/books?query=asdfHarry%20Potter%20and%20the%20Philosopher%27s%20Stone&market=372&language=English&format=audiobook&format=ebook&kid=false&includeerotic=false&offset=0&limit=50&sortby=author_lastname&sortorder=asc&v=14", + "method": "GET", + "title": "author_lastname|asc" + } + ] + }, + "_embedded": { + "books": [] + } +} diff --git a/bookbeat/utils.go b/bookbeat/utils.go new file mode 100644 index 0000000..d285e3b --- /dev/null +++ b/bookbeat/utils.go @@ -0,0 +1,43 @@ +package bookbeat + +import ( + "net/url" +) + +// ExtractGenreNames selects only the name from slice of genres +func ExtractGenreNames(genres []Genre) []string { + list := make([]string, 0, len(genres)) + for _, genre := range genres { + list = append(list, genre.Name) + } + return list +} + +// SanitizeCoverURL removes query parameters and fragments from cover URLs +func SanitizeCoverURL(coverURL string) string { + if u, err := url.Parse(coverURL); err == nil { + u.RawQuery = "" + u.Fragment = "" + return u.String() + } + return coverURL +} + +// ExtractContributors separates authors and narrators from contributors +func ExtractContributors(contributors []Contributor) (authors, narrators []string) { + authors = make([]string, 0, len(contributors)) + narrators = make([]string, 0, len(contributors)) + + for _, entry := range contributors { + if entry.Displayname == "" { + continue + } + switch entry.Role { + case "bb-author": + authors = append(authors, entry.Displayname) + case "bb-narrator": + narrators = append(narrators, entry.Displayname) + } + } + return authors, narrators +} diff --git a/bookbeat/utils_test.go b/bookbeat/utils_test.go new file mode 100644 index 0000000..4327ea8 --- /dev/null +++ b/bookbeat/utils_test.go @@ -0,0 +1,170 @@ +package bookbeat_test + +import ( + "testing" + + "github.com/ahobsonsayers/abs-tract/bookbeat" + "github.com/stretchr/testify/assert" +) + +func TestExtractGenreNames(t *testing.T) { + tests := []struct { + name string + genres []bookbeat.Genre + expected []string + }{ + { + name: "multiple genres", + genres: []bookbeat.Genre{ + {Genreid: 1, Name: "Fiction"}, + {Genreid: 2, Name: "Mystery"}, + {Genreid: 3, Name: "Thriller"}, + }, + expected: []string{"Fiction", "Mystery", "Thriller"}, + }, + { + name: "single genre", + genres: []bookbeat.Genre{ + {Genreid: 1, Name: "Non-Fiction"}, + }, + expected: []string{"Non-Fiction"}, + }, + { + name: "empty genres", + genres: []bookbeat.Genre{}, + expected: []string{}, + }, + { + name: "nil genres", + genres: nil, + expected: []string{}, + }, + { + name: "genres with empty names", + genres: []bookbeat.Genre{ + {Genreid: 1, Name: ""}, + {Genreid: 2, Name: "Fantasy"}, + }, + expected: []string{"", "Fantasy"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := bookbeat.ExtractGenreNames(tt.genres) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractContributorsWithMixedContributors(t *testing.T) { + contributors := []bookbeat.Contributor{ + {ID: 1, Displayname: "John Doe", Role: "bb-author"}, + {ID: 2, Displayname: "Jane Smith", Role: "bb-narrator"}, + {ID: 3, Displayname: "Bob Wilson", Role: "bb-author"}, + {ID: 4, Displayname: "Alice Johnson", Role: "bb-narrator"}, + {ID: 5, Displayname: "Other Role", Role: "bb-translator"}, + } + authors, narrators := bookbeat.ExtractContributors(contributors) + assert.Equal(t, []string{"John Doe", "Bob Wilson"}, authors) + assert.Equal(t, []string{"Jane Smith", "Alice Johnson"}, narrators) +} + +func TestExtractContributorsWithOnlyAuthors(t *testing.T) { + contributors := []bookbeat.Contributor{ + {ID: 1, Displayname: "Author One", Role: "bb-author"}, + {ID: 2, Displayname: "Author Two", Role: "bb-author"}, + } + authors, narrators := bookbeat.ExtractContributors(contributors) + assert.Equal(t, []string{"Author One", "Author Two"}, authors) + assert.Equal(t, []string{}, narrators) +} + +func TestExtractContributorsWithOnlyNarrators(t *testing.T) { + contributors := []bookbeat.Contributor{ + {ID: 1, Displayname: "Narrator One", Role: "bb-narrator"}, + {ID: 2, Displayname: "Narrator Two", Role: "bb-narrator"}, + } + authors, narrators := bookbeat.ExtractContributors(contributors) + assert.Equal(t, []string{}, authors) + assert.Equal(t, []string{"Narrator One", "Narrator Two"}, narrators) +} + +func TestExtractContributorsWithNoRelevantRoles(t *testing.T) { + contributors := []bookbeat.Contributor{ + {ID: 1, Displayname: "Translator", Role: "bb-translator"}, + {ID: 2, Displayname: "Editor", Role: "bb-editor"}, + {ID: 3, Displayname: "Unknown", Role: "unknown-role"}, + } + authors, narrators := bookbeat.ExtractContributors(contributors) + assert.Equal(t, []string{}, authors) + assert.Equal(t, []string{}, narrators) +} + +func TestExtractContributorsWithEmptyContributors(t *testing.T) { + authors, narrators := bookbeat.ExtractContributors([]bookbeat.Contributor{}) + assert.Equal(t, []string{}, authors) + assert.Equal(t, []string{}, narrators) +} +func TestExtractContributorsWithNilContributors(t *testing.T) { + authors, narrators := bookbeat.ExtractContributors(nil) + assert.Equal(t, []string{}, authors) + assert.Equal(t, []string{}, narrators) +} + +func TestExtractContributorsWithEmptyNames(t *testing.T) { + contributors := []bookbeat.Contributor{ + {ID: 1, Displayname: "", Role: "bb-author"}, + {ID: 2, Displayname: "Valid Author", Role: "bb-author"}, + {ID: 3, Displayname: "", Role: "bb-narrator"}, + } + authors, narrators := bookbeat.ExtractContributors(contributors) + assert.Equal(t, []string{"Valid Author"}, authors) + assert.Equal(t, []string{}, narrators) +} + +func TestSanitizeCoverURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "URL with query parameters", + input: "https://example.com/cover.jpg?param=value&size=large", + expected: "https://example.com/cover.jpg", + }, + { + name: "URL without query parameters", + input: "https://example.com/cover.jpg", + expected: "https://example.com/cover.jpg", + }, + { + name: "URL with fragment", + input: "https://example.com/cover.jpg?size=large#fragment", + expected: "https://example.com/cover.jpg", + }, + { + name: "Invalid URL", + input: "not-a-valid-url", + expected: "not-a-valid-url", + }, + { + name: "Empty URL", + input: "", + expected: "", + }, + { + name: "Error URL", + input: ":", + expected: ":", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := bookbeat.SanitizeCoverURL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/schema/openapi.yaml b/schema/openapi.yaml index 0ba7b68..d10c110 100644 --- a/schema/openapi.yaml +++ b/schema/openapi.yaml @@ -67,6 +67,42 @@ paths: "500": $ref: "#/components/responses/500" + /bookbeat/{market}/{format}/{languages}/search: + get: + operationId: searchBookbeat + summary: Search for books by format and languages using bookbeat + description: Search for books using bookbeat filtered by format and languages + parameters: + - name: market + in: path + description: Country code of the market to use + schema: + $ref: "#/components/schemas/BookbeatMarket" + - name: format + in: path + required: true + schema: + $ref: "#/components/schemas/BookbeatFormat" + description: Book format (audiobook, ebook, or all) + - name: languages + in: path + required: true + schema: + $ref: "#/components/schemas/BookbeatLanguageCodes" + description: Comma-separated list of language codes or "all" (e.g., "en,de,fr" or "all") + example: "en,de,fr" + - $ref: "#/components/parameters/query" + - $ref: "#/components/parameters/author" + responses: + "200": + $ref: "#/components/responses/200" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + components: securitySchemes: api_key: @@ -132,6 +168,22 @@ components: sequence: type: string + BookbeatLanguageCodes: + type: string + description: Either "all" for all languages, or comma-separated list of valid language codes + pattern: '^(all|(en|de|ar|eu|ca|cs|da|nl|et|fi|fr|hu|it|nb|nn|pl|pt|ru|es|sv|tr)(,(en|de|ar|eu|ca|cs|da|nl|et|fi|fr|hu|it|nb|nn|pl|pt|ru|es|sv|tr))*)$' + example: "en,de,fr" + + BookbeatFormat: + type: string + enum: ["audiobook", "ebook", "all"] + description: Book format type or all formats + + BookbeatMarket: + type: string + enum: ["gr","nl","be","fr","es","hu","it","ro","ch","at","uk","dk","se","no","pl","de","pt","lu","ie","mt","cy","fi","bg","lt","lv","ee","hr","si","cz","sk"] + description: Market country code + parameters: query: name: query diff --git a/server/book.go b/server/book.go index a907aa1..4d7920c 100644 --- a/server/book.go +++ b/server/book.go @@ -4,6 +4,7 @@ import ( "context" "strconv" + "github.com/ahobsonsayers/abs-tract/bookbeat" "github.com/ahobsonsayers/abs-tract/goodreads" "github.com/ahobsonsayers/abs-tract/kindle" "github.com/samber/lo" @@ -59,6 +60,37 @@ func searchKindleBooks( return books, nil } +func searchBookbeatBooks( + ctx context.Context, + marketStr BookbeatMarket, + formatStr BookbeatFormat, + languagesStr BookbeatLanguageCodes, + title string, + author *string, +) ([]BookMetadata, error) { + + market := string(marketStr) + format := string(formatStr) + languages := string(languagesStr) + bookbeatClient, err := bookbeat.NewClient(market, format, languages) + if err != nil { + return nil, err + } + + bookbeatBooks, err := bookbeatClient.Search(ctx, title, author) + if err != nil { + return nil, err + } + + books := make([]BookMetadata, 0, len(bookbeatBooks)) + for _, bookbeatBook := range bookbeatBooks { + book := bookbeatBookToBookMetadata(bookbeatBook) + books = append(books, book) + } + + return books, nil +} + func goodreadsBookToBookMetadata(goodreadsBook goodreads.Book) BookMetadata { var subtitle *string if goodreadsBook.BestEdition.Subtitle() != "" { @@ -123,3 +155,33 @@ func kindleBookToBookMetadata(kindleBook kindle.Book) BookMetadata { PublishedYear: publishedYear, } } + +func bookbeatBookToBookMetadata(bookbeatBook bookbeat.Book) BookMetadata { + publishedYear := lo.ToPtr(strconv.Itoa(bookbeatBook.PublishedYear)) + + var series []SeriesMetadata + if bookbeatBook.Series != nil { + series = make([]SeriesMetadata, 0, 1) + series = append(series, SeriesMetadata{ + Series: bookbeatBook.Series.Series, + Sequence: lo.ToPtr(strconv.Itoa(bookbeatBook.Series.Sequence)), + }) + } + + return BookMetadata{ + Author: &bookbeatBook.Authors, + Cover: &bookbeatBook.Cover, + Description: &bookbeatBook.Description, + Duration: &bookbeatBook.Duration, + Genres: &bookbeatBook.Genres, + Isbn: &bookbeatBook.ISBN, + Language: &bookbeatBook.Language, + Narrator: &bookbeatBook.Narrators, + PublishedYear: publishedYear, + Publisher: &bookbeatBook.Publisher, + Series: &series, + Tags: &bookbeatBook.Tags, + Title: bookbeatBook.Title, + Subtitle: &bookbeatBook.Subtitle, + } +} diff --git a/server/server.gen.go b/server/server.gen.go index ab1693e..d2b7f93 100644 --- a/server/server.gen.go +++ b/server/server.gen.go @@ -1,6 +1,6 @@ // Package server provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.1.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package server import ( @@ -25,18 +25,59 @@ const ( Api_keyScopes = "api_key.Scopes" ) +// Defines values for BookbeatFormat. +const ( + All BookbeatFormat = "all" + Audiobook BookbeatFormat = "audiobook" + Ebook BookbeatFormat = "ebook" +) + +// Defines values for BookbeatMarket. +const ( + BookbeatMarketAt BookbeatMarket = "at" + BookbeatMarketBe BookbeatMarket = "be" + BookbeatMarketBg BookbeatMarket = "bg" + BookbeatMarketCh BookbeatMarket = "ch" + BookbeatMarketCy BookbeatMarket = "cy" + BookbeatMarketCz BookbeatMarket = "cz" + BookbeatMarketDe BookbeatMarket = "de" + BookbeatMarketDk BookbeatMarket = "dk" + BookbeatMarketEe BookbeatMarket = "ee" + BookbeatMarketEs BookbeatMarket = "es" + BookbeatMarketFi BookbeatMarket = "fi" + BookbeatMarketFr BookbeatMarket = "fr" + BookbeatMarketGr BookbeatMarket = "gr" + BookbeatMarketHr BookbeatMarket = "hr" + BookbeatMarketHu BookbeatMarket = "hu" + BookbeatMarketIe BookbeatMarket = "ie" + BookbeatMarketIt BookbeatMarket = "it" + BookbeatMarketLt BookbeatMarket = "lt" + BookbeatMarketLu BookbeatMarket = "lu" + BookbeatMarketLv BookbeatMarket = "lv" + BookbeatMarketMt BookbeatMarket = "mt" + BookbeatMarketNl BookbeatMarket = "nl" + BookbeatMarketNo BookbeatMarket = "no" + BookbeatMarketPl BookbeatMarket = "pl" + BookbeatMarketPt BookbeatMarket = "pt" + BookbeatMarketRo BookbeatMarket = "ro" + BookbeatMarketSe BookbeatMarket = "se" + BookbeatMarketSi BookbeatMarket = "si" + BookbeatMarketSk BookbeatMarket = "sk" + BookbeatMarketUk BookbeatMarket = "uk" +) + // Defines values for SearchKindleParamsRegion. const ( - Au SearchKindleParamsRegion = "au" - Ca SearchKindleParamsRegion = "ca" - De SearchKindleParamsRegion = "de" - Es SearchKindleParamsRegion = "es" - Fr SearchKindleParamsRegion = "fr" - In SearchKindleParamsRegion = "in" - It SearchKindleParamsRegion = "it" - Jp SearchKindleParamsRegion = "jp" - Uk SearchKindleParamsRegion = "uk" - Us SearchKindleParamsRegion = "us" + SearchKindleParamsRegionAu SearchKindleParamsRegion = "au" + SearchKindleParamsRegionCa SearchKindleParamsRegion = "ca" + SearchKindleParamsRegionDe SearchKindleParamsRegion = "de" + SearchKindleParamsRegionEs SearchKindleParamsRegion = "es" + SearchKindleParamsRegionFr SearchKindleParamsRegion = "fr" + SearchKindleParamsRegionIn SearchKindleParamsRegion = "in" + SearchKindleParamsRegionIt SearchKindleParamsRegion = "it" + SearchKindleParamsRegionJp SearchKindleParamsRegion = "jp" + SearchKindleParamsRegionUk SearchKindleParamsRegion = "uk" + SearchKindleParamsRegionUs SearchKindleParamsRegion = "us" ) // BookMetadata defines model for BookMetadata. @@ -62,6 +103,15 @@ type BookMetadata struct { Title string `json:"title"` } +// BookbeatFormat Book format type or all formats +type BookbeatFormat string + +// BookbeatLanguageCodes Either "all" for all languages, or comma-separated list of valid language codes +type BookbeatLanguageCodes = string + +// BookbeatMarket Market country code +type BookbeatMarket string + // SeriesMetadata defines model for SeriesMetadata. type SeriesMetadata struct { Sequence *string `json:"sequence,omitempty"` @@ -94,6 +144,12 @@ type N500 struct { Error *string `json:"error,omitempty"` } +// SearchBookbeatParams defines parameters for SearchBookbeat. +type SearchBookbeatParams struct { + Query Query `form:"query" json:"query"` + Author *Author `form:"author,omitempty" json:"author,omitempty"` +} + // SearchGoodreadsParams defines parameters for SearchGoodreads. type SearchGoodreadsParams struct { Query Query `form:"query" json:"query"` @@ -111,6 +167,9 @@ type SearchKindleParamsRegion string // ServerInterface represents all server handlers. type ServerInterface interface { + // Search for books by format and languages using bookbeat + // (GET /bookbeat/{market}/{format}/{languages}/search) + SearchBookbeat(w http.ResponseWriter, r *http.Request, market BookbeatMarket, format BookbeatFormat, languages BookbeatLanguageCodes, params SearchBookbeatParams) // Search for books using goodreads // (GET /goodreads/search) SearchGoodreads(w http.ResponseWriter, r *http.Request, params SearchGoodreadsParams) @@ -123,6 +182,12 @@ type ServerInterface interface { type Unimplemented struct{} +// Search for books by format and languages using bookbeat +// (GET /bookbeat/{market}/{format}/{languages}/search) +func (_ Unimplemented) SearchBookbeat(w http.ResponseWriter, r *http.Request, market BookbeatMarket, format BookbeatFormat, languages BookbeatLanguageCodes, params SearchBookbeatParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Search for books using goodreads // (GET /goodreads/search) func (_ Unimplemented) SearchGoodreads(w http.ResponseWriter, r *http.Request, params SearchGoodreadsParams) { @@ -144,14 +209,92 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// SearchBookbeat operation middleware +func (siw *ServerInterfaceWrapper) SearchBookbeat(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "market" ------------- + var market BookbeatMarket + + err = runtime.BindStyledParameterWithOptions("simple", "market", chi.URLParam(r, "market"), &market, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: false}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "market", Err: err}) + return + } + + // ------------- Path parameter "format" ------------- + var format BookbeatFormat + + err = runtime.BindStyledParameterWithOptions("simple", "format", chi.URLParam(r, "format"), &format, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "format", Err: err}) + return + } + + // ------------- Path parameter "languages" ------------- + var languages BookbeatLanguageCodes + + err = runtime.BindStyledParameterWithOptions("simple", "languages", chi.URLParam(r, "languages"), &languages, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "languages", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, Api_keyScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params SearchBookbeatParams + + // ------------- Required query parameter "query" ------------- + + if paramValue := r.URL.Query().Get("query"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "query"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "query", r.URL.Query(), ¶ms.Query) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "query", Err: err}) + return + } + + // ------------- Optional query parameter "author" ------------- + + err = runtime.BindQueryParameter("form", true, false, "author", r.URL.Query(), ¶ms.Author) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "author", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SearchBookbeat(w, r, market, format, languages, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // SearchGoodreads operation middleware func (siw *ServerInterfaceWrapper) SearchGoodreads(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() var err error + ctx := r.Context() + ctx = context.WithValue(ctx, Api_keyScopes, []string{}) + r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context var params SearchGoodreadsParams @@ -186,12 +329,11 @@ func (siw *ServerInterfaceWrapper) SearchGoodreads(w http.ResponseWriter, r *htt handler = middleware(handler) } - handler.ServeHTTP(w, r.WithContext(ctx)) + handler.ServeHTTP(w, r) } // SearchKindle operation middleware func (siw *ServerInterfaceWrapper) SearchKindle(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() var err error @@ -204,8 +346,12 @@ func (siw *ServerInterfaceWrapper) SearchKindle(w http.ResponseWriter, r *http.R return } + ctx := r.Context() + ctx = context.WithValue(ctx, Api_keyScopes, []string{}) + r = r.WithContext(ctx) + // Parameter object where we will unmarshal all parameters from the context var params SearchKindleParams @@ -240,7 +386,7 @@ func (siw *ServerInterfaceWrapper) SearchKindle(w http.ResponseWriter, r *http.R handler = middleware(handler) } - handler.ServeHTTP(w, r.WithContext(ctx)) + handler.ServeHTTP(w, r) } type UnescapedCookieParamError struct { @@ -356,6 +502,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/bookbeat/{market}/{format}/{languages}/search", wrapper.SearchBookbeat) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/goodreads/search", wrapper.SearchGoodreads) }) @@ -382,6 +531,53 @@ type N500JSONResponse struct { Error *string `json:"error,omitempty"` } +type SearchBookbeatRequestObject struct { + Market BookbeatMarket `json:"market,omitempty"` + Format BookbeatFormat `json:"format"` + Languages BookbeatLanguageCodes `json:"languages"` + Params SearchBookbeatParams +} + +type SearchBookbeatResponseObject interface { + VisitSearchBookbeatResponse(w http.ResponseWriter) error +} + +type SearchBookbeat200JSONResponse struct{ N200JSONResponse } + +func (response SearchBookbeat200JSONResponse) VisitSearchBookbeatResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SearchBookbeat400JSONResponse struct{ N400JSONResponse } + +func (response SearchBookbeat400JSONResponse) VisitSearchBookbeatResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type SearchBookbeat401JSONResponse struct{ N401JSONResponse } + +func (response SearchBookbeat401JSONResponse) VisitSearchBookbeatResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type SearchBookbeat500JSONResponse struct{ N500JSONResponse } + +func (response SearchBookbeat500JSONResponse) VisitSearchBookbeatResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type SearchGoodreadsRequestObject struct { Params SearchGoodreadsParams } @@ -473,6 +669,9 @@ func (response SearchKindle500JSONResponse) VisitSearchKindleResponse(w http.Res // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Search for books by format and languages using bookbeat + // (GET /bookbeat/{market}/{format}/{languages}/search) + SearchBookbeat(ctx context.Context, request SearchBookbeatRequestObject) (SearchBookbeatResponseObject, error) // Search for books using goodreads // (GET /goodreads/search) SearchGoodreads(ctx context.Context, request SearchGoodreadsRequestObject) (SearchGoodreadsResponseObject, error) @@ -510,6 +709,35 @@ type strictHandler struct { options StrictHTTPServerOptions } +// SearchBookbeat operation middleware +func (sh *strictHandler) SearchBookbeat(w http.ResponseWriter, r *http.Request, market BookbeatMarket, format BookbeatFormat, languages BookbeatLanguageCodes, params SearchBookbeatParams) { + var request SearchBookbeatRequestObject + + request.Market = market + request.Format = format + request.Languages = languages + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SearchBookbeat(ctx, request.(SearchBookbeatRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SearchBookbeat") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SearchBookbeatResponseObject); ok { + if err := validResponse.VisitSearchBookbeatResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // SearchGoodreads operation middleware func (sh *strictHandler) SearchGoodreads(w http.ResponseWriter, r *http.Request, params SearchGoodreadsParams) { var request SearchGoodreadsRequestObject @@ -566,20 +794,28 @@ func (sh *strictHandler) SearchKindle(w http.ResponseWriter, r *http.Request, re // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xWTXPjNgz9Kxq0R43ldLcX3XbbTuvJbtNJsoc24+nQEiIxlkgGBDPjZvTfO6DkyB+y", - "07SHvfRiS8QD+AjiAXqGwrbOGjTsIX8Gp0i1yEjxTQWuLcmTNpDDY0DaQApGtQj51pqCL2pslcB448Ti", - "mbSpoOvSwedEhO0r4WPQhCXkTAHPBewE7J01HiPD7+Zz+SusYTQcOTvX6EKxtiZ78NbI2hjPkXVIrHvv", - "VnFR94+asY0P3xLeQw7fZGNist7fZx+tXX9GVqViBV26ZaeI1CaedliwqwcsuKdboi9IO+EDOVxdit/7", - "/8QaifpLOc72q/t/VGVyjY8BPfdELr4SkS+mLx/9F5bC5PuvlpKFYSSjmuQG6Qkp+SnGOnbclmXccK8S", - "jugoryPde0utYqkQWUgP2aU7AjsyFfYJo+Ugb9efErYJ15hERKJbVeFU7D2/iQ3KQGpr3N/jx8GSaJN4", - "LKwpPaTjYbThcT9tGCskCVihoQMxHW26r5gUtF/tZyouTJymUaYKctKpqEbi8YlEurBqtK+x/B3VecS0", - "1SPpN/SImwg/3SVS8GHFmpvpo7Cq3pjCU7G63b56N8CWR3WdwgHjo2r20i9Mga9k5/z2A245qSssAmne", - "3EgKBwU5/ecax8FRoyqRxsnx4cvtL1fXiz8+3C6ufh3rRTl9iZte5drcW/FvdIHGR/aD8+fFLaQQqJHA", - "zM7nWWYdGm8DFTizVGWDk88EOyYZfgiebZtsk5X8RvZJ98yekHyvn/nsYjYXLwmqnIYc3s3mszmk4BTX", - "8YBZZW1JqEqfeVRU1LJYIR+r8Saak3tLycratU+C16ZKXvwh7tMrdlG+OPy8Y9+d6nfT5TtCsn4qd+mr", - "wKF7dcvpoTzl/YLLBDSOwvPY91vsxT/BXuzMk/NYAcUCDG2r5DPl9WQLPFtrUzaYPRNW2pruX95gH+XE", - "9V1ujQd3F9UgVTRqoWex9x2GJrSiOhUghUKBTANIAWMfl2KN40hLH39wooW1/OzKc5Tx//Xy9noZrrbb", - "7W7x+l762t1S0uDjJ0d/s/s188kWqpnsM33fasReW8/5u7mwWnZ/BwAA///3jNWoywsAAA==", + "H4sIAAAAAAAC/+xXW5PTNhT+KxqVmWY7bpwt9CVvsKXtDlA6LDy07LajWCe2iCwZXUJD7P/eOZId5+Jk", + "l90HXnhJbJ377dPxmma6rLQC5SydrmnFDCvBgQlvzLtCG3wSik7pRw9mRROqWAl02lETarMCSoZsblUh", + "xTojVE6bJmlljmjoXg189MIAp1NnPJxS2CCzrbSyEDz8aTLBv0wrB8oFn6tKiow5oVX6wWqFZ72+yugK", + "jBNRumQuK+KjcFCGh0cG5nRKv0v7xKRR3qbPtF68Asc4c4w2SecdM4atQrTtgZ59gMxFdznYzIgK/aFT", + "+voFyj15kNdgTCzKYbZvtf+McfIGPnqwLjpy/pUceadi+4jPwNGTn79aSi6VA6OYJFdglmDI86DrULBr", + "y2BwpxMO3GFWBHfn2pTMYYfgQbLvXbI1YAekTC8hUPby9uYlcZq4AkjgIKJkOQzp3pEbMMC9YR1x18Yv", + "LYUIRSxkWnFLkz4YoVxvTygHORhUmIMye8N0YHR3YhIq7Gw3U+FgIBrJVO4x0iGtCvW5I4ms/EwKWwD/", + "C9hpjmGqBSO+ACOuAvtxlEio9TMnnBwOxbH8C1N4TFezjavvW7abg75OQi/PgLlf2xrstwPSSSwQQWGi", + "DWFStkfYGqB8iTaY50LPtF7gWfvPpNyy2ofRWX3ZFvZC85jlXePPhSvAkGvUc03RZrDdtYNN0JtMlyX7", + "0QLeXg44kcI6oudkyaTgG16SBRMJhf9YWWHOKKiEQzLHca+YQxygU/rPiElZj0DVHGpmavB1xurM1pzV", + "Stbg6rmo56YufC1crWa1UnUl68rVxtdga7usnTkbJQ/VcPbD2SN6InOvmFnAQL3iOcm0V86sQtRbNcox", + "WCVpQmd4HGIPWSk8TajA0TaaJjQrsHj46rGMHH8sSiikVqgg6K2QRQZZfC3xNcNLfS7QRo7UwLJEQ8hS", + "hJ0BqdlnfFoM9sfeHB1grMVbTGVwy8yeHoqW72YQ7SHzRrjVFQ52i+uV+HcB/TpTAONg+n3m6bu3v79+", + "c/n307eXr//oS8cq8QJW8e4Raq5RXooMlA3et8KvLt9iso1Exc5VdpqmugJltTcZjLXJ01bIpsjbjz69", + "8NbpknTJIn8avRTRsyUYG9tiMj4fT1AKlbJK0Cl9PJ6MJ7H3ixBgOmt7K12XoYuadB3HvEnXm5lrUgvM", + "ZAVK5EMdeBXIYVhRoSXeCpWTTjmZC+nAACezVQcsTPWDit2IpQ6X0CXfKOwaP3jc76nv981fbHU+wgBe", + "ljEcvDq9BTLigqvv0RHFCVOr1k0pSuGiW5E/ITPvyCfhCu0dES6oCsjWxkA+aS+Dqk/aLM5wCtADTGjf", + "FlHXzqJ825a5Nd9Nk5xC5NEGdJPoWdLi8xFn2pv21NJ9F+fay2LAuYsjYLwLw+hkB+ojGOfjhFxv4Pia", + "bpHPjiH2QHDbHfSw+HavpRDmkFjfh2n8mLkDY7v0NTfD3zJD0hu+FJn6L4jTvE863vO78J5vreGneZEp", + "IKQvS4Zfd4cDf2Sw94AgaElzrbkBxu09YWUjfwQ2ftui7+HGt5LevaT7yQ61WwjFJaRrA7nQ6r4XQ9Ry", + "pHwvOuJe7QbGP3qxA7T9aor7Buu2loAQHYq0a8+Hqlt2vB1YSb5BwH36pS1ts71RhfJtdqn3N5gGGz6+", + "h27zlzpjcnC3ibuSRHqhrZs+nqBXN83/AQAA//8Hjlcq1RIAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/server.go b/server/server.go index bf96603..183aa2d 100644 --- a/server/server.go +++ b/server/server.go @@ -33,3 +33,20 @@ func (*server) SearchKindle( return SearchKindle200JSONResponse{N200JSONResponse{Matches: &books}}, nil } + +func (*server) SearchBookbeat( + ctx context.Context, + request SearchBookbeatRequestObject, +) (SearchBookbeatResponseObject, error) { + books, err := searchBookbeatBooks(ctx, + request.Market, + request.Format, + request.Languages, + request.Params.Query, + request.Params.Author) + if err != nil { + return SearchBookbeat500JSONResponse{N500JSONResponse{Error: lo.ToPtr(err.Error())}}, nil + } + + return SearchBookbeat200JSONResponse{N200JSONResponse{Matches: &books}}, nil +}