Open Library search API

If nothing is found on the server, returns results from open library
API. Clicking on a book imports it.
This commit is contained in:
2025-12-30 18:13:11 +01:00
parent 4d901ccc02
commit 1bb841332c
18 changed files with 478 additions and 57 deletions

View File

@@ -107,6 +107,7 @@
<h3 class="subtitle">{{data.author}}</h3>
<p>{{data.summary}}</p>
<div class="my-5" v-if="data.isbn">ISBN: {{data.isbn}}</div>
<div class="my-5" v-if="data.openlibraryid">OLID: {{data.openlibraryid}}</div>
</div>
<div class="column">
<div class="iconscontainer" :class="data.read ? 'remove-border-bottom' : ''">

View File

@@ -31,7 +31,7 @@ function openBook() {
if (props.id != 0) {
router.push(`/book/${props.id}`);
} else {
console.log("Open Library Id : " + props.openlibraryid);
router.push(`/importopenlibrary/${props.openlibraryid}`)
}
}

View File

@@ -0,0 +1,32 @@
<script setup>
import { ref } from 'vue'
import { postImportBook } from './api.js'
import { useRouter } from 'vue-router'
const router = useRouter();
const props = defineProps({
openlibraryid: String
});
const error = ref(null);
const data = ref(null);
async function importOpenLibraryId() {
const res = await postImportBook(props.openlibraryid);
const json = await res.json();
if (res.ok) {
router.push(`/book/${json.id}`);
} else {
error.value = json;
}
}
importOpenLibraryId();
</script>
<template>
<div v-if="error">Importing {{props.openlibraryid}}...</div>
<div v-else-if="!data">Importing {{props.openlibraryid}}...</div>
</template>
<style scoped></style>

View File

@@ -62,6 +62,10 @@ export function postBook(book) {
return genericPayloadCall('/book', book.value, 'POST')
}
export async function postImportBook(id) {
return genericPayloadCall('/importbook', {openlibraryid: id}, 'POST');
}
export async function putReadBook(bookId) {
return genericPayloadCall('/book/' + bookId + "/read", {read: true}, 'PUT')
}

View File

@@ -8,12 +8,14 @@ import SignUp from './SignUp.vue'
import LogIn from './LogIn.vue'
import Home from './Home.vue'
import SearchBook from './SearchBook.vue'
import OpenLibraryImport from './OpenLibraryImport.vue'
import { useAuthStore } from './auth.store'
const routes = [
{ path: '/', component: Home },
{ path: '/books', component: BooksBrowser },
{ path: '/book/:id', component: BookForm, props: true },
{ path: '/importopenlibrary/:openlibraryid', component: OpenLibraryImport, props: true },
{ path: '/author/:id', component: AuthorForm, props: true },
{ path: '/search/:searchterm', component: SearchBook, props: true },
{ path: '/add', component: AddBook },

View File

@@ -15,6 +15,7 @@ type fetchedBook struct {
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
ISBN string `json:"isbn"`
OpenLibraryId string `json:"openlibraryid"`
Summary string `json:"summary"`
Rating int `json:"rating"`
Read bool `json:"read"`

View File

@@ -0,0 +1,66 @@
package apitest
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/testutils"
"github.com/stretchr/testify/assert"
)
type hasId struct {
ID uint `json:"id"`
}
func TestPostImportBookHandler_Ok(t *testing.T) {
id := testPostImportBookHandler(t, "OL21177W", http.StatusOK)
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "Wuthering Heights", book.Title)
assert.Equal(t, "Emily Brontë", book.Author)
assert.Equal(t, "OL21177W", book.OpenLibraryId)
}
func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
id := testPostImportBookHandler(t, "OL7525169M", http.StatusOK)
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "Dr. Bloodmoney, or How We Got Along After the Bomb", book.Title)
assert.Equal(t, "Philip K. Dick", book.Author)
assert.Equal(t, "OL7525169M", book.OpenLibraryId)
}
func TestPostImportBookHandler_NoOLID(t *testing.T) {
testPostImportBookHandler(t, "", http.StatusBadRequest)
}
func testPostImportBookHandler(t *testing.T, openlibraryid string, expectedCode int) uint {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
queryJson := `{
"openlibraryid":"%s"
}`
queryJson = fmt.Sprintf(queryJson, openlibraryid)
req, _ := http.NewRequest("POST", "/importbook",
strings.NewReader(string(queryJson)))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
if w.Code == 200 {
var parsedId hasId
err := json.Unmarshal(w.Body.Bytes(), &parsedId)
if err != nil {
t.Error(err)
}
return parsedId.ID
} else {
return 0
}
}

View File

@@ -4,6 +4,7 @@ import "gorm.io/gorm"
type Author struct {
gorm.Model
Name string
Description string
Name string
Description string
OpenLibraryId string
}

View File

@@ -2,61 +2,44 @@ package openlibrary
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
type OpenLibraryQueryResult struct {
Books []OpenLibraryBook `json:"docs"`
NumFound int `json:"numFound"`
}
type OpenLibraryBook struct {
Title string `json:"title"`
Authors []string `json:"author_name"`
OpenLibraryId string `json:"cover_edition_key"`
}
func CallOpenLibrary(searchterm string, limit int, offset int) (OpenLibraryQueryResult, error) {
var queryResult OpenLibraryQueryResult
func computeOpenLibraryUrl(paths ...string) (*url.URL, error) {
baseUrl := "https://openlibrary.org"
booksApiUrl := "search.json"
u, err := url.Parse(baseUrl)
if err != nil {
return queryResult, err
return nil, err
}
u = u.JoinPath(booksApiUrl)
if limit != 0 {
addQueryParamInt(u, "limit", limit)
for _, p := range paths {
u = u.JoinPath(p)
}
if offset != 0 {
addQueryParamInt(u, "offset", offset)
}
addQueryParam(u, "q", searchterm)
client := &http.Client{}
req, err := http.NewRequest("GET", u.String(), nil)
return u, nil
}
func fetchAndParseResult[T any](u *url.URL, queryResult *T) error {
resp, err := doOpenLibraryQuery(u)
if err != nil {
return queryResult, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "PersonalLibraryManager/0.1 (artlef@protonmail.com)")
resp, err := client.Do(req)
if err != nil {
return queryResult, err
return err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(queryResult)
return err
}
func doOpenLibraryQuery(u *url.URL) (*http.Response, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return queryResult, err
return nil, err
}
bodyString := string(bodyBytes)
decoder := json.NewDecoder(strings.NewReader(bodyString))
err = decoder.Decode(&queryResult)
return queryResult, err
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "PersonalLibraryManager/0.1 (artlef@protonmail.com)")
return client.Do(req)
}
func addQueryParamInt(u *url.URL, paramName string, paramValue int) {

View File

@@ -7,21 +7,61 @@ import (
"github.com/stretchr/testify/assert"
)
func TestCallOpenLibrary(t *testing.T) {
result, err := openlibrary.CallOpenLibrary("man in high castle", 0, 0)
func TestCallOpenLibrarySearch(t *testing.T) {
result, err := openlibrary.CallOpenLibrarySearch("man in high castle", 0, 0)
assert.Nil(t, err)
assert.Equal(t, result.NumFound, 25)
assert.Equal(t, len(result.Books), 25)
assert.Equal(t, 25, result.NumFound)
assert.Equal(t, 25, len(result.Books))
}
func TestCallOpenLibraryLimit(t *testing.T) {
result, err := openlibrary.CallOpenLibrary("man in high castle", 10, 0)
func TestCallOpenLibrarySearchLimit(t *testing.T) {
result, err := openlibrary.CallOpenLibrarySearch("man in high castle", 10, 0)
assert.Nil(t, err)
assert.Equal(t, len(result.Books), 10)
assert.Equal(t, 10, len(result.Books))
}
func TestCallOpenLibraryOffset(t *testing.T) {
result, err := openlibrary.CallOpenLibrary("man in high castle", 0, 10)
func TestCallOpenLibrarySearchOffset(t *testing.T) {
result, err := openlibrary.CallOpenLibrarySearch("man in high castle", 0, 10)
assert.Nil(t, err)
assert.Equal(t, len(result.Books), 15)
assert.Equal(t, 15, len(result.Books))
}
func TestCallOpenLibraryBook_FirstAuthorFormat(t *testing.T) {
result, err := openlibrary.CallOpenLibraryBook("OL21177W")
assert.Nil(t, err)
assert.Equal(t, openlibrary.OpenLibraryBookResult{
Title: "Wuthering Heights",
Description: "Wuthering Heights is an 1847 novel by Emily Brontë, initially published under the pseudonym Ellis Bell. It concerns two families of the landed gentry living on the West Yorkshire moors, the Earnshaws and the Lintons, and their turbulent relationships with Earnshaw's adopted son, Heathcliff. The novel was influenced by Romanticism and Gothic fiction.",
AuthorID: "OL24529A",
}, result)
}
func TestCallOpenLibraryBook_SecondAuthorFormat(t *testing.T) {
result, err := openlibrary.CallOpenLibraryBook("OL18388220M")
assert.Nil(t, err)
assert.Equal(t, openlibrary.OpenLibraryBookResult{
Title: "Dr Bloodmoney",
Description: "",
AuthorID: "OL274606A",
}, result)
}
func TestCallOpenLibraryAuthor_SimpleBioFormat(t *testing.T) {
result, err := openlibrary.CallOpenLibraryAuthor("OL24529A")
assert.Nil(t, err)
expectedAuthorBio := "Emily Jane Brontë was an English novelist and poet, now best remembered for her novel [Wuthering Heights][1], a classic of English literature. Emily was the second eldest of the three surviving Brontë sisters, between Charlotte and Anne. She published under the androgynous pen name Ellis Bell. ([Source][2].)\r\n\r\n\r\n [1]: http://upstream.openlibrary.org/works/OL10427528W/Wuthering_Heights\r\n [2]: http://en.wikipedia.org/wiki/Emily_Bronte"
assert.Equal(t, openlibrary.OpenLibraryAuthorResult{
Name: "Emily Brontë",
Description: expectedAuthorBio,
}, result)
}
func TestCallOpenLibraryAuthor_OtherBioFormat(t *testing.T) {
result, err := openlibrary.CallOpenLibraryAuthor("OL274606A")
assert.Nil(t, err)
expectedAuthorBio := "Philip Kindred Dick was an American novelist, short story writer, and essayist whose published work during his lifetime was almost entirely in the science fiction genre. Dick explored sociological, political and metaphysical themes in novels dominated by monopolistic corporations, authoritarian governments, and altered states. In his later works, Dick's thematic focus strongly reflected his personal interest in metaphysics and theology. He often drew upon his own life experiences and addressed the nature of drug abuse, paranoia and schizophrenia, and transcendental experiences in novels such as A Scanner Darkly and VALIS.\r\n\r\nSource and more information: [Wikipedia (EN)](http://en.wikipedia.org/wiki/Philip_K._Dick)"
assert.Equal(t, openlibrary.OpenLibraryAuthorResult{
Name: "Philip K. Dick",
Description: expectedAuthorBio,
}, result)
}

View File

@@ -0,0 +1,58 @@
package openlibrary
import (
"encoding/json"
"fmt"
)
type OpenLibraryAuthorResult struct {
Name string
Description string
}
type openLibraryParsedAuthor struct {
Name string `json:"name" binding:"required"`
Description openLibraryAuthorBio `json:"bio"`
}
type openLibraryAuthorBio struct {
Value string
}
func (b *openLibraryAuthorBio) UnmarshalJSON(data []byte) error {
var possibleFormat struct {
Type string `json:"type"`
Value string `json:"value"`
}
err := json.Unmarshal(data, &possibleFormat)
if err != nil {
//if unmarshalling failed, try to decode as string
var value string
err = json.Unmarshal(data, &value)
if err != nil {
return err
}
b.Value = value
} else {
b.Value = possibleFormat.Value
}
return nil
}
func CallOpenLibraryAuthor(openLibraryId string) (OpenLibraryAuthorResult, error) {
var response OpenLibraryAuthorResult
u, err := computeOpenLibraryUrl("authors", fmt.Sprintf("%s.json", openLibraryId))
if err != nil {
return response, err
}
var queryResult openLibraryParsedAuthor
err = fetchAndParseResult(u, &queryResult)
if err != nil {
return response, err
}
response = OpenLibraryAuthorResult{
Name: queryResult.Name,
Description: queryResult.Description.Value,
}
return response, err
}

View File

@@ -0,0 +1,93 @@
package openlibrary
import (
"encoding/json"
"fmt"
"strings"
)
type OpenLibraryBookResult struct {
Title string `json:"title"`
Description string `json:"description"`
AuthorID string `json:"author_id"`
}
type openLibraryParsedBook struct {
Title string `json:"title"`
Description string `json:"description"`
AuthorInfo []openLibraryBookAuthorResult `json:"authors"`
}
type openLibraryBookAuthorResult struct {
AuthorOLID string
Type string
Key string
}
type keyContainer struct {
Key string `json:"key"`
}
func (r *openLibraryBookAuthorResult) UnmarshalJSON(data []byte) error {
var parentAuthor struct {
AuthorKey keyContainer `json:"author"`
TypeKey keyContainer `json:"type"`
Key string `json:"key"`
}
if err := json.Unmarshal(data, &parentAuthor); err != nil {
return err
}
r.AuthorOLID = parseUrlPathToField(parentAuthor.AuthorKey.Key)
r.Type = parseUrlPathToField(parentAuthor.TypeKey.Key)
r.Key = parseUrlPathToField(parentAuthor.Key)
return nil
}
// some fields follow this format "/authors/OL274606A"
// this method extracts the last part "OL274606A"
func parseUrlPathToField(urlpath string) string {
splittedPath := strings.Split(urlpath, "/")
if len(splittedPath) > 2 {
return splittedPath[2]
} else {
return urlpath
}
}
func CallOpenLibraryBook(openLibraryId string) (OpenLibraryBookResult, error) {
var response OpenLibraryBookResult
u, err := computeOpenLibraryUrl("works", fmt.Sprintf("%s.json", openLibraryId))
if err != nil {
return response, err
}
var queryResult openLibraryParsedBook
err = fetchAndParseResult(u, &queryResult)
if err != nil {
return response, err
}
author := computeAuthorFromParsedBook(&queryResult)
response = OpenLibraryBookResult{
Title: queryResult.Title,
Description: queryResult.Description,
AuthorID: author,
}
return response, err
}
func computeAuthorFromParsedBook(parsedBook *openLibraryParsedBook) string {
authors := parsedBook.AuthorInfo
for _, author := range authors {
if author.Key != "" {
return author.Key
}
if author.Type == "author_role" {
return author.AuthorOLID
}
}
if len(authors) > 0 {
return authors[0].AuthorOLID
}
return ""
}

View File

@@ -0,0 +1,29 @@
package openlibrary
type OpenLibrarySearchResult struct {
Books []OpenLibrarySearchBook `json:"docs"`
NumFound int `json:"numFound"`
}
type OpenLibrarySearchBook struct {
Title string `json:"title"`
Authors []string `json:"author_name"`
OpenLibraryId string `json:"cover_edition_key"`
}
func CallOpenLibrarySearch(searchterm string, limit int, offset int) (OpenLibrarySearchResult, error) {
var queryResult OpenLibrarySearchResult
u, err := computeOpenLibraryUrl("search.json")
if err != nil {
return queryResult, err
}
if limit != 0 {
addQueryParamInt(u, "limit", limit)
}
if offset != 0 {
addQueryParamInt(u, "offset", offset)
}
addQueryParam(u, "q", searchterm)
err = fetchAndParseResult(u, &queryResult)
return queryResult, err
}

View File

@@ -10,6 +10,7 @@ type BookGet struct {
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
ISBN string `json:"isbn"`
OpenLibraryId string `json:"openlibraryid"`
Summary string `json:"summary"`
Rating int `json:"rating"`
Read bool `json:"read"`
@@ -22,7 +23,7 @@ type BookGet struct {
func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (BookGet, error) {
var book BookGet
query := db.Model(&model.Book{})
selectQueryString := "books.title, authors.name as author, books.isbn, books.summary, " +
selectQueryString := "books.title, authors.name as author, books.isbn, books.open_library_id, books.summary, " +
"user_books.rating, user_books.read, user_books.want_read, " +
"DATE(user_books.start_read_date) as start_read_date, " +
"DATE(user_books.end_read_date) AS end_read_date, " +

View File

@@ -59,7 +59,7 @@ func saveBookToDb(ac appcontext.AppContext, b bookPostCreate, user *model.User)
func fetchOrCreateAuthor(ac appcontext.AppContext, name string) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("name = ?", name)
res := ac.Db.Where("name = ?", name).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -0,0 +1,107 @@
package routes
import (
"errors"
"git.artlef.fr/PersonalLibraryManager/internal/appcontext"
"git.artlef.fr/PersonalLibraryManager/internal/model"
"git.artlef.fr/PersonalLibraryManager/internal/myvalidator"
"git.artlef.fr/PersonalLibraryManager/internal/openlibrary"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type bookPostImport struct {
OpenLibraryId string `json:"openlibraryid" binding:"required,max=50"`
}
func PostImportBookHandler(ac appcontext.AppContext) {
var request bookPostImport
err := ac.C.ShouldBindJSON(&request)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
openLibraryBook, err := openlibrary.CallOpenLibraryBook(request.OpenLibraryId)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
book, err := saveOpenLibraryBookToDb(ac, request.OpenLibraryId, openLibraryBook, &user)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(200, gin.H{"id": book.ID})
}
func saveOpenLibraryBookToDb(ac appcontext.AppContext, openLibraryId string, openLibraryBook openlibrary.OpenLibraryBookResult, user *model.User) (*model.Book, error) {
author, err := fetchOrCreateOpenLibraryAuthor(ac, openLibraryBook.AuthorID)
if err != nil {
return nil, err
}
book := model.Book{
Title: openLibraryBook.Title,
Summary: openLibraryBook.Description,
OpenLibraryId: openLibraryId,
Author: *author,
AddedBy: *user,
}
err = ac.Db.Save(&book).Error
return &book, err
}
func fetchOrCreateOpenLibraryAuthor(ac appcontext.AppContext, openlibraryAuthorId string) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("open_library_id = ?", openlibraryAuthorId).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
newAuthor, err := createAuthorFromOpenLibrary(ac, openlibraryAuthorId)
if err != nil {
return &author, err
}
return newAuthor, nil
} else {
return &author, err
}
} else {
return &author, nil
}
}
func createAuthorFromOpenLibrary(ac appcontext.AppContext, openlibraryAuthorId string) (*model.Author, error) {
authorFromOL, err := openlibrary.CallOpenLibraryAuthor(openlibraryAuthorId)
if err != nil {
return nil, err
}
var author model.Author
res := ac.Db.Where("name = ?", authorFromOL.Name).First(&author)
err = res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
author = model.Author{
Name: authorFromOL.Name,
Description: authorFromOL.Description,
OpenLibraryId: openlibraryAuthorId,
}
err = ac.Db.Save(&author).Error
return &author, err
} else {
return nil, err
}
} else {
//if the author already exists, only fill the open library id
author.OpenLibraryId = openlibraryAuthorId
ac.Db.Save(&author)
}
return &author, err
}

View File

@@ -38,7 +38,7 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
if len(books) > 0 {
returnedBooks = books
} else {
queryResult, err := openlibrary.CallOpenLibrary(searchterm, limit, offset)
queryResult, err := openlibrary.CallOpenLibrarySearch(searchterm, limit, offset)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
@@ -48,7 +48,7 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
ac.C.JSON(http.StatusOK, returnedBooks)
}
func OpenLibraryBooksToBookSearchGet(OLbooks []openlibrary.OpenLibraryBook) []query.BookSearchGet {
func OpenLibraryBooksToBookSearchGet(OLbooks []openlibrary.OpenLibrarySearchBook) []query.BookSearchGet {
var books []query.BookSearchGet
for _, b := range OLbooks {
bookSearchGet := query.BookSearchGet{
@@ -83,7 +83,7 @@ func GetSearchBooksCountHandler(ac appcontext.AppContext) {
if count > 0 {
finalCount = count
} else {
queryResult, err := openlibrary.CallOpenLibrary(searchterm, 0, 0)
queryResult, err := openlibrary.CallOpenLibrarySearch(searchterm, 0, 0)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return

View File

@@ -66,6 +66,9 @@ func Setup(config *config.Config) *gin.Engine {
r.POST("/book", func(c *gin.Context) {
routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.POST("/importbook", func(c *gin.Context) {
routes.PostImportBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.GET("/author/:id", func(c *gin.Context) {
routes.GetAuthorHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})