diff --git a/cmd/serve.go b/cmd/serve.go index 06757f7..20059d3 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -1,14 +1,31 @@ package cmd import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "github.com/spf13/cobra" + "printer.backend/internal/server" ) var serveCmd = &cobra.Command{ Use: "serve", - Short: "Start the HTTP server", - Run: func(cmd *cobra.Command, args []string) { - // TODO: implement server + Short: "Start API and admin dashboard HTTP servers", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cmd.Printf("API: http://%s\n", cfg.APIAddr()) + cmd.Printf("Admin: http://%s\n", cfg.AdminAddr()) + + if err := server.Run(ctx, cfg); err != nil { + return fmt.Errorf("server: %w", err) + } + cmd.Println("Server beendet.") + return nil }, } diff --git a/config.example.json b/config.example.json index 15fd0fc..b7abd0c 100644 --- a/config.example.json +++ b/config.example.json @@ -1,4 +1,5 @@ { "host": "127.0.0.1", - "port": 8080 + "api_port": 8080, + "admin_port": 8081 } diff --git a/internal/config/config.go b/internal/config/config.go index 2638d9e..bb5fa04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,15 +8,20 @@ import ( // Config is the application configuration loaded from JSON. type Config struct { - Host string `json:"host"` - Port int `json:"port"` + Host string `json:"host"` + APIPort int `json:"api_port"` + AdminPort int `json:"admin_port"` + + // Port is deprecated; mapped to APIPort when api_port is unset. + Port int `json:"port"` } // Default returns sensible defaults when no config file is used. func Default() *Config { return &Config{ - Host: "127.0.0.1", - Port: 8080, + Host: "127.0.0.1", + APIPort: 8080, + AdminPort: 8081, } } @@ -32,5 +37,20 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("parse config: %w", err) } + cfg.applyLegacyPort() return cfg, nil } + +func (c *Config) applyLegacyPort() { + if c.Port != 0 && c.APIPort == 8080 { + c.APIPort = c.Port + } +} + +func (c *Config) APIAddr() string { + return fmt.Sprintf("%s:%d", c.Host, c.APIPort) +} + +func (c *Config) AdminAddr() string { + return fmt.Sprintf("%s:%d", c.Host, c.AdminPort) +} diff --git a/internal/server/admin/admin.go b/internal/server/admin/admin.go new file mode 100644 index 0000000..a433db0 --- /dev/null +++ b/internal/server/admin/admin.go @@ -0,0 +1,27 @@ +package admin + +import ( + "embed" + "encoding/json" + "io/fs" + "net/http" +) + +//go:embed static +var staticFS embed.FS + +// NewHandler returns the admin dashboard HTTP handler. +func NewHandler(apiBaseURL string) http.Handler { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic("admin static fs: " + err.Error()) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /config.json", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"api_base": apiBaseURL}) + }) + mux.Handle("/", http.FileServer(http.FS(sub))) + return mux +} diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html new file mode 100644 index 0000000..8651d43 --- /dev/null +++ b/internal/server/admin/static/index.html @@ -0,0 +1,200 @@ + + + + + + Printer Backend — Admin + + + + +
+
+

Printer Backend

+

Admin-Dashboard

+
+ +
+
+

API-Status

+

+ + +

+

+ +
+ +
+

API-Basis-URL

+ + +

+      
+ +
+

System

+

+

Lokale Uhrzeit (Browser)

+ +
+
+
+ + + + diff --git a/internal/server/api/api.go b/internal/server/api/api.go new file mode 100644 index 0000000..6b798ca --- /dev/null +++ b/internal/server/api/api.go @@ -0,0 +1,27 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// NewHandler returns the API HTTP handler (routes under /). +func NewHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", health) + mux.HandleFunc("GET /", root) + return mux +} + +func health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func root(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "service": "printer-backend-api", + "message": "API placeholder — endpoints folgen", + }) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9758355 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,68 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "sync" + "time" + + "printer.backend/internal/config" + "printer.backend/internal/server/admin" + "printer.backend/internal/server/api" +) + +// Run starts the API and admin HTTP servers and blocks until ctx is cancelled. +func Run(ctx context.Context, cfg *config.Config) error { + apiSrv := &http.Server{ + Addr: cfg.APIAddr(), + Handler: api.NewHandler(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } + apiBase := "http://" + cfg.APIAddr() + adminSrv := &http.Server{ + Addr: cfg.AdminAddr(), + Handler: admin.NewHandler(apiBase), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } + + var wg sync.WaitGroup + errCh := make(chan error, 2) + + start := func(name string, srv *http.Server) { + wg.Add(1) + go func() { + defer wg.Done() + log.Printf("%s listening on http://%s", name, srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("%s: %w", name, err) + } + }() + } + + start("API", apiSrv) + start("Admin", adminSrv) + + select { + case <-ctx.Done(): + case err := <-errCh: + return err + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var shutdownErr error + for _, srv := range []*http.Server{apiSrv, adminSrv} { + if err := srv.Shutdown(shutdownCtx); err != nil { + shutdownErr = errors.Join(shutdownErr, err) + } + } + + wg.Wait() + return shutdownErr +}