Files
logiflow/internal/handler/server_impl.go
anxi0uz 5a45c8b52e feat: implement order workflow, metrics, and handler unit tests
- implement UpdateOrderStatus and GetOrdersReport service methods
- implement all order, user handlers (UpdateMe, GetMyTrips, CancelOrder, GetOrder, etc.)
- extract OrderServicer interface for testability
- add Prometheus metrics middleware (requests total, duration)
- fix GetAll storage flavor for PostgreSQL ($1 placeholders)
- add 17 unit tests for order handlers via httptest
- wire Grafana datasource and update Prometheus scrape config
- update README with full API reference, pricing, roles, monitoring
2026-04-16 17:59:06 +03:00

242 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/config"
"github.com/anxi0uz/logiflow/internal/services"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"github.com/rs/xid"
slogchi "github.com/samber/slog-chi"
)
type ctxKey string
const (
requestIDKey ctxKey = "X-Request-ID"
tokenKey ctxKey = "Authorization"
// response messages
MsgInternalError = "Internal server error"
MsgInvalidBody = "Invalid request body"
MsgNotFound = "Not found"
MsgUnauthorized = "Unauthorized"
MsgMissingToken = "Missing token"
MsgForbidden = "Forbidden"
// response types
RespError = "error"
RespSuccess = "success"
RespNotFound = "not found"
)
type responseOptions struct {
respType string
requestID string
}
type Server struct {
DB *pgxpool.Pool
Config *config.Config
ctx context.Context
Redis *redis.Client
JwtKey []byte
OrderSerice services.OrderServicer
}
func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server {
return &Server{
DB: db,
Redis: redis,
ctx: context.Background(),
Config: cfg,
JwtKey: []byte(cfg.JwtOpt.Key),
OrderSerice: services.NewOrderService(db, *cfg),
}
}
func (s *Server) Run() error {
r := chi.NewMux()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.MiddlewareRequestID)
r.Use(s.MiddlewareMetrics)
r.Use(slogchi.NewWithConfig(slog.Default(), slogchi.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn, // 400499 → Warn
ServerErrorLevel: slog.LevelError, // 500+ → Error
WithRequestID: true, // берёт request-id из контекста
Filters: []slogchi.Filter{
slogchi.IgnorePath("/health", "/metrics", "/favicon.ico"),
},
}))
r.Use(s.AuthMiddleware)
h := api.HandlerFromMux(s, r)
r.Handle("/metrics", promhttp.Handler())
srv := &http.Server{
Handler: h,
Addr: s.Config.ServerURL(),
ReadTimeout: s.Config.ReadTimeout(),
IdleTimeout: s.Config.IdleTimeout(),
WriteTimeout: s.Config.WriteTimeout(),
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP сервер упал", "error", err)
}
}()
slog.Info("Приложение запущено успешно ", slog.String("URL", s.Config.ServerURL()))
<-s.ctx.Done()
slog.Info("Остановка HTTP сервера...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
return srv.Shutdown(shutdownCtx)
}
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" || r.URL.Path == "/metrics" {
next.ServeHTTP(w, r)
return
}
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return
}
slog.Info("Проверка авторизации")
claims, err := s.validateAccessToken(r.Context(), tokenStr)
if err != nil {
slog.WarnContext(r.Context(), "Токен не прошел валидацию", slog.String("Token", tokenStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return
}
ctx := context.WithValue(r.Context(), "user", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) MiddlewareRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = xid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, rid)
w.Header().Set("X-Request-ID", rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) JSON(w http.ResponseWriter, r *http.Request, status int, payload any, respType string) {
options := responseOptions{
respType: respType,
requestID: extractRequestID(r),
}
success := status >= 200 && status < 300
resp := api.ApiResponse{
RequestID: &options.requestID,
Status: &status,
Success: &success,
Data: &map[string]interface{}{respType: payload},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.ErrorContext(r.Context(), "json encode failed after header written",
slog.Int("status", status),
slog.String("error", err.Error()),
slog.String("request_id", options.requestID),
)
}
}
func extractRequestID(r *http.Request) string {
if r == nil {
return ""
}
if v := r.Context().Value(requestIDKey); v != nil {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
func (s *Server) validateAccessToken(ctx context.Context, tokenStr string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
slog.WarnContext(ctx, "неизвестный алгоритм", "alg", t.Header["alg"])
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return s.JwtKey, nil
})
if err != nil {
return nil, fmt.Errorf("token parse error: %w", err)
}
if !token.Valid {
return nil, errors.New("invalid token")
}
if token.Header["alg"] != "HS256" {
return nil, errors.New("only HS256 allowed")
}
redisKey := "access_token:" + tokenStr
if _, err := s.Redis.Get(ctx, redisKey).Result(); err != nil {
slog.ErrorContext(ctx, "redis error during token validation", "error", err.Error())
return nil, fmt.Errorf("redis error: %w", err)
}
if claims.TokenID == "" {
return nil, errors.New("missing token id (jti)")
}
if claims.ID == uuid.Nil {
return nil, errors.New("missing user id in claims")
}
return claims, nil
}