Feat: Backend scaffolding and local dev stack

This commit is contained in:
MangoPig
2026-06-16 07:34:34 +01:00
parent 4ebee9e695
commit 76c24782c8
45 changed files with 1726 additions and 63 deletions

View File

@@ -0,0 +1,85 @@
// Path: Backend/internal/bootstrap/bootstrap.go
package bootstrap
import (
"log/slog"
"os"
"strings"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type App struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
func New(serviceName string) (*App, error) {
cfg := config.Load()
logger := newLogger(cfg)
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
return nil, err
}
valkey, err := cache.NewValkey(cfg.ValkeyURL)
if err != nil {
db.Close()
return nil, err
}
return &App{
ServiceName: serviceName,
Config: cfg,
Logger: logger.With("service", serviceName),
BuildInfo: buildinfo.Current(),
Database: db,
Cache: valkey,
}, nil
}
func (a *App) Close() error {
var closeErr error
if a.Cache != nil {
if err := a.Cache.Close(); err != nil {
closeErr = err
}
}
if a.Database != nil {
a.Database.Close()
}
return closeErr
}
func newLogger(cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
switch strings.ToLower(cfg.LogLevel) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
options := &slog.HandlerOptions{Level: level}
if cfg.IsDevelopment() {
return slog.New(slog.NewTextHandler(os.Stdout, options))
}
return slog.New(slog.NewJSONHandler(os.Stdout, options))
}

View File

@@ -0,0 +1,23 @@
// Path: Backend/internal/buildinfo/buildinfo.go
package buildinfo
var (
Version = "dev"
Commit = "unknown"
BuiltAt = "unknown"
)
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuiltAt string `json:"builtAt"`
}
func Current() Info {
return Info{
Version: Version,
Commit: Commit,
BuiltAt: BuiltAt,
}
}

40
Backend/internal/cache/valkey.go vendored Normal file
View File

@@ -0,0 +1,40 @@
// Path: Backend/internal/cache/valkey.go
package cache
import (
"context"
"github.com/redis/go-redis/v9"
)
type Client struct {
Redis *redis.Client
}
func NewValkey(valkeyURL string) (*Client, error) {
options, err := redis.ParseURL(valkeyURL)
if err != nil {
return nil, err
}
client := redis.NewClient(options)
return &Client{Redis: client}, nil
}
func (c *Client) Health(ctx context.Context) error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Ping(ctx).Err()
}
func (c *Client) Close() error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Close()
}

View File

@@ -0,0 +1,79 @@
// Path: Backend/internal/config/config.go
package config
import (
"fmt"
"os"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
LogLevel string
WebPort string
APIPort string
WorkerPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
}
func Load() *Config {
return &Config{
AppName: getEnv("APP_NAME", "moku"),
Environment: getEnv("GO_ENV", "development"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
func (c *Config) Address(serviceName string) string {
var port string
switch strings.ToLower(serviceName) {
case "web":
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
default:
port = c.WebPort
}
return fmt.Sprintf(":%s", port)
}
func (c *Config) IsDevelopment() bool {
return strings.EqualFold(c.Environment, "development")
}
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
value, exists := os.LookupEnv(key)
if !exists {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,95 @@
// Path: Backend/internal/database/postgres.go
package database
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"moku-backend/db"
)
type DB struct {
Pool *pgxpool.Pool
}
func NewPostgres(databaseURL string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &DB{Pool: pool}, nil
}
func (d *DB) MigrateUp() error {
return d.runGoose(goose.Up)
}
func (d *DB) MigrateDown() error {
return d.runGoose(goose.Down)
}
func (d *DB) MigrateReset() error {
return d.runGoose(goose.Reset)
}
func (d *DB) MigrateStatus() error {
return d.runGoose(goose.Status)
}
func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error {
if d == nil || d.Pool == nil {
return nil
}
sqlDB := stdlib.OpenDBFromPool(d.Pool)
defer sqlDB.Close()
goose.SetBaseFS(db.Migrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return command(sqlDB, "migrations")
}
func (d *DB) Health(ctx context.Context) error {
if d == nil || d.Pool == nil {
return nil
}
return d.Pool.Ping(ctx)
}
func (d *DB) Close() {
if d == nil || d.Pool == nil {
return
}
d.Pool.Close()
}

View File

@@ -0,0 +1,53 @@
// Path: Backend/internal/httpx/api_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type apiRoutes struct {
cfg RouterConfig
}
func newAPIRoutes(cfg RouterConfig) routeRegistrar {
return apiRoutes{cfg: cfg}
}
func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) {
apiRouter.Get("/", routes.handleIndex)
apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces)
})
}
func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"version": "v1",
"status": "scaffolded",
})
}
func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "organizations",
"count": 0,
},
})
}
func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "workspaces",
"count": 0,
},
})
}

View File

