Files
MusicServer/internal/server/server.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

136 lines
3.8 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"music-server/internal/backend"
"music-server/internal/config"
"music-server/internal/db"
"music-server/internal/db/repository"
"music-server/internal/logging"
"go.uber.org/zap"
)
// Database is the database connection used by the server.
type Database interface {
Health(ctx context.Context) map[string]string
Close()
}
type Server struct {
cfg config.Config
db Database
q repository.Querier
music *backend.MusicService
library *backend.LibraryService
sync *backend.SyncService
characters *backend.CharacterService
releases *backend.ReleaseService
stats *backend.StatisticsService
search *backend.SearchCache
httpServer *http.Server
}
// New creates a Server with all services wired up.
func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
characters, err := backend.NewCharacterService(cfg.CharactersPath)
if err != nil {
return nil, fmt.Errorf("characters path: %w", err)
}
s := &Server{
cfg: cfg,
db: database,
q: q,
music: backend.NewMusicService(q, cfg.SoundCheckPath),
library: backend.NewLibraryService(q),
characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
}
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
return s, nil
}
// afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
}
if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
}
}
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
// It exits the process if any of these steps fail.
func NewServerInstance() *Server {
cfg, err := config.Load()
logging.Init(cfg.LogLevel, cfg.LogJSON)
logger := logging.GetLogger()
if err != nil {
logger.Fatal("Invalid settings", zap.Error(err))
}
logger.Info("Starting server",
zap.String("host", cfg.DBHost),
zap.String("dbPort", cfg.DBPort),
zap.String("username", cfg.DBUser),
zap.String("dbName", cfg.DBName),
zap.String("musicPath", cfg.MusicPath),
zap.String("charactersPath", cfg.CharactersPath),
)
ctx := context.Background()
database, err := db.NewDatabase(ctx, db.ConnString(cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName))
if err != nil {
logger.Fatal("Failed to initialize database", zap.Error(err))
}
if err := database.RunMigrations(); err != nil {
logger.Fatal("Migration failed", zap.Error(err))
}
s, err := New(cfg, repository.New(database.Pool), database)
if err != nil {
logger.Fatal("Failed to create server", zap.Error(err))
}
return s
}
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
func (s *Server) ListenAndServe() error {
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// Shutdown stops accepting requests, waits for running requests and then closes the database.
func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
s.sync.Wait()
s.characters.Close()
s.db.Close()
return err
}
// HTTPServer returns the underlying http.Server.
func (s *Server) HTTPServer() *http.Server {
return s.httpServer
}