diff --git a/cmd/fynx-server/main.go b/cmd/fynx-server/main.go index aed1ef1..2745d66 100644 --- a/cmd/fynx-server/main.go +++ b/cmd/fynx-server/main.go @@ -1,10 +1,13 @@ package main import ( + "context" "log/slog" "net/http" "os" + "os/signal" "strings" + "syscall" "time" "github.com/fynx-ai/fynx/internal/api" @@ -84,9 +87,9 @@ func main() { mux := http.NewServeMux() handler.RegisterRoutes(mux) - slog.Info("starting fynx-server", - "port", port, - "version", version, + slog.Info("starting fynx-server", + "port", port, + "version", version, "packs", len(classifier.PackVersions()), "dashboard", "http://localhost:"+port+"/dashboard", ) @@ -99,8 +102,29 @@ func main() { IdleTimeout: 60 * time.Second, } - if err := server.ListenAndServe(); err != nil { - slog.Error("server error", "error", err) + // Graceful shutdown handling + shutdownChan := make(chan os.Signal, 1) + signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Wait for shutdown signal + <-shutdownChan + slog.Info("shutting down server...") + + // Give outstanding requests 30 seconds to complete + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + slog.Error("server shutdown error", "error", err) os.Exit(1) } + + slog.Info("server stopped gracefully") } diff --git a/internal/api/handler.go b/internal/api/handler.go index ea3e929..109922a 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "strings" "sync" @@ -14,6 +15,16 @@ import ( "github.com/fynx-ai/fynx/internal/engine" ) +// contextKey is a typed key for context values to avoid collisions. +type contextKey int + +const ( + // ctxKeyAPIKey is the context key for the API key string. + ctxKeyAPIKey contextKey = iota + // ctxKeyKeyInfo is the context key for the APIKeyInfo. + ctxKeyKeyInfo +) + // Handler provides HTTP handlers for the Fynx API. type Handler struct { classifier *engine.Classifier @@ -207,9 +218,9 @@ func (h *Handler) withAuth(next http.HandlerFunc) http.HandlerFunc { return } - // Add key info to context - ctx := context.WithValue(r.Context(), "api_key", apiKey) - ctx = context.WithValue(ctx, "key_info", keyInfo) + // Add key info to context using typed keys + ctx := context.WithValue(r.Context(), ctxKeyAPIKey, apiKey) + ctx = context.WithValue(ctx, ctxKeyKeyInfo, keyInfo) next(w, r.WithContext(ctx)) } } @@ -275,7 +286,7 @@ func (h *Handler) handleClassify(w http.ResponseWriter, r *http.Request) { h.metrics.LatencyCount.Add(1) // Track usage - if apiKey, ok := r.Context().Value("api_key").(string); ok { + if apiKey, ok := r.Context().Value(ctxKeyAPIKey).(string); ok { h.usage.Track(apiKey, 1) } @@ -359,7 +370,7 @@ func (h *Handler) handleBatchClassify(w http.ResponseWriter, r *http.Request) { h.metrics.LatencyCount.Add(1) // Track usage - if apiKey, ok := r.Context().Value("api_key").(string); ok { + if apiKey, ok := r.Context().Value(ctxKeyAPIKey).(string); ok { h.usage.Track(apiKey, len(req.Items)) } @@ -447,8 +458,8 @@ func (h *Handler) handleGetTaxonomy(w http.ResponseWriter, r *http.Request) { // GET /v1/usage func (h *Handler) handleUsage(w http.ResponseWriter, r *http.Request) { - apiKey, _ := r.Context().Value("api_key").(string) - keyInfo, _ := r.Context().Value("key_info").(*APIKeyInfo) + apiKey, _ := r.Context().Value(ctxKeyAPIKey).(string) + keyInfo, _ := r.Context().Value(ctxKeyKeyInfo).(*APIKeyInfo) usage := h.usage.Get(apiKey) limit := 10000 // default @@ -457,7 +468,8 @@ func (h *Handler) handleUsage(w http.ResponseWriter, r *http.Request) { } now := time.Now() - resetAt := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + // Use AddDate to properly handle year rollover (e.g., December -> January) + resetAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0) h.jsonResponse(w, http.StatusOK, map[string]any{ "period": now.Format("2006-01"), @@ -539,7 +551,9 @@ func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { func (h *Handler) jsonResponse(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - json.NewEncoder(w).Encode(data) + if err := json.NewEncoder(w).Encode(data); err != nil { + slog.Error("failed to encode JSON response", "error", err) + } } func (h *Handler) errorResponse(w http.ResponseWriter, status int, code, message string) { diff --git a/internal/engine/classifier.go b/internal/engine/classifier.go index e610d14..0c00156 100644 --- a/internal/engine/classifier.go +++ b/internal/engine/classifier.go @@ -64,6 +64,34 @@ type Meta struct { TraceID string `json:"trace_id,omitempty"` } +// Span represents a matched region in the input text. +type Span struct { + Start int `json:"start"` + End int `json:"end"` + Text string `json:"text,omitempty"` +} + +// Label represents a classification result. +type Label struct { + Path string `json:"path"` + Severity string `json:"severity"` +} + +// Evidence represents the proof for a classification. +type Evidence struct { + Path string `json:"path"` + RuleID string `json:"rule_id"` + Rationale string `json:"rationale"` + Spans []Span `json:"spans"` + PackID string `json:"pack_id"` + PackVersion string `json:"pack_version"` +} + +// Matcher is the interface for rule matchers (regex, keyword, etc). +type Matcher interface { + Match(text string) []Span +} + // NewClassifier creates a new classifier. func NewClassifier(version string) *Classifier { return &Classifier{ diff --git a/internal/engine/engine.go b/internal/engine/engine.go deleted file mode 100644 index 257bfde..0000000 --- a/internal/engine/engine.go +++ /dev/null @@ -1,80 +0,0 @@ -// Package engine implements the Fynx classification engine. -// It loads compiled policy packs and matches text against rules, -// returning deterministic labels and evidence. -package engine - -import "context" - -// Engine is the main classification engine. -type Engine struct { - packs []Pack -} - -// Pack represents a loaded policy pack. -type Pack struct { - ID string - Version string - Rules []Rule -} - -// Rule represents a single classification rule. -type Rule struct { - ID string - Path string - Severity string - Rationale string - Matcher Matcher -} - -// Matcher is the interface for rule matchers (regex, keyword, etc). -type Matcher interface { - Match(text string) []Span -} - -// Span represents a matched region in the input text. -type Span struct { - Start int `json:"start"` - End int `json:"end"` - Text string `json:"text,omitempty"` -} - -// Label represents a classification result. -type Label struct { - Path string `json:"path"` - Severity string `json:"severity"` -} - -// Evidence represents the proof for a classification. -type Evidence struct { - Path string `json:"path"` - RuleID string `json:"rule_id"` - Rationale string `json:"rationale"` - Spans []Span `json:"spans"` - PackID string `json:"pack_id"` - PackVersion string `json:"pack_version"` -} - -// Result is the classification result. -type Result struct { - Labels []Label `json:"labels"` - Evidence []Evidence `json:"evidence"` -} - -// New creates a new Engine. -func New() *Engine { - return &Engine{} -} - -// LoadPack loads a policy pack into the engine. -func (e *Engine) LoadPack(pack Pack) { - e.packs = append(e.packs, pack) -} - -// Classify classifies the given text and returns labels with evidence. -func (e *Engine) Classify(ctx context.Context, text string) (*Result, error) { - // TODO: implement actual classification - return &Result{ - Labels: []Label{}, - Evidence: []Evidence{}, - }, nil -}