go-places/api/cmd/main.go
2026-10-07 15:14:50 +02:00

211 lines
5.8 KiB
Go

// Command go-places runs the go-places HTTP service: the GraphQL endpoint,
// the picture upload endpoint, media serving and the health probes.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go-places-api/internal/analysis"
"go-places-api/internal/auth"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/events"
"go-places-api/internal/service"
"go-places-api/internal/store"
"go-places-api/internal/transport"
)
func main() {
if err := run(); err != nil {
slog.Error("startup failed", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
logger := newLogger()
slog.SetDefault(logger)
logger.Info("starting go-places",
"addr", cfg.Addr,
"database", cfg.DatabaseURL,
"mediaDir", cfg.MediaDir,
"authMode", cfg.AuthMode,
"analyzerMode", cfg.AnalyzerMode,
)
// The store is opened before anything else so a bad database path fails
// startup rather than the first request.
db, err := store.OpenSQL(cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
if err != nil {
return err
}
publisher, err := newPublisher(cfg, logger)
if err != nil {
return err
}
defer publisher.Close()
svc := service.New(service.Options{
Store: db,
Blobs: blobs,
Analyzer: newAnalyzer(cfg),
Events: publisher,
Config: cfg,
Logger: slogAdapter{logger},
})
verifier := newVerifier(cfg, logger)
logger.Info("token verifier ready", "strategy", verifier.Name())
server, err := transport.NewServer(transport.Options{
Service: svc,
Config: cfg,
Auth: auth.NewMiddleware(verifier),
Blobs: blobs,
Logger: logger,
})
if err != nil {
return err
}
return serve(cfg, server.Handler(), logger)
}
// newPublisher returns the configured event sink. Without AMQP_URL the events go
// to the log, which keeps the service runnable on its own.
func newPublisher(cfg config.Config, logger *slog.Logger) (events.Publisher, error) {
if cfg.AMQPURL == "" {
logger.Info("no AMQP_URL configured, publishing events to the log only")
return events.NewLogPublisher(logger), nil
}
p, err := events.NewAMQP(cfg.AMQPURL, cfg.EventsExchange, logger)
if err != nil {
// A broker that is down at boot should not stop the service: events are
// best effort and the log publisher keeps them visible.
logger.Warn("could not connect to the event broker, falling back to the log",
"error", err)
return events.NewLogPublisher(logger), nil
}
logger.Info("connected to the event broker", "exchange", cfg.EventsExchange)
return p, nil
}
// newAnalyzer builds the image analyzer. In remote mode the local pass is still
// chained in front, so a picture gets badges even if the inference service is
// unreachable.
func newAnalyzer(cfg config.Config) analysis.Analyzer {
local := analysis.NewLocal(analysis.DefaultMaxDimension)
if cfg.AnalyzerMode != "remote" {
return local
}
remote := analysis.NewRemote(cfg.AnalyzerURL, cfg.AnalyzerKey, cfg.AnalyzerTimeout)
return analysis.NewComposite(local, remote)
}
// newVerifier selects how bearer tokens are checked. The dev verifier is
// refused unless it was asked for explicitly, so it cannot be reached by
// accident in a deployed environment.
func newVerifier(cfg config.Config, logger *slog.Logger) auth.Verifier {
switch cfg.AuthMode {
case "jwt":
return auth.NewJWTVerifier(cfg.JWTSecret, cfg.JWTAudience, "")
default:
logger.Warn("AUTH_MODE=dev: tokens are checked against a fixed table, " +
"do not use this outside development")
return auth.NewDevVerifier(nil)
}
}
// serve runs the HTTP server until the process is asked to stop, then drains
// in-flight requests before returning.
func serve(cfg config.Config, handler http.Handler, logger *slog.Logger) error {
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
// Generous timeouts: uploads of several megabytes over a slow link are
// legitimate, so the write window has to outlast one.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 2 * time.Minute,
WriteTimeout: 2 * time.Minute,
IdleTimeout: 2 * time.Minute,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errc := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- fmt.Errorf("http server: %w", err)
return
}
errc <- nil
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
logger.Info("shutdown signal received, draining connections")
}
// A second signal aborts the drain immediately.
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("http shutdown: %w", err)
}
logger.Info("shutdown complete")
return nil
}
// newLogger returns a structured logger reading LOG_LEVEL (debug, info, warn,
// error) and LOG_FORMAT (json or text).
func newLogger() *slog.Logger {
level := slog.LevelInfo
switch os.Getenv("LOG_LEVEL") {
case "debug":
level = slog.LevelDebug
case "warn", "warning":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
if os.Getenv("LOG_FORMAT") == "text" {
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
// slogAdapter narrows *slog.Logger to the small logging surface the service
// declares, which keeps slog out of the service package.
type slogAdapter struct{ l *slog.Logger }
func (a slogAdapter) Warn(msg string, args ...any) { a.l.Warn(msg, args...) }
func (a slogAdapter) Info(msg string, args ...any) { a.l.Info(msg, args...) }