Compare commits

...

2 Commits

Author SHA1 Message Date
Branden J Brown
dba10cee1a kaiyan-api: implement api server 2025-04-20 23:16:05 -04:00
Branden J Brown
d70bdba98c emote: add json annotations 2025-04-20 22:22:51 -04:00
3 changed files with 176 additions and 9 deletions

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

View File

@ -8,15 +8,15 @@ import (
// Emote is the information Kaiyan needs about an emote.
type Emote struct {
// ID is the emote ID per the source.
ID string
ID string `json:"id"`
// Name is the text of the emote as would be parsed from message text.
Name string
Name string `json:"name"`
// Source is the name of the emote source, e.g. "7TV", "Twitch:cirno_tv", &c.
Source string
Source string `json:"source"`
// Link is a hyperlink to manage the emote.
Link string
Link string `json:"link"`
// Image is a hyperlink to the emote image of any size.
Image string
Image string `json:"image"`
}
// Parser finds emotes in a message.

View File

@ -16,11 +16,11 @@ type Store interface {
// Metric is the metrics for a single emote.
type Metric struct {
// Emote is the emote that the metrics describe.
Emote Emote
Emote Emote `json:"emote"`
// Tokens is the total number of occurrences of the emote.
Tokens int64
Tokens int64 `json:"tokens"`
// Messages is the number of unique messages which contained the emote.
Messages int64
Messages int64 `json:"messages"`
// Users is the number of users who used the emote.
Users int64
Users int64 `json:"users"`
}