feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI
This commit is contained in:
+82
-35
@@ -4,36 +4,62 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
patterns []string
|
||||
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
|
||||
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
|
||||
type Deps struct {
|
||||
Store *store.Store
|
||||
Auth *auth.Service
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
store *store.Store
|
||||
auth *auth.Service
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
api huma.API
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
logger: logger,
|
||||
patterns: append([]string(nil), routePatterns...),
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
store: deps.Store,
|
||||
auth: deps.Auth,
|
||||
logger: logger,
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
handler := httpx.Chain(mux,
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||
s.api = s.registerAPI(apiRouter)
|
||||
s.registerMetaRoutes(s.api)
|
||||
s.registerAuthRoutes(apiRouter)
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
s.registerRoutes(router)
|
||||
|
||||
handler := httpx.Chain(router,
|
||||
httpx.SecurityHeaders(cfg.FilesDomain),
|
||||
httpx.RequestID,
|
||||
httpx.RequestInfoMiddleware,
|
||||
httpx.Logger(logger),
|
||||
httpx.Recoverer(logger),
|
||||
httpx.JSONBodyLimit(1<<20),
|
||||
@@ -50,20 +76,30 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
var routePatterns = []string{
|
||||
"GET /healthz",
|
||||
"GET /readyz",
|
||||
"GET /api/v1/meta",
|
||||
"GET /api/v1/openapi.json",
|
||||
"/",
|
||||
// registerAPI создаёт huma-API: OpenAPI 3.1 и TS-типы выводятся из Go-типов
|
||||
// (AGENT.md 8.1, решение D-006).
|
||||
func (s *Server) registerAPI(router chi.Router) huma.API {
|
||||
cfg := huma.DefaultConfig("glchat API", s.cfg.Version)
|
||||
cfg.Info.Description = "Self-hosted платформа общения: REST /api/v1 и WebSocket Gateway."
|
||||
cfg.Info.License = &huma.License{Name: "AGPL-3.0-or-later", Identifier: "AGPL-3.0-or-later"}
|
||||
cfg.Servers = []*huma.Server{{URL: "/api/v1"}}
|
||||
cfg.OpenAPIPath = "/openapi"
|
||||
cfg.DocsPath = "/docs"
|
||||
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"sessionCookie": {Type: "apiKey", In: "cookie", Name: sessionCookieName},
|
||||
"bearerAuth": {Type: "http", Scheme: "bearer"},
|
||||
}
|
||||
return humachi.New(router, cfg)
|
||||
}
|
||||
|
||||
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) registerRoutes(router chi.Router) {
|
||||
router.Get("/healthz", s.handleHealthz)
|
||||
router.Get("/readyz", s.handleReadyz)
|
||||
|
||||
router.NotFound(s.handleFallback)
|
||||
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
||||
@@ -104,16 +140,27 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg))
|
||||
type metaOutput struct {
|
||||
Body meta.Response
|
||||
}
|
||||
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *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.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
|
||||
func (s *Server) registerMetaRoutes(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getMeta",
|
||||
Method: http.MethodGet,
|
||||
Path: "/meta",
|
||||
Summary: "Метаданные инстанса, версия API и флаги функций",
|
||||
Tags: []string{"Meta"},
|
||||
}, func(_ context.Context, _ *struct{}) (*metaOutput, error) {
|
||||
return &metaOutput{Body: meta.New(s.cfg)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeBearer(value string) string {
|
||||
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||||
return strings.TrimSpace(value[7:])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
var startedAt = time.Now()
|
||||
|
||||
Reference in New Issue
Block a user