diff --git a/cmd/kaiyan-api/main.go b/cmd/kaiyan-api/main.go new file mode 100644 index 0000000..af7607b --- /dev/null +++ b/cmd/kaiyan-api/main.go @@ -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) +}