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 (
"context"
"encoding/json"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"time"
@ -16,8 +17,8 @@ import (
"git.yetaga.in/alazyreader/library/media"
"github.com/kelseyhightower/envconfig"
"golang.org/x/sync/errgroup"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
"tailscale.com/util/must"
)
func obscureStr(in string, l int) string {
@ -26,166 +27,135 @@ func obscureStr(in string, l int) string {
type Library interface {
GetAllBooks(context.Context) ([]media.Book, error)
AddBook(context.Context, *media.Book) error
DeleteBook(context.Context, *media.Book) error
}
type RecordCollection interface {
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() {
var c config.Config
err := envconfig.Process("library", &c)
if err != nil {
log.Fatalln(err)
}
f, err := frontend.Root()
if err != nil {
log.Fatalln(err)
}
must.Do(envconfig.Process("library", &c))
var lib Library
var err error
if c.DBType == "memory" {
lib = &database.Memory{}
} else if c.DBType == "sql" {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3)
}
if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
}
log.Fatalf("vars: %+v", c)
}
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
var latest, run int
lib, latest, run, err = setupSQL(c)
if err != nil {
log.Fatalln(err)
}
err = sql.PrepareDatabase(context.Background())
if err != nil {
log.Fatalln(err)
}
latest, run, err := sql.RunMigrations(context.Background())
if err != nil {
log.Fatalln(err)
log.Fatalf("err starting sql connection: %v", err)
}
log.Printf("latest migration: %d; migrations run: %d", latest, run)
lib = sql
}
discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile)
if err != nil {
log.Fatalln(err)
}
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 publicListener(8080, &Router{
static: f,
lib: lib,
rcol: discogsCache,
})
return start(servers)(
publicServer(8080, &Router{
static: frontendRoot,
lib: lib,
rcol: discogsCache,
}))
})
errGroup.Go(func() error {
f, _ := frontend.AdminRoot()
return tailscaleListener("library-admin", &AdminRouter{
static: f,
lib: lib,
})
return start(servers)(
tailscaleListener("library-admin", &AdminRouter{
static: adminRoot,
lib: lib,
}))
})
errGroup.Go(func() error {
return shutdown(servers)
})
log.Println(errGroup.Wait())
}
func publicListener(port int, handler http.Handler) error {
log.Printf("Listening on http://0.0.0.0:%d/", port)
return http.ListenAndServe(fmt.Sprintf(":%d", port), handler)
func setupSQL(c config.Config) (Library, int, int, error) {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3)
}
if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
}
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)
if err != nil {
return nil, 0, 0, err
}
err = sql.PrepareDatabase(context.Background())
if err != nil {
return nil, 0, 0, err
}
latest, run, err := sql.RunMigrations(context.Background())
if err != nil {
return nil, 0, 0, err
}
return sql, latest, run, nil
}
func tailscaleListener(hostname string, handler *AdminRouter) error {
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 {
return err
}
servers <- s
return s.Serve(l)
}
}
func shutdown(servers chan (*http.Server)) error {
sigint := make(chan os.Signal, 1)
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 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{
Dir: ".config/" + hostname,
Hostname: hostname,
}
defer s.Close()
ln, err := s.Listen("tcp", ":80")
if err != nil {
fmt.Printf("%+v\n", err)
return nil
return nil, nil, err
}
handler.ts, err = s.LocalClient()
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
id, title, authors, sortauthor, isbn10, isbn13, format,
genre, publisher, series, volume, year, signed,
description, notes, coverurl, childrens
id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher,
series, volume, year, signed, description, notes, coverurl, childrens
FROM %s`, m.tableName)
books := []media.Book{}
@ -169,12 +168,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
b := media.Book{}
var authors string
err := rows.Scan(
&b.ID, &b.Title, &authors,
&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)
&b.ID, &b.Title, &authors, &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 {
return nil, err
}
@ -193,25 +188,14 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error {
res, err := m.connection.ExecContext(ctx, `
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
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.Title,
strings.Join(b.Authors, ";"),
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,
b.Title, strings.Join(b.Authors, ";"), 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 {
return err
@ -236,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+`
SET id=?
title=?
authors=?
sortauthor=?
isbn10=?
isbn13=?
format=?
genre=?
publisher=?
series=?
volume=?
year=?
signed=?
description=?
notes=?
coverurl=?
childrens=?
SET
id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=?
series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=?
WHERE id=?`,
new.Title,
strings.Join(new.Authors, ";"),
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)
new.Title, strings.Join(new.Authors, ";"), 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 {
return err
}
@ -284,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
return nil
}
func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
return nil
}
func parseMigrationFileName(filename string) (int, string, error) {
sp := strings.SplitN(filename, "-", 2)
i, err := strconv.Atoi(sp[0])