📝 docs: update comprehensive documentation and project infrastructure

Documentation Updates:
- Enhanced AGENTS.md with user authentication details
- Updated README.md with authentication API documentation
- Added CONTRIBUTING.md guidelines for BDD testing
- Version management guide improvements
- Local CI/CD testing documentation

Project Infrastructure:
- Updated .gitignore for new file patterns
- Enhanced git hooks documentation
- YAML linting configuration
- Script improvements and organization
- Configuration management updates

API Enhancements:
- Greet service integration with authentication
- Server middleware for JWT validation
- Telemetry improvements
- Version management utilities

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-04-09 00:26:15 +02:00
parent 30af706590
commit c1e628f339
39 changed files with 1230 additions and 1187 deletions

View File

@@ -13,6 +13,11 @@ import (
"dance-lessons-coach/pkg/version"
)
// NewZerologWriter creates a zerolog writer based on configuration
func NewZerologWriter() *os.File {
return os.Stderr
}
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
@@ -20,6 +25,8 @@ type Config struct {
Logging LoggingConfig `mapstructure:"logging"`
Telemetry TelemetryConfig `mapstructure:"telemetry"`
API APIConfig `mapstructure:"api"`
Auth AuthConfig `mapstructure:"auth"`
Database DatabaseConfig `mapstructure:"database"`
}
// ServerConfig holds server-related configuration
@@ -42,11 +49,17 @@ type LoggingConfig struct {
// TelemetryConfig holds OpenTelemetry-related configuration
type TelemetryConfig struct {
Enabled bool `mapstructure:"enabled"`
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
ServiceName string `mapstructure:"service_name"`
Insecure bool `mapstructure:"insecure"`
Sampler SamplerConfig `mapstructure:"sampler"`
Enabled bool `mapstructure:"enabled"`
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
ServiceName string `mapstructure:"service_name"`
Insecure bool `mapstructure:"insecure"`
Sampler SamplerConfig `mapstructure:"sampler"`
Persistence PersistenceTelemetryConfig `mapstructure:"persistence"`
}
// PersistenceTelemetryConfig holds persistence layer telemetry configuration
type PersistenceTelemetryConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// APIConfig holds API version configuration
@@ -54,6 +67,25 @@ type APIConfig struct {
V2Enabled bool `mapstructure:"v2_enabled"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
AdminMasterPassword string `mapstructure:"admin_master_password"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Name string `mapstructure:"name"`
SSLMode string `mapstructure:"ssl_mode"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
}
// VersionInfo holds application version information
type VersionInfo struct {
Version string `mapstructure:"-"` // Set via ldflags
@@ -65,7 +97,7 @@ type VersionInfo struct {
// VersionCommand handles version display
func (c *Config) VersionCommand() string {
// This will be enhanced when we integrate with cobra
return fmt.Sprintf("DanceLessonsCoach %s (commit: %s, built: %s, go: %s)",
return fmt.Sprintf("dance-lessons-coach %s (commit: %s, built: %s, go: %s)",
version.Version, version.Commit, version.Date, version.GoVersion)
}
@@ -96,14 +128,19 @@ func LoadConfig() (*Config, error) {
// Telemetry defaults
v.SetDefault("telemetry.enabled", false)
v.SetDefault("telemetry.otlp_endpoint", "localhost:4317")
v.SetDefault("telemetry.service_name", "DanceLessonsCoach")
v.SetDefault("telemetry.service_name", "dance-lessons-coach")
v.SetDefault("telemetry.insecure", true)
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
v.SetDefault("telemetry.sampler.ratio", 1.0)
v.SetDefault("telemetry.persistence.enabled", false)
// API defaults
v.SetDefault("api.v2_enabled", false)
// Auth defaults
v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production")
v.SetDefault("auth.admin_master_password", "admin123")
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
v.SetConfigFile(configFile)
@@ -128,7 +165,7 @@ func LoadConfig() (*Config, error) {
// Bind environment variables
v.AutomaticEnv()
v.SetEnvPrefix("DLC") // DanceLessonsCoach prefix
v.SetEnvPrefix("DLC") // dance-lessons-coach prefix
v.BindEnv("server.host", "DLC_SERVER_HOST")
v.BindEnv("server.port", "DLC_SERVER_PORT")
v.BindEnv("shutdown.timeout", "DLC_SHUTDOWN_TIMEOUT")
@@ -141,12 +178,24 @@ func LoadConfig() (*Config, error) {
v.BindEnv("telemetry.otlp_endpoint", "DLC_TELEMETRY_OTLP_ENDPOINT")
v.BindEnv("telemetry.service_name", "DLC_TELEMETRY_SERVICE_NAME")
v.BindEnv("telemetry.insecure", "DLC_TELEMETRY_INSECURE")
// Auth environment variables
v.BindEnv("auth.jwt_secret", "DLC_AUTH_JWT_SECRET")
v.BindEnv("auth.admin_master_password", "DLC_AUTH_ADMIN_MASTER_PASSWORD")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
// API environment variables
v.BindEnv("api.v2_enabled", "DLC_API_V2_ENABLED")
// Database environment variables
v.BindEnv("database.host", "DLC_DATABASE_HOST")
v.BindEnv("database.port", "DLC_DATABASE_PORT")
v.BindEnv("database.user", "DLC_DATABASE_USER")
v.BindEnv("database.password", "DLC_DATABASE_PASSWORD")
v.BindEnv("database.name", "DLC_DATABASE_NAME")
v.BindEnv("database.ssl_mode", "DLC_DATABASE_SSL_MODE")
// Unmarshal into Config struct
var config Config
if err := v.Unmarshal(&config); err != nil {
@@ -200,6 +249,11 @@ func (c *Config) GetServiceName() string {
return c.Telemetry.ServiceName
}
// GetPersistenceTelemetryEnabled returns whether persistence layer telemetry is enabled
func (c *Config) GetPersistenceTelemetryEnabled() bool {
return c.Telemetry.Enabled && c.Telemetry.Persistence.Enabled
}
// GetTelemetryInsecure returns whether to use insecure connection
func (c *Config) GetTelemetryInsecure() bool {
return c.Telemetry.Insecure
@@ -220,6 +274,21 @@ func (c *Config) GetV2Enabled() bool {
return c.API.V2Enabled
}
// GetJWTSecret returns the JWT secret
func (c *Config) GetJWTSecret() string {
return c.Auth.JWTSecret
}
// GetAdminMasterPassword returns the admin master password
func (c *Config) GetAdminMasterPassword() string {
return c.Auth.AdminMasterPassword
}
// GetLoggingJSON returns whether JSON logging is enabled
func (c *Config) GetLoggingJSON() bool {
return c.Logging.JSON
}
// GetLogLevel returns the logging level
func (c *Config) GetLogLevel() string {
return c.Logging.Level
@@ -230,6 +299,75 @@ func (c *Config) GetLogOutput() string {
return c.Logging.Output
}
// GetDatabaseHost returns the database host
func (c *Config) GetDatabaseHost() string {
if c.Database.Host == "" {
return "localhost"
}
return c.Database.Host
}
// GetDatabasePort returns the database port
func (c *Config) GetDatabasePort() int {
if c.Database.Port == 0 {
return 5432
}
return c.Database.Port
}
// GetDatabaseUser returns the database user
func (c *Config) GetDatabaseUser() string {
if c.Database.User == "" {
return "postgres"
}
return c.Database.User
}
// GetDatabasePassword returns the database password
func (c *Config) GetDatabasePassword() string {
return c.Database.Password
}
// GetDatabaseName returns the database name
func (c *Config) GetDatabaseName() string {
if c.Database.Name == "" {
return "dance_lessons_coach"
}
return c.Database.Name
}
// GetDatabaseSSLMode returns the database SSL mode
func (c *Config) GetDatabaseSSLMode() string {
if c.Database.SSLMode == "" {
return "disable"
}
return c.Database.SSLMode
}
// GetDatabaseMaxOpenConns returns the maximum number of open connections
func (c *Config) GetDatabaseMaxOpenConns() int {
if c.Database.MaxOpenConns == 0 {
return 25
}
return c.Database.MaxOpenConns
}
// GetDatabaseMaxIdleConns returns the maximum number of idle connections
func (c *Config) GetDatabaseMaxIdleConns() int {
if c.Database.MaxIdleConns == 0 {
return 5
}
return c.Database.MaxIdleConns
}
// GetDatabaseConnMaxLifetime returns the maximum lifetime of connections
func (c *Config) GetDatabaseConnMaxLifetime() time.Duration {
if c.Database.ConnMaxLifetime == 0 {
return time.Hour
}
return c.Database.ConnMaxLifetime
}
// SetupLogging configures zerolog based on the configuration
func (c *Config) SetupLogging() {
// Parse log level

View File

@@ -88,6 +88,7 @@ func (h *apiV1GreetHandler) RegisterRoutes(router chi.Router) {
// @Accept json
// @Produce json
// @Success 200 {object} GreetResponse "Successful response"
// @Security BearerAuth
// @Router /v1/greet [get]
func (h *apiV1GreetHandler) handleGreetQuery(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
@@ -104,6 +105,7 @@ func (h *apiV1GreetHandler) handleGreetQuery(w http.ResponseWriter, r *http.Requ
// @Param name path string true "Name to greet"
// @Success 200 {object} GreetResponse "Successful response"
// @Failure 400 {object} ErrorResponse "Invalid name parameter"
// @Security BearerAuth
// @Router /v1/greet/{name} [get]
func (h *apiV1GreetHandler) handleGreetPath(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")

View File

@@ -55,6 +55,7 @@ type greetResponse struct {
// @Param request body GreetRequest true "Greeting request"
// @Success 200 {object} GreetResponseV2 "Successful response"
// @Failure 400 {object} ValidationError "Validation error"
// @Security BearerAuth
// @Router /v2/greet [post]
func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Request) {
// Read request body

View File

@@ -3,21 +3,46 @@ package greet
import (
"context"
"dance-lessons-coach/pkg/user"
"github.com/rs/zerolog/log"
)
// Context key for storing authenticated user
type contextKey string
const (
// UserContextKey is the context key for storing authenticated user
UserContextKey contextKey = "authenticatedUser"
)
type Service struct{}
func NewService() *Service {
return &Service{}
}
// GetAuthenticatedUserFromContext extracts the authenticated user from context
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
user, ok := ctx.Value(UserContextKey).(*user.User)
return user, ok
}
// Greet returns a greeting message for the given name.
// If name is empty, it defaults to "world".
// If name is empty, it checks for authenticated user and uses their username.
// If no authenticated user and no name, it defaults to "world".
// Implements the Greeter interface.
func (s *Service) Greet(ctx context.Context, name string) string {
log.Trace().Ctx(ctx).Str("name", name).Msg("Greet function called")
// If no name provided, check for authenticated user
if name == "" {
if authenticatedUser, ok := GetAuthenticatedUserFromContext(ctx); ok {
name = authenticatedUser.Username
log.Trace().Ctx(ctx).Str("authenticated_user", name).Msg("Using authenticated username for greeting")
}
}
if name == "" {
return "Hello world!"
}

View File

@@ -20,8 +20,11 @@ import (
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/telemetry"
"dance-lessons-coach/pkg/user"
userapi "dance-lessons-coach/pkg/user/api"
"dance-lessons-coach/pkg/validation"
"dance-lessons-coach/pkg/version"
"encoding/json"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
@@ -37,6 +40,8 @@ type Server struct {
config *config.Config
tracerProvider *sdktrace.TracerProvider
validator *validation.Validator
userRepo user.UserRepository
userService user.UserService
}
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
@@ -48,17 +53,46 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
log.Trace().Msg("Validator created successfully")
}
// Initialize user repository and services
userRepo, userService, err := initializeUserServices(cfg)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
}
s := &Server{
router: chi.NewRouter(),
readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(),
config: cfg,
validator: validator,
router: chi.NewRouter(),
readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(),
config: cfg,
validator: validator,
userRepo: userRepo,
userService: userService,
}
s.setupRoutes()
return s
}
// initializeUserServices initializes the user repository and unified user service
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, error) {
// Create user repository using PostgreSQL
repo, err := user.NewPostgresRepository(cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to create PostgreSQL user repository: %w", err)
}
// Create JWT config
jwtConfig := user.JWTConfig{
Secret: cfg.GetJWTSecret(),
ExpirationTime: time.Hour * 24, // 24 hours
Issuer: "dance-lessons-coach",
}
// Create unified user service
userService := user.NewUserService(repo, jwtConfig, cfg.GetAdminMasterPassword())
return repo, userService, nil
}
func (s *Server) setupRoutes() {
// Use Zerolog middleware instead of Chi's default logger
s.router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{
@@ -109,9 +143,31 @@ func (s *Server) setupRoutes() {
func (s *Server) registerApiV1Routes(r chi.Router) {
greetService := greet.NewService()
greetHandler := greet.NewApiV1GreetHandler(greetService)
// Create auth middleware if available
var authMiddleware *AuthMiddleware
if s.userService != nil {
authMiddleware = NewAuthMiddleware(s.userService)
}
r.Route("/greet", func(r chi.Router) {
// Add optional authentication middleware
if authMiddleware != nil {
r.Use(authMiddleware.Middleware)
}
greetHandler.RegisterRoutes(r)
})
// Register user authentication routes
if s.userService != nil && s.userRepo != nil {
// Use unified user service - much simpler!
if s.userService != nil {
handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator)
r.Route("/auth", func(r chi.Router) {
handler.RegisterRoutes(r)
})
}
}
}
func (s *Server) registerApiV2Routes(r chi.Router) {
@@ -155,24 +211,75 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
// handleReadiness godoc
//
// @Summary Readiness check
// @Description Check if the service is ready to accept traffic
// @Description Check if the service is ready to accept traffic including detailed connection status
// @Tags System/Health
// @Accept json
// @Produce json
// @Success 200 {object} map[string]bool "Service is ready"
// @Failure 503 {object} map[string]bool "Service is not ready"
// @Success 200 {object} object "Service is ready with connection details"
// @Failure 503 {object} object "Service is not ready with failure details"
// @Router /ready [get]
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Readiness check requested")
// Check if server is shutting down
select {
case <-s.readyCtx.Done():
log.Trace().Msg("Readiness check: not ready (shutting down)")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"ready":false}`))
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": false,
"reason": "server_shutting_down",
"connections": map[string]interface{}{
"database": "not_checked",
},
})
return
default:
log.Trace().Msg("Readiness check: ready")
w.Write([]byte(`{"ready":true}`))
// Server is not shutting down, check all connections
connectionStatus := make(map[string]interface{})
allHealthy := true
var failureReason string
// Check database if available
if s.userRepo != nil {
if err := s.userRepo.CheckDatabaseHealth(r.Context()); err != nil {
log.Warn().Err(err).Msg("Database health check failed")
connectionStatus["database"] = map[string]interface{}{
"status": "unhealthy",
"error": err.Error(),
}
allHealthy = false
failureReason = "database_unhealthy"
} else {
connectionStatus["database"] = map[string]interface{}{
"status": "healthy",
}
}
} else {
connectionStatus["database"] = map[string]interface{}{
"status": "not_configured",
}
}
if allHealthy {
log.Trace().Msg("Readiness check: ready")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": true,
"connections": connectionStatus,
})
} else {
log.Warn().Str("reason", failureReason).Msg("Readiness check: not ready")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": false,
"reason": failureReason,
"connections": connectionStatus,
})
}
}
}

View File

@@ -1,4 +1,4 @@
// Package telemetry provides OpenTelemetry instrumentation for the DanceLessonsCoach application
// Package telemetry provides OpenTelemetry instrumentation for the dance-lessons-coach application
package telemetry
import (

View File

@@ -1,4 +1,4 @@
// Package version provides version information and management for DanceLessonsCoach
// Package version provides version information and management for dance-lessons-coach
package version
import (
@@ -91,7 +91,7 @@ func getBuildDate() {
// Info returns formatted version information
func Info() string {
return fmt.Sprintf("DanceLessonsCoach %s (commit: %s, built: %s UTC, go: %s)", Version, Commit, Date, GoVersion)
return fmt.Sprintf("dance-lessons-coach %s (commit: %s, built: %s UTC, go: %s)", Version, Commit, Date, GoVersion)
}
// Short returns just the version number
@@ -101,7 +101,7 @@ func Short() string {
// Full returns detailed version information
func Full() string {
return fmt.Sprintf(`DanceLessonsCoach Version Information:
return fmt.Sprintf(`dance-lessons-coach Version Information:
Version: %s
Commit: %s
Built: %s (UTC)