All checks were successful
check / check (push) Successful in 2m43s
Replace github.com/go-chi/chi v1.5.5 with github.com/go-chi/chi/v5 v5.2.1 to resolve GO-2026-4316 (open redirect in RedirectSlashes middleware). Update all import paths across the codebase and update documentation references in README.md and CONVENTIONS.md.
1226 lines
30 KiB
Markdown
1226 lines
30 KiB
Markdown
# Go HTTP Server Conventions
|
|
|
|
This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Required Libraries](#1-required-libraries)
|
|
2. [Project Structure](#2-project-structure)
|
|
3. [Dependency Injection (Uber fx)](#3-dependency-injection-uber-fx)
|
|
4. [Server Architecture](#4-server-architecture)
|
|
5. [Routing (go-chi)](#5-routing-go-chi)
|
|
6. [Handler Conventions](#6-handler-conventions)
|
|
7. [Middleware Conventions](#7-middleware-conventions)
|
|
8. [Configuration (Viper)](#8-configuration-viper)
|
|
9. [Logging (slog)](#9-logging-slog)
|
|
10. [Database Wrapper](#10-database-wrapper)
|
|
11. [Globals Package](#11-globals-package)
|
|
12. [Static Assets & Templates](#12-static-assets--templates)
|
|
13. [Health Check](#13-health-check)
|
|
14. [External Integrations](#14-external-integrations)
|
|
|
|
---
|
|
|
|
## 1. Required Libraries
|
|
|
|
These libraries are **mandatory** for all new projects:
|
|
|
|
| Purpose | Library | Import Path |
|
|
| -------------------- | --------------- | ------------------------------------- |
|
|
| Dependency Injection | Uber fx | `go.uber.org/fx` |
|
|
| HTTP Router | go-chi | `github.com/go-chi/chi/v5` |
|
|
| Logging | slog (stdlib) | `log/slog` |
|
|
| Configuration | Viper | `github.com/spf13/viper` |
|
|
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
|
|
| CORS | go-chi/cors | `github.com/go-chi/cors` |
|
|
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
|
|
| Metrics | Prometheus | `github.com/prometheus/client_golang` |
|
|
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
|
|
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
|
|
|
|
---
|
|
|
|
## 2. Project Structure
|
|
|
|
```
|
|
project-root/
|
|
├── cmd/
|
|
│ └── {appname}/
|
|
│ └── main.go # Entry point
|
|
├── internal/
|
|
│ ├── config/
|
|
│ │ └── config.go # Configuration loading
|
|
│ ├── database/
|
|
│ │ └── database.go # Database wrapper
|
|
│ ├── globals/
|
|
│ │ └── globals.go # Build-time variables
|
|
│ ├── handlers/
|
|
│ │ ├── handlers.go # Base handler struct and helpers
|
|
│ │ ├── index.go # Individual handlers...
|
|
│ │ ├── healthcheck.go
|
|
│ │ └── {feature}.go
|
|
│ ├── healthcheck/
|
|
│ │ └── healthcheck.go # Health check service
|
|
│ ├── logger/
|
|
│ │ └── logger.go # Logger setup
|
|
│ ├── middleware/
|
|
│ │ └── middleware.go # All middleware definitions
|
|
│ └── server/
|
|
│ ├── server.go # Server struct and lifecycle
|
|
│ ├── http.go # HTTP server setup
|
|
│ └── routes.go # Route definitions
|
|
├── static/
|
|
│ ├── static.go # Embed directive
|
|
│ ├── css/
|
|
│ └── js/
|
|
├── templates/
|
|
│ ├── templates.go # Embed and parse
|
|
│ └── *.html
|
|
├── go.mod
|
|
├── go.sum
|
|
├── Makefile
|
|
└── Dockerfile
|
|
```
|
|
|
|
### Key Principles
|
|
|
|
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
|
|
- **`internal/`**: All application packages. Not importable by external projects.
|
|
- **One package per concern**: config, database, handlers, middleware, etc.
|
|
- **Flat handler files**: One file per handler or logical group of handlers.
|
|
|
|
---
|
|
|
|
## 3. Dependency Injection (Uber fx)
|
|
|
|
### Entry Point Pattern
|
|
|
|
```go
|
|
// cmd/httpd/main.go
|
|
package main
|
|
|
|
import (
|
|
"yourproject/internal/config"
|
|
"yourproject/internal/database"
|
|
"yourproject/internal/globals"
|
|
"yourproject/internal/handlers"
|
|
"yourproject/internal/healthcheck"
|
|
"yourproject/internal/logger"
|
|
"yourproject/internal/middleware"
|
|
"yourproject/internal/server"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
var (
|
|
Appname string = "CHANGEME"
|
|
Version string
|
|
)
|
|
|
|
func main() {
|
|
globals.Appname = Appname
|
|
globals.Version = Version
|
|
|
|
fx.New(
|
|
fx.Provide(
|
|
config.New,
|
|
database.New,
|
|
globals.New,
|
|
handlers.New,
|
|
logger.New,
|
|
server.New,
|
|
middleware.New,
|
|
healthcheck.New,
|
|
),
|
|
fx.Invoke(func(*server.Server) {}),
|
|
).Run()
|
|
}
|
|
```
|
|
|
|
### Params Struct Pattern
|
|
|
|
Every component that receives dependencies uses a params struct with `fx.In`:
|
|
|
|
```go
|
|
type HandlersParams struct {
|
|
fx.In
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Database *database.Database
|
|
Healthcheck *healthcheck.Healthcheck
|
|
}
|
|
|
|
type Handlers struct {
|
|
params *HandlersParams
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
}
|
|
```
|
|
|
|
### Factory Function Pattern
|
|
|
|
All components expose a `New` function with this signature:
|
|
|
|
```go
|
|
func New(lc fx.Lifecycle, params SomeParams) (*Something, error) {
|
|
s := new(Something)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
// Initialize resources
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
// Cleanup resources
|
|
return nil
|
|
},
|
|
})
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Dependency Order
|
|
|
|
Providers are resolved automatically by fx, but conceptually follow this order:
|
|
|
|
1. `globals.New` - Build-time variables (no dependencies)
|
|
2. `logger.New` - Logger (depends on Globals)
|
|
3. `config.New` - Configuration (depends on Globals, Logger)
|
|
4. `database.New` - Database (depends on Logger, Config)
|
|
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database)
|
|
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
|
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
|
8. `server.New` - Server (depends on all above)
|
|
|
|
---
|
|
|
|
## 4. Server Architecture
|
|
|
|
### Server Struct
|
|
|
|
The Server struct is the central orchestrator:
|
|
|
|
```go
|
|
// internal/server/server.go
|
|
type ServerParams struct {
|
|
fx.In
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Middleware *middleware.Middleware
|
|
Handlers *handlers.Handlers
|
|
}
|
|
|
|
type Server struct {
|
|
startupTime time.Time
|
|
port int
|
|
exitCode int
|
|
sentryEnabled bool
|
|
log *slog.Logger
|
|
ctx context.Context
|
|
cancelFunc context.CancelFunc
|
|
httpServer *http.Server
|
|
router *chi.Mux
|
|
params ServerParams
|
|
mw *middleware.Middleware
|
|
h *handlers.Handlers
|
|
}
|
|
```
|
|
|
|
### Server Factory
|
|
|
|
```go
|
|
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|
s := new(Server)
|
|
s.params = params
|
|
s.mw = params.Middleware
|
|
s.h = params.Handlers
|
|
s.log = params.Logger.Get()
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
s.startupTime = time.Now()
|
|
go s.Run()
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
// Server shutdown logic
|
|
return nil
|
|
},
|
|
})
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### HTTP Server Setup
|
|
|
|
```go
|
|
// internal/server/http.go
|
|
func (s *Server) serveUntilShutdown() {
|
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
|
s.httpServer = &http.Server{
|
|
Addr: listenAddr,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
MaxHeaderBytes: 1 << 20,
|
|
Handler: s,
|
|
}
|
|
|
|
s.SetupRoutes()
|
|
|
|
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
s.log.Error("listen error", "error", err)
|
|
if s.cancelFunc != nil {
|
|
s.cancelFunc()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.router.ServeHTTP(w, r)
|
|
}
|
|
```
|
|
|
|
### Signal Handling and Graceful Shutdown
|
|
|
|
```go
|
|
func (s *Server) serve() int {
|
|
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
|
|
|
// Signal watcher
|
|
go func() {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Ignore(syscall.SIGPIPE)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
sig := <-c
|
|
s.log.Info("signal received", "signal", sig)
|
|
if s.cancelFunc != nil {
|
|
s.cancelFunc()
|
|
}
|
|
}()
|
|
|
|
go s.serveUntilShutdown()
|
|
|
|
for range s.ctx.Done() {
|
|
}
|
|
s.cleanShutdown()
|
|
return s.exitCode
|
|
}
|
|
|
|
func (s *Server) cleanShutdown() {
|
|
s.exitCode = 0
|
|
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
|
s.log.Error("server clean shutdown failed", "error", err)
|
|
}
|
|
if shutdownCancel != nil {
|
|
shutdownCancel()
|
|
}
|
|
s.cleanupForExit()
|
|
if s.sentryEnabled {
|
|
sentry.Flush(2 * time.Second)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Routing (go-chi)
|
|
|
|
### Route Setup Pattern
|
|
|
|
```go
|
|
// internal/server/routes.go
|
|
func (s *Server) SetupRoutes() {
|
|
s.router = chi.NewRouter()
|
|
|
|
// Global middleware (applied to all routes)
|
|
s.router.Use(middleware.Recoverer)
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(s.mw.Logging())
|
|
|
|
// Conditional middleware
|
|
if viper.GetString("METRICS_USERNAME") != "" {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.CORS())
|
|
s.router.Use(middleware.Timeout(60 * time.Second))
|
|
|
|
if s.sentryEnabled {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
s.router.Use(sentryHandler.Handle)
|
|
}
|
|
|
|
// Routes
|
|
s.router.Get("/", s.h.HandleIndex())
|
|
|
|
// Static files
|
|
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
|
|
|
|
// API versioning
|
|
s.router.Route("/api/v1", func(r chi.Router) {
|
|
r.Get("/now", s.h.HandleNow())
|
|
})
|
|
|
|
// Routes with specific middleware
|
|
auth := s.mw.Auth()
|
|
s.router.Get("/login", auth(s.h.HandleLoginGET()).ServeHTTP)
|
|
|
|
// Health check (standard path)
|
|
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
|
|
|
|
// Protected route groups
|
|
if viper.GetString("METRICS_USERNAME") != "" {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Middleware Ordering (Critical)
|
|
|
|
1. `middleware.Recoverer` - Panic recovery (must be first)
|
|
2. `middleware.RequestID` - Generate request IDs
|
|
3. `s.mw.Logging()` - Request logging
|
|
4. `s.mw.Metrics()` - Prometheus metrics (if enabled)
|
|
5. `s.mw.CORS()` - CORS headers
|
|
6. `middleware.Timeout(60s)` - Request timeout
|
|
7. `sentryhttp.Handler` - Sentry error reporting (if enabled)
|
|
|
|
### API Versioning
|
|
|
|
Use route groups for API versioning:
|
|
|
|
```go
|
|
s.router.Route("/api/v1", func(r chi.Router) {
|
|
r.Get("/resource", s.h.HandleResource())
|
|
})
|
|
```
|
|
|
|
### Static File Serving
|
|
|
|
Static files are served at `/s/` prefix:
|
|
|
|
```go
|
|
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Handler Conventions
|
|
|
|
### Handler Base Struct
|
|
|
|
```go
|
|
// internal/handlers/handlers.go
|
|
type HandlersParams struct {
|
|
fx.In
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Database *database.Database
|
|
Healthcheck *healthcheck.Healthcheck
|
|
}
|
|
|
|
type Handlers struct {
|
|
params *HandlersParams
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
}
|
|
|
|
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|
s := new(Handlers)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
s.hc = params.Healthcheck
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
// Compile templates or other initialization
|
|
return nil
|
|
},
|
|
})
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Closure-Based Handler Pattern
|
|
|
|
For JSON route handlers, both the request and the response structures are
|
|
defined in the scope of the method that returns the HandlerFunc. They can
|
|
be called simply `Request` and `Response` or slightly more descriptive
|
|
names.
|
|
|
|
All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created:
|
|
|
|
```go
|
|
// internal/handlers/index.go
|
|
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
|
// Initialization runs once
|
|
t := templates.GetParsed()
|
|
|
|
// Handler runs per-request
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := t.ExecuteTemplate(w, "index.html", nil)
|
|
if err != nil {
|
|
s.log.Error("template execution failed", "error", err)
|
|
http.Error(w, http.StatusText(500), 500)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### JSON Handler Pattern
|
|
|
|
```go
|
|
// internal/handlers/now.go
|
|
func (s *Handlers) HandleNow() http.HandlerFunc {
|
|
|
|
type request struct {
|
|
// request format
|
|
}
|
|
|
|
// Response struct defined in closure scope
|
|
type response struct {
|
|
Now time.Time `json:"now"`
|
|
}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
s.respondJSON(w, r, &response{Now: time.Now()}, 200)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Response Helpers
|
|
|
|
```go
|
|
// internal/handlers/handlers.go
|
|
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
|
w.WriteHeader(status)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if data != nil {
|
|
err := json.NewEncoder(w).Encode(data)
|
|
if err != nil {
|
|
s.log.Error("json encode error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
|
return json.NewDecoder(r.Body).Decode(v)
|
|
}
|
|
```
|
|
|
|
### Handler Naming Convention
|
|
|
|
- `HandleIndex()` - Main page
|
|
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix
|
|
- `HandleNow()` - API endpoints
|
|
- `HandleHealthCheck()` - System endpoints
|
|
|
|
---
|
|
|
|
## 7. Middleware Conventions
|
|
|
|
### Middleware Struct
|
|
|
|
```go
|
|
// internal/middleware/middleware.go
|
|
type MiddlewareParams struct {
|
|
fx.In
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
}
|
|
|
|
type Middleware struct {
|
|
log *slog.Logger
|
|
params *MiddlewareParams
|
|
}
|
|
|
|
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
|
s := new(Middleware)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Middleware Signature
|
|
|
|
All custom middleware methods return `func(http.Handler) http.Handler`:
|
|
|
|
```go
|
|
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Before request
|
|
s.log.Info("AUTH: before request")
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
// After request (optional)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Logging Middleware with Status Capture
|
|
|
|
```go
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
statusCode int
|
|
}
|
|
|
|
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
|
return &loggingResponseWriter{w, http.StatusOK}
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.statusCode = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
lrw := NewLoggingResponseWriter(w)
|
|
ctx := r.Context()
|
|
defer func() {
|
|
latency := time.Since(start)
|
|
s.log.InfoContext(ctx, "request",
|
|
"request_start", start,
|
|
"method", r.Method,
|
|
"url", r.URL.String(),
|
|
"useragent", r.UserAgent(),
|
|
"request_id", ctx.Value(middleware.RequestIDKey).(string),
|
|
"referer", r.Referer(),
|
|
"proto", r.Proto,
|
|
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
|
"status", lrw.statusCode,
|
|
"latency_ms", latency.Milliseconds(),
|
|
)
|
|
}()
|
|
next.ServeHTTP(lrw, r)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### CORS Middleware
|
|
|
|
```go
|
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|
return cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
ExposedHeaders: []string{"Link"},
|
|
AllowCredentials: false,
|
|
MaxAge: 300,
|
|
})
|
|
}
|
|
```
|
|
|
|
### Metrics Middleware
|
|
|
|
```go
|
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
|
mdlw := ghmm.New(ghmm.Config{
|
|
Recorder: metrics.NewRecorder(metrics.Config{}),
|
|
})
|
|
return func(next http.Handler) http.Handler {
|
|
return std.Handler("", mdlw, next)
|
|
}
|
|
}
|
|
|
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|
return basicauth.New(
|
|
"metrics",
|
|
map[string][]string{
|
|
viper.GetString("METRICS_USERNAME"): {
|
|
viper.GetString("METRICS_PASSWORD"),
|
|
},
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Configuration (Viper)
|
|
|
|
### Config Struct
|
|
|
|
```go
|
|
// internal/config/config.go
|
|
type ConfigParams struct {
|
|
fx.In
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
type Config struct {
|
|
DBURL string
|
|
Debug bool
|
|
MaintenanceMode bool
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
SentryDSN string
|
|
params *ConfigParams
|
|
log *slog.Logger
|
|
}
|
|
```
|
|
|
|
### Configuration Loading
|
|
|
|
```go
|
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
name := params.Globals.Appname
|
|
|
|
// Config file settings
|
|
viper.SetConfigName(name)
|
|
viper.SetConfigType("yaml")
|
|
viper.AddConfigPath(fmt.Sprintf("/etc/%s", name))
|
|
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name))
|
|
|
|
// Environment variables override everything
|
|
viper.AutomaticEnv()
|
|
|
|
// Defaults
|
|
viper.SetDefault("DEBUG", "false")
|
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
|
viper.SetDefault("PORT", "8080")
|
|
viper.SetDefault("DBURL", "")
|
|
viper.SetDefault("SENTRY_DSN", "")
|
|
viper.SetDefault("METRICS_USERNAME", "")
|
|
viper.SetDefault("METRICS_PASSWORD", "")
|
|
|
|
// Read config file (optional)
|
|
if err := viper.ReadInConfig(); err != nil {
|
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
|
// Config file not found is OK
|
|
} else {
|
|
log.Error("config file malformed", "error", err)
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Build config struct
|
|
s := &Config{
|
|
DBURL: viper.GetString("DBURL"),
|
|
Debug: viper.GetBool("debug"),
|
|
Port: viper.GetInt("PORT"),
|
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
log: log,
|
|
params: ¶ms,
|
|
}
|
|
|
|
// Enable debug logging if configured
|
|
if s.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
s.log = params.Logger.Get()
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Configuration Precedence
|
|
|
|
1. **Environment variables** (highest priority via `AutomaticEnv()`)
|
|
2. **`.env` file** (loaded via `godotenv/autoload` import)
|
|
3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml`
|
|
4. **Defaults** (lowest priority)
|
|
|
|
### Environment Loading
|
|
|
|
Import godotenv with autoload to automatically load `.env` files:
|
|
|
|
```go
|
|
import (
|
|
_ "github.com/joho/godotenv/autoload"
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Logging (slog)
|
|
|
|
### Logger Struct
|
|
|
|
```go
|
|
// internal/logger/logger.go
|
|
type LoggerParams struct {
|
|
fx.In
|
|
Globals *globals.Globals
|
|
}
|
|
|
|
type Logger struct {
|
|
log *slog.Logger
|
|
level *slog.LevelVar
|
|
params LoggerParams
|
|
}
|
|
```
|
|
|
|
### Logger Setup with TTY Detection
|
|
|
|
```go
|
|
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
|
l := new(Logger)
|
|
l.level = new(slog.LevelVar)
|
|
l.level.Set(slog.LevelInfo)
|
|
|
|
// TTY detection for dev vs prod output
|
|
tty := false
|
|
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
|
tty = true
|
|
}
|
|
|
|
var handler slog.Handler
|
|
if tty {
|
|
// Text output for development
|
|
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: l.level,
|
|
AddSource: true,
|
|
})
|
|
} else {
|
|
// JSON output for production
|
|
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
|
Level: l.level,
|
|
AddSource: true,
|
|
})
|
|
}
|
|
|
|
l.log = slog.New(handler)
|
|
return l, nil
|
|
}
|
|
```
|
|
|
|
### Logger Methods
|
|
|
|
```go
|
|
func (l *Logger) EnableDebugLogging() {
|
|
l.level.Set(slog.LevelDebug)
|
|
l.log.Debug("debug logging enabled", "debug", true)
|
|
}
|
|
|
|
func (l *Logger) Get() *slog.Logger {
|
|
return l.log
|
|
}
|
|
|
|
func (l *Logger) Identify() {
|
|
l.log.Info("starting",
|
|
"appname", l.params.Globals.Appname,
|
|
"version", l.params.Globals.Version,
|
|
)
|
|
}
|
|
```
|
|
|
|
### Logging Patterns
|
|
|
|
```go
|
|
// Info with fields
|
|
s.log.Info("message", "key", "value")
|
|
|
|
// Error with error object
|
|
s.log.Error("operation failed", "error", err)
|
|
|
|
// With context
|
|
s.log.InfoContext(ctx, "processing request", "request_id", reqID)
|
|
|
|
// Structured request logging
|
|
s.log.Info("request completed",
|
|
"request_start", start,
|
|
"method", r.Method,
|
|
"url", r.URL.String(),
|
|
"status", statusCode,
|
|
"latency_ms", latency.Milliseconds(),
|
|
)
|
|
|
|
// Using slog.Group for nested attributes
|
|
s.log.Info("request",
|
|
slog.Group("http",
|
|
"method", r.Method,
|
|
"url", r.URL.String(),
|
|
),
|
|
slog.Group("timing",
|
|
"start", start,
|
|
"latency_ms", latency.Milliseconds(),
|
|
),
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Database Wrapper
|
|
|
|
### Database Struct
|
|
|
|
```go
|
|
// internal/database/database.go
|
|
type DatabaseParams struct {
|
|
fx.In
|
|
Logger *logger.Logger
|
|
Config *config.Config
|
|
}
|
|
|
|
type Database struct {
|
|
URL string
|
|
log *slog.Logger
|
|
params *DatabaseParams
|
|
}
|
|
```
|
|
|
|
### Database Factory with Lifecycle
|
|
|
|
```go
|
|
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
|
s := new(Database)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
|
|
s.log.Info("Database instantiated")
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
s.log.Info("Database OnStart Hook")
|
|
// Connect to database here
|
|
// Example: s.db, err = sql.Open("postgres", s.params.Config.DBURL)
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
// Disconnect from database here
|
|
// Example: s.db.Close()
|
|
return nil
|
|
},
|
|
})
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Usage Pattern
|
|
|
|
The Database struct is injected into handlers and other services:
|
|
|
|
```go
|
|
type HandlersParams struct {
|
|
fx.In
|
|
Database *database.Database
|
|
// ...
|
|
}
|
|
|
|
// Access in handler
|
|
func (s *Handlers) HandleSomething() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Use s.params.Database
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Globals Package
|
|
|
|
### Package Variables and Struct
|
|
|
|
```go
|
|
// internal/globals/globals.go
|
|
package globals
|
|
|
|
import "go.uber.org/fx"
|
|
|
|
// Package-level variables (set from main)
|
|
var (
|
|
Appname string
|
|
Version string
|
|
)
|
|
|
|
// Struct for DI
|
|
type Globals struct {
|
|
Appname string
|
|
Version string
|
|
}
|
|
|
|
func New(lc fx.Lifecycle) (*Globals, error) {
|
|
n := &Globals{
|
|
Appname: Appname,
|
|
Version: Version,
|
|
}
|
|
return n, nil
|
|
}
|
|
```
|
|
|
|
### Setting Globals in Main
|
|
|
|
```go
|
|
// cmd/httpd/main.go
|
|
var (
|
|
Appname string = "CHANGEME" // Default, overridden by build
|
|
Version string // Set at build time
|
|
)
|
|
|
|
func main() {
|
|
globals.Appname = Appname
|
|
globals.Version = Version
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Build-Time Variable Injection
|
|
|
|
Use ldflags to inject version information at build time:
|
|
|
|
```makefile
|
|
VERSION := $(shell git describe --tags --always)
|
|
|
|
build:
|
|
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Static Assets & Templates
|
|
|
|
### Static File Embedding
|
|
|
|
```go
|
|
// static/static.go
|
|
package static
|
|
|
|
import "embed"
|
|
|
|
//go:embed css js
|
|
var Static embed.FS
|
|
```
|
|
|
|
Directory structure:
|
|
|
|
```
|
|
static/
|
|
├── static.go
|
|
├── css/
|
|
│ ├── bootstrap-4.5.3.min.css
|
|
│ └── style.css
|
|
└── js/
|
|
├── bootstrap-4.5.3.bundle.min.js
|
|
└── jquery-3.5.1.slim.min.js
|
|
```
|
|
|
|
### Template Embedding and Lazy Parsing
|
|
|
|
```go
|
|
// templates/templates.go
|
|
package templates
|
|
|
|
import (
|
|
"embed"
|
|
"text/template"
|
|
)
|
|
|
|
//go:embed *.html
|
|
var TemplatesRaw embed.FS
|
|
var TemplatesParsed *template.Template
|
|
|
|
func GetParsed() *template.Template {
|
|
if TemplatesParsed == nil {
|
|
TemplatesParsed = template.Must(template.ParseFS(TemplatesRaw, "*"))
|
|
}
|
|
return TemplatesParsed
|
|
}
|
|
```
|
|
|
|
### Template Composition
|
|
|
|
Templates use Go's template composition:
|
|
|
|
```html
|
|
<!-- index.html -->
|
|
{{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
|
|
|
|
<main>
|
|
<!-- Page content -->
|
|
</main>
|
|
|
|
{{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
|
|
```
|
|
|
|
### Static Asset References
|
|
|
|
Reference static files with `/s/` prefix:
|
|
|
|
```html
|
|
<link rel="stylesheet" href="/s/css/bootstrap-4.5.3.min.css" />
|
|
<link rel="stylesheet" href="/s/css/style.css" />
|
|
<script src="/s/js/jquery-3.5.1.slim.min.js"></script>
|
|
```
|
|
|
|
---
|
|
|
|
## 13. Health Check
|
|
|
|
### Health Check Service
|
|
|
|
```go
|
|
// internal/healthcheck/healthcheck.go
|
|
type HealthcheckParams struct {
|
|
fx.In
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Logger *logger.Logger
|
|
Database *database.Database
|
|
}
|
|
|
|
type Healthcheck struct {
|
|
StartupTime time.Time
|
|
log *slog.Logger
|
|
params *HealthcheckParams
|
|
}
|
|
|
|
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
|
|
s := new(Healthcheck)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
s.StartupTime = time.Now()
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
return nil
|
|
},
|
|
})
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
### Health Check Response
|
|
|
|
```go
|
|
type HealthcheckResponse struct {
|
|
Status string `json:"status"`
|
|
Now string `json:"now"`
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
UptimeHuman string `json:"uptime_human"`
|
|
Version string `json:"version"`
|
|
Appname string `json:"appname"`
|
|
Maintenance bool `json:"maintenance_mode"`
|
|
}
|
|
|
|
func (s *Healthcheck) uptime() time.Duration {
|
|
return time.Since(s.StartupTime)
|
|
}
|
|
|
|
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
|
resp := &HealthcheckResponse{
|
|
Status: "ok",
|
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
|
UptimeSeconds: int64(s.uptime().Seconds()),
|
|
UptimeHuman: s.uptime().String(),
|
|
Appname: s.params.Globals.Appname,
|
|
Version: s.params.Globals.Version,
|
|
}
|
|
return resp
|
|
}
|
|
```
|
|
|
|
### Standard Endpoint
|
|
|
|
Health check is served at the standard `.well-known` path:
|
|
|
|
```go
|
|
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
|
|
```
|
|
|
|
---
|
|
|
|
## 14. External Integrations
|
|
|
|
### Sentry Error Reporting
|
|
|
|
Sentry is conditionally enabled based on `SENTRY_DSN` environment variable:
|
|
|
|
```go
|
|
func (s *Server) enableSentry() {
|
|
s.sentryEnabled = false
|
|
|
|
if s.params.Config.SentryDSN == "" {
|
|
return
|
|
}
|
|
|
|
err := sentry.Init(sentry.ClientOptions{
|
|
Dsn: s.params.Config.SentryDSN,
|
|
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
|
})
|
|
if err != nil {
|
|
s.log.Error("sentry init failure", "error", err)
|
|
os.Exit(1)
|
|
return
|
|
}
|
|
s.log.Info("sentry error reporting activated")
|
|
s.sentryEnabled = true
|
|
}
|
|
```
|
|
|
|
Sentry middleware with repanic (bubbles panics to chi's Recoverer):
|
|
|
|
```go
|
|
if s.sentryEnabled {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
s.router.Use(sentryHandler.Handle)
|
|
}
|
|
```
|
|
|
|
Flush Sentry on shutdown:
|
|
|
|
```go
|
|
if s.sentryEnabled {
|
|
sentry.Flush(2 * time.Second)
|
|
}
|
|
```
|
|
|
|
### Prometheus Metrics
|
|
|
|
Metrics are conditionally enabled and protected by basic auth:
|
|
|
|
```go
|
|
// Only enable if credentials are configured
|
|
if viper.GetString("METRICS_USERNAME") != "" {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
// Protected /metrics endpoint
|
|
if viper.GetString("METRICS_USERNAME") != "" {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
|
})
|
|
}
|
|
```
|
|
|
|
### Environment Variables Summary
|
|
|
|
| Variable | Description | Default |
|
|
| ------------------ | -------------------------------- | ------- |
|
|
| `PORT` | HTTP listen port | 8080 |
|
|
| `DEBUG` | Enable debug logging | false |
|
|
| `DBURL` | Database connection URL | "" |
|
|
| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
|
|
| `MAINTENANCE_MODE` | Enable maintenance mode | false |
|
|
| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
|
|
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
|