Add initial project structure with core functionality for book and stationery uploads
- Created main application entry point in `main.go`. - Added configuration management in `config/config.go` and tests in `config/config_test.go`. - Implemented handlers for book and stationery uploads in `handlers/book.go` and `handlers/stationery.go`, including validation logic. - Established database connection and services in `services/database.go` and `services/book_service.go`. - Defined models for books and stationery in `models/book.go` and `models/stationery.go`. - Set up Firebase integration for image uploads in `services/firebase.go`. - Created migration scripts for database schema in `migrations/001_create_tables.sql` and subsequent updates. - Added CORS and error handling middleware. - Included comprehensive tests for handlers, services, and utilities. - Documented API endpoints and usage in `README.md` and migration instructions in `migrations/README.md`. - Introduced `.gitignore` to exclude unnecessary files and directories. - Added Go module support with `go.mod` and `go.sum` files. - Implemented utility functions for slug generation and validation in `utils/slug.go` and `utils/validation.go`.
This commit is contained in:
107
config/config.go
Normal file
107
config/config.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
// Server
|
||||
Port string
|
||||
FrontendURL string
|
||||
|
||||
// Database
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
// Firebase
|
||||
FirebaseProjectID string
|
||||
FirebaseStorageBucket string
|
||||
FirebaseCredentialsFile string
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from environment variables
|
||||
// Loading order (later files override earlier ones):
|
||||
// 1. .env (base/default values)
|
||||
// 2. .env.local (local development overrides - takes precedence)
|
||||
// Note: OS environment variables take highest precedence (godotenv won't override them)
|
||||
func LoadConfig() (*Config, error) {
|
||||
// Load .env file if it exists (optional, won't error if missing)
|
||||
_ = godotenv.Load(".env")
|
||||
|
||||
// Load .env.local if it exists (overrides .env values)
|
||||
// This allows local development overrides without modifying .env
|
||||
_ = godotenv.Load(".env.local")
|
||||
|
||||
cfg := &Config{
|
||||
// Server
|
||||
Port: getEnv("PORT", "8080"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:5173"),
|
||||
|
||||
// Database
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", ""),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", ""),
|
||||
|
||||
// Firebase
|
||||
FirebaseProjectID: getEnv("FIREBASE_PROJECT_ID", ""),
|
||||
FirebaseStorageBucket: getEnv("FIREBASE_STORAGE_BUCKET", ""),
|
||||
FirebaseCredentialsFile: getEnv("FIREBASE_CREDENTIALS_FILE", ""),
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks that all required configuration values are set
|
||||
func (c *Config) Validate() error {
|
||||
var missing []string
|
||||
|
||||
if c.DBUser == "" {
|
||||
missing = append(missing, "DB_USER")
|
||||
}
|
||||
if c.DBPassword == "" {
|
||||
missing = append(missing, "DB_PASSWORD")
|
||||
}
|
||||
if c.DBName == "" {
|
||||
missing = append(missing, "DB_NAME")
|
||||
}
|
||||
if c.FirebaseProjectID == "" {
|
||||
missing = append(missing, "FIREBASE_PROJECT_ID")
|
||||
}
|
||||
if c.FirebaseStorageBucket == "" {
|
||||
missing = append(missing, "FIREBASE_STORAGE_BUCKET")
|
||||
}
|
||||
if c.FirebaseCredentialsFile == "" {
|
||||
// Check if GOOGLE_APPLICATION_CREDENTIALS is set as fallback
|
||||
if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") == "" {
|
||||
missing = append(missing, "FIREBASE_CREDENTIALS_FILE (or GOOGLE_APPLICATION_CREDENTIALS)")
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required environment variables: %v", missing)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable or returns a default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
205
config/config_test.go
Normal file
205
config/config_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
// Save original env values
|
||||
originalEnv := make(map[string]string)
|
||||
envVars := []string{
|
||||
"PORT", "FRONTEND_URL",
|
||||
"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME",
|
||||
"FIREBASE_PROJECT_ID", "FIREBASE_STORAGE_BUCKET", "FIREBASE_CREDENTIALS_FILE",
|
||||
}
|
||||
for _, key := range envVars {
|
||||
originalEnv[key] = os.Getenv(key)
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
defer func() {
|
||||
// Restore original env values
|
||||
for key, value := range originalEnv {
|
||||
if value != "" {
|
||||
os.Setenv(key, value)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func()
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
setup: func() {
|
||||
os.Setenv("DB_USER", "test_user")
|
||||
os.Setenv("DB_PASSWORD", "test_password")
|
||||
os.Setenv("DB_NAME", "test_db")
|
||||
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
|
||||
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
|
||||
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing DB_USER",
|
||||
setup: func() {
|
||||
os.Setenv("DB_PASSWORD", "test_password")
|
||||
os.Setenv("DB_NAME", "test_db")
|
||||
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
|
||||
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
|
||||
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing FIREBASE_PROJECT_ID",
|
||||
setup: func() {
|
||||
os.Setenv("DB_USER", "test_user")
|
||||
os.Setenv("DB_PASSWORD", "test_password")
|
||||
os.Setenv("DB_NAME", "test_db")
|
||||
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
|
||||
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "uses defaults for optional fields",
|
||||
setup: func() {
|
||||
os.Setenv("DB_USER", "test_user")
|
||||
os.Setenv("DB_PASSWORD", "test_password")
|
||||
os.Setenv("DB_NAME", "test_db")
|
||||
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
|
||||
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
|
||||
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clean up env before each test
|
||||
for _, key := range envVars {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
|
||||
tt.setup()
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && cfg == nil {
|
||||
t.Error("LoadConfig() returned nil config without error")
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Verify defaults are used
|
||||
if cfg.Port == "" {
|
||||
t.Error("Port should have default value")
|
||||
}
|
||||
if cfg.DBHost == "" {
|
||||
t.Error("DBHost should have default value")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: &Config{
|
||||
DBUser: "user",
|
||||
DBPassword: "password",
|
||||
DBName: "dbname",
|
||||
FirebaseProjectID: "project-id",
|
||||
FirebaseStorageBucket: "bucket.appspot.com",
|
||||
FirebaseCredentialsFile: "./credentials.json",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing DB_USER",
|
||||
config: &Config{
|
||||
DBPassword: "password",
|
||||
DBName: "dbname",
|
||||
FirebaseProjectID: "project-id",
|
||||
FirebaseStorageBucket: "bucket.appspot.com",
|
||||
FirebaseCredentialsFile: "./credentials.json",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing FIREBASE_PROJECT_ID",
|
||||
config: &Config{
|
||||
DBUser: "user",
|
||||
DBPassword: "password",
|
||||
DBName: "dbname",
|
||||
FirebaseStorageBucket: "bucket.appspot.com",
|
||||
FirebaseCredentialsFile: "./credentials.json",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_EnvFilePrecedence(t *testing.T) {
|
||||
// This test verifies that .env.local overrides .env values
|
||||
// Note: This is a conceptual test - actual file loading would require test files
|
||||
|
||||
// Save original env
|
||||
originalPort := os.Getenv("PORT")
|
||||
defer func() {
|
||||
if originalPort != "" {
|
||||
os.Setenv("PORT", originalPort)
|
||||
} else {
|
||||
os.Unsetenv("PORT")
|
||||
}
|
||||
}()
|
||||
|
||||
// Set required vars
|
||||
os.Setenv("DB_USER", "test")
|
||||
os.Setenv("DB_PASSWORD", "test")
|
||||
os.Setenv("DB_NAME", "test")
|
||||
os.Setenv("FIREBASE_PROJECT_ID", "test")
|
||||
os.Setenv("FIREBASE_STORAGE_BUCKET", "test.appspot.com")
|
||||
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test.json")
|
||||
|
||||
// Test that LoadConfig works
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("LoadConfig() returned nil")
|
||||
}
|
||||
|
||||
// Verify it uses default PORT if not set
|
||||
if cfg.Port == "" {
|
||||
t.Error("Port should have default value")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user