Skip to content

Commit 21ecaba

Browse files
committed
feat: Leaderboard implementation
1 parent 81378b9 commit 21ecaba

File tree

11 files changed

+1436
-142
lines changed

11 files changed

+1436
-142
lines changed

cmd/gamifykit-server/main.go

Lines changed: 139 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,173 @@ package main
22

33
import (
44
"context"
5-
"flag"
5+
"fmt"
66
"log/slog"
77
"net/http"
88
"os"
9-
"time"
9+
"os/signal"
10+
"syscall"
1011

1112
mem "gamifykit/adapters/memory"
13+
redisAdapter "gamifykit/adapters/redis"
14+
sqlxAdapter "gamifykit/adapters/sqlx"
1215
"gamifykit/api/httpapi"
16+
"gamifykit/config"
1317
"gamifykit/engine"
1418
"gamifykit/gamify"
1519
"gamifykit/realtime"
1620
)
1721

1822
func main() {
19-
var (
20-
addr = flag.String("addr", ":8080", "listen address")
21-
prefix = flag.String("prefix", "/api", "HTTP API path prefix")
22-
cors = flag.String("cors", "*", "Access-Control-Allow-Origin value (empty to disable)")
23-
)
24-
flag.Parse()
23+
// Load configuration
24+
cfg, err := config.Load()
25+
if err != nil {
26+
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
27+
os.Exit(1)
28+
}
2529

26-
// Configure JSON logging for production use
27-
logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
28-
Level: slog.LevelInfo,
29-
})
30-
slog.SetDefault(slog.New(logHandler))
30+
// Setup logging based on configuration
31+
setupLogging(cfg)
32+
33+
// Load secrets if in production
34+
ctx := context.Background()
35+
if cfg.Environment == config.EnvProduction {
36+
if err := cfg.LoadSecretsFromEnv(ctx); err != nil {
37+
slog.Error("Failed to load secrets", "error", err)
38+
os.Exit(1)
39+
}
40+
}
41+
42+
slog.Info("starting gamifykit server",
43+
"environment", cfg.Environment,
44+
"profile", cfg.Profile,
45+
"address", cfg.Server.Address,
46+
"storage_adapter", cfg.Storage.Adapter)
47+
48+
// Setup storage adapter
49+
storage, err := setupStorage(ctx, cfg)
50+
if err != nil {
51+
slog.Error("Failed to setup storage", "error", err)
52+
os.Exit(1)
53+
}
3154

32-
// Build service with sensible defaults.
55+
// Build service
3356
hub := realtime.NewHub()
3457
svc := gamify.New(
3558
gamify.WithRealtime(hub),
36-
gamify.WithStorage(mem.New()),
59+
gamify.WithStorage(storage),
3760
gamify.WithDispatchMode(engine.DispatchAsync),
3861
)
3962

40-
// HTTP API
41-
handler := httpapi.NewMux(svc, hub, httpapi.Options{PathPrefix: *prefix, AllowCORSOrigin: *cors})
63+
// Setup HTTP API
64+
handler := httpapi.NewMux(svc, hub, httpapi.Options{
65+
PathPrefix: cfg.Server.PathPrefix,
66+
AllowCORSOrigin: cfg.Server.CORSOrigin,
67+
})
4268

69+
// Create HTTP server
4370
srv := &http.Server{
44-
Addr: *addr,
71+
Addr: cfg.Server.Address,
4572
Handler: handler,
46-
ReadHeaderTimeout: 5 * time.Second,
47-
ReadTimeout: 10 * time.Second,
48-
WriteTimeout: 10 * time.Second,
49-
IdleTimeout: 60 * time.Second,
73+
ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout,
74+
ReadTimeout: cfg.Server.ReadTimeout,
75+
WriteTimeout: cfg.Server.WriteTimeout,
76+
IdleTimeout: cfg.Server.IdleTimeout,
5077
}
5178

52-
slog.Info("starting gamifykit server",
53-
"address", *addr,
54-
"api_prefix", *prefix,
55-
"cors_origin", *cors)
79+
// Start server in a goroutine
80+
go func() {
81+
slog.Info("server listening", "address", cfg.Server.Address)
82+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
83+
slog.Error("failed to start server", "error", err)
84+
os.Exit(1)
85+
}
86+
}()
5687

