Stage 0: scaffold monorepo, backend skeleton, docs, CI
Tests · Go / test (push) Successful in 32s

- go.work (Go 1.26.3) with backend module; deps added incrementally (gin+zap only)

- backend: /healthz + /readyz, env config, graceful shutdown

- docs: ARCHITECTURE, FUNCTIONAL (+ru mirror), TESTING

- PLAN.md (stage tracker + per-stage open details) and CLAUDE.md (per-stage workflow)

- .gitea go-unit CI (gofmt/vet/build/test)
This commit is contained in:
Ilia Denisov
2026-06-02 11:57:58 +02:00
commit effe6675bc
19 changed files with 1174 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
// Package config loads and validates the backend's runtime configuration from
// the process environment.
package config
import (
"fmt"
"os"
)
// Config holds the backend's runtime configuration.
type Config struct {
// HTTPAddr is the listen address of the HTTP listener (host:port).
HTTPAddr string
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
LogLevel string
}
// Defaults applied when the corresponding environment variable is unset.
const (
defaultHTTPAddr = ":8080"
defaultLogLevel = "info"
)
// Load reads the configuration from the environment, applies defaults for
// unset variables, and validates the result.
func Load() (Config, error) {
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
}
if err := c.validate(); err != nil {
return Config{}, err
}
return c, nil
}
// validate reports whether the configuration values are acceptable.
func (c Config) validate() error {
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("config: invalid BACKEND_LOG_LEVEL %q", c.LogLevel)
}
if c.HTTPAddr == "" {
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
}
return nil
}
// envOr returns the value of the environment variable named key, or fallback
// when the variable is unset or empty.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+46
View File
@@ -0,0 +1,46 @@
package config
import "testing"
// TestLoadDefaults verifies that Load applies defaults when the environment is
// empty.
func TestLoadDefaults(t *testing.T) {
t.Setenv("BACKEND_HTTP_ADDR", "")
t.Setenv("BACKEND_LOG_LEVEL", "")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.HTTPAddr != defaultHTTPAddr {
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, defaultHTTPAddr)
}
if c.LogLevel != defaultLogLevel {
t.Errorf("LogLevel = %q, want %q", c.LogLevel, defaultLogLevel)
}
}
// TestLoadOverrides verifies that environment variables override the defaults.
func TestLoadOverrides(t *testing.T) {
t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("BACKEND_LOG_LEVEL", "debug")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.HTTPAddr != "127.0.0.1:9090" {
t.Errorf("HTTPAddr = %q, want %q", c.HTTPAddr, "127.0.0.1:9090")
}
if c.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug")
}
}
// TestLoadRejectsInvalidLevel verifies that an unknown log level is rejected.
func TestLoadRejectsInvalidLevel(t *testing.T) {
t.Setenv("BACKEND_LOG_LEVEL", "verbose")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an invalid log level, got nil")
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package server wires the backend's HTTP listener: the gin engine, its route
// groups and the start/stop lifecycle. At this stage it serves only the
// infrastructure probes; the domain route groups described in PLAN.md
// (/api/v1/public, /user, /internal, /admin) are added by later stages.
package server
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
// during a graceful shutdown.
const shutdownTimeout = 10 * time.Second
// Server owns the gin engine and the underlying HTTP server.
type Server struct {
log *zap.Logger
http *http.Server
}
// New returns a Server that will listen on addr. The logger receives lifecycle
// and request diagnostics.
func New(addr string, log *zap.Logger) *Server {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery())
registerProbes(engine)
return &Server{
log: log,
http: &http.Server{Addr: addr, Handler: engine},
}
}
// registerProbes installs the unauthenticated infrastructure probes: /healthz
// reports process liveness and /readyz reports readiness to serve traffic.
// Until later stages add real dependencies (Postgres, warmed caches),
// readiness mirrors liveness.
func registerProbes(engine *gin.Engine) {
ok := func(c *gin.Context) { c.String(http.StatusOK, "ok") }
engine.GET("/healthz", ok)
engine.GET("/readyz", ok)
}
// Run starts the listener and blocks until ctx is cancelled, then shuts the
// server down gracefully within shutdownTimeout. It returns the first error
// that is not the expected http.ErrServerClosed.
func (s *Server) Run(ctx context.Context) error {
errc := make(chan error, 1)
go func() {
s.log.Info("http listener starting", zap.String("addr", s.http.Addr))
errc <- s.http.ListenAndServe()
}()
select {
case err := <-errc:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
s.log.Info("http listener stopping")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return s.http.Shutdown(shutdownCtx)
}
}
+22
View File
@@ -0,0 +1,22 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap/zaptest"
)
// TestProbes verifies that the infrastructure probes answer 200 OK.
func TestProbes(t *testing.T) {
srv := New(":0", zaptest.NewLogger(t))
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want %d", path, rec.Code, http.StatusOK)
}
}
}