diff --git a/cmd/pluto-api/main.go b/cmd/pluto-api/main.go new file mode 100644 index 0000000..d501ba3 --- /dev/null +++ b/cmd/pluto-api/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "time" + + "example.com/atlasos/internal/httpapp" +) + +func main() { + addr := env("PLUTO_HTTP_ADDR", ":8080") + + app, err := httpapp.New(httpapp.Config{ + Addr: addr, + TemplatesDir: "web/templates", + StaticDir: "web/static", + }) + if err != nil { + log.Fatalf("init app: %v", err) + } + + srv := &http.Server{ + Addr: addr, + Handler: app.Router(), + ReadHeaderTimeout: 5 * time.Second, + } + + // Start server + go func() { + log.Printf("[pluto-api] listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + // Graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + <-stop + log.Printf("[pluto-api] shutdown requested") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Printf("[pluto-api] shutdown error: %v", err) + } else { + log.Printf("[pluto-api] shutdown complete") + } +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/httpapp/handlers.go b/internal/httpapp/handlers.go new file mode 100644 index 0000000..af2a71f --- /dev/null +++ b/internal/httpapp/handlers.go @@ -0,0 +1,59 @@ +package httpapp + +import ( + "encoding/json" + "log" + "net/http" +) + +func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { + data := map[string]any{ + "Title": "Dashboard", + "Build": map[string]string{ + "version": "v0.1.0-dev", + }, + } + a.render(w, "dashboard.html", data) +} + +func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) { + id, _ := r.Context().Value(requestIDKey).(string) + resp := map[string]any{ + "status": "ok", + "ts": id, // request ID for correlation + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *App) handleMetrics(w http.ResponseWriter, r *http.Request) { + // Stub metrics (Prometheus format). We'll wire real collectors later. + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte( + `# HELP pluto_build_info Build info +# TYPE pluto_build_info gauge +pluto_build_info{version="v0.1.0-dev"} 1 +# HELP pluto_up Whether the pluto-api process is up +# TYPE pluto_up gauge +pluto_up 1 +`, + )) +} + +func (a *App) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // base.html defines layout; dashboard.html will invoke it via template inheritance style. + if err := a.tmpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("template render error: %v", err) + http.Error(w, "template render error", http.StatusInternalServerError) + return + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("json encode error: %v", err) + } +} diff --git a/internal/httpapp/middleware.go b/internal/httpapp/middleware.go new file mode 100644 index 0000000..119e5d8 --- /dev/null +++ b/internal/httpapp/middleware.go @@ -0,0 +1,46 @@ +package httpapp + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log" + "net/http" + "time" +) + +type ctxKey string + +const requestIDKey ctxKey = "reqid" + +func requestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-Id") + if id == "" { + id = newReqID() + } + w.Header().Set("X-Request-Id", id) + ctx := context.WithValue(r.Context(), requestIDKey, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + d := time.Since(start) + id, _ := r.Context().Value(requestIDKey).(string) + log.Printf("%s %s %s rid=%s dur=%s", r.RemoteAddr, r.Method, r.URL.Path, id, d) + }) +} + +func newReqID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Fallback to timestamp-based ID if crypto/rand fails (extremely rare) + log.Printf("rand.Read failed, using fallback: %v", err) + return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano))) + } + return hex.EncodeToString(b[:]) +}