Compare commits

..

2 Commits

Author SHA1 Message Date
fa73364107 start adding method handlers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-01 17:31:18 -05:00
034232f866 refactor refactor refactor 2024-01-01 16:26:36 -05:00
3 changed files with 315 additions and 189 deletions

196
cmd/serve/api.go Normal file
View File

@ -0,0 +1,196 @@
package main
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"git.yetaga.in/alazyreader/library/media"
"tailscale.com/client/tailscale"
)
type Router struct {
static fs.FS
lib Library
rcol RecordCollection
}
type AdminRouter struct {
static fs.FS
lib Library
ts *tailscale.LocalClient
}
type handler struct {
get func()
post func()
put func()
delete func()
}
func (h handler) Handle(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodHead && h.get != nil {
h.get()
} else if req.Method == http.MethodGet && h.get != nil {
h.get()
} else if req.Method == http.MethodPost && h.post != nil {
h.post()
} else if req.Method == http.MethodPut && h.put != nil {
h.put()
} else if req.Method == http.MethodDelete && h.delete != nil {
h.delete()
} else {
badMethod(w)
}
}
func writeError[T any](t T, err error) func(w http.ResponseWriter) (T, bool) {
return func(w http.ResponseWriter) (T, bool) {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return t, err == nil
}
}
func writeNoBody(w http.ResponseWriter, status int) {
w.WriteHeader(status)
}
func writeJSON(w http.ResponseWriter, b any, status int) {
bytes, err := json.Marshal(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(bytes)
w.Write([]byte("\n"))
}
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/records":
handler{
get: func() { getRecords(router.rcol, w, r) },
}.Handle(w, r)
case "/api/books":
handler{
get: func() { getBooks(router.lib, w, r) },
}.Handle(w, r)
default:
static(router.static).ServeHTTP(w, r)
}
}
func (router *AdminRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/whoami":
handler{
get: func() { getWhoAmI(router.ts, w, r) },
}.Handle(w, r)
case "/api/books":
handler{
get: func() { getBooks(router.lib, w, r) },
post: func() { addBook(router.lib, w, r) },
delete: func() { deleteBook(router.lib, w, r) },
}.Handle(w, r)
default:
static(router.static).ServeHTTP(w, r)
}
}
func badMethod(w http.ResponseWriter) {
writeJSON(w,
struct{ Error string }{Error: "method not supported"},
http.StatusMethodNotAllowed)
}
func getBooks(l Library, w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, books, http.StatusOK)
}
func addBook(l Library, w http.ResponseWriter, r *http.Request) {
if r.Body == nil {
http.Error(w, "no body provided", http.StatusBadRequest)
return
}
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error reading body", http.StatusBadRequest)
return
}
book := &media.Book{}
err = json.Unmarshal(b, book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
err = l.AddBook(r.Context(), book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
writeNoBody(w, http.StatusAccepted)
}
func deleteBook(l Library, w http.ResponseWriter, r *http.Request) {
if r.Body == nil {
http.Error(w, "no body provided", http.StatusBadRequest)
return
}
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error reading body", http.StatusBadRequest)
return
}
book := &media.Book{}
err = json.Unmarshal(b, book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
err = l.DeleteBook(r.Context(), book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
writeNoBody(w, http.StatusAccepted)
}
func getRecords(l RecordCollection, w http.ResponseWriter, r *http.Request) {
records, err := l.GetAllRecords(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, records, http.StatusOK)
}
func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request) {
whois, ok := writeError(ts.WhoIs(r.Context(), r.RemoteAddr))(w)
if !ok {
return
}
writeJSON(w, struct {
Username string `json:"Username"`
DisplayName string `json:"DisplayName"`
ProfilePicURL string `json:"ProfilePicURL"`
}{
Username: whois.UserProfile.LoginName,
DisplayName: whois.UserProfile.DisplayName,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
}, http.StatusOK)
}
func static(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}

View File

@ -2,11 +2,12 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io/fs"
"log" "log"
"net"
"net/http" "net/http"
"os"
"os/signal"
"strings" "strings"
"time" "time"
@ -16,8 +17,8 @@ import (
"git.yetaga.in/alazyreader/library/media" "git.yetaga.in/alazyreader/library/media"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet" "tailscale.com/tsnet"
"tailscale.com/util/must"
) )
func obscureStr(in string, l int) string { func obscureStr(in string, l int) string {
@ -26,100 +27,62 @@ func obscureStr(in string, l int) string {
type Library interface { type Library interface {
GetAllBooks(context.Context) ([]media.Book, error) GetAllBooks(context.Context) ([]media.Book, error)
AddBook(context.Context, *media.Book) error
DeleteBook(context.Context, *media.Book) error
} }
type RecordCollection interface { type RecordCollection interface {
GetAllRecords(context.Context) ([]media.Record, error) GetAllRecords(context.Context) ([]media.Record, error)
} }
type Router struct {
static fs.FS
lib Library
rcol RecordCollection
}
type AdminRouter struct {
static fs.FS
lib Library
ts *tailscale.LocalClient
}
func writeJSON(w http.ResponseWriter, b []byte, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
w.Write([]byte("\n"))
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/api/records" {
RecordsAPIHandler(r.rcol).ServeHTTP(w, req)
return
}
if req.URL.Path == "/api/books" {
BooksAPIHandler(r.lib).ServeHTTP(w, req)
return
}
StaticHandler(r.static).ServeHTTP(w, req)
}
func (r *AdminRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
whois, _ := r.ts.WhoIs(req.Context(), req.RemoteAddr)
w.Write([]byte(fmt.Sprintf("%+v", whois.UserProfile.DisplayName)))
// StaticHandler(r.static).ServeHTTP(w, req)
}
func BooksAPIHandler(l Library) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, b, http.StatusOK)
})
}
func RecordsAPIHandler(l RecordCollection) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllRecords(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, b, http.StatusOK)
})
}
func StaticHandler(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}
func main() { func main() {
var c config.Config var c config.Config
err := envconfig.Process("library", &c) must.Do(envconfig.Process("library", &c))
if err != nil {
log.Fatalln(err)
}
f, err := frontend.Root()
if err != nil {
log.Fatalln(err)
}
var lib Library var lib Library
var err error
if c.DBType == "memory" { if c.DBType == "memory" {
lib = &database.Memory{} lib = &database.Memory{}
} else if c.DBType == "sql" { } else if c.DBType == "sql" {
var latest, run int
lib, latest, run, err = setupSQL(c)
if err != nil {
log.Fatalf("err starting sql connection: %v", err)
}
log.Printf("latest migration: %d; migrations run: %d", latest, run)
}
discogsCache := must.Get(database.NewDiscogsCache(
c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
))
frontendRoot := must.Get(frontend.Root())
adminRoot := must.Get(frontend.AdminRoot())
servers := make(chan (*http.Server), 3)
errGroup := errgroup.Group{}
errGroup.Go(func() error {
return start(servers)(
publicServer(8080, &Router{
static: frontendRoot,
lib: lib,
rcol: discogsCache,
}))
})
errGroup.Go(func() error {
return start(servers)(
tailscaleListener("library-admin", &AdminRouter{
static: adminRoot,
lib: lib,
}))
})
errGroup.Go(func() error {
return shutdown(servers)
})
log.Println(errGroup.Wait())
}
func setupSQL(c config.Config) (Library, int, int, error) {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
if c.DBPass != "" { if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3) c.DBPass = obscureStr(c.DBPass, 3)
@ -127,65 +90,72 @@ func main() {
if c.DiscogsToken != "" { if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3) c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
} }
log.Fatalf("vars: %+v", c) return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c)
} }
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
if err != nil { if err != nil {
log.Fatalln(err) return nil, 0, 0, err
} }
err = sql.PrepareDatabase(context.Background()) err = sql.PrepareDatabase(context.Background())
if err != nil { if err != nil {
log.Fatalln(err) return nil, 0, 0, err
} }
latest, run, err := sql.RunMigrations(context.Background()) latest, run, err := sql.RunMigrations(context.Background())
if err != nil { if err != nil {
log.Fatalln(err) return nil, 0, 0, err
} }
log.Printf("latest migration: %d; migrations run: %d", latest, run) return sql, latest, run, nil
lib = sql
} }
discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile)
func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error {
return func(s *http.Server, l net.Listener, err error) error {
if err != nil { if err != nil {
log.Fatalln(err) return err
}
servers <- s
return s.Serve(l)
} }
errGroup := errgroup.Group{}
errGroup.Go(func() error {
return publicListener(8080, &Router{
static: f,
lib: lib,
rcol: discogsCache,
})
})
errGroup.Go(func() error {
f, _ := frontend.AdminRoot()
return tailscaleListener("library-admin", &AdminRouter{
static: f,
lib: lib,
})
})
log.Println(errGroup.Wait())
} }
func publicListener(port int, handler http.Handler) error { func shutdown(servers chan (*http.Server)) error {
log.Printf("Listening on http://0.0.0.0:%d/", port) sigint := make(chan os.Signal, 1)
return http.ListenAndServe(fmt.Sprintf(":%d", port), handler) signal.Notify(sigint, os.Interrupt)
<-sigint
close(servers)
var err error
for server := range servers {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
if shutdownerr := server.Shutdown(ctx); shutdownerr != nil {
err = shutdownerr
}
cancel()
}
return err
} }
func tailscaleListener(hostname string, handler *AdminRouter) error { func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) {
server := &http.Server{Handler: handler}
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080))
if err != nil {
return nil, nil, err
}
log.Println("starting public server")
return server, ln, nil
}
func tailscaleListener(hostname string, handler *AdminRouter) (*http.Server, net.Listener, error) {
s := &tsnet.Server{ s := &tsnet.Server{
Dir: ".config/" + hostname, Dir: ".config/" + hostname,
Hostname: hostname, Hostname: hostname,
} }
defer s.Close()
ln, err := s.Listen("tcp", ":80") ln, err := s.Listen("tcp", ":80")
if err != nil { if err != nil {
fmt.Printf("%+v\n", err) return nil, nil, err
return nil
} }
handler.ts, err = s.LocalClient() handler.ts, err = s.LocalClient()
if err != nil { if err != nil {
return err return nil, nil, err
} }
return (&http.Server{Handler: handler}).Serve(ln) server := &http.Server{Handler: handler}
return server, ln, nil
} }