57-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
58-
slog.Error("failed to start server", "error", err)
59-
os.Exit(1)
60-
}
88+
// Setup graceful shutdown
89+
quit := make(chan os.Signal, 1)
90+
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
91+
<-quit
6192

62-
// Graceful shutdown - this will only be reached if the server is stopped externally
63-
slog.Info("server shutting down")
64-
if err := srv.Shutdown(context.Background()); err != nil {
93+
slog.Info("shutting down server", "timeout", cfg.Server.ShutdownTimeout)
94+
95+
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
96+
defer cancel()
97+
98+
if err := srv.Shutdown(shutdownCtx); err != nil {
6599
slog.Error("error during server shutdown", "error", err)
66100
os.Exit(1)
67101
}
102+
103+
slog.Info("server stopped")
104+
}
105+
106+
// setupLogging configures the logger based on configuration
107+
func setupLogging(cfg *config.Config) {
108+
var handler slog.Handler
109+
110+
opts := &slog.HandlerOptions{
111+
Level: parseLogLevel(cfg.Logging.Level),
112+
}
113+
114+
switch cfg.Logging.Format {
115+
case "text":
116+
handler = slog.NewTextHandler(os.Stdout, opts)
117+
case "json":
118+
handler = slog.NewJSONHandler(os.Stdout, opts)
119+
default:
120+
handler = slog.NewJSONHandler(os.Stdout, opts)
121+
}
122+
123+
// Add attributes if specified
124+
if len(cfg.Logging.Attributes) > 0 {
125+
handler = handler.WithAttrs(convertAttributes(cfg.Logging.Attributes))
126+
}
127+
128+
slog.SetDefault(slog.New(handler))
129+
}
130+
131+
// parseLogLevel converts string log level to slog.Level
132+
func parseLogLevel(level string) slog.Level {
133+
switch level {
134+
case "debug":
135+
return slog.LevelDebug
136+
case "info":
137+
return slog.LevelInfo
138+
case "warn":
139+
return slog.LevelWarn
140+
case "error":
141+
return slog.LevelError
142+
default:
143+
return slog.LevelInfo
144+
}
145+
}
146+
147+
// convertAttributes converts map[string]string to []slog.Attr
148+
func convertAttributes(attrs map[string]string) []slog.Attr {
149+
var result []slog.Attr
150+
for k, v := range attrs {
151+
result = append(result, slog.String(k, v))
152+
}
153+
return result
154+
}
155+
156+
// setupStorage creates the appropriate storage adapter based on configuration
157+
func setupStorage(ctx context.Context, cfg *config.Config) (engine.Storage, error) {
158+
switch cfg.Storage.Adapter {
159+
case "memory":
160+
return mem.New(), nil
161+
162+
case "redis":
163+
return redisAdapter.New(cfg.Storage.Redis)
164+
165+
case "sql":
166+
return sqlxAdapter.New(cfg.Storage.SQL)
167+
168+
case "file":
169+
return mem.New(), fmt.Errorf("file storage not yet implemented, using memory fallback")
170+
171+
default:
172+
return mem.New(), fmt.Errorf("unknown storage adapter: %s", cfg.Storage.Adapter)
173+
}
68174
}

