Perbaikan template go

This commit is contained in:
2025-08-13 09:50:09 +07:00
parent f1efac98a0
commit 315a240b08
7 changed files with 862 additions and 113 deletions

23
go.mod
View File

@@ -6,10 +6,16 @@ require (
github.com/coder/websocket v1.8.13
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/jackc/pgx/v5 v5.7.5
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.8.12
github.com/testcontainers/testcontainers-go v0.38.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/sync v0.16.0
)
require (
@@ -49,11 +55,9 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
@@ -72,6 +76,7 @@ require (
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -83,13 +88,14 @@ require (
github.com/shirou/gopsutil/v4 v4.25.5 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/gin-swagger v1.6.0 // indirect
github.com/swaggo/swag v1.8.12 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
@@ -100,7 +106,6 @@ require (
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect

21
go.sum
View File

@@ -54,6 +54,8 @@ github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBv
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -87,6 +89,10 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -159,6 +165,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -211,11 +219,21 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
@@ -245,6 +263,8 @@ golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sU
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -288,6 +308,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=

View File

@@ -9,6 +9,7 @@ import (
type Config struct {
Server ServerConfig
Database DatabaseConfig
Keycloak KeycloakConfig
}
type ServerConfig struct {
@@ -25,6 +26,13 @@ type DatabaseConfig struct {
Schema string
}
type KeycloakConfig struct {
Issuer string
Audience string
JwksURL string
Enabled bool
}
func LoadConfig() *Config {
config := &Config{
Server: ServerConfig{
@@ -39,6 +47,12 @@ func LoadConfig() *Config {
Database: getEnv("BLUEPRINT_DB_DATABASE", "api_service"),
Schema: getEnv("BLUEPRINT_DB_SCHEMA", "public"),
},
Keycloak: KeycloakConfig{
Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"),
Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"),
JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"),
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true),
},
}
return config
@@ -59,6 +73,14 @@ func getEnvAsInt(key string, defaultValue int) int {
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := getEnv(key, "")
if value, err := strconv.ParseBool(valueStr); err == nil {
return value
}
return defaultValue
}
func (c *Config) Validate() error {
if c.Database.Host == "" {
log.Fatal("Database host is required")
@@ -72,5 +94,19 @@ func (c *Config) Validate() error {
if c.Database.Database == "" {
log.Fatal("Database name is required")
}
// Validate Keycloak configuration if enabled
if c.Keycloak.Enabled {
if c.Keycloak.Issuer == "" {
log.Fatal("Keycloak issuer is required when Keycloak is enabled")
}
if c.Keycloak.Audience == "" {
log.Fatal("Keycloak audience is required when Keycloak is enabled")
}
if c.Keycloak.JwksURL == "" {
log.Fatal("Keycloak JWKS URL is required when Keycloak is enabled")
}
}
return nil
}

View File

@@ -7,76 +7,426 @@ import (
"log"
"os"
"strconv"
"strings"
"sync"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "github.com/joho/godotenv/autoload"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Service represents a service that interacts with a database.
type Service interface {
// Health returns a map of health status information.
// The keys and values in the map are service-specific.
Health() map[string]string
// DatabaseType represents supported database types
type DatabaseType string
// Close terminates the database connection.
// It returns an error if the connection cannot be closed.
const (
Postgres DatabaseType = "postgres"
MySQL DatabaseType = "mysql"
SQLServer DatabaseType = "sqlserver"
SQLite DatabaseType = "sqlite"
MongoDB DatabaseType = "mongodb"
)
// DatabaseConfig represents configuration for a single database connection
type DatabaseConfig struct {
Name string
Type DatabaseType
Host string
Port string
Database string
Username string
Password string
Schema string
SSLMode string
Path string // For SQLite
Options string // Additional connection options
}
// Service represents a service that interacts with multiple databases
type Service interface {
// Health returns health status for all databases
Health() map[string]map[string]string
// GetDB returns a specific SQL database connection by name
GetDB(name string) (*sql.DB, error)
// GetMongoClient returns a specific MongoDB client by name
GetMongoClient(name string) (*mongo.Client, error)
// Close terminates all database connections
Close() error
// ListDBs returns list of available database names
ListDBs() []string
// GetDBType returns the type of a specific database
GetDBType(name string) (DatabaseType, error)
}
type service struct {
db *sql.DB
sqlDatabases map[string]*sql.DB
mongoClients map[string]*mongo.Client
configs map[string]DatabaseConfig
mu sync.RWMutex
}
var (
database = os.Getenv("BLUEPRINT_DB_DATABASE")
password = os.Getenv("BLUEPRINT_DB_PASSWORD")
username = os.Getenv("BLUEPRINT_DB_USERNAME")
port = os.Getenv("BLUEPRINT_DB_PORT")
host = os.Getenv("BLUEPRINT_DB_HOST")
schema = os.Getenv("BLUEPRINT_DB_SCHEMA")
dbInstance *service
dbManager *service
once sync.Once
)
// New creates a new database service with multiple connections
func New() Service {
// Reuse Connection
if dbInstance != nil {
return dbInstance
}
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable&search_path=%s", username, password, host, port, database, schema)
db, err := sql.Open("pgx", connStr)
if err != nil {
log.Fatal(err)
}
dbInstance = &service{
db: db,
}
return dbInstance
once.Do(func() {
dbManager = &service{
sqlDatabases: make(map[string]*sql.DB),
mongoClients: make(map[string]*mongo.Client),
configs: make(map[string]DatabaseConfig),
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (s *service) Health() map[string]string {
// Load database configurations from environment
configs := loadDatabaseConfigs()
// Initialize all database connections
for _, config := range configs {
if err := dbManager.addDatabase(config); err != nil {
log.Printf("Failed to connect to database %s: %v", config.Name, err)
}
}
})
return dbManager
}
// loadDatabaseConfigs loads database configurations from environment variables
func loadDatabaseConfigs() []DatabaseConfig {
var configs []DatabaseConfig
// Load configurations from environment
// Format: DB_{NAME}_{PROPERTY}
// Check for DB_ prefixed configurations
envVars := os.Environ()
dbConfigs := make(map[string]map[string]string)
for _, envVar := range envVars {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := parts[1]
if strings.HasPrefix(key, "DB_") {
segments := strings.Split(key, "_")
if len(segments) >= 3 {
dbName := strings.ToLower(segments[1])
property := strings.ToLower(strings.Join(segments[2:], "_"))
if dbConfigs[dbName] == nil {
dbConfigs[dbName] = make(map[string]string)
}
dbConfigs[dbName][property] = value
}
}
}
// Convert map to DatabaseConfig structs
for name, config := range dbConfigs {
dbType := DatabaseType(getEnvFromMap(config, "type", "postgres"))
dbConfig := DatabaseConfig{
Name: name,
Type: dbType,
Host: getEnvFromMap(config, "host", "localhost"),
Port: getEnvFromMap(config, "port", getDefaultPort(dbType)),
Database: getEnvFromMap(config, "database", name),
Username: getEnvFromMap(config, "username", ""),
Password: getEnvFromMap(config, "password", ""),
Schema: getEnvFromMap(config, "schema", ""),
SSLMode: getEnvFromMap(config, "sslmode", "disable"),
Path: getEnvFromMap(config, "path", ""),
Options: getEnvFromMap(config, "options", ""),
}
configs = append(configs, dbConfig)
}
// If no configurations found, use default
if len(configs) == 0 {
configs = []DatabaseConfig{
{
Name: "primary",
Type: Postgres,
Host: getEnv("DB_PRIMARY_HOST", "localhost"),
Port: getEnv("DB_PRIMARY_PORT", "5432"),
Database: getEnv("DB_PRIMARY_DATABASE", "blueprint"),
Username: getEnv("DB_PRIMARY_USERNAME", "postgres"),
Password: getEnv("DB_PRIMARY_PASSWORD", ""),
Schema: getEnv("DB_PRIMARY_SCHEMA", "public"),
SSLMode: getEnv("DB_PRIMARY_SSLMODE", "disable"),
},
}
}
return configs
}
// getEnvFromMap helper function
func getEnvFromMap(config map[string]string, key, defaultValue string) string {
if value, exists := config[key]; exists {
return value
}
return defaultValue
}
// getEnv helper function
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getDefaultPort returns default port for database type
func getDefaultPort(dbType DatabaseType) string {
switch dbType {
case Postgres:
return "5432"
case MySQL:
return "3306"
case SQLServer:
return "1433"
case MongoDB:
return "27017"
case SQLite:
return ""
default:
return "5432"
}
}
// addDatabase adds a new database connection
func (s *service) addDatabase(config DatabaseConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
switch config.Type {
case Postgres:
return s.addPostgres(config)
case MySQL:
return s.addMySQL(config)
case SQLServer:
return s.addSQLServer(config)
case SQLite:
return s.addSQLite(config)
case MongoDB:
return s.addMongoDB(config)
default:
return fmt.Errorf("unsupported database type: %s", config.Type)
}
}
// addPostgres adds PostgreSQL connection
func (s *service) addPostgres(config DatabaseConfig) error {
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
config.SSLMode,
)
if config.Schema != "" {
connStr += "&search_path=" + config.Schema
}
db, err := sql.Open("pgx", connStr)
if err != nil {
return fmt.Errorf("failed to open PostgreSQL connection: %w", err)
}
return s.configureSQLDB(config.Name, db)
}
// addMySQL adds MySQL connection
func (s *service) addMySQL(config DatabaseConfig) error {
connStr := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
db, err := sql.Open("mysql", connStr)
if err != nil {
return fmt.Errorf("failed to open MySQL connection: %w", err)
}
return s.configureSQLDB(config.Name, db)
}
// addSQLServer adds SQL Server connection
func (s *service) addSQLServer(config DatabaseConfig) error {
connStr := fmt.Sprintf("sqlserver://%s:%s@%s:%s?database=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
db, err := sql.Open("sqlserver", connStr)
if err != nil {
return fmt.Errorf("failed to open SQL Server connection: %w", err)
}
return s.configureSQLDB(config.Name, db)
}
// addSQLite adds SQLite connection
func (s *service) addSQLite(config DatabaseConfig) error {
dbPath := config.Path
if dbPath == "" {
dbPath = fmt.Sprintf("./data/%s.db", config.Name)
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return fmt.Errorf("failed to open SQLite connection: %w", err)
}
return s.configureSQLDB(config.Name, db)
}
// addMongoDB adds MongoDB connection
func (s *service) addMongoDB(config DatabaseConfig) error {
uri := fmt.Sprintf("mongodb://%s:%s@%s:%s/%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
clientOptions := options.Client().ApplyURI(uri)
client, err := mongo.Connect(context.Background(), clientOptions)
if err != nil {
return fmt.Errorf("failed to connect to MongoDB: %w", err)
}
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := client.Ping(ctx, nil); err != nil {
client.Disconnect(context.Background())
return fmt.Errorf("failed to ping MongoDB: %w", err)
}
s.mongoClients[config.Name] = client
s.configs[config.Name] = config
log.Printf("Successfully connected to MongoDB: %s", config.Name)
return nil
}
// configureSQLDB configures common SQL database settings
func (s *service) configureSQLDB(name string, db *sql.DB) error {
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return fmt.Errorf("failed to ping database: %w", err)
}
s.sqlDatabases[name] = db
log.Printf("Successfully connected to SQL database: %s", name)
return nil
}
// # Example multi-database configuration for different database types
// # PostgreSQL
// DB_TYPE_PRIMARY=postgres
// DB_HOST_PRIMARY=localhost
// DB_PORT_PRIMARY=5432
// DB_NAME_PRIMARY=myapp_postgres
// DB_USER_PRIMARY=postgres
// DB_PASS_PRIMARY=postgres_password
// DB_SCHEMA_PRIMARY=public
// DB_SSLMODE_PRIMARY=disable
// # MySQL
// DB_TYPE_MYSQL=mysql
// DB_HOST_MYSQL=localhost
// DB_PORT_MYSQL=3306
// DB_NAME_MYSQL=myapp_mysql
// DB_USER_MYSQL=root
// DB_PASS_MYSQL=mysql_password
// # SQL Server
// DB_TYPE_SQLSERVER=mssql
// DB_HOST_SQLSERVER=localhost
// DB_PORT_SQLSERVER=1433
// DB_NAME_SQLSERVER=myapp_mssql
// DB_USER_SQLSERVER=sa
// DB_PASS_SQLSERVER=mssql_password
// # MongoDB
// DB_TYPE_MONGODB=mongodb
// DB_HOST_MONGODB=localhost
// DB_PORT_MONGODB=27017
// DB_NAME_MONGODB=myapp_mongo
// DB_USER_MONGODB=mongo_user
// DB_PASS_MONGODB=mongo_password
// # SQLite
// DB_TYPE_SQLITE=sqlite
// DB_PATH_SQLITE=./data/myapp_sqlite.db
// Health checks the health of all database connections by pinging each database.
// It returns a map with database names as keys and their health statistics as values.
func (s *service) Health() map[string]map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]map[string]string)
// Check SQL databases
for name, db := range s.sqlDatabases {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := s.db.PingContext(ctx)
err := db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
log.Fatalf("db down: %v", err) // Log the error and terminate the program
return stats
stats["type"] = "sql"
result[name] = stats
continue
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
stats["type"] = "sql"
// Get database stats (like open connections, in use, idle, etc.)
dbStats := s.db.Stats()
// Get database stats
dbStats := db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
@@ -85,8 +435,8 @@ func (s *service) Health() map[string]string {
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
// Evaluate stats to provide a health message
if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example
// Evaluate stats to provide health messages
if dbStats.OpenConnections > 40 {
stats["message"] = "The database is experiencing heavy load."
}
@@ -102,14 +452,129 @@ func (s *service) Health() map[string]string {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
return stats
result[name] = stats
}
// Close closes the database connection.
// It logs a message indicating the disconnection from the specific database.
// If the connection is successfully closed, it returns nil.
// If an error occurs while closing the connection, it returns the error.
func (s *service) Close() error {
log.Printf("Disconnected from database: %s", database)
return s.db.Close()
// Check MongoDB connections
for name, client := range s.mongoClients {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the MongoDB
err := client.Ping(ctx, nil)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("mongodb down: %v", err)
stats["type"] = "mongodb"
result[name] = stats
continue
}
// MongoDB is up
stats["status"] = "up"
stats["message"] = "It's healthy"
stats["type"] = "mongodb"
result[name] = stats
}
return result
}
// GetDB returns a specific SQL database connection by name
func (s *service) GetDB(name string) (*sql.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, exists := s.sqlDatabases[name]
if !exists {
return nil, fmt.Errorf("database %s not found", name)
}
return db, nil
}
// GetMongoClient returns a specific MongoDB client by name
func (s *service) GetMongoClient(name string) (*mongo.Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
client, exists := s.mongoClients[name]
if !exists {
return nil, fmt.Errorf("MongoDB client %s not found", name)
}
return client, nil
}
// ListDBs returns list of available database names
func (s *service) ListDBs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.sqlDatabases)+len(s.mongoClients))
// Add SQL databases
for name := range s.sqlDatabases {
names = append(names, name)
}
// Add MongoDB clients
for name := range s.mongoClients {
names = append(names, name)
}
return names
}
// GetDBType returns the type of a specific database
func (s *service) GetDBType(name string) (DatabaseType, error) {
s.mu.RLock()
defer s.mu.RUnlock()
config, exists := s.configs[name]
if !exists {
return "", fmt.Errorf("database %s not found", name)
}
return config.Type, nil
}
// Close closes all database connections
// It logs messages indicating disconnection from each database
func (s *service) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
// Close SQL databases
for name, db := range s.sqlDatabases {
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close database %s: %w", name, err))
} else {
log.Printf("Disconnected from SQL database: %s", name)
}
}
// Close MongoDB clients
for name, client := range s.mongoClients {
if err := client.Disconnect(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("failed to disconnect MongoDB client %s: %w", name, err))
} else {
log.Printf("Disconnected from MongoDB: %s", name)
}
}
s.sqlDatabases = make(map[string]*sql.DB)
s.mongoClients = make(map[string]*mongo.Client)
s.configs = make(map[string]DatabaseConfig)
if len(errs) > 0 {
return fmt.Errorf("errors closing databases: %v", errs)
}
return nil
}

View File

@@ -13,9 +13,9 @@ import (
func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) {
var (
dbName = "database"
dbPwd = "password"
dbUser = "user"
dbName = "testdb"
dbPwd = "testpass"
dbUser = "testuser"
)
dbContainer, err := postgres.Run(
@@ -33,23 +33,6 @@ func mustStartPostgresContainer() (func(context.Context, ...testcontainers.Termi
return nil, err
}
database = dbName
password = dbPwd
username = dbUser
dbHost, err := dbContainer.Host(context.Background())
if err != nil {
return dbContainer.Terminate, err
}
dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp")
if err != nil {
return dbContainer.Terminate, err
}
host = dbHost
port = dbPort.Port()
return dbContainer.Terminate, err
}
@@ -78,16 +61,20 @@ func TestHealth(t *testing.T) {
stats := srv.Health()
if stats["status"] != "up" {
t.Fatalf("expected status to be up, got %s", stats["status"])
// Since we don't have any databases configured in test, we expect empty stats
if len(stats) == 0 {
t.Log("No databases configured, health check returns empty stats")
return
}
if _, ok := stats["error"]; ok {
t.Fatalf("expected error not to be present")
// If we have databases, check their health
for dbName, dbStats := range stats {
if dbStats["status"] != "up" {
t.Errorf("database %s status is not up: %s", dbName, dbStats["status"])
}
if err, ok := dbStats["error"]; ok && err != "" {
t.Errorf("database %s has error: %s", dbName, err)
}
if stats["message"] != "It's healthy" {
t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"])
}
}

226
internal/middleware/auth.go Normal file
View File

@@ -0,0 +1,226 @@
package middleware
/** Keylock Auth Middleware **/
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
var (
ErrInvalidToken = errors.New("invalid token")
)
// Configurable Keycloak parameters - replace with your actual values or load from config/env
const (
KeycloakIssuer = "https://keycloak.example.com/auth/realms/yourrealm"
KeycloakAudience = "your-client-id"
JwksURL = KeycloakIssuer + "/protocol/openid-connect/certs"
)
// JwksCache caches JWKS keys with expiration
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
}
func NewJwksCache() *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
resp, err := http.Get(JwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64UrlDecode(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64UrlDecode(eStr)
if err != nil {
return nil, err
}
var eInt int
for _, b := range eBytes {
eInt = eInt<<8 + int(b)
}
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}
return pubKey, nil
}
func base64UrlDecode(s string) ([]byte, error) {
// Add padding if missing
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
return base64.URLEncoding.DecodeString(s)
}
var jwksCache = NewJwksCache()
// AuthMiddleware validates Bearer token as Keycloak JWT token
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("AuthMiddleware: Checking Authorization header") // Debug log
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
fmt.Println("AuthMiddleware: Authorization header missing") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
fmt.Println("AuthMiddleware: Invalid Authorization header format") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
fmt.Printf("AuthMiddleware: Unexpected signing method: %v\n", token.Header["alg"]) // Debug log
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
fmt.Println("AuthMiddleware: kid header not found") // Debug log
return nil, errors.New("kid header not found")
}
return jwksCache.GetKey(kid)
}, jwt.WithIssuer(KeycloakIssuer), jwt.WithAudience(KeycloakAudience))
if err != nil || !token.Valid {
fmt.Printf("AuthMiddleware: Invalid or expired token: %v\n", err) // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
fmt.Println("AuthMiddleware: Token valid, proceeding") // Debug log
// Token is valid, proceed
c.Next()
}
}
/** JWT Bearer authentication middleware */
// import (
// "net/http"
// "strings"
// "github.com/gin-gonic/gin"
// )
// // AuthMiddleware validates Bearer token in Authorization header
// func AuthMiddleware() gin.HandlerFunc {
// return func(c *gin.Context) {
// authHeader := c.GetHeader("Authorization")
// if authHeader == "" {
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
// return
// }
// parts := strings.SplitN(authHeader, " ", 2)
// if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
// return
// }
// token := parts[1]
// // For now, use a static token for validation. Replace with your logic.
// const validToken = "your-static-token"
// if token != validToken {
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
// return
// }
// c.Next()
// }
// }

View File

@@ -18,6 +18,7 @@ func RegisterRoutes() *gin.Engine {
// Add middleware
router.Use(middleware.CORSConfig())
router.Use(middleware.ErrorHandler())
// router.Use(middleware.AuthMiddleware()) // Added auth middleware here
router.Use(gin.Logger())
router.Use(gin.Recovery())
@@ -27,6 +28,7 @@ func RegisterRoutes() *gin.Engine {
// API v1 group
v1 := router.Group("/api/v1")
{
router.Use(middleware.AuthMiddleware()) // Added auth middleware here
// Health endpoints
healthHandler := handlers.NewHealthHandler()
v1.GET("/health", healthHandler.GetHealth)
@@ -39,6 +41,8 @@ func RegisterRoutes() *gin.Engine {
// WebSocket endpoint
v1.GET("/websocket", WebSocketHandler)
v1.GET("/webservice", WebServiceHandler)
}
return router
@@ -49,3 +53,8 @@ func WebSocketHandler(c *gin.Context) {
// This will be implemented with proper WebSocket handling
c.JSON(http.StatusOK, gin.H{"message": "WebSocket endpoint"})
}
func WebServiceHandler(c *gin.Context) {
// This will be implemented with proper WebSocket handling
c.JSON(http.StatusOK, gin.H{"message": "WebSocket endpoint"})
}