kaiyan-api: implement api server

This commit is contained in:
Branden J Brown 2025-04-20 23:16:05 -04:00
parent d70bdba98c
commit dba10cee1a

167
cmd/kaiyan-api/main.go Normal file
View File

@ -0,0 +1,167 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"time"
"github.com/go-json-experiment/json"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
"git.sunturtle.xyz/zephyr/kaiyan/emote"
"git.sunturtle.xyz/zephyr/kaiyan/emote/sqlitestore"
)
func main() {
var (
listen string
dburi string
)
flag.StringVar(&listen, "listen", ":80", "address to bind")
flag.StringVar(&dburi, "db", "", "SQLite database URI")
flag.Parse()
if dburi == "" {
fail("-db is mandatory")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
go func() {
<-ctx.Done()
stop()
}()
opts := sqlitex.PoolOptions{
Flags: sqlite.OpenReadWrite | sqlite.OpenURI,
}
db, err := sqlitex.NewPool(dburi, opts)
if err != nil {
fail("%v", err)
}
st, err := sqlitestore.Open(ctx, db)
if err != nil {
fail("%v", err)
}
mux := http.NewServeMux()
mux.Handle("GET /twitch/{channel}", metrics(st))
// TODO(branden): GET /metrics
l, err := net.Listen("tcp", listen)
if err != nil {
fail(err.Error())
}
srv := http.Server{
Addr: listen,
Handler: mux,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 1 * time.Minute,
BaseContext: func(l net.Listener) context.Context { return ctx },
}
go func() {
slog.InfoContext(ctx, "HTTP API", slog.Any("addr", l.Addr()))
// TODO(branden): tls? or do we just use caddy/nginx after all?
err := srv.Serve(l)
if err == http.ErrServerClosed {
return
}
slog.ErrorContext(ctx, "HTTP API closed", slog.Any("err", err))
}()
<-ctx.Done()
stop()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.ErrorContext(ctx, "HTTP API shutdown", slog.Any("err", err))
}
}
func metrics(st *sqlitestore.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := slog.With(slog.Group("request",
slog.String("host", r.Host),
slog.String("remote", r.RemoteAddr),
))
channel := r.PathValue("channel")
var start, end time.Time
if s := r.FormValue("start"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writerror(w, http.StatusBadRequest, err.Error())
return
}
start = t
} else {
writerror(w, http.StatusBadRequest, "start time is required")
return
}
if s := r.FormValue("end"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writerror(w, http.StatusBadRequest, err.Error())
return
}
end = t
} else {
// We can fill in with the current time.
end = time.Now().Truncate(time.Second)
}
if end.Before(start) {
// Be liberal and just swap them.
start, end = end, start
}
if end.Sub(start) > 366*24*time.Hour {
writerror(w, http.StatusBadRequest, "timespan is too long")
return
}
log.LogAttrs(r.Context(), slog.LevelInfo, "metrics",
slog.String("channel", channel),
slog.Time("start", start),
slog.Time("end", end),
)
m, err := st.Metrics(r.Context(), channel, start, end, nil)
if err != nil {
log.ErrorContext(r.Context(), "metrics", slog.Any("err", err))
writerror(w, http.StatusInternalServerError, "data unavailable")
return
}
log.LogAttrs(r.Context(), slog.LevelInfo, "done",
slog.String("channel", channel),
slog.Time("start", start),
slog.Time("end", end),
)
writedata(w, m)
}
}
func writerror(w http.ResponseWriter, status int, err string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
v := struct {
Error string `json:"error"`
}{err}
json.MarshalWrite(w, &v)
}
func writedata(w http.ResponseWriter, data []emote.Metric) {
w.Header().Set("Content-Type", "application/json")
if len(data) == 0 {
w.Write([]byte(`{"data":[]}`))
return
}
v := struct {
Data []emote.Metric `json:"data"`
}{data}
json.MarshalWrite(w, &v)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}