initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
211
api/cmd/main.go
Normal file
211
api/cmd/main.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// 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...) }
|
||||
Loading…
Add table
Add a link
Reference in a new issue