From 3fe0850711930d1df91b7f6abf7ebbac3f52ac69 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Wed, 6 May 2026 12:20:31 -0400 Subject: [PATCH] CUB-112: scaffold Go backend with Chi, pgx, health check --- backend/Dockerfile | 46 ++++++--------- backend/cmd/server/main.go | 80 ++++++++++++++++++++++++++ backend/go.mod | 9 +++ backend/internal/config/config.go | 24 ++++++++ backend/internal/db/db.go | 34 +++++++++++ backend/internal/handlers/health.go | 50 ++++++++++++++++ backend/internal/repositories/.gitkeep | 1 + backend/internal/router/router.go | 45 +++++++++++++++ backend/internal/services/.gitkeep | 1 + 9 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/db/db.go create mode 100644 backend/internal/handlers/health.go create mode 100644 backend/internal/repositories/.gitkeep create mode 100644 backend/internal/router/router.go create mode 100644 backend/internal/services/.gitkeep diff --git a/backend/Dockerfile b/backend/Dockerfile index 23aacef..6fdd102 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,37 +1,25 @@ -# ── Stage 1: Build ────────────────────────────────────────── -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build -WORKDIR /src +# Build stage +FROM golang:1.24-alpine AS builder -# Copy csproj first for layer caching — restores before copying source -COPY Extrudex.csproj . -RUN dotnet restore - -# Copy the rest of the source -COPY . . -RUN dotnet publish Extrudex.csproj \ - -c Release \ - -o /app/publish \ - --no-restore - -# ── Stage 2: Runtime ──────────────────────────────────────── -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install curl for health check (not included in aspnet base image) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +# Copy go mod files first for caching +COPY go.mod go.sum ./ +RUN go mod download -# Non-root user for security -RUN adduser --disabled-password --gecos "" appuser -USER appuser +# Copy source and build +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server -# Copy published output from build stage -COPY --from=build /app/publish . +# Final stage +FROM alpine:latest +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# Copy binary from builder +COPY --from=builder /app/server . -# ASP.NET Core listens on 8080 by default in .NET 8+ EXPOSE 8080 -# Health check against /health endpoint -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl --fail http://localhost:8080/health || exit 1 - -ENTRYPOINT ["dotnet", "Extrudex.dll"] \ No newline at end of file +CMD ["./server"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..5e9cbe5 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/config" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/db" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/router" +) + +func main() { + // Setup structured logging + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + // Load configuration + cfg, err := config.Load() + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + slog.Info("config loaded", "port", cfg.Port, "cors_origin", cfg.CorsOrigin) + + // Connect to database + dbPool, err := db.NewPool(cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer db.ClosePool(dbPool) + + slog.Info("database connected") + + // Create router + r := router.New(cfg, dbPool) + + // Create HTTP server + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + slog.Info("server starting", "addr", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + slog.Info("server shutting down") + + // Graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + slog.Error("server shutdown error", "error", err) + } + + db.ClosePool(dbPool) + slog.Info("server stopped") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..dc4de1c --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,9 @@ +module github.com/CubeCraft-Creations/Extrudex/backend + +go 1.24 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/jackc/pgx/v5 v5.7.4 + github.com/kelseyhightower/envconfig v1.4.0 +) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..64c4bd8 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,24 @@ +package config + +import ( + "fmt" + + "github.com/kelseyhightower/envconfig" +) + +// Config holds all application configuration loaded from environment variables. +type Config struct { + DatabaseURL string `envconfig:"database_url" required:"true"` + Port string `envconfig:"port" default:"8080"` + CorsOrigin string `envconfig:"cors_origin" default:"*"` + LogLevel string `envconfig:"log_level" default:"info"` +} + +// Load reads configuration from environment variables and returns a populated Config. +func Load() (*Config, error) { + var cfg Config + if err := envconfig.Process("", &cfg); err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return &cfg, nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..e165613 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPool creates a new pgx connection pool and verifies connectivity with a ping. +func NewPool(databaseURL string) (*pgxpool.Pool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create db pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping db: %w", err) + } + + return pool, nil +} + +// ClosePool gracefully closes the connection pool. +func ClosePool(pool *pgxpool.Pool) { + if pool != nil { + pool.Close() + } +} diff --git a/backend/internal/handlers/health.go b/backend/internal/handlers/health.go new file mode 100644 index 0000000..fcda6b0 --- /dev/null +++ b/backend/internal/handlers/health.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// HealthHandler provides a health check endpoint that verifies database connectivity. +type HealthHandler struct { + dbPool *pgxpool.Pool +} + +// NewHealthHandler creates a new HealthHandler with the given database pool. +func NewHealthHandler(dbPool *pgxpool.Pool) *HealthHandler { + return &HealthHandler{dbPool: dbPool} +} + +// ServeHTTP handles GET /health requests. +func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + dbConnected := false + if h.dbPool != nil { + if err := h.dbPool.Ping(ctx); err == nil { + dbConnected = true + } else { + slog.Warn("health check db ping failed", "error", err) + } + } + + resp := map[string]any{ + "status": "ok", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "db_connected": dbConnected, + } + + w.Header().Set("Content-Type", "application/json") + if !dbConnected { + w.WriteHeader(http.StatusServiceUnavailable) + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + slog.Error("failed to encode health response", "error", err) + } +} diff --git a/backend/internal/repositories/.gitkeep b/backend/internal/repositories/.gitkeep new file mode 100644 index 0000000..00a3b0e --- /dev/null +++ b/backend/internal/repositories/.gitkeep @@ -0,0 +1 @@ +# Repositories diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..c0482fd --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,45 @@ +package router + +import ( + "log/slog" + "net/http" + "time" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/config" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/handlers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/jackc/pgx/v5/pgxpool" +) + +// New creates and configures a Chi router with all middleware and handlers mounted. +func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router { + r := chi.NewRouter() + + // Middleware + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(60 * time.Second)) + + // CORS + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", cfg.CorsOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + }) + + // Health check + healthHandler := handlers.NewHealthHandler(dbPool) + r.Get("/health", healthHandler.ServeHTTP) + + return r +} diff --git a/backend/internal/services/.gitkeep b/backend/internal/services/.gitkeep new file mode 100644 index 0000000..8e5b66b --- /dev/null +++ b/backend/internal/services/.gitkeep @@ -0,0 +1 @@ +# Services