package service import ( "context" "encoding/json" "log" "net/http" "os" "strings" "time" ) // Config describes one internal Go service behind the Elysia gateway. type Config struct { Name string `json:"name"` Domain string `json:"domain"` DefaultPort string `json:"defaultPort"` Capabilities []string `json:"capabilities"` } type response struct { Status string `json:"status"` Service string `json:"service"` Domain string `json:"domain"` Capabilities []string `json:"capabilities,omitempty"` Error string `json:"error,omitempty"` Time time.Time `json:"time"` } // EnvPort reads PORT with a safe fallback for local service runs. func EnvPort(defaultPort string) string { if port := strings.TrimSpace(os.Getenv("PORT")); port != "" { return port } if strings.TrimSpace(defaultPort) != "" { return defaultPort } return "8080" } // MustRun starts a service and terminates the process if startup fails. func MustRun(cfg Config) { if err := Run(cfg); err != nil { log.Fatalf("%s stopped: %v", cfg.Name, err) } } // Run starts an HTTP server for an internal service. func Run(cfg Config) error { if _, err := bootstrapDefaultStore(); err != nil { return err } port := EnvPort(cfg.DefaultPort) server := &http.Server{ Addr: ":" + port, Handler: requestIDMiddleware(loggingMiddleware(NewHandler(cfg))), ReadHeaderTimeout: 5 * time.Second, } log.Printf("%s listening on :%s", cfg.Name, port) return server.ListenAndServe() } // NewHandler returns the standard internal service HTTP API. func NewHandler(cfg Config) http.Handler { if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" { _, _ = bootstrapDefaultStore() } if cfg.Name == "" { cfg.Name = "Fable Service" } if cfg.Domain == "" { cfg.Domain = "unknown" } mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { store := backendStore status := http.StatusOK payload := response{ Status: "ok", Service: cfg.Name, Domain: cfg.Domain, Capabilities: cfg.Capabilities, Time: time.Now().UTC(), } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := store.Ping(ctx); err != nil { status = http.StatusServiceUnavailable payload.Status = "degraded" payload.Error = err.Error() } writeJSON(w, status, payload) }) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { store := backendStore status := http.StatusOK payload := response{ Status: "ready", Service: cfg.Name, Domain: cfg.Domain, Capabilities: cfg.Capabilities, Time: time.Now().UTC(), } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := store.Ping(ctx); err != nil { status = http.StatusServiceUnavailable payload.Status = "not_ready" payload.Error = err.Error() } writeJSON(w, status, payload) }) mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, cfg) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if routeAPI(w, r, cfg) { return } if r.URL.Path != "/" { writeJSON(w, http.StatusNotFound, map[string]string{"error": "route not found"}) return } writeJSON(w, http.StatusOK, response{ Status: "ok", Service: cfg.Name, Domain: cfg.Domain, Capabilities: cfg.Capabilities, Time: time.Now().UTC(), }) }) return mux } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { log.Printf("write response: %v", err) } } func requestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestID := strings.TrimSpace(r.Header.Get("X-Request-Id")) if requestID == "" { requestID = time.Now().UTC().Format("20060102150405.000000000") } w.Header().Set("X-Request-Id", requestID) next.ServeHTTP(w, r) }) } func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() next.ServeHTTP(w, r) log.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(started)) }) }