+
HWLab
+
Backend is running. UI coming in Phase 3.
+
+
+
+ ```
+
+ 4. Create `internal/api/handlers/health.go`:
+ ```go
+ package handlers
+
+ import (
+ "encoding/json"
+ "net/http"
+ )
+
+ type HealthResponse struct {
+ Status string `json:"status"`
+ Version string `json:"version"`
+ }
+
+ func Health(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(HealthResponse{
+ Status: "ok",
+ Version: "0.1.0",
+ })
+ }
+ ```
+
+ 5. Create `internal/api/router.go` — chi router with go:embed SPA fallback. The embed directive MUST be in the same file as the var declaration:
+ ```go
+ package api
+
+ import (
+ "embed"
+ "io/fs"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+
+ "git.georgsen.dk/hwlab/internal/api/handlers"
+ )
+
+ //go:embed ../../web/dist
+ var staticFiles embed.FS
+
+ func NewRouter() http.Handler {
+ r := chi.NewRouter()
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+ r.Use(middleware.RealIP)
+
+ r.Route("/api", func(r chi.Router) {
+ r.Get("/health", handlers.Health)
+ })
+
+ // SPA fallback — serve index.html for all non-API routes
+ staticFS, _ := fs.Sub(staticFiles, "web/dist")
+ fileServer := http.FileServer(http.FS(staticFS))
+ r.Handle("/*", fileServer)
+
+ return r
+ }
+ ```
+
+ NOTE: The go:embed path `../../web/dist` is relative to the file location `internal/api/router.go`. Verify path resolves correctly. If the embed directive causes issues due to directory depth, move the embed var to `cmd/hwlab/main.go` and pass the fs.FS into NewRouter as a parameter instead.
+
+ 6. Create `cmd/hwlab/main.go` — wire-up only, no business logic:
+ ```go
+ package main
+
+ import (
+ "fmt"
+ "log"
+ "net/http"
+
+ "git.georgsen.dk/hwlab/internal/api"
+ "git.georgsen.dk/hwlab/internal/config"
+ )
+
+ func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+
+ router := api.NewRouter()
+ addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
+ log.Printf("HWLab starting on %s", addr)
+ if err := http.ListenAndServe(addr, router); err != nil {
+ log.Fatalf("server: %v", err)
+ }
+ }
+ ```
+
+ 7. Write test `internal/api/handlers/health_test.go`:
+ ```go
+ package handlers_test
+
+ import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "git.georgsen.dk/hwlab/internal/api/handlers"
+ )
+
+ func TestHealth(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
+ w := httptest.NewRecorder()
+ handlers.Health(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp handlers.HealthResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Status != "ok" {
+ t.Errorf("expected status=ok, got %s", resp.Status)
+ }
+ if resp.Version != "0.1.0" {
+ t.Errorf("expected version=0.1.0, got %s", resp.Version)
+ }
+ }
+ ```
+