initial commit

This commit is contained in:
Daniel Noulet 2026-10-04 16:20:07 +02:00
commit ebd5bb09fd
30 changed files with 1920 additions and 0 deletions

89
internal/config/config.go Normal file
View file

@ -0,0 +1,89 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
}
type ServerConfig struct {
Host string
Port string
}
type DatabaseConfig struct {
URL string
}
type JWTConfig struct {
SecretKey string
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
cfg.applyDefaults()
return &cfg, nil
}
func LoadFromEnv() (*Config, error) {
cfg := DefaultConfig()
if host := os.Getenv("GO_MAD_SERVER_HOST"); host != "" {
cfg.Server.Host = host
}
if port := os.Getenv("GO_MAD_SERVER_PORT"); port != "" {
cfg.Server.Port = port
}
if dbURL := os.Getenv("GO_MAD_DATABASE_URL"); dbURL != "" {
cfg.Database.URL = dbURL
}
if jwtSecret := os.Getenv("GO_MAD_JWT_SECRET"); jwtSecret != "" {
cfg.JWT.SecretKey = jwtSecret
}
cfg.applyDefaults()
return cfg, nil
}
func DefaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: "8080",
},
Database: DatabaseConfig{
URL: "postgres://mad:mad@localhost:5432/mad?sslmode=disable",
},
JWT: JWTConfig{
SecretKey: "change-me-to-a-32-plus-char-secret",
},
}
}
func (c *Config) applyDefaults() {
if c.Server.Port == "" {
c.Server.Port = "8080"
}
if c.Database.URL == "" {
c.Database.URL = "postgres://mad:mad@localhost:5432/mad?sslmode=disable"
}
if c.JWT.SecretKey == "" {
c.JWT.SecretKey = "change-me-to-a-32-plus-char-secret"
}
}

View file

@ -0,0 +1,171 @@
package database
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
)
func Open(databaseURL string) (*sql.DB, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * 60)
if err := db.PingContext(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("pinging database: %w", err)
}
log.Println("Database connection opened successfully")
return db, nil
}
type Migration struct {
Version string
Description string
SQL string
}
func FindMigrations(migrationPath string) ([]Migration, error) {
var migrations []Migration
entries, err := os.ReadDir(migrationPath)
if err != nil {
return nil, fmt.Errorf("reading migration directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
path := filepath.Join(migrationPath, entry.Name())
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading migration file %s: %w", entry.Name(), err)
}
contentStr := string(content)
migration := parseMigration(contentStr)
migrations = append(migrations, migration)
}
return migrations, nil
}
func parseMigration(content string) Migration {
// Parse goose-style migration: -- +goose Up / -- +goose Down
lines := strings.Split(content, "\n")
var upSQL strings.Builder
inUp := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "-- +goose Up") {
inUp = true
continue
}
if strings.Contains(trimmed, "-- +goose Down") {
inUp = false
continue
}
if inUp {
upSQL.WriteString(line)
upSQL.WriteString("\n")
}
}
// Extract version from filename (first part before _)
version := ""
desc := ""
if idx := strings.Index(content, "_"); idx >= 0 {
version = content[:idx]
desc = content[idx+1 : strings.Index(content, "_")] // simplistic
}
return Migration{
Version: version,
Description: desc,
SQL: upSQL.String(),
}
}
func Migrate(db *sql.DB, migrationPath string) error {
migrations, err := FindMigrations(migrationPath)
if err != nil {
return fmt.Errorf("finding migrations: %w", err)
}
if len(migrations) == 0 {
log.Println("No migration files found")
return nil
}
log.Printf("Found %d migration(s), running up...", len(migrations))
// Ensure schema_migrations table exists
_, err = db.ExecContext(context.Background(), `
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
description VARCHAR(255),
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("creating schema_migrations table: %w", err)
}
for _, m := range migrations {
// Check if already applied
var count int
err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", m.Version).Scan(&count)
if err != nil {
return fmt.Errorf("checking migration %s: %w", m.Version, err)
}
if count > 0 {
log.Printf("Migration %s already applied, skipping", m.Version)
continue
}
// Execute the up SQL
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return fmt.Errorf("beginning transaction for migration %s: %w", m.Version, err)
}
_, err = tx.ExecContext(context.Background(), m.SQL)
if err != nil {
tx.Rollback()
return fmt.Errorf("executing migration %s: %w", m.Version, err)
}
// Record the migration as applied
_, err = tx.ExecContext(context.Background(), "INSERT INTO schema_migrations (version, description) VALUES ($1, $2)", m.Version, m.Description)
if err != nil {
tx.Rollback()
return fmt.Errorf("recording migration %s: %w", m.Version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing migration %s: %w", m.Version, err)
}
log.Printf("Applied migration %s: %s", m.Version, m.Description)
}
log.Println("Migrations completed successfully")
return nil
}