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
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"net/http"
"strings"
"glchat/internal/httpx"
)
// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta"
// into its method and path parts. Patterns without a method apply to all.
func methodOfPattern(pattern string) (method, path string) {
if i := strings.IndexByte(pattern, ' '); i > 0 {
return pattern[:i], pattern[i+1:]
}
return "", pattern
}
func (s *Server) routeExists(method, path string) bool {
for _, pattern := range s.patterns {
patternMethod, patternPath := methodOfPattern(pattern)
if patternMethod == "" || patternMethod == method {
continue
}
if patternPath == path {
return true
}
if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) {
return true
}
}
return false
}
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
// unknown path under them is a real 404 and must not receive the SPA shell.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
func isReservedPath(path string) bool {
for _, prefix := range reservedPrefixes {
if path == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) {
if s.routeExists(r.Method, r.URL.Path) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
return
}
if isReservedPath(r.URL.Path) {
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
return
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
s.static.serve(w, r)
return
}
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
}
+81
View File
@@ -0,0 +1,81 @@
package server
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints
// implemented so far. Phase 1 replaces it with a schema generated from typed
// handler definitions (AGENT.md 8.1).
var openAPIDocument = []byte(`{
"openapi": "3.1.0",
"info": {
"title": "glchat API",
"version": "0.1.0",
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.",
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" }
},
"servers": [{ "url": "/api/v1" }],
"paths": {
"/meta": {
"get": {
"operationId": "getMeta",
"summary": "Instance metadata, API version and feature flags",
"tags": ["Meta"],
"responses": {
"200": {
"description": "Instance metadata",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Meta" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Meta": {
"type": "object",
"required": ["name", "version", "api_version", "base_url", "features"],
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"commit": { "type": "string" },
"build_date": { "type": "string" },
"api_version": { "type": "string", "enum": ["v1"] },
"base_url": { "type": "string" },
"files_url": { "type": "string" },
"gateway_url": { "type": "string" },
"rtc_path": { "type": "string" },
"max_upload_size": { "type": "integer", "format": "int64" },
"features": {
"type": "object",
"properties": {
"registration_enabled": { "type": "boolean" },
"anti_bot_enabled": { "type": "boolean" },
"voice_enabled": { "type": "boolean" },
"web_push_enabled": { "type": "boolean" },
"oauth_enabled": { "type": "boolean" },
"passkeys_enabled": { "type": "boolean" }
}
}
}
},
"Error": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"details": { "type": "object", "additionalProperties": true }
}
}
}
}
}
}
}
`)
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"context"
"log/slog"
"net/http"
"time"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/httpx"
"glchat/internal/meta"
)
type Server struct {
cfg config.Config
db *database.DB
logger *slog.Logger
http *http.Server
static *staticHandler
patterns []string
}
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
s := &Server{
cfg: cfg,
db: db,
logger: logger,
patterns: append([]string(nil), routePatterns...),
static: newStaticHandler(cfg.WebRoot),
}
mux := http.NewServeMux()
s.routes(mux)
handler := httpx.Chain(mux,
httpx.SecurityHeaders(cfg.FilesDomain),
httpx.RequestID,
httpx.Logger(logger),
httpx.Recoverer(logger),
httpx.JSONBodyLimit(1<<20),
)
s.http = &http.Server{
Addr: cfg.ListenAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16,
}
return s
}
var routePatterns = []string{
"GET /healthz",
"GET /readyz",
"GET /api/v1/meta",
"GET /api/v1/openapi.json",
"/",
}
func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /api/v1/meta", s.handleMeta)
mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI)
mux.HandleFunc("/", s.handleFallback)
}
func (s *Server) Handler() http.Handler { return s.http.Handler }
func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() }
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"version": s.cfg.Version,
"uptime": time.Since(startedAt).Round(time.Second).String(),
})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := map[string]string{"database": "ok"}
ready := true
if err := s.db.Ready(ctx); err != nil {
checks["database"] = "failed"
ready = false
s.logger.WarnContext(ctx, "readiness check failed", slog.Any("error", err))
}
status := http.StatusOK
state := "ready"
if !ready {
status = http.StatusServiceUnavailable
state = "unavailable"
}
httpx.WriteJSON(w, status, map[string]any{
"status": state,
"version": s.cfg.Version,
"checks": checks,
})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg))
}
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(openAPIDocument); err != nil {
s.logger.Error("write openapi document", slog.Any("error", err))
}
}
var startedAt = time.Now()
+241
View File
@@ -0,0 +1,241 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"glchat/internal/config"
"glchat/internal/database"
)
func newTestServer(t *testing.T) (*Server, *database.DB) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
cfg := config.Config{
Domain: "gl.mhspx.su",
WebRoot: filepath.Join("testdata", "web"),
FilesDomain: "files.gl.mhspx.su",
InstanceName: "glchat",
ListenAddr: "127.0.0.1:0",
Version: "v0.1.0-test",
Commit: "deadbee",
BuildDate: "2026-09-19T00:00:00Z",
MaxUploadSize: 26214400,
LogLevel: "error",
LogFormat: "json",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, db, logger), db
}
func TestHealthz(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status field = %v, want ok", body["status"])
}
if body["version"] != "v0.1.0-test" {
t.Errorf("version field = %v", body["version"])
}
if rec.Header().Get("X-Request-Id") == "" {
t.Error("X-Request-Id header is missing")
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("security headers are missing")
}
}
func TestReadyzReportsDatabase(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]string `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Status != "ready" {
t.Errorf("status = %q, want ready", body.Status)
}
if body.Checks["database"] != "ok" {
t.Errorf("database check = %q, want ok", body.Checks["database"])
}
}
func TestReadyzFailsWhenDatabaseClosed(t *testing.T) {
srv, db := newTestServer(t)
if err := db.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestMetaEndpoint(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body struct {
APIVersion string `json:"api_version"`
BaseURL string `json:"base_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.APIVersion != "v1" {
t.Errorf("api_version = %q, want v1", body.APIVersion)
}
if body.BaseURL != "https://gl.mhspx.su" {
t.Errorf("base_url = %q", body.BaseURL)
}
}
func TestOpenAPIDocumentIsServed(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var doc map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("openapi document is not valid json: %v", err)
}
if doc["openapi"] != "3.1.0" {
t.Errorf("openapi version = %v, want 3.1.0", doc["openapi"])
}
if _, ok := doc["paths"].(map[string]any)["/meta"]; !ok {
t.Error("openapi document does not describe /meta")
}
}
func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Errorf("error envelope is incomplete: %s", rec.Body.String())
}
}
func TestServesWebClientWithSPAFallback(t *testing.T) {
srv, _ := newTestServer(t)
cases := []struct {
path string
wantStatus int
wantSubstr string
wantHeaders map[string]string
}{
{path: "/", wantStatus: http.StatusOK, wantSubstr: "glchat"},
{path: "/app/guild/1/channel/2", wantStatus: http.StatusOK, wantSubstr: "glchat"},
{path: "/assets/app.js", wantStatus: http.StatusOK, wantSubstr: "console.log"},
}
for _, tc := range cases {
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
if rec.Code != tc.wantStatus {
t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus)
}
if !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Errorf("GET %s body = %q, want it to contain %q", tc.path, rec.Body.String(), tc.wantSubstr)
}
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Errorf("assets Cache-Control = %q, want immutable", got)
}
}
func TestServeWebClientReportsMissingBundle(t *testing.T) {
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(cfg, db, logger)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code)
}
}
func TestMethodNotAllowedOnMeta(t *testing.T) {
srv, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/meta", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"errors"
"io/fs"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// staticHandler serves the built web client with SPA fallback: unknown paths
// return index.html so client-side routes survive a page reload.
type staticHandler struct {
root string
}
func newStaticHandler(root string) *staticHandler {
return &staticHandler{root: root}
}
func (h *staticHandler) serve(w http.ResponseWriter, r *http.Request) {
cleaned := path.Clean("/" + r.URL.Path)
rel := strings.TrimPrefix(cleaned, "/")
if rel == "" {
h.serveIndex(w, r)
return
}
info, err := fs.Stat(os.DirFS(h.root), rel)
if err != nil || info.IsDir() {
if err != nil && !errors.Is(err, fs.ErrNotExist) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.serveIndex(w, r)
return
}
if contentType := mime.TypeByExtension(filepath.Ext(rel)); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if strings.HasPrefix(rel, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFile(w, r, filepath.Join(h.root, filepath.FromSlash(rel)))
}
func (h *staticHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
index := filepath.Join(h.root, "index.html")
if _, err := os.Stat(index); err != nil {
writeClientNotBuilt(w)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, index)
}
func writeClientNotBuilt(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("glchat: web client bundle is not present in this image\n"))
}
+1
View File
@@ -0,0 +1 @@
console.log("app");
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><body><div id="root">glchat</div></body></html>