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) }