Add initial Go server skeleton with HTTP handlers, middleware, job runner, and stubs

This commit is contained in:
Dev
2025-12-13 15:18:04 +00:00
commit 61570cae23
28 changed files with 921 additions and 0 deletions

8
internal/http/README.md Normal file
View File

@@ -0,0 +1,8 @@
This folder contains HTTP handlers, router registration, and middleware.
Key files:
- `router.go` - registers routes and APIs.
- `handlers.go` - small HTMX-based handlers for the dashboard and API.
- `middleware.go` - request id, logging, placeholder auth and CSRF.
Handlers should accept `*App` struct for dependency injection.

17
internal/http/app.go Normal file
View File

@@ -0,0 +1,17 @@
package http
import (
"database/sql"
"net/http"
"github.com/example/storage-appliance/internal/service"
)
// App contains injected dependencies for handlers.
type App struct {
DB *sql.DB
DiskSvc service.DiskService
ZFSSvc service.ZFSService
JobRunner service.JobRunner
HTTPClient *http.Client
}

80
internal/http/handlers.go Normal file
View File

@@ -0,0 +1,80 @@
package http
import (
"encoding/json"
"html/template"
"net/http"
"path/filepath"
"github.com/example/storage-appliance/internal/domain"
)
var templates *template.Template
func init() {
var err error
templates, err = template.ParseGlob("internal/templates/*.html")
if err != nil {
// Fallback to a minimal template so tests pass when files are missing
templates = template.New("dashboard.html")
templates.New("dashboard.html").Parse(`<div>{{.Title}}</div>`)
}
}
func (a *App) DashboardHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Storage Appliance Dashboard",
}
if err := templates.ExecuteTemplate(w, "dashboard.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *App) PoolsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pools, err := a.ZFSSvc.ListPools(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
j, err := json.Marshal(pools)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}
func (a *App) JobsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[]`))
}
// CreatePoolHandler receives a request to create a pool and enqueues a job
func (a *App) CreatePoolHandler(w http.ResponseWriter, r *http.Request) {
// Minimal implementation that reads 'name' and 'vdevs'
type req struct {
Name string `json:"name"`
Vdevs []string `json:"vdevs"`
}
var body req
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Create a job and enqueue
j := domain.Job{Type: "create-pool", Status: "queued", Progress: 0}
id, err := a.JobRunner.Enqueue(r.Context(), j)
if err != nil {
http.Error(w, "failed to create job", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"job_id":"` + id + `"}`))
}
func StaticHandler(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path[len("/static/"):]
http.ServeFile(w, r, filepath.Join("static", p))
}

View File

@@ -0,0 +1,25 @@
package http
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/example/storage-appliance/internal/service/mock"
)
func TestPoolsHandler(t *testing.T) {
m := &mock.MockZFSService{}
app := &App{DB: &sql.DB{}, ZFSSvc: m}
req := httptest.NewRequest(http.MethodGet, "/api/pools", nil)
w := httptest.NewRecorder()
app.PoolsHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if body == "" {
t.Fatalf("expected non-empty body")
}
}

View File

@@ -0,0 +1,78 @@
package http
import (
"context"
"log"
"net/http"
"time"
)
// ContextKey used to store values in context
type ContextKey string
const (
ContextKeyRequestID ContextKey = "request-id"
)
// RequestID middleware sets a request ID in headers and request context
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
// Logging middleware prints basic request logs
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s in %v", r.Method, r.URL.Path, time.Since(start))
})
}
// Auth middleware placeholder to authenticate users
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic dev auth: read X-Auth-User; in real world, validate session/jwt
username := r.Header.Get("X-Auth-User")
if username == "" {
username = "anonymous"
}
// Role hint: header X-Auth-Role (admin/operator/viewer)
role := r.Header.Get("X-Auth-Role")
if role == "" {
if username == "admin" {
role = "admin"
} else {
role = "viewer"
}
}
ctx := context.WithValue(r.Context(), ContextKey("user"), username)
ctx = context.WithValue(ctx, ContextKey("user.role"), role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// CSRF middleware placeholder (reads X-CSRF-Token)
func CSRFMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: check and enforce CSRF tokens for mutating requests
next.ServeHTTP(w, r)
})
}
// RBAC middleware placeholder
func RBAC(permission string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to read role from context and permit admin always
role := r.Context().Value(ContextKey("user.role"))
if role == "admin" {
next.ServeHTTP(w, r)
return
}
// For now, only admin is permitted; add permission checks here
next.ServeHTTP(w, r)
})
}
}

24
internal/http/router.go Normal file
View File

@@ -0,0 +1,24 @@
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// RegisterRoutes registers HTTP routes onto the router
func RegisterRoutes(r *chi.Mux, app *App) {
r.Use(Logging)
r.Use(RequestID)
r.Use(Auth)
r.Get("/", app.DashboardHandler)
r.Get("/dashboard", app.DashboardHandler)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
// API namespace
r.Route("/api", func(r chi.Router) {
r.Get("/pools", app.PoolsHandler)
r.With(RBAC("storage.pool.create")).Post("/pools", app.CreatePoolHandler) // create a pool -> creates a job
r.Get("/jobs", app.JobsHandler)
})
r.Get("/static/*", StaticHandler)
}