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