config/README.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Configuration Management
2+
3+
GamifyKit provides a comprehensive configuration management system that supports environment-based configuration, validation, and secret management.
4+
5+
## Features
6+
7+
- **Environment-based configuration** with environment variable overrides
8+
- **Configuration profiles** for different environments (development, testing, production)
9+
- **Schema validation** with helpful error messages
10+
- **Secret management** integration with external stores
11+
- **JSON file support** for complex configurations
12+
13+
## Quick Start
14+
15+
### Basic Usage
16+
17+
```go
18+
package main
19+
20+
import (
21+
"gamifykit/config"
22+
)
23+
24+
func main() {
25+
// Load configuration from environment variables
26+
cfg, err := config.Load()
27+
if err != nil {
28+
panic(err)
29+
}
30+
31+
// Use configuration
32+
fmt.Printf("Server will listen on %s\n", cfg.Server.Address)
33+
}
34+
```
35+
36+
### Using Configuration Profiles
37+
38+
```go
39+
// Load development profile
40+
cfg, err := config.LoadProfile("development")
41+
42+
// Load production profile
43+
cfg, err := config.LoadProfile("production")
44+
```
45+
46+
### Loading from JSON File
47+
48+
```go
49+
cfg, err := config.LoadFromFile("config.json")
50+
```
51+
52+
## Configuration Structure
53+
54+
```json
55+
{
56+
"environment": "development",
57+
"profile": "development",
58+
"server": {
59+
"address": ":8080",
60+
"path_prefix": "/api",
61+
"cors_origin": "*",
62+
"read_timeout": "10s",
63+
"write_timeout": "10s",
64+
"idle_timeout": "60s"
65+
},
66+
"storage": {
67+
"adapter": "memory",
68+
"redis": { ... },
69+
"sql": { ... }
70+
},
71+
"logging": {
72+
"level": "info",
73+
"format": "json",
74+
"output": "stdout"
75+
},
76+
"metrics": {
77+
"enabled": true,
78+
"address": ":9090"
79+
}
80+
}
81+
```
82+
83+
## Environment Variables
84+
85+
All configuration values can be overridden using environment variables with the `GAMIFYKIT_` prefix:
86+
87+
```bash
88+
export GAMIFYKIT_ENV=production
89+
export GAMIFYKIT_SERVER_ADDR=:9090
90+
export GAMIFYKIT_STORAGE_ADAPTER=redis
91+
export GAMIFYKIT_LOG_LEVEL=debug
92+
```
93+
94+
### Complete Environment Variable Reference
95+
96+
| Variable | Description | Default |
97+
|----------|-------------|---------|
98+
| `GAMIFYKIT_ENV` | Environment (development/testing/staging/production) | development |
99+
| `GAMIFYKIT_PROFILE` | Configuration profile name | default |
100+
| `GAMIFYKIT_SERVER_ADDR` | Server listen address | :8080 |
101+
| `GAMIFYKIT_SERVER_PATH_PREFIX` | API path prefix | /api |
102+
| `GAMIFYKIT_SERVER_CORS_ORIGIN` | CORS origin | * |
103+
| `GAMIFYKIT_STORAGE_ADAPTER` | Storage adapter (memory/redis/sql/file) | memory |
104+
| `GAMIFYKIT_LOG_LEVEL` | Log level (debug/info/warn/error) | info |
105+
| `GAMIFYKIT_LOG_FORMAT` | Log format (json/text) | json |
106+
| `GAMIFYKIT_METRICS_ENABLED` | Enable metrics collection | false |
107+
108+
## Configuration Profiles
109+
110+
### Development Profile
111+
- Memory storage
112+
- Debug logging
113+
- Local server settings
114+
- Metrics disabled
115+
116+
### Testing Profile
117+
- Memory storage
118+
- Warning level logging
119+
- Random server port
120+
- Metrics disabled
121+
122+
### Staging Profile
123+
- File storage
124+
- Info level logging
125+
- Basic security features enabled
126+
127+
### Production Profiles
128+
- Redis or SQL storage
129+
- JSON structured logging
130+
- Comprehensive security
131+
- Metrics enabled
132+
133+
## Secret Management
134+
135+
For production deployments, sensitive configuration like database passwords should be stored in external secret stores:
136+
137+
```go
138+
// Load secrets from environment (for development)
139+
err := cfg.LoadSecretsFromEnv(ctx)
140+
141+
// Or use a custom secret store
142+
store := MySecretStore{}
143+
err := cfg.LoadSecrets(ctx, store)
144+
```
145+
146+
Required secrets in production:
147+
- `GAMIFYKIT_DATABASE_DSN` - Database connection string
148+
- `GAMIFYKIT_REDIS_PASSWORD` - Redis password (if applicable)
149+
150+
## Validation
151+
152+
Configuration is automatically validated on load. Validation includes:
153+
154+
- Required fields presence
155+
- Value ranges and formats
156+
- Cross-field consistency
157+
- Adapter-specific requirements
158+
159+
Invalid configurations will return detailed error messages indicating exactly what needs to be fixed.
160+
161+
## Custom Secret Stores
162+
163+
Implement the `SecretStore` interface for custom secret management:
164+
165+
```go
166+
type SecretStore interface {
167+
Get(ctx context.Context, key string) (string, error)
168+
GetWithDefault(ctx context.Context, key, defaultValue string) string
169+
}
170+
```
171+
172+
This allows integration with:
173+
- AWS Secrets Manager
174+
- HashiCorp Vault
175+
- Azure Key Vault
176+
- Google Cloud Secret Manager
177+
- Local encrypted files

0 commit comments

Comments
 (0)