View File

@ -153,9 +153,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
} }
allBooksQuery := fmt.Sprintf(`SELECT allBooksQuery := fmt.Sprintf(`SELECT
id, title, authors, sortauthor, isbn10, isbn13, format, id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher,
genre, publisher, series, volume, year, signed, series, volume, year, signed, description, notes, coverurl, childrens
description, notes, coverurl, childrens
FROM %s`, m.tableName) FROM %s`, m.tableName)
books := []media.Book{} books := []media.Book{}
@ -169,12 +168,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
b := media.Book{} b := media.Book{}
var authors string var authors string
err := rows.Scan( err := rows.Scan(
&b.ID, &b.Title, &authors, &b.ID, &b.Title, &authors, &b.SortAuthor, &b.ISBN10, &b.ISBN13, &b.Format, &b.Genre, &b.Publisher,
&b.SortAuthor, &b.ISBN10, &b.ISBN13, &b.Series, &b.Volume, &b.Year, &b.Signed, &b.Description, &b.Notes, &b.CoverURL, &b.Childrens)
&b.Format, &b.Genre, &b.Publisher,
&b.Series, &b.Volume, &b.Year,
&b.Signed, &b.Description, &b.Notes,
&b.CoverURL, &b.Childrens)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -193,25 +188,14 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error {
res, err := m.connection.ExecContext(ctx, ` res, err := m.connection.ExecContext(ctx, `
INSERT INTO `+m.tableName+` INSERT INTO `+m.tableName+`
(title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, coverurl, childrens) (
title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series,
volume, year, signed, description, notes, coverurl, childrens
)
VALUES VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.Title, b.Title, strings.Join(b.Authors, ";"), b.SortAuthor, b.ISBN10, b.ISBN13, b.Format, b.Genre, b.Publisher, b.Series,
strings.Join(b.Authors, ";"), b.Volume, b.Year, b.Signed, b.Description, b.Notes, b.CoverURL, b.Childrens,
b.SortAuthor,
b.ISBN10,
b.ISBN13,
b.Format,
b.Genre,
b.Publisher,
b.Series,
b.Volume,
b.Year,
b.Signed,
b.Description,
b.Notes,
b.CoverURL,
b.Childrens,
) )
if err != nil { if err != nil {
return err return err
@ -236,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
res, err := m.connection.ExecContext(ctx, ` res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+` UPDATE `+m.tableName+`
SET id=? SET
title=? id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=?
authors=? series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=?
sortauthor=?
isbn10=?
isbn13=?
format=?
genre=?
publisher=?
series=?
volume=?
year=?
signed=?
description=?
notes=?
coverurl=?
childrens=?
WHERE id=?`, WHERE id=?`,
new.Title, new.Title, strings.Join(new.Authors, ";"), new.SortAuthor, new.ISBN10, new.ISBN13, new.Format, new.Genre, new.Publisher,
strings.Join(new.Authors, ";"), new.Series, new.Volume, new.Year, new.Signed, new.Description, new.Notes, new.CoverURL, new.Childrens, old.ID,
new.SortAuthor, )
new.ISBN10,
new.ISBN13,
new.Format,
new.Genre,
new.Publisher,
new.Series,
new.Volume,
new.Year,
new.Signed,
new.Description,
new.Notes,
new.CoverURL,
new.Childrens,
old.ID)
if err != nil { if err != nil {
return err return err
} }
@ -284,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
return nil return nil
} }
func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
return nil
}
func parseMigrationFileName(filename string) (int, string, error) { func parseMigrationFileName(filename string) (int, string, error) {
sp := strings.SplitN(filename, "-", 2) sp := strings.SplitN(filename, "-", 2)
i, err := strconv.Atoi(sp[0]) i, err := strconv.Atoi(sp[0])