@@ -0,0 +1,80 @@
// Path: Backend/internal/httpx/middleware.go
package httpx
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
type contextKey string
const requestIDContextKey contextKey = "requestID"
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(statusCode int) {
r.statusCode = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewString()
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
requestID := RequestIDFromContext(r.Context())
logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path)
WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.")
}
}()
next.ServeHTTP(w, r)
})
}
}
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(recorder, r)
logger.Info(
"http request",
"request_id", RequestIDFromContext(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", recorder.statusCode,
"duration", time.Since(startedAt).String(),
)
})
}
}
func RequestIDFromContext(ctx context.Context) string {
requestID, ok := ctx.Value(requestIDContextKey).(string)
if !ok {
return ""
}
return requestID
}

View File

@@ -0,0 +1,33 @@
// Path: Backend/internal/httpx/response.go
package httpx
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
RequestID string `json:"requestId,omitempty"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, status int, requestID, code, message string) {
WriteJSON(w, status, ErrorResponse{
Error: code,
Message: message,
RequestID: requestID,
})
}

View File

@@ -0,0 +1,70 @@
// Path: Backend/internal/httpx/router.go
package httpx
import (
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type RouterConfig struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
type routeRegistrar interface {
Register(chi.Router)
}
func NewRouter(cfg RouterConfig) http.Handler {
router := chi.NewRouter()
registerMiddleware(router, cfg)
registerRoutes(router, routesForService(cfg)...)
router.NotFound(notFoundHandler)
return router
}
func registerMiddleware(router chi.Router, cfg RouterConfig) {
router.Use(chimiddleware.RealIP)
router.Use(RequestID)
router.Use(Recoverer(cfg.Logger))
router.Use(RequestLogger(cfg.Logger))
}
func registerRoutes(router chi.Router, registrars ...routeRegistrar) {
for _, registrar := range registrars {
registrar.Register(router)
}
}
func routesForService(cfg RouterConfig) []routeRegistrar {
registrars := []routeRegistrar{newSharedRoutes(cfg)}
switch strings.ToLower(cfg.ServiceName) {
case "web":
registrars = append(registrars, newWebRoutes(cfg))
case "api":
registrars = append(registrars, newAPIRoutes(cfg))
}
return registrars
}
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
}

View File

@@ -0,0 +1,69 @@
// Path: Backend/internal/httpx/shared_routes.go
package httpx
import (
"context"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type sharedRoutes struct {
cfg RouterConfig
}
func newSharedRoutes(cfg RouterConfig) routeRegistrar {
return sharedRoutes{cfg: cfg}
}
func (routes sharedRoutes) Register(router chi.Router) {
router.Get("/", routes.handleIndex)
router.Get("/health", routes.handleHealth)
router.Get("/version", routes.handleVersion)
}
func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"service": routes.cfg.ServiceName,
"status": "ok",
})
}
func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
databaseStatus := "ok"
if err := routes.cfg.Database.Health(ctx); err != nil {
databaseStatus = err.Error()
}
cacheStatus := "ok"
if err := routes.cfg.Cache.Health(ctx); err != nil {
cacheStatus = err.Error()
}
statusCode := http.StatusOK
if databaseStatus != "ok" || cacheStatus != "ok" {
statusCode = http.StatusServiceUnavailable
}
WriteJSON(w, statusCode, map[string]any{
"service": routes.cfg.ServiceName,
"status": map[string]string{
"database": databaseStatus,
"cache": cacheStatus,
},
})
}
func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
"build": routes.cfg.BuildInfo,
})
}

View File

@@ -0,0 +1,51 @@
// Path: Backend/internal/httpx/web_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type webRoutes struct {
cfg RouterConfig
}
func newWebRoutes(cfg RouterConfig) routeRegistrar {
return webRoutes{cfg: cfg}
}
func (routes webRoutes) Register(router chi.Router) {
router.Get("/session", routes.handleSession)
router.Get("/bootstrap", routes.handleBootstrap)
router.Get("/me", routes.handleCurrentUser)
}
func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"session": map[string]any{
"authenticated": false,
"mode": "cookie",
},
})
}
func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": map[string]any{
"name": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
},
"features": map[string]bool{
"auth": false,
"workspaces": false,
},
})
}
func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.")
}

View File

@@ -0,0 +1,65 @@
// Path: Backend/internal/process/process.go
package process
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error {
server := &http.Server{
Addr: address,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
logger.Info("http server starting", "service", serviceName, "address", address)
serverErr <- server.ListenAndServe()
}()
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serverErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
case <-signalCtx.Done():
logger.Info("shutdown requested", "service", serviceName)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown %s server: %w", serviceName, err)
}
logger.Info("http server stopped", "service", serviceName)
return nil
}
func WaitForShutdown(serviceName string, logger *slog.Logger) error {
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("process running", "service", serviceName)
<-signalCtx.Done()
logger.Info("shutdown requested", "service", serviceName)
return nil
}