Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions cmd/fynx-server/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package main

import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/fynx-ai/fynx/internal/api"

Check failure on line 13 in cmd/fynx-server/main.go

View workflow job for this annotation

GitHub Actions / Lint

could not import github.com/fynx-ai/fynx/internal/api (-: # github.com/fynx-ai/fynx/internal/api
"github.com/fynx-ai/fynx/internal/engine"
"github.com/fynx-ai/fynx/internal/packs"
"github.com/fynx-ai/fynx/pkg/crypto"
Expand Down Expand Up @@ -84,9 +87,9 @@
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",
)
Expand All @@ -99,8 +102,29 @@
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")
}
32 changes: 23 additions & 9 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
Expand All @@ -14,6 +15,16 @@
"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
Expand Down Expand Up @@ -207,9 +218,9 @@
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))
}
}
Expand Down Expand Up @@ -275,7 +286,7 @@
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)
}

Expand Down Expand Up @@ -359,7 +370,7 @@
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))
}

Expand Down Expand Up @@ -395,7 +406,7 @@

// GET /v1/packs/{packId}
func (h *Handler) handleGetPack(w http.ResponseWriter, r *http.Request) {
packID := r.PathValue("packId")

Check failure on line 409 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Lint

r.PathValue undefined (type *http.Request has no field or method PathValue)

Check failure on line 409 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Lint

r.PathValue undefined (type *http.Request has no field or method PathValue)

Check failure on line 409 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Test

r.PathValue undefined (type *http.Request has no field or method PathValue)
pack := h.classifier.GetPack(packID)
if pack == nil {
h.errorResponse(w, http.StatusNotFound, "NOT_FOUND", "pack not found")
Expand Down Expand Up @@ -429,7 +440,7 @@

// GET /v1/packs/{packId}/taxonomy
func (h *Handler) handleGetTaxonomy(w http.ResponseWriter, r *http.Request) {
packID := r.PathValue("packId")

Check failure on line 443 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Lint

r.PathValue undefined (type *http.Request has no field or method PathValue) (typecheck)

Check failure on line 443 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Lint

r.PathValue undefined (type *http.Request has no field or method PathValue)) (typecheck)

Check failure on line 443 in internal/api/handler.go

View workflow job for this annotation

GitHub Actions / Test

r.PathValue undefined (type *http.Request has no field or method PathValue)
pack := h.classifier.GetPack(packID)
if pack == nil {
h.errorResponse(w, http.StatusNotFound, "NOT_FOUND", "pack not found")
Expand All @@ -447,8 +458,8 @@

// 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
Expand All @@ -457,7 +468,8 @@
}

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"),
Expand Down Expand Up @@ -539,7 +551,9 @@
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) {
Expand Down
28 changes: 28 additions & 0 deletions internal/engine/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
80 changes: 0 additions & 80 deletions internal/engine/engine.go

This file was deleted.

Loading