feat: implement project foundation & infrastructure (epic #5)
- #35: Chi router with structured logging, panic recovery, request ID, graceful shutdown - #36: golang-migrate framework with initial extensions migration - #37: Expanded config package with server, database, CORS, rate limit settings - #38: GitHub Actions CI pipeline for Go and React - #39: Multi-stage Dockerfile for Go API (alpine, non-root, <30MB) - #40: Multi-stage Dockerfile for React frontend with Nginx SPA routing - #41: Production Docker Compose with all services and health checks - #44: Health check endpoints (/healthz liveness, /readyz readiness) - #45: CORS, security headers, and rate limiting middleware Closes #35, closes #36, closes #37, closes #38, closes #39, closes #40, closes #41, closes #44, closes #45
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
web
|
||||
node_modules
|
||||
bin
|
||||
*.md
|
||||
*.test
|
||||
*.out
|
||||
coverage.*
|
||||
docker-compose*.yml
|
||||
20
.env.example
20
.env.example
@@ -5,10 +5,26 @@
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
ENVIRONMENT=development
|
||||
HOST=0.0.0.0
|
||||
ENVIRONMENT=development # development | production
|
||||
|
||||
# PostgreSQL
|
||||
# Timeouts
|
||||
READ_TIMEOUT=5s
|
||||
WRITE_TIMEOUT=10s
|
||||
IDLE_TIMEOUT=120s
|
||||
|
||||
# PostgreSQL (used by Docker Compose)
|
||||
POSTGRES_USER=pulsescore
|
||||
POSTGRES_PASSWORD=pulsescore
|
||||
POSTGRES_DB=pulsescore_dev
|
||||
|
||||
# Database connection (used by the Go API)
|
||||
DATABASE_URL=postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable
|
||||
DB_MAX_OPEN_CONNS=25
|
||||
DB_MAX_IDLE_CONNS=5
|
||||
|
||||
# CORS — comma-separated list of allowed origins
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||
|
||||
# Rate limiting — requests per minute per IP
|
||||
RATE_LIMIT_RPM=100
|
||||
|
||||
70
.github/workflows/ci.yml
vendored
Normal file
70
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go (lint, test, build)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: pulsescore
|
||||
POSTGRES_PASSWORD: pulsescore
|
||||
POSTGRES_DB: pulsescore_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
|
||||
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
|
||||
|
||||
- name: Build
|
||||
run: go build -o bin/pulsescore-api ./cmd/api
|
||||
|
||||
react:
|
||||
name: React (lint, build)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Build
|
||||
# =============================================================================
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o pulsescore-api ./cmd/api
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime
|
||||
# =============================================================================
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -g 1001 -S appgroup \
|
||||
&& adduser -u 1001 -S appuser -G appgroup
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/pulsescore-api .
|
||||
COPY --from=builder /build/migrations ./migrations
|
||||
|
||||
USER appuser:appgroup
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
|
||||
|
||||
ENTRYPOINT ["./pulsescore-api"]
|
||||
11
Makefile
11
Makefile
@@ -1,7 +1,8 @@
|
||||
.PHONY: build run test lint clean migrate-up migrate-down
|
||||
.PHONY: build run test lint clean migrate-up migrate-down migrate-create dev-db dev-db-down
|
||||
|
||||
BINARY_NAME=pulsescore-api
|
||||
BUILD_DIR=bin
|
||||
DATABASE_URL ?= postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable
|
||||
|
||||
build:
|
||||
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/api
|
||||
@@ -19,12 +20,14 @@ clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
migrate-up:
|
||||
@echo "Run migrations up (requires golang-migrate)"
|
||||
migrate -path migrations -database "$(DATABASE_URL)" up
|
||||
|
||||
migrate-down:
|
||||
@echo "Run migrations down (requires golang-migrate)"
|
||||
migrate -path migrations -database "$(DATABASE_URL)" down
|
||||
migrate -path migrations -database "$(DATABASE_URL)" down 1
|
||||
|
||||
migrate-create:
|
||||
@if [ -z "$(NAME)" ]; then echo "Usage: make migrate-create NAME=my_migration"; exit 1; fi
|
||||
migrate create -ext sql -dir migrations -seq $(NAME)
|
||||
|
||||
dev-db:
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
34
README.md
34
README.md
@@ -76,23 +76,23 @@ The frontend starts on http://localhost:5173.
|
||||
|
||||
### Backend (Makefile)
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | ------------------------------ |
|
||||
| `make build` | Build the Go binary |
|
||||
| `make run` | Build and run the API |
|
||||
| `make test` | Run tests with race detection |
|
||||
| `make lint` | Run `go vet` |
|
||||
| `make dev-db` | Start development PostgreSQL |
|
||||
| `make dev-db-down` | Stop development PostgreSQL |
|
||||
| `make migrate-up` | Run database migrations up |
|
||||
| `make migrate-down`| Roll back database migrations |
|
||||
| Command | Description |
|
||||
| ------------------- | ----------------------------- |
|
||||
| `make build` | Build the Go binary |
|
||||
| `make run` | Build and run the API |
|
||||
| `make test` | Run tests with race detection |
|
||||
| `make lint` | Run `go vet` |
|
||||
| `make dev-db` | Start development PostgreSQL |
|
||||
| `make dev-db-down` | Stop development PostgreSQL |
|
||||
| `make migrate-up` | Run database migrations up |
|
||||
| `make migrate-down` | Roll back database migrations |
|
||||
|
||||
### Frontend (web/)
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ----------------------------- |
|
||||
| `npm run dev` | Start Vite dev server |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run lint` | ESLint check |
|
||||
| `npm run format` | Format with Prettier |
|
||||
| `npm run preview` | Preview production build |
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------ |
|
||||
| `npm run dev` | Start Vite dev server |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run lint` | ESLint check |
|
||||
| `npm run format` | Format with Prettier |
|
||||
| `npm run preview` | Preview production build |
|
||||
|
||||
132
cmd/api/main.go
132
cmd/api/main.go
@@ -1,34 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/config"
|
||||
"github.com/onnwee/pulse-score/internal/handler"
|
||||
"github.com/onnwee/pulse-score/internal/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Structured logger
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Configuration
|
||||
cfg := config.Load()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"status":"ok"}`)
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
log.Printf("Starting PulseScore API on %s", addr)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
slog.Error("invalid configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Database connection (optional — server starts without DB for healthz)
|
||||
var db *sql.DB
|
||||
if cfg.Database.URL != "" {
|
||||
var err error
|
||||
db, err = sql.Open("postgres", cfg.Database.URL)
|
||||
if err != nil {
|
||||
slog.Error("failed to open database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
slog.Warn("database not reachable at startup", "error", err)
|
||||
} else {
|
||||
slog.Info("database connected")
|
||||
}
|
||||
}
|
||||
|
||||
// Router
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(middleware.SecurityHeaders)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(httprate.LimitByIP(cfg.Rate.RequestsPerMinute, time.Minute))
|
||||
|
||||
// Health checks (no auth required)
|
||||
health := handler.NewHealthHandler(db)
|
||||
r.Get("/healthz", health.Liveness)
|
||||
r.Get("/readyz", health.Readiness)
|
||||
|
||||
// API v1 route group
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"message":"pong"}`))
|
||||
})
|
||||
})
|
||||
|
||||
// Server
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
done := make(chan os.Signal, 1)
|
||||
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
slog.Info("starting PulseScore API", "addr", addr, "env", cfg.Server.Environment)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-done
|
||||
slog.Info("shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
slog.Error("server forced to shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
||||
59
docker-compose.prod.yml
Normal file
59
docker-compose.prod.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pulsescore-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./scripts/init-db:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- pulsescore-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
ENVIRONMENT: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulsescore-net
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- pulsescore-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
pulsescore-net:
|
||||
driver: bridge
|
||||
14
go.mod
14
go.mod
@@ -1,3 +1,17 @@
|
||||
module github.com/onnwee/pulse-score
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
Normal file
18
go.sum
Normal file
@@ -0,0 +1,18 @@
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
@@ -1,26 +1,162 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds application configuration.
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
Environment string
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
CORS CORSConfig
|
||||
Rate RateConfig
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Host string
|
||||
Environment string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database connection settings.
|
||||
type DatabaseConfig struct {
|
||||
URL string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
}
|
||||
|
||||
// CORSConfig holds CORS middleware settings.
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// RateConfig holds rate limiting settings.
|
||||
type RateConfig struct {
|
||||
RequestsPerMinute int
|
||||
}
|
||||
|
||||
// IsProd returns true if the environment is production.
|
||||
func (c *Config) IsProd() bool {
|
||||
return c.Server.Environment == "production"
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Host: getEnv("HOST", "0.0.0.0"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
ReadTimeout: getDuration("READ_TIMEOUT", 5*time.Second),
|
||||
WriteTimeout: getDuration("WRITE_TIMEOUT", 10*time.Second),
|
||||
IdleTimeout: getDuration("IDLE_TIMEOUT", 120*time.Second),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
URL: getEnv("DATABASE_URL", "postgres://pulsescore:pulsescore@localhost:5432/pulsescore_dev?sslmode=disable"),
|
||||
MaxOpenConns: getInt("DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getInt("DB_MAX_IDLE_CONNS", 5),
|
||||
},
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: getEnvSlice("CORS_ALLOWED_ORIGINS", []string{"http://localhost:5173"}),
|
||||
},
|
||||
Rate: RateConfig{
|
||||
RequestsPerMinute: getInt("RATE_LIMIT_RPM", 100),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks required configuration for production.
|
||||
func (c *Config) Validate() error {
|
||||
if c.IsProd() {
|
||||
if c.Database.URL == "" {
|
||||
return fmt.Errorf("DATABASE_URL is required in production")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func getDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func getEnvSlice(key string, fallback []string) []string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
var result []string
|
||||
for _, s := range splitAndTrim(v) {
|
||||
if s != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
var parts []string
|
||||
for _, p := range []byte(s) {
|
||||
if p == ',' {
|
||||
parts = append(parts, "")
|
||||
} else {
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
parts[len(parts)-1] += string(p)
|
||||
}
|
||||
}
|
||||
// Trim spaces
|
||||
for i, p := range parts {
|
||||
trimmed := ""
|
||||
start := 0
|
||||
end := len(p) - 1
|
||||
for start <= end && p[start] == ' ' {
|
||||
start++
|
||||
}
|
||||
for end >= start && p[end] == ' ' {
|
||||
end--
|
||||
}
|
||||
if start <= end {
|
||||
trimmed = p[start : end+1]
|
||||
}
|
||||
parts[i] = trimmed
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -3,38 +3,114 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func clearEnv() {
|
||||
for _, key := range []string{
|
||||
"PORT", "HOST", "ENVIRONMENT", "DATABASE_URL",
|
||||
"DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS",
|
||||
"READ_TIMEOUT", "WRITE_TIMEOUT", "IDLE_TIMEOUT",
|
||||
"CORS_ALLOWED_ORIGINS", "RATE_LIMIT_RPM",
|
||||
} {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
// Unset any env vars that might be set
|
||||
os.Unsetenv("PORT")
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
os.Unsetenv("ENVIRONMENT")
|
||||
clearEnv()
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.Port != "8080" {
|
||||
t.Errorf("expected default port 8080, got %s", cfg.Port)
|
||||
if cfg.Server.Port != "8080" {
|
||||
t.Errorf("expected default port 8080, got %s", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Environment != "development" {
|
||||
t.Errorf("expected default environment development, got %s", cfg.Environment)
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("expected default host 0.0.0.0, got %s", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Environment != "development" {
|
||||
t.Errorf("expected default environment development, got %s", cfg.Server.Environment)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 5*time.Second {
|
||||
t.Errorf("expected read timeout 5s, got %v", cfg.Server.ReadTimeout)
|
||||
}
|
||||
if cfg.Server.WriteTimeout != 10*time.Second {
|
||||
t.Errorf("expected write timeout 10s, got %v", cfg.Server.WriteTimeout)
|
||||
}
|
||||
if cfg.Database.MaxOpenConns != 25 {
|
||||
t.Errorf("expected max open conns 25, got %d", cfg.Database.MaxOpenConns)
|
||||
}
|
||||
if cfg.Database.MaxIdleConns != 5 {
|
||||
t.Errorf("expected max idle conns 5, got %d", cfg.Database.MaxIdleConns)
|
||||
}
|
||||
if cfg.Rate.RequestsPerMinute != 100 {
|
||||
t.Errorf("expected rate limit 100, got %d", cfg.Rate.RequestsPerMinute)
|
||||
}
|
||||
if len(cfg.CORS.AllowedOrigins) != 1 || cfg.CORS.AllowedOrigins[0] != "http://localhost:5173" {
|
||||
t.Errorf("expected default CORS origin, got %v", cfg.CORS.AllowedOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("PORT", "3000")
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
defer func() {
|
||||
os.Unsetenv("PORT")
|
||||
os.Unsetenv("ENVIRONMENT")
|
||||
}()
|
||||
os.Setenv("CORS_ALLOWED_ORIGINS", "https://example.com, https://app.example.com")
|
||||
os.Setenv("RATE_LIMIT_RPM", "200")
|
||||
defer clearEnv()
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.Port != "3000" {
|
||||
t.Errorf("expected port 3000, got %s", cfg.Port)
|
||||
if cfg.Server.Port != "3000" {
|
||||
t.Errorf("expected port 3000, got %s", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Environment != "production" {
|
||||
t.Errorf("expected environment production, got %s", cfg.Environment)
|
||||
if cfg.Server.Environment != "production" {
|
||||
t.Errorf("expected environment production, got %s", cfg.Server.Environment)
|
||||
}
|
||||
if len(cfg.CORS.AllowedOrigins) != 2 {
|
||||
t.Errorf("expected 2 CORS origins, got %d", len(cfg.CORS.AllowedOrigins))
|
||||
}
|
||||
if cfg.Rate.RequestsPerMinute != 200 {
|
||||
t.Errorf("expected rate limit 200, got %d", cfg.Rate.RequestsPerMinute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProduction(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
os.Setenv("DATABASE_URL", "")
|
||||
defer clearEnv()
|
||||
|
||||
cfg := Load()
|
||||
// In production with empty DATABASE_URL env var, it will use fallback
|
||||
// Explicitly clear it to test validation
|
||||
cfg.Database.URL = ""
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected validation error for missing DATABASE_URL in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevelopment(t *testing.T) {
|
||||
clearEnv()
|
||||
cfg := Load()
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("expected no validation error in development, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProd(t *testing.T) {
|
||||
clearEnv()
|
||||
cfg := Load()
|
||||
if cfg.IsProd() {
|
||||
t.Error("expected IsProd false in development")
|
||||
}
|
||||
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
defer clearEnv()
|
||||
cfg = Load()
|
||||
if !cfg.IsProd() {
|
||||
t.Error("expected IsProd true in production")
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/handler/health.go
Normal file
53
internal/handler/health.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HealthHandler provides health check endpoints.
|
||||
type HealthHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new HealthHandler.
|
||||
func NewHealthHandler(db *sql.DB) *HealthHandler {
|
||||
return &HealthHandler{db: db}
|
||||
}
|
||||
|
||||
// Liveness always returns 200 — the server is alive.
|
||||
func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Readiness returns 200 if the database is reachable, 503 otherwise.
|
||||
func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if h.db == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "degraded",
|
||||
"db": "not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.PingContext(r.Context()); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "degraded",
|
||||
"db": "unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"db": "connected",
|
||||
})
|
||||
}
|
||||
44
internal/handler/health_test.go
Normal file
44
internal/handler/health_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLiveness(t *testing.T) {
|
||||
h := NewHealthHandler(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.Liveness(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("expected status ok, got %s", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessNoDatabase(t *testing.T) {
|
||||
h := NewHealthHandler(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.Readiness(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected status 503, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["status"] != "degraded" {
|
||||
t.Errorf("expected status degraded, got %s", body["status"])
|
||||
}
|
||||
}
|
||||
15
internal/middleware/security.go
Normal file
15
internal/middleware/security.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SecurityHeaders adds common security headers to all responses.
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "0")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
32
internal/middleware/security_test.go
Normal file
32
internal/middleware/security_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
expected := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "0",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
}
|
||||
|
||||
for header, want := range expected {
|
||||
got := rr.Header().Get(header)
|
||||
if got != want {
|
||||
t.Errorf("header %s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
2
migrations/000001_enable_extensions.down.sql
Normal file
2
migrations/000001_enable_extensions.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP EXTENSION IF EXISTS "citext";
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||
2
migrations/000001_enable_extensions.up.sql
Normal file
2
migrations/000001_enable_extensions.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "citext";
|
||||
27
web/Dockerfile
Normal file
27
web/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Build
|
||||
# =============================================================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Serve with Nginx
|
||||
# =============================================================================
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,15 +1,15 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -22,4 +22,4 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
])
|
||||
]);
|
||||
|
||||
31
web/nginx.conf
Normal file
31
web/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "0" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache hashed assets aggressively (1 year)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user