search for a book with a single value

This commit is contained in:
rmanach 2025-01-07 09:29:21 +01:00
parent 1c6b758f0c
commit 42a3181797
12 changed files with 341 additions and 12 deletions

1
go.mod
View File

@ -7,5 +7,6 @@ require github.com/rs/zerolog v1.33.0
require ( require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
golang.org/x/sys v0.12.0 // indirect golang.org/x/sys v0.12.0 // indirect
) )

2
go.sum
View File

@ -5,6 +5,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=

View File

@ -3,6 +3,7 @@ package home
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"librapi/services"
"librapi/templates" "librapi/templates"
"net/http" "net/http"
@ -11,11 +12,35 @@ import (
const URL = "/" const URL = "/"
func Handler() func(http.ResponseWriter, *http.Request) { type SearchField struct {
Name string
Value string
Err string
}
type SearchForm struct {
Search SearchField
Error error
Method string
Results []services.BookMetadata
}
func NewSearchForm() SearchForm {
return SearchForm{
Search: SearchField{
Name: "search",
},
Method: http.MethodPost,
}
}
func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
getHome(w, r) getHome(w, r)
case http.MethodPost:
postHome(w, r, bs)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }
@ -26,7 +51,45 @@ func getHome(w http.ResponseWriter, _ *http.Request) {
home := templates.GetHome() home := templates.GetHome()
buf := bytes.NewBufferString("") buf := bytes.NewBufferString("")
if err := home.Execute(buf, nil); err != nil { if err := home.Execute(buf, &SearchForm{}); err != nil {
log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return
}
fmt.Fprint(w, buf)
}
func extractSearchForm(r *http.Request) SearchForm {
sf := NewSearchForm()
sf.Search.Value = r.FormValue(sf.Search.Name)
return sf
}
func postHome(w http.ResponseWriter, r *http.Request, bs services.IStore) {
home := templates.GetHome()
buf := bytes.NewBufferString("")
sf := extractSearchForm(r)
bms, err := bs.Search(sf.Search.Value)
if err != nil {
sf.Error = err
if err := home.Execute(buf, sf); err != nil {
log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, buf.String())
return
}
sf.Results = bms
if err := home.Execute(buf, sf); err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return

View File

@ -104,6 +104,26 @@ func (bf *BookForm) IsSuccess() bool {
return bf.Method == http.MethodPost && bf.Error == "" && !bf.HasErrors() return bf.Method == http.MethodPost && bf.Error == "" && !bf.HasErrors()
} }
func (bf *BookForm) IntoMetadata() *services.BookMetadata {
bm := &services.BookMetadata{
Name: bf.Name.Value,
Editor: bf.Editor.Value,
Authors: bf.Authors.Value,
Year: uint16(bf.Year.Value),
Keywords: nil,
}
if desc := bf.Description.Value; desc != "" {
bm.Description = &desc
}
if keywords := bf.Keywords.Value; len(keywords) > 0 {
bm.Keywords = keywords
}
return bm
}
func Handler(a services.IAuthenticate, s services.IStore) func(http.ResponseWriter, *http.Request) { func Handler(a services.IAuthenticate, s services.IStore) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
@ -203,10 +223,11 @@ func postUploadFile(w http.ResponseWriter, r *http.Request, a services.IAuthenti
filename := bf.File.Value.GetFilename() filename := bf.File.Value.GetFilename()
log.Info().Str("filename", filename).Msg("file is uploading...") log.Info().Str("filename", filename).Msg("file is uploading...")
if err := s.Save(filename, bf.File.Value.file); err != nil { if err := s.Save(bf.IntoMetadata(), bf.File.Value.file); err != nil {
if err := uploadForm.Execute(buf, &BookForm{Error: err.Error()}); err != nil { if err := uploadForm.Execute(buf, &BookForm{Error: err.Error()}); err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return
} }
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)

View File

@ -32,7 +32,7 @@ func main() {
srv := server.NewServer( srv := server.NewServer(
ctx, ctx,
services.GetEnv().GetPort(), services.GetEnv().GetPort(),
server.NewHandler(home.URL, home.Handler()), server.NewHandler(home.URL, home.Handler(bs)),
server.NewHandler(upload.URL, upload.Handler(auth, bs)), server.NewHandler(upload.URL, upload.Handler(auth, bs)),
server.NewHandler(login.URL, login.Handler(auth)), server.NewHandler(login.URL, login.Handler(auth)),
) )

View File

@ -1,12 +1,18 @@
package services package services
import ( import (
"database/sql"
_ "embed"
"encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -16,14 +22,89 @@ var (
) )
type IStore interface { type IStore interface {
Save(name string, content io.ReadCloser) error Save(bm *BookMetadata, content io.ReadCloser) error
Search(value string) ([]BookMetadata, error)
} }
var _ IStore = (*BookStore)(nil) var _ IStore = (*BookStore)(nil)
//go:embed sql/create_books_table.sql
var sqlCreateTable string
//go:embed sql/insert_book.sql
var sqlInsertBook string
//go:embed sql/search_books.sql
var sqlSearchBooks string
type BookMetadata struct {
Name string
Description *string
Editor string
Authors []string
Year uint16
Keywords []string
Path string
}
func (bm *BookMetadata) getAuthors() (string, error) {
authors, err := json.Marshal(bm.Authors)
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return "", err
}
return string(authors), nil
}
func (bm *BookMetadata) getKeywords() (*string, error) {
if bm.Keywords == nil {
return nil, nil
}
keywords, err := json.Marshal(bm.Keywords)
if err != nil {
log.Err(err).Msg("unable to marshal keywords")
return nil, err
}
strKeywords := string(keywords)
return &strKeywords, nil
}
func (bm *BookMetadata) getFormattedName() string {
name := strings.ReplaceAll(strings.ToLower(bm.Name), " ", "-")
editor := strings.ReplaceAll(strings.ToLower(bm.Editor), " ", "-")
author := strings.ReplaceAll(strings.ToLower(bm.Authors[0]), " ", "-")
return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, bm.Year)
}
func (bm *BookMetadata) intoStmtValues() ([]any, error) {
authors, err := bm.getAuthors()
if err != nil {
return nil, err
}
keywords, err := bm.getKeywords()
if err != nil {
log.Err(err).Msg("unable to marshal authors")
return nil, err
}
return []any{
bm.Name,
bm.Description,
bm.Editor,
authors,
bm.Year,
keywords,
bm.Path,
}, nil
}
type BookStore struct { type BookStore struct {
processing *sync.WaitGroup processing *sync.WaitGroup
dir string dir string
db *sql.DB
} }
func NewBookStore(dir string) *BookStore { func NewBookStore(dir string) *BookStore {
@ -31,29 +112,71 @@ func NewBookStore(dir string) *BookStore {
log.Fatal().Err(err).Msg("unable to create store dir") log.Fatal().Err(err).Msg("unable to create store dir")
} }
return &BookStore{ bs := &BookStore{
processing: &sync.WaitGroup{}, processing: &sync.WaitGroup{},
dir: dir, dir: dir,
} }
bs.init(dir)
return bs
}
func (bs *BookStore) init(dir string) {
db, err := sql.Open("sqlite3", filepath.Join(dir, "librapi.db"))
if err != nil {
log.Fatal().Err(err).Msg("unable initialize sqlite3 database")
}
if _, err := db.Exec(sqlCreateTable); err != nil {
log.Fatal().Err(err).Msg("unable to create books table")
}
bs.db = db
}
func (bs *BookStore) store(bm *BookMetadata) error {
stmt, err := bs.db.Prepare(sqlInsertBook)
if err != nil {
log.Err(err).Msg("unable to parse insert book sql")
return err
}
defer stmt.Close()
values, err := bm.intoStmtValues()
if err != nil {
return err
}
if _, err := stmt.Exec(values...); err != nil {
log.Err(err).Msg("unable to insert new book")
return err
}
return nil
} }
func (bs *BookStore) Done() <-chan struct{} { func (bs *BookStore) Done() <-chan struct{} {
chDone := make(chan struct{}) chDone := make(chan struct{})
go func() { go func() {
bs.processing.Wait() bs.processing.Wait()
if err := bs.db.Close(); err != nil {
log.Err(err).Msg("unable to close the database")
}
chDone <- struct{}{} chDone <- struct{}{}
log.Info().Msg("book store processing done") log.Info().Msg("book store processing done")
}() }()
return chDone return chDone
} }
func (bs *BookStore) Save(name string, content io.ReadCloser) error { func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error {
bs.processing.Add(1) bs.processing.Add(1)
defer bs.processing.Done() defer bs.processing.Done()
defer content.Close() defer content.Close()
dst, err := os.Create(filepath.Join(GetEnv().GetDir(), name)) bm.Path = filepath.Join(GetEnv().GetDir(), bm.getFormattedName())
dst, err := os.Create(bm.Path)
if err != nil { if err != nil {
log.Err(err).Msg(ErrFileCreation.Error()) log.Err(err).Msg(ErrFileCreation.Error())
return ErrFileCreation return ErrFileCreation
@ -65,5 +188,45 @@ func (bs *BookStore) Save(name string, content io.ReadCloser) error {
return ErrFileCopy return ErrFileCopy
} }
return nil return bs.store(bm)
}
func (bs *BookStore) Search(value string) ([]BookMetadata, error) {
stmt, err := bs.db.Prepare(sqlSearchBooks)
if err != nil {
log.Err(err).Msg("unable to parse search book sql")
return nil, err
}
defer stmt.Close()
values := []any{}
for i := 0; i <= 5; i++ {
values = append(values, fmt.Sprintf("%%%s%%", value))
}
rows, err := stmt.Query(values...)
if err != nil {
log.Err(err).Msg("unable to query books")
return nil, err
}
bms := []BookMetadata{}
for rows.Next() {
var bm BookMetadata
var authors string
var keyword *string
if err := rows.Scan(&bm.Name, &bm.Description, &bm.Editor, &authors, &bm.Year, &keyword, &bm.Path); err != nil {
log.Err(err).Msg("unable to scan row")
return nil, err
}
var authorsSlice []string
if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil {
return nil, err
}
bm.Authors = authorsSlice
bms = append(bms, bm)
}
return bms, nil
} }

View File

@ -10,7 +10,7 @@ import (
) )
const ( const (
defaultAPISessionExpirationDuration = 30 * time.Second defaultAPISessionExpirationDuration = 5 * 60 * time.Second
defaultPort = 8585 defaultPort = 8585
defaultMainDir = "./store" defaultMainDir = "./store"
) )

View File

@ -0,0 +1,9 @@
create table if not exists books (
name text primary key,
description text,
editor text not null,
authors jsonb not null,
year int not null,
keywords jsonb,
path text not null
)

View File

@ -0,0 +1,2 @@
insert into books(name, description, editor, authors, year, keywords, path)
values (?,?,?,?,?,?,?)

View File

@ -0,0 +1,20 @@
select
b.*
from
books b
where
lower(b.name) like lower(?)
or lower(b.description) like lower(?)
or lower(b.editor) like lower(?)
or lower(b.year) like lower(?)
union
select
b2.*
from books b2, json_each(b2.authors)
where lower(json_each.value) like lower(?)
union
select
b3.*
from books b3, json_each(b3.keywords)
where lower(json_each.value) like lower(?)

View File

@ -1,4 +1,46 @@
{{ define "content" }} {{ define "content" }}
<h3>A simple API to store, search and download books.</h3> <h3>A simple API to store, search and download books.</h3>
<p>No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.</p> <div>No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.</div>
<form action="/home" method="post" enctype="multipart/form-data">
<div class="main-container">
<div class="form-item">
<div class="form-container">
<h4>Search</h4>
</div>
<div class="form-container">
<input type="text" name="search" value="{{.Search.Value}}" />
</div>
{{ if .Search.Err }}
<div class="error">{{.Search.Err}}</div>
{{ end }}
</div>
<div class="form-item">
<div class="form-container">
<button id="submit" type="submit">Search</button>
</div>
</div>
</div>
</form>
{{ if ne (errStr .Error) "" }}
<div class="error">{{.Error | errStr}}</div>
{{ end }}
<table>
<tr>
<th>Name</th>
<th>Editor</th>
<th>Authors</th>
<th>Year</th>
</tr>
{{range .Results}}
<tr>
<td><div style="margin: 10px;">{{.Name}}</div></td>
<td style="text-align: center;"><div style="margin: 10px;">{{.Editor}}</div></td>
<td style="text-align: center;"><div style="margin: 10px;">{{.Authors | join }}</div></td>
<td><div style="margin: 20px;">{{.Year}}</div></td>
<td><div style="margin: 20px;">{{.Path | bookUrl}}</div></td>
</tr>
{{ end }}
</table>
</ul>
{{ end }} {{ end }}

View File

@ -2,8 +2,10 @@ package templates
import ( import (
_ "embed" _ "embed"
"fmt"
"html/template" "html/template"
"mime/multipart" "mime/multipart"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -52,6 +54,10 @@ var funcMap = template.FuncMap{
} }
return "" return ""
}, },
"bookUrl": func(path string) string {
_, filename := filepath.Split(path)
return fmt.Sprintf("https://books.thegux.fr/downloads/%s", filename)
},
} }
var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template { var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template {
@ -60,7 +66,7 @@ var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template {
log.Fatal().Err(err).Msg("unable to parse base tmpl") log.Fatal().Err(err).Msg("unable to parse base tmpl")
} }
if _, err := baseTmpl.New("home").Parse(home); err != nil { if _, err := baseTmpl.New("home").Funcs(funcMap).Parse(home); err != nil {
log.Fatal().Err(err).Msg("unable to parse home tmpl") log.Fatal().Err(err).Msg("unable to parse home tmpl")
} }