feat(server): скелет Go-сервера с healthz, readyz и api/v1/meta

- internal/config: конфигурация из env с валидацией и дефолтами (§5.1, §9.1)
- internal/httpx: middleware (request id, slog-логи, recover, security-заголовки),
  единый формат ошибок §8.5 и лимит тела запроса
- internal/database: SQLite WAL + busy_timeout + foreign_keys, один writer
  и пул чтения, goose-миграции embedded, PRAGMA optimize по расписанию
- internal/meta: полезная нагрузка /api/v1/meta (версия, фичи, лимиты)
- internal/server: маршруты, SPA-раздача собранного клиента, OpenAPI 3.1
- cmd/glchat: точка входа с graceful shutdown и JSON-логами
- тесты: конфиг, миграции и идемпотентность, HTTP-эндпоинты, статика, 404/405
This commit is contained in:
2026-09-19 18:20:56 +03:00
parent 9839dbeede
commit 69aa98e18c
22 changed files with 1887 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/server"
)
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
if err := run(); err != nil {
slog.Error("glchat exited with error", slog.Any("error", err))
os.Exit(1)
}
}
func run() error {
if v := os.Getenv("APP_VERSION"); v != "" {
version = v
}
if c := os.Getenv("APP_COMMIT"); c != "" {
commit = c
}
if b := os.Getenv("APP_BUILD_DATE"); b != "" {
buildDate = b
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
cfg.Version = version
cfg.Commit = commit
cfg.BuildDate = buildDate
logger := newLogger(cfg)
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := database.Open(ctx, database.Options{
Path: cfg.DatabasePath,
ReadPool: cfg.ReadPoolSize,
CacheSize: cfg.SQLiteCache,
MmapSize: cfg.SQLiteMmap,
Migrate: true,
})
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("close database", slog.Any("error", err))
}
}()
schemaVersion, err := db.SchemaVersion(ctx)
if err != nil {
return err
}
journalMode, err := db.JournalMode(ctx)
if err != nil {
return err
}
logger.Info("database ready",
slog.String("path", db.Path()),
slog.Int64("schema_version", schemaVersion),
slog.String("journal_mode", journalMode),
)
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
defer stopMaintenance()
srv := server.New(cfg, db, logger)
errCh := make(chan error, 1)
go func() {
logger.Info("http server listening",
slog.String("addr", cfg.ListenAddr),
slog.String("version", cfg.Version),
slog.String("domain", cfg.Domain),
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Info("shutdown requested")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTTL)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return nil
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.Level(cfg.LogLevelNumeric())}
var handler slog.Handler
if cfg.LogFormat == "text" {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(handler).With(
slog.String("service", "glchat"),
slog.String("version", cfg.Version),
)
}