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:
@@ -0,0 +1,51 @@
|
||||
package meta
|
||||
|
||||
import "glchat/internal/config"
|
||||
|
||||
const APIVersion = "v1"
|
||||
|
||||
type Features struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AntiBotEnabled bool `json:"anti_bot_enabled"`
|
||||
VoiceEnabled bool `json:"voice_enabled"`
|
||||
WebPushEnabled bool `json:"web_push_enabled"`
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
PasskeysEnabled bool `json:"passkeys_enabled"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
APIVersion string `json:"api_version"`
|
||||
BaseURL string `json:"base_url"`
|
||||
FilesURL string `json:"files_url"`
|
||||
GatewayURL string `json:"gateway_url"`
|
||||
RTCPath string `json:"rtc_path"`
|
||||
MaxUploadSize int64 `json:"max_upload_size"`
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config) Response {
|
||||
return Response{
|
||||
Name: cfg.InstanceName,
|
||||
Version: cfg.Version,
|
||||
Commit: cfg.Commit,
|
||||
BuildDate: cfg.BuildDate,
|
||||
APIVersion: APIVersion,
|
||||
BaseURL: cfg.BaseURL(),
|
||||
FilesURL: cfg.FilesURL(),
|
||||
GatewayURL: "wss://" + cfg.Domain + "/gateway",
|
||||
RTCPath: "/rtc",
|
||||
MaxUploadSize: cfg.MaxUploadSize,
|
||||
Features: Features{
|
||||
RegistrationEnabled: true,
|
||||
AntiBotEnabled: false,
|
||||
VoiceEnabled: false,
|
||||
WebPushEnabled: false,
|
||||
OAuthEnabled: false,
|
||||
PasskeysEnabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/config"
|
||||
)
|
||||
|
||||
func TestNewBuildsMetaPayload(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Domain: "gl.mhspx.su",
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
Version: "v0.1.0",
|
||||
Commit: "abc1234",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
}
|
||||
|
||||
resp := New(cfg)
|
||||
if resp.APIVersion != APIVersion {
|
||||
t.Errorf("APIVersion = %q, want %q", resp.APIVersion, APIVersion)
|
||||
}
|
||||
if resp.BaseURL != "https://gl.mhspx.su" {
|
||||
t.Errorf("BaseURL = %q", resp.BaseURL)
|
||||
}
|
||||
if resp.FilesURL != "https://files.gl.mhspx.su" {
|
||||
t.Errorf("FilesURL = %q", resp.FilesURL)
|
||||
}
|
||||
if resp.GatewayURL != "wss://gl.mhspx.su/gateway" {
|
||||
t.Errorf("GatewayURL = %q", resp.GatewayURL)
|
||||
}
|
||||
if resp.RTCPath != "/rtc" {
|
||||
t.Errorf("RTCPath = %q", resp.RTCPath)
|
||||
}
|
||||
if resp.Version != "v0.1.0" || resp.Commit != "abc1234" {
|
||||
t.Errorf("version metadata lost: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONUsesSnakeCaseKeys(t *testing.T) {
|
||||
raw, err := json.Marshal(New(config.Config{Domain: "example.com", FilesDomain: "files.example.com"}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal meta: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal meta: %v", err)
|
||||
}
|
||||
for _, key := range []string{"api_version", "base_url", "files_url", "gateway_url", "max_upload_size", "features"} {
|
||||
if _, ok := decoded[key]; !ok {
|
||||
t.Errorf("meta payload is missing key %q: %s", key, raw)
|
||||
}
|
||||
}
|
||||
features, ok := decoded["features"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("features is not an object: %s", raw)
|
||||
}
|
||||
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "oauth_enabled", "passkeys_enabled"} {
|
||||
if _, ok := features[key]; !ok {
|
||||
t.Errorf("features is missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user