first commit. added CI, Docker compose, blank client and server. used unit tests, they pass
CI / Go (build + vet + test) (push) Successful in 10m49s
CI / Web (build + lint) (push) Successful in 10m31s

This commit is contained in:
vantablackTea
2026-09-05 01:04:12 +03:00
commit 1a669c948a
30 changed files with 2208 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
// Command api is the HTTP entrypoint of the messenger backend.
//
// It wires configuration, logging and the HTTP server together and
// supports graceful shutdown on SIGINT/SIGTERM.
package main
import (
"context"
"errors"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"messenger/internal/platform/config"
"messenger/internal/platform/logger"
"messenger/internal/server"
)
func main() {
log := logger.New()
cfg, err := config.Load()
if err != nil {
log.Error("invalid configuration", "error", err)
os.Exit(1)
}
httpServer := server.New(cfg, log)
// Run the server in a goroutine so that shutdown can be triggered
// from the signal handler below.
go func() {
log.Info("http server listening",
"addr", cfg.HTTPAddr,
"static_dir", cfg.StaticDir,
"env", cfg.Environment,
)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("http server failed", "error", err)
os.Exit(1)
}
}()
// Wait for a termination signal, then drain in-flight requests.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
log.Info("server stopped cleanly")
}
+3
View File
@@ -0,0 +1,3 @@
module messenger
go 1.27
View File
+56
View File
@@ -0,0 +1,56 @@
// Package config loads the server configuration from environment
// variables with sane defaults for local development.
package config
import (
"fmt"
"os"
"strings"
)
// Config holds all runtime configuration for the API server.
type Config struct {
// HTTPAddr is the listen address, e.g. ":8080".
HTTPAddr string
// StaticDir points to the built frontend (React/Vite dist).
// Empty disables static file serving.
StaticDir string
// Environment is "development", "staging" or "production".
Environment string
// AllowedOrigins is a comma-separated list of CORS origins
// allowed to call the API from a browser.
AllowedOrigins []string
}
// Load reads configuration from the environment, falling back to
// development-friendly defaults. Unknown values are validated.
func Load() (*Config, error) {
cfg := &Config{
HTTPAddr: fromEnv("MESSENGER_HTTP_ADDR", ":8080"),
StaticDir: os.Getenv("MESSENGER_STATIC_DIR"),
Environment: fromEnv("MESSENGER_ENV", "development"),
}
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
if origin = strings.TrimSpace(origin); origin != "" {
cfg.AllowedOrigins = append(cfg.AllowedOrigins, origin)
}
}
switch cfg.Environment {
case "development", "staging", "production":
default:
return nil, fmt.Errorf("MESSENGER_ENV must be one of development|staging|production, got %q", cfg.Environment)
}
return cfg, nil
}
// fromEnv returns the value of the environment variable name, or
// fallback if the variable is unset or empty.
func fromEnv(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,49 @@
package config
import (
"testing"
)
func TestLoadDefaults(t *testing.T) {
t.Setenv("MESSENGER_HTTP_ADDR", "")
t.Setenv("MESSENGER_ENV", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.HTTPAddr != ":8080" {
t.Errorf("HTTPAddr = %q, want %q", cfg.HTTPAddr, ":8080")
}
if cfg.Environment != "development" {
t.Errorf("Environment = %q, want development", cfg.Environment)
}
}
func TestLoadFromEnv(t *testing.T) {
t.Setenv("MESSENGER_HTTP_ADDR", ":9000")
t.Setenv("MESSENGER_ENV", "production")
t.Setenv("MESSENGER_ALLOWED_ORIGINS", "https://a.example, https://b.example")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.HTTPAddr != ":9000" {
t.Errorf("HTTPAddr = %q, want :9000", cfg.HTTPAddr)
}
if cfg.Environment != "production" {
t.Errorf("Environment = %q, want production", cfg.Environment)
}
if len(cfg.AllowedOrigins) != 2 || cfg.AllowedOrigins[1] != "https://b.example" {
t.Errorf("AllowedOrigins = %v, want [https://a.example https://b.example]", cfg.AllowedOrigins)
}
}
func TestLoadInvalidEnv(t *testing.T) {
t.Setenv("MESSENGER_ENV", "nope")
if _, err := Load(); err == nil {
t.Fatal("Load() error = nil, want validation error")
}
}
+19
View File
@@ -0,0 +1,19 @@
// Package logger provides a structured JSON logger built on log/slog.
// In development a human-readable text handler is used.
package logger
import (
"log/slog"
"os"
)
// New returns a *slog.Logger configured for the current environment.
func New() *slog.Logger {
level := slog.LevelDebug
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
+63
View File
@@ -0,0 +1,63 @@
package server
import (
"log/slog"
"net/http"
"runtime/debug"
"slices"
"time"
)
func cors(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && slices.Contains(allowedOrigins, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func logRequests(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
func recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered", "value", rec, "stack", string(debug.Stack()))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
+84
View File
@@ -0,0 +1,84 @@
// Package server wires the HTTP router, middleware and static file
// serving into a production-ready http.Server.
package server
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"messenger/internal/platform/config"
)
// New builds an *http.Server with all routes and middleware applied.
func New(cfg *config.Config, log *slog.Logger) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
mux.HandleFunc("GET /api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"message": "pong",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
if cfg.StaticDir != "" {
mux.Handle("/", spaHandler{staticDir: cfg.StaticDir, log: log})
}
handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))
return &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
}
// spaHandler serves the built frontend with a fallback to index.html
// so client-side routes work on refresh.
type spaHandler struct {
staticDir string
log *slog.Logger
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fs := http.Dir(h.staticDir)
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
f, err := fs.Open(path)
if err != nil {
index, indexErr := fs.Open("/index.html")
if indexErr != nil {
http.NotFound(w, r)
return
}
defer index.Close()
http.ServeContent(w, r, "index.html", time.Time{}, index)
return
}
defer f.Close()
http.ServeContent(w, r, path, time.Time{}, f)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}