commit 66ec1ec60a5934b8a0973adc2995575866d22934 Author: Maxime Duchene-Savard Date: Fri Jun 19 03:23:44 2026 -0400 first commit diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ccb9642 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "net/http" + + "boruto/internal/app" + "boruto/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + handler := app.NewRouter(cfg) + + server := &http.Server{ + Addr: cfg.HTTP.Addr, + Handler: handler, + ReadTimeout: cfg.HTTP.ReadTimeout, + WriteTimeout: cfg.HTTP.WriteTimeout, + IdleTimeout: cfg.HTTP.IdleTimeout, + } + + log.Printf("starting server on %s in %s mode", cfg.HTTP.Addr, cfg.AppEnv) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5a25709 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module boruto + +go 1.24.1 + +require github.com/go-chi/chi/v5 v5.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cf7adc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= diff --git a/internal/app/routes.go b/internal/app/routes.go new file mode 100644 index 0000000..7b8aea8 --- /dev/null +++ b/internal/app/routes.go @@ -0,0 +1,29 @@ +package app + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "boruto/internal/config" + "boruto/internal/http/handlers" +) + +func NewRouter(cfg config.Config) http.Handler { + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Get("/health", handlers.Health) + + r.Route("/users", func(r chi.Router) { + r.Get("/", handlers.ListUsers) + r.Post("/", handlers.CreateUser) + }) + + return r +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..710c4ae --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + AppEnv string + + HTTP HTTPConfig + + Database DatabaseConfig +} + +type HTTPConfig struct { + Addr string + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration +} + +type DatabaseConfig struct { + URL string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +func Load() (Config, error) { + cfg := Config{ + AppEnv: getString("APP_ENV", "development"), + + HTTP: HTTPConfig{ + Addr: getString("HTTP_ADDR", ":8080"), + ReadTimeout: getDuration("HTTP_READ_TIMEOUT", 5*time.Second), + WriteTimeout: getDuration("HTTP_WRITE_TIMEOUT", 10*time.Second), + IdleTimeout: getDuration("HTTP_IDLE_TIMEOUT", 60*time.Second), + }, + + Database: DatabaseConfig{ + URL: getString("DATABASE_URL", ""), + MaxOpenConns: getInt("DB_MAX_OPEN_CONNS", 25), + MaxIdleConns: getInt("DB_MAX_IDLE_CONNS", 25), + ConnMaxLifetime: getDuration("DB_CONN_MAX_LIFETIME", 5*time.Minute), + }, + } + + if err := cfg.Validate(); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func (c Config) Validate() error { + if c.AppEnv == "" { + return fmt.Errorf("APP_ENV is required") + } + + switch c.AppEnv { + case "development", "test", "staging", "production": + default: + return fmt.Errorf("APP_ENV must be one of: development, test, staging, production") + } + + if c.HTTP.Addr == "" { + return fmt.Errorf("HTTP_ADDR is required") + } + + if c.Database.URL == "" && c.AppEnv != "test" { + return fmt.Errorf("DATABASE_URL is required") + } + + if c.Database.MaxOpenConns < 1 { + return fmt.Errorf("DB_MAX_OPEN_CONNS must be at least 1") + } + + if c.Database.MaxIdleConns < 1 { + return fmt.Errorf("DB_MAX_IDLE_CONNS must be at least 1") + } + + if c.Database.MaxIdleConns > c.Database.MaxOpenConns { + return fmt.Errorf("DB_MAX_IDLE_CONNS cannot exceed DB_MAX_OPEN_CONNS") + } + + return nil +} + +func getString(key, fallback string) string { + value, ok := os.LookupEnv(key) + if !ok || value == "" { + return fallback + } + + return value +} + +func getInt(key string, fallback int) int { + value, ok := os.LookupEnv(key) + if !ok || value == "" { + return fallback + } + + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + + return parsed +} + +func getDuration(key string, fallback time.Duration) time.Duration { + value, ok := os.LookupEnv(key) + if !ok || value == "" { + return fallback + } + + parsed, err := time.ParseDuration(value) + if err != nil { + return fallback + } + + return parsed +} diff --git a/internal/http/handlers/handlers.go b/internal/http/handlers/handlers.go new file mode 100644 index 0000000..3cda773 --- /dev/null +++ b/internal/http/handlers/handlers.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +type response map[string]any + +func Health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, response{"status": "ok"}) +} + +func ListUsers(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, response{"users": []response{}}) +} + +func CreateUser(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusCreated, response{"status": "created"}) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(payload); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +}