diff --git a/.env.example b/.env.example index 3105bf6..d5168d8 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,21 @@ DB_PORT=3306 DB_DATABASE=primaya DB_USERNAME=root DB_PASSWORD= +DB_DEFAULT_RS_CODE=AWALBROS_DEV +DB_DEFAULT_RS_NAME=RS Dev Awalbros + +# Database khusus master CPONE middleware. Jika host/user/password dikosongkan, +# gunakan nilai DB_* di atas. +CPONE_DB_HOST=127.0.0.1 +CPONE_DB_PORT=3306 +CPONE_DB_DATABASE=cpone_middleware +CPONE_DB_USERNAME=root +CPONE_DB_PASSWORD= + +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=25 +DB_CONN_MAX_LIFETIME_MINUTES=5 CPONE_BEARER_TOKEN=replace-with-a-secure-token +CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token +CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key diff --git a/README.md b/README.md index 227f8aa..4659635 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,80 @@ DB_PORT=3306 DB_DATABASE=primaya DB_USERNAME=root DB_PASSWORD= +DB_DEFAULT_RS_CODE=AWALBROS_DEV +DB_DEFAULT_RS_NAME=RS Dev Awalbros + +CPONE_DB_HOST=127.0.0.1 +CPONE_DB_PORT=3306 +CPONE_DB_DATABASE=cpone_middleware +CPONE_DB_USERNAME=root +CPONE_DB_PASSWORD= CPONE_BEARER_TOKEN=replace-with-a-secure-token +CPONE_DATABASE_SETTINGS_TOKEN=replace-with-a-different-admin-token +CPONE_DATABASE_CREDENTIAL_KEY=replace-with-a-long-random-encryption-key ``` +`DB_*` tetap menjadi koneksi default (saat ini database dev Awalbros). Request +lama yang tidak mengirim kode RS akan tetap menggunakan koneksi ini. + +## Multi-database per kode RS + +Tambahkan atau perbarui koneksi RS melalui API berikut. Koneksi akan dites +sebelum disimpan; bila `Ping` gagal, setting tidak disimpan. + +```http +POST /api/cpone/database-settings +Authorization: Bearer +Content-Type: application/json + +{ + "kode_rs": "PRIMAYA_BEKASI_BARAT", + "nama": "Primaya Hospital Bekasi Barat", + "host": "10.10.10.20", + "port": "3306", + "database": "his_bekasi_barat", + "username": "cpone", + "password": "secret" +} +``` + +Endpoint pengelolaan yang tersedia: + +```text +POST /api/cpone/database-settings +GET /api/cpone/database-settings +GET /api/cpone/database-settings/{kodeRs} +``` + +Saat startup, service otomatis membuat database `CPONE_DB_DATABASE` beserta +tabel `hospitals` dan `hospital_databases`. Koneksi HIS lama dari `DB_*` otomatis +menjadi RS default. Akun `CPONE_DB_USERNAME` harus memiliki izin `CREATE +DATABASE` pada bootstrap pertama; alternatifnya jalankan migration +`database/migrations/001_create_cpone_management.sql` secara manual. + +Response list/detail tidak pernah menampilkan password, hanya field +`has_password`. Password koneksi RS disimpan terenkripsi AES-GCM menggunakan +`CPONE_DATABASE_CREDENTIAL_KEY`. Jangan mengubah key tersebut setelah data mulai +disimpan karena password lama tidak akan bisa didekripsi. Jika key belum diisi, +service memakai `CPONE_DATABASE_SETTINGS_TOKEN`, lalu `CPONE_BEARER_TOKEN` +sebagai fallback; token fallback itu juga tidak boleh diubah tanpa rotasi +credential. + +Endpoint pengelolaan memakai `CPONE_DATABASE_SETTINGS_TOKEN`. Jika variabel ini +belum diisi, service memakai `CPONE_BEARER_TOKEN` untuk kompatibilitas; gunakan +token admin yang berbeda di production. + +Pilih database pada semua endpoint bisnis dengan header: + +```http +X-RS-Code: PRIMAYA_BEKASI_BARAT +``` + +Sebagai alternatif dapat memakai query `?kode_rs=PRIMAYA_BEKASI_BARAT`. +Header memiliki prioritas lebih tinggi daripada query. Response juga memuat +header `X-RS-Code` agar koneksi yang terpilih mudah diaudit. Jika keduanya tidak +ada, service memakai `DB_DEFAULT_RS_CODE`. + ## Run ```bash diff --git a/cmd/server/main.go b/cmd/server/main.go index 11afaca..eb8639d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "database/sql" "errors" "log" "net/http" @@ -11,11 +10,9 @@ import ( "syscall" "time" - _ "github.com/go-sql-driver/mysql" - "primaya-api/cpone-middleware/internal/config" + "primaya-api/cpone-middleware/internal/databaseconfig" apphttp "primaya-api/cpone-middleware/internal/http" - "primaya-api/cpone-middleware/internal/repository" ) func main() { @@ -24,36 +21,54 @@ func main() { log.Fatalf("load config: %v", err) } - db, err := sql.Open("mysql", cfg.MySQLDSN()) + bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second) + registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{ + Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase, + Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword, + }, cfg.DBDefaultRSCode, databaseconfig.Setting{ + RSCode: cfg.DBDefaultRSCode, Name: cfg.DBDefaultRSName, + Host: cfg.DBHost, Port: cfg.DBPort, Database: cfg.DBDatabase, + Username: cfg.DBUsername, Password: cfg.DBPassword, + }, databaseconfig.PoolConfig{ + MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns, + ConnMaxLifetime: cfg.DBConnMaxLifetime, + }, cfg.DBCredentialSecret) + bootstrapCancel() if err != nil { - log.Fatalf("open mysql: %v", err) + log.Fatalf("load database registry: %v", err) } - defer db.Close() + defer registry.Close() - db.SetMaxOpenConns(cfg.DBMaxOpenConns) - db.SetMaxIdleConns(cfg.DBMaxIdleConns) - db.SetConnMaxLifetime(cfg.DBConnMaxLifetime) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := db.PingContext(ctx); err != nil { + defaultDBCtx, defaultDBCancel := context.WithTimeout(context.Background(), 10*time.Second) + repo, err := registry.Resolve(defaultDBCtx, cfg.DBDefaultRSCode) + defaultDBCancel() + if err != nil { log.Fatalf("ping mysql: %v", err) } - repo := repository.NewMySQLLayananRepository(db) handler := apphttp.NewHandler(repo) + settingsHandler := apphttp.NewDatabaseSettingsHandler(registry) + protected := func(handler http.Handler) http.Handler { + return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler)) + } + adminProtected := func(handler http.Handler) http.Handler { + return apphttp.BearerAuth(cfg.DBSettingsToken, handler) + } mux := http.NewServeMux() - mux.Handle("GET /api/cpone/paket", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListPaket))) - mux.Handle("GET /api/cpone/paket/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetPaket))) - mux.Handle("GET /api/cpone/tarif-kerjasama", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.ListTarifKerjasama))) - mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetTarifKerjasama))) - mux.Handle("GET /api/cpone/layanan-mapping-harga", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.MappingHarga))) - mux.Handle("POST /api/cpone/patients/medrec", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.StoreMedrec))) - mux.Handle("POST /api/cpone/patients/lab-registration", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.RegisterLab))) - mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabServices))) - mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.AddLabPackage))) - mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", apphttp.BearerAuth(cfg.BearerToken, http.HandlerFunc(handler.GetLabResult))) + mux.Handle("GET /api/cpone/paket", protected(http.HandlerFunc(handler.ListPaket))) + mux.Handle("GET /api/cpone/paket/{id}", protected(http.HandlerFunc(handler.GetPaket))) + mux.Handle("GET /api/cpone/tarif-kerjasama", protected(http.HandlerFunc(handler.ListTarifKerjasama))) + mux.Handle("GET /api/cpone/tarif-kerjasama/{id}", protected(http.HandlerFunc(handler.GetTarifKerjasama))) + mux.Handle("GET /api/cpone/layanan-mapping-harga", protected(http.HandlerFunc(handler.MappingHarga))) + mux.Handle("POST /api/cpone/patients/medrec", protected(http.HandlerFunc(handler.StoreMedrec))) + mux.Handle("POST /api/cpone/patients/lab-registration", protected(http.HandlerFunc(handler.RegisterLab))) + mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices))) + mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage))) + mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult))) + mux.Handle("GET /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.List))) + mux.Handle("GET /api/cpone/database-settings/{kodeRs}", adminProtected(http.HandlerFunc(settingsHandler.Get))) + mux.Handle("POST /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.Store))) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/database/migrations/001_create_cpone_management.sql b/database/migrations/001_create_cpone_management.sql new file mode 100644 index 0000000..6206f02 --- /dev/null +++ b/database/migrations/001_create_cpone_management.sql @@ -0,0 +1,33 @@ +CREATE DATABASE IF NOT EXISTS `cpone_middleware` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE `cpone_middleware`; + +CREATE TABLE IF NOT EXISTS hospitals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(50) NOT NULL, + name VARCHAR(150) NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_hospitals_code (code), + KEY idx_hospitals_active (active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS hospital_databases ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + hospital_id BIGINT UNSIGNED NOT NULL, + host VARCHAR(255) NOT NULL, + port SMALLINT UNSIGNED NOT NULL DEFAULT 3306, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + encrypted_password TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_hospital_databases_hospital (hospital_id), + CONSTRAINT fk_hospital_databases_hospital + FOREIGN KEY (hospital_id) REFERENCES hospitals(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/internal/config/config.go b/internal/config/config.go index d66b61d..6d8ab91 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "bufio" "fmt" - "net/url" "os" "strconv" "strings" @@ -11,17 +10,26 @@ import ( ) type Config struct { - AppHost string - AppPort string - DBHost string - DBPort string - DBDatabase string - DBUsername string - DBPassword string - BearerToken string - DBMaxOpenConns int - DBMaxIdleConns int - DBConnMaxLifetime time.Duration + AppHost string + AppPort string + DBHost string + DBPort string + DBDatabase string + DBUsername string + DBPassword string + BearerToken string + DBSettingsToken string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetime time.Duration + DBDefaultRSCode string + DBDefaultRSName string + ManagementDBHost string + ManagementDBPort string + ManagementDBDatabase string + ManagementDBUsername string + ManagementDBPassword string + DBCredentialSecret string } func Load(path string) (Config, error) { @@ -30,17 +38,26 @@ func Load(path string) (Config, error) { } cfg := Config{ - AppHost: env("APP_HOST", "0.0.0.0"), - AppPort: env("APP_PORT", "8080"), - DBHost: env("DB_HOST", "127.0.0.1"), - DBPort: env("DB_PORT", "3306"), - DBDatabase: env("DB_DATABASE", ""), - DBUsername: env("DB_USERNAME", "root"), - DBPassword: env("DB_PASSWORD", ""), - BearerToken: env("CPONE_BEARER_TOKEN", ""), - DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), - DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), - DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, + AppHost: env("APP_HOST", "0.0.0.0"), + AppPort: env("APP_PORT", "8080"), + DBHost: env("DB_HOST", "127.0.0.1"), + DBPort: env("DB_PORT", "3306"), + DBDatabase: env("DB_DATABASE", ""), + DBUsername: env("DB_USERNAME", "root"), + DBPassword: env("DB_PASSWORD", ""), + BearerToken: env("CPONE_BEARER_TOKEN", ""), + DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")), + DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), + DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, + DBDefaultRSCode: strings.ToUpper(strings.TrimSpace(env("DB_DEFAULT_RS_CODE", "AWALBROS_DEV"))), + DBDefaultRSName: env("DB_DEFAULT_RS_NAME", "RS Dev Awalbros"), + ManagementDBHost: env("CPONE_DB_HOST", env("DB_HOST", "127.0.0.1")), + ManagementDBPort: env("CPONE_DB_PORT", env("DB_PORT", "3306")), + ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"), + ManagementDBUsername: env("CPONE_DB_USERNAME", env("DB_USERNAME", "root")), + ManagementDBPassword: env("CPONE_DB_PASSWORD", env("DB_PASSWORD", "")), + DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))), } if cfg.DBDatabase == "" { @@ -49,6 +66,12 @@ func Load(path string) (Config, error) { if cfg.BearerToken == "" { return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") } + if cfg.DBDefaultRSCode == "" { + return Config{}, fmt.Errorf("DB_DEFAULT_RS_CODE wajib diisi") + } + if cfg.ManagementDBDatabase == "" { + return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi") + } return cfg, nil } @@ -57,23 +80,6 @@ func (c Config) Addr() string { return c.AppHost + ":" + c.AppPort } -func (c Config) MySQLDSN() string { - values := url.Values{} - values.Set("charset", "utf8mb4") - values.Set("parseTime", "true") - values.Set("loc", "Local") - - return fmt.Sprintf( - "%s:%s@tcp(%s:%s)/%s?%s", - c.DBUsername, - c.DBPassword, - c.DBHost, - c.DBPort, - c.DBDatabase, - values.Encode(), - ) -} - func loadDotEnv(path string) error { file, err := os.Open(path) if err != nil { diff --git a/internal/databaseconfig/registry.go b/internal/databaseconfig/registry.go new file mode 100644 index 0000000..8c07aeb --- /dev/null +++ b/internal/databaseconfig/registry.go @@ -0,0 +1,484 @@ +package databaseconfig + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + mysql "github.com/go-sql-driver/mysql" + + "primaya-api/cpone-middleware/internal/repository" +) + +var ErrSettingNotFound = errors.New("database setting not found") + +var ( + validRSCode = regexp.MustCompile(`^[A-Z0-9_-]+$`) + validDatabaseName = regexp.MustCompile(`^[A-Za-z0-9_]+$`) +) + +type Setting struct { + RSCode string `json:"kode_rs"` + Name string `json:"nama"` + Host string `json:"host"` + Port string `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` +} + +type PublicSetting struct { + RSCode string `json:"kode_rs"` + Name string `json:"nama"` + Host string `json:"host"` + Port string `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + HasPassword bool `json:"has_password"` + IsDefault bool `json:"is_default"` + Active bool `json:"active"` +} + +type PoolConfig struct { + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +type Registry struct { + mu sync.RWMutex + managementDB *sql.DB + defaultCode string + poolConfig PoolConfig + credentialKey [32]byte + settings map[string]Setting + pools map[string]*sql.DB + retiredPools []*sql.DB +} + +// NewRegistry creates the management database and tables, then seeds the +// existing HIS connection as the default hospital. +func NewRegistry(ctx context.Context, managementSetting Setting, defaultCode string, defaultSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) { + if !validDatabaseName.MatchString(managementSetting.Database) { + return nil, errors.New("nama database manajemen hanya boleh berisi huruf, angka, dan underscore") + } + if strings.TrimSpace(credentialSecret) == "" { + return nil, errors.New("credential secret database wajib diisi") + } + + managementDB, err := openManagementDatabase(ctx, managementSetting, poolConfig) + if err != nil { + return nil, err + } + r := &Registry{ + managementDB: managementDB, + defaultCode: NormalizeRSCode(defaultCode), + poolConfig: poolConfig, + credentialKey: sha256.Sum256([]byte(credentialSecret)), + settings: make(map[string]Setting), + pools: make(map[string]*sql.DB), + } + if err := r.migrate(ctx); err != nil { + _ = managementDB.Close() + return nil, err + } + defaultSetting.RSCode = r.defaultCode + if strings.TrimSpace(defaultSetting.Name) == "" { + defaultSetting.Name = "RS Dev Awalbros" + } + if err := r.seedDefault(ctx, defaultSetting); err != nil { + _ = managementDB.Close() + return nil, err + } + if err := r.load(ctx); err != nil { + _ = managementDB.Close() + return nil, err + } + return r, nil +} + +func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) } + +func (r *Registry) DefaultCode() string { return r.defaultCode } + +func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) { + code = NormalizeRSCode(code) + if code == "" { + code = r.defaultCode + } + r.mu.RLock() + setting, exists := r.settings[code] + db := r.pools[code] + r.mu.RUnlock() + if !exists { + return repository.MySQLLayananRepository{}, ErrSettingNotFound + } + if db != nil { + return repository.NewMySQLLayananRepository(db), nil + } + newDB, err := r.openAndPing(ctx, setting) + if err != nil { + return repository.MySQLLayananRepository{}, err + } + r.mu.Lock() + if existing := r.pools[code]; existing != nil { + r.mu.Unlock() + _ = newDB.Close() + return repository.NewMySQLLayananRepository(existing), nil + } + r.pools[code] = newDB + r.mu.Unlock() + return repository.NewMySQLLayananRepository(newDB), nil +} + +func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting, bool, error) { + setting = normalizeSetting(setting) + if err := Validate(setting); err != nil { + return PublicSetting{}, false, err + } + newDB, err := r.openAndPing(ctx, setting) + if err != nil { + return PublicSetting{}, false, fmt.Errorf("koneksi database RS gagal: %w", err) + } + encryptedPassword, err := r.encrypt(setting.Password) + if err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + + tx, err := r.managementDB.BeginTx(ctx, nil) + if err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + defer tx.Rollback() + var hospitalID int64 + err = tx.QueryRowContext(ctx, `SELECT id FROM hospitals WHERE code = ? FOR UPDATE`, setting.RSCode).Scan(&hospitalID) + created := errors.Is(err, sql.ErrNoRows) + if err != nil && !created { + _ = newDB.Close() + return PublicSetting{}, false, err + } + if created { + result, execErr := tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, is_default, active) VALUES (?, ?, 0, 1)`, setting.RSCode, setting.Name) + if execErr != nil { + _ = newDB.Close() + return PublicSetting{}, false, execErr + } + hospitalID, err = result.LastInsertId() + if err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + } else if _, err = tx.ExecContext(ctx, `UPDATE hospitals SET name = ?, active = 1 WHERE id = ?`, setting.Name, hospitalID); err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO hospital_databases (hospital_id, host, port, database_name, username, encrypted_password) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE host = VALUES(host), port = VALUES(port), database_name = VALUES(database_name), + username = VALUES(username), encrypted_password = VALUES(encrypted_password)`, + hospitalID, setting.Host, setting.Port, setting.Database, setting.Username, encryptedPassword) + if err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + if err = tx.Commit(); err != nil { + _ = newDB.Close() + return PublicSetting{}, false, err + } + + r.mu.Lock() + oldPool := r.pools[setting.RSCode] + r.settings[setting.RSCode] = setting + r.pools[setting.RSCode] = newDB + if oldPool != nil { + r.retiredPools = append(r.retiredPools, oldPool) + } + r.mu.Unlock() + return publicSetting(setting, r.defaultCode), created, nil +} + +func (r *Registry) List() []PublicSetting { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]PublicSetting, 0, len(r.settings)) + for _, setting := range r.settings { + result = append(result, publicSetting(setting, r.defaultCode)) + } + sort.Slice(result, func(i, j int) bool { return result[i].RSCode < result[j].RSCode }) + return result +} + +func (r *Registry) Get(code string) (PublicSetting, bool) { + code = NormalizeRSCode(code) + r.mu.RLock() + defer r.mu.RUnlock() + setting, exists := r.settings[code] + return publicSetting(setting, r.defaultCode), exists +} + +func (r *Registry) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + var firstErr error + for code, db := range r.pools { + if err := db.Close(); err != nil && firstErr == nil { + firstErr = err + } + delete(r.pools, code) + } + for _, db := range r.retiredPools { + if err := db.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if err := r.managementDB.Close(); err != nil && firstErr == nil { + firstErr = err + } + r.retiredPools = nil + return firstErr +} + +func openManagementDatabase(ctx context.Context, setting Setting, pool PoolConfig) (*sql.DB, error) { + serverSetting := setting + serverSetting.Database = "" + bootstrap, err := sql.Open("mysql", mysqlDSN(serverSetting)) + if err != nil { + return nil, err + } + if err := bootstrap.PingContext(ctx); err != nil { + _ = bootstrap.Close() + return nil, fmt.Errorf("koneksi server database manajemen gagal: %w", err) + } + _, err = bootstrap.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS `"+setting.Database+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + _ = bootstrap.Close() + if err != nil { + return nil, fmt.Errorf("buat database manajemen %s: %w", setting.Database, err) + } + db, err := sql.Open("mysql", mysqlDSN(setting)) + if err != nil { + return nil, err + } + applyPoolConfig(db, pool) + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("koneksi database manajemen gagal: %w", err) + } + return db, nil +} + +func (r *Registry) migrate(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS hospitals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), UNIQUE KEY uq_hospitals_code (code), KEY idx_hospitals_active (active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS hospital_databases ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hospital_id BIGINT UNSIGNED NOT NULL, + host VARCHAR(255) NOT NULL, port SMALLINT UNSIGNED NOT NULL DEFAULT 3306, + database_name VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL, encrypted_password TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), UNIQUE KEY uq_hospital_databases_hospital (hospital_id), + CONSTRAINT fk_hospital_databases_hospital FOREIGN KEY (hospital_id) REFERENCES hospitals(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + } + for _, statement := range statements { + if _, err := r.managementDB.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("migrasi database manajemen: %w", err) + } + } + return nil +} + +func (r *Registry) seedDefault(ctx context.Context, setting Setting) error { + setting = normalizeSetting(setting) + if err := Validate(setting); err != nil { + return fmt.Errorf("setting database default tidak valid: %w", err) + } + encryptedPassword, err := r.encrypt(setting.Password) + if err != nil { + return err + } + tx, err := r.managementDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE hospitals SET is_default = 0 WHERE code <> ?`, setting.RSCode); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, is_default, active) VALUES (?, ?, 1, 1) + ON DUPLICATE KEY UPDATE is_default = 1, active = 1`, setting.RSCode, setting.Name) + if err != nil { + return err + } + var hospitalID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM hospitals WHERE code = ?`, setting.RSCode).Scan(&hospitalID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO hospital_databases (hospital_id, host, port, database_name, username, encrypted_password) + VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE hospital_id = hospital_id`, hospitalID, setting.Host, + setting.Port, setting.Database, setting.Username, encryptedPassword) + if err != nil { + return err + } + return tx.Commit() +} + +func (r *Registry) load(ctx context.Context) error { + rows, err := r.managementDB.QueryContext(ctx, `SELECT h.code, h.name, d.host, d.port, d.database_name, d.username, d.encrypted_password + FROM hospitals h INNER JOIN hospital_databases d ON d.hospital_id = h.id WHERE h.active = 1 ORDER BY h.code`) + if err != nil { + return err + } + defer rows.Close() + settings := make(map[string]Setting) + for rows.Next() { + var setting Setting + var encryptedPassword string + if err := rows.Scan(&setting.RSCode, &setting.Name, &setting.Host, &setting.Port, &setting.Database, &setting.Username, &encryptedPassword); err != nil { + return err + } + setting.Password, err = r.decrypt(encryptedPassword) + if err != nil { + return fmt.Errorf("decrypt password %s: %w", setting.RSCode, err) + } + settings[setting.RSCode] = setting + } + if err := rows.Err(); err != nil { + return err + } + r.settings = settings + return nil +} + +func (r *Registry) encrypt(plainText string) (string, error) { + block, err := aes.NewCipher(r.credentialKey[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plainText), nil)), nil +} + +func (r *Registry) decrypt(encoded string) (string, error) { + sealed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(r.credentialKey[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(sealed) < gcm.NonceSize() { + return "", errors.New("encrypted password tidak valid") + } + plainText, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], nil) + if err != nil { + return "", err + } + return string(plainText), nil +} + +func (r *Registry) openAndPing(ctx context.Context, setting Setting) (*sql.DB, error) { + db, err := sql.Open("mysql", mysqlDSN(setting)) + if err != nil { + return nil, err + } + applyPoolConfig(db, r.poolConfig) + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func applyPoolConfig(db *sql.DB, pool PoolConfig) { + db.SetMaxOpenConns(pool.MaxOpenConns) + db.SetMaxIdleConns(pool.MaxIdleConns) + db.SetConnMaxLifetime(pool.ConnMaxLifetime) +} + +func Validate(setting Setting) error { + setting = normalizeSetting(setting) + if setting.RSCode == "" { + return errors.New("kode_rs wajib diisi") + } + if len(setting.RSCode) > 50 { + return errors.New("kode_rs maksimal 50 karakter") + } + if !validRSCode.MatchString(setting.RSCode) { + return errors.New("kode_rs hanya boleh berisi huruf, angka, tanda hubung, dan underscore") + } + if setting.Name == "" { + return errors.New("nama rumah sakit wajib diisi") + } + if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" { + return errors.New("host, port, database, dan username wajib diisi") + } + port, err := strconv.Atoi(setting.Port) + if err != nil || port < 1 || port > 65535 { + return errors.New("port harus berupa angka antara 1 sampai 65535") + } + return nil +} + +func normalizeSetting(setting Setting) Setting { + setting.RSCode = NormalizeRSCode(setting.RSCode) + setting.Name = strings.TrimSpace(setting.Name) + setting.Host = strings.TrimSpace(setting.Host) + setting.Port = strings.TrimSpace(setting.Port) + setting.Database = strings.TrimSpace(setting.Database) + setting.Username = strings.TrimSpace(setting.Username) + return setting +} + +func publicSetting(setting Setting, defaultCode string) PublicSetting { + return PublicSetting{RSCode: setting.RSCode, Name: setting.Name, Host: setting.Host, Port: setting.Port, + Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "", + IsDefault: setting.RSCode == defaultCode, Active: true} +} + +func mysqlDSN(setting Setting) string { + cfg := mysql.NewConfig() + cfg.User = setting.Username + cfg.Passwd = setting.Password + cfg.Net = "tcp" + cfg.Addr = net.JoinHostPort(setting.Host, setting.Port) + cfg.DBName = setting.Database + cfg.ParseTime = true + cfg.Loc = time.Local + cfg.Params = map[string]string{"charset": "utf8mb4"} + return cfg.FormatDSN() +} diff --git a/internal/databaseconfig/registry_test.go b/internal/databaseconfig/registry_test.go new file mode 100644 index 0000000..f82198b --- /dev/null +++ b/internal/databaseconfig/registry_test.go @@ -0,0 +1,63 @@ +package databaseconfig + +import ( + "crypto/sha256" + "strings" + "testing" +) + +func TestCredentialEncryptionRoundTrip(t *testing.T) { + registry := &Registry{credentialKey: sha256.Sum256([]byte("test-secret"))} + encrypted, err := registry.encrypt("database-password") + if err != nil { + t.Fatal(err) + } + if encrypted == "database-password" || strings.Contains(encrypted, "database-password") { + t.Fatalf("password was not encrypted: %q", encrypted) + } + decrypted, err := registry.decrypt(encrypted) + if err != nil { + t.Fatal(err) + } + if decrypted != "database-password" { + t.Fatalf("decrypted = %q", decrypted) + } +} + +func TestCredentialCannotBeDecryptedWithDifferentKey(t *testing.T) { + first := &Registry{credentialKey: sha256.Sum256([]byte("first-secret"))} + second := &Registry{credentialKey: sha256.Sum256([]byte("second-secret"))} + encrypted, err := first.encrypt("database-password") + if err != nil { + t.Fatal(err) + } + if _, err := second.decrypt(encrypted); err == nil { + t.Fatal("expected decryption failure with a different key") + } +} + +func TestValidateDatabaseSetting(t *testing.T) { + valid := Setting{RSCode: "rs_bekasi", Name: "RS Bekasi", Host: "host", Port: "3306", Database: "db", Username: "user"} + if err := Validate(valid); err != nil { + t.Fatalf("valid setting rejected: %v", err) + } + + tests := []struct { + name string + mutate func(*Setting) + }{ + {name: "blank code", mutate: func(s *Setting) { s.RSCode = " " }}, + {name: "invalid code", mutate: func(s *Setting) { s.RSCode = "RS BEKASI" }}, + {name: "blank hospital name", mutate: func(s *Setting) { s.Name = "" }}, + {name: "invalid port", mutate: func(s *Setting) { s.Port = "70000" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setting := valid + test.mutate(&setting) + if err := Validate(setting); err == nil { + t.Fatal("expected validation error") + } + }) + } +} diff --git a/internal/http/database_settings.go b/internal/http/database_settings.go new file mode 100644 index 0000000..e966cdd --- /dev/null +++ b/internal/http/database_settings.go @@ -0,0 +1,72 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "primaya-api/cpone-middleware/internal/databaseconfig" +) + +type DatabaseSettingsStore interface { + List() []databaseconfig.PublicSetting + Get(code string) (databaseconfig.PublicSetting, bool) + Upsert(ctx context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error) +} + +type DatabaseSettingsHandler struct{ store DatabaseSettingsStore } + +func NewDatabaseSettingsHandler(store DatabaseSettingsStore) DatabaseSettingsHandler { + return DatabaseSettingsHandler{store: store} +} + +func (h DatabaseSettingsHandler) List(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: h.store.List()}) +} + +func (h DatabaseSettingsHandler) Get(w http.ResponseWriter, r *http.Request) { + setting, found := h.store.Get(r.PathValue("kodeRs")) + if !found { + WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Setting database tidak ditemukan", Data: nil}) + return + } + WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: setting}) +} + +func (h DatabaseSettingsHandler) Store(w http.ResponseWriter, r *http.Request) { + var payload databaseconfig.Setting + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: "Payload JSON tidak valid: " + err.Error(), Data: nil}) + return + } + if err := databaseconfig.Validate(payload); err != nil { + WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: err.Error(), Data: nil}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + setting, created, err := h.store.Upsert(ctx, payload) + if err != nil { + status := http.StatusInternalServerError + message := "Gagal menyimpan setting database" + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "koneksi database") { + status = http.StatusBadGateway + message = err.Error() + } + WriteJSON(w, status, Response{Success: false, Message: message, Data: nil}) + return + } + status := http.StatusOK + message := "Setting database berhasil diperbarui" + if created { + status = http.StatusCreated + message = "Setting database berhasil ditambahkan" + } + WriteJSON(w, status, Response{Success: true, Message: message, Data: setting}) +} diff --git a/internal/http/database_settings_test.go b/internal/http/database_settings_test.go new file mode 100644 index 0000000..54741ca --- /dev/null +++ b/internal/http/database_settings_test.go @@ -0,0 +1,59 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "primaya-api/cpone-middleware/internal/databaseconfig" +) + +type fakeDatabaseSettingsStore struct { + setting databaseconfig.Setting +} + +func (f *fakeDatabaseSettingsStore) List() []databaseconfig.PublicSetting { return nil } +func (f *fakeDatabaseSettingsStore) Get(string) (databaseconfig.PublicSetting, bool) { + return databaseconfig.PublicSetting{}, false +} +func (f *fakeDatabaseSettingsStore) Upsert(_ context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error) { + f.setting = setting + return databaseconfig.PublicSetting{ + RSCode: setting.RSCode, Host: setting.Host, Port: setting.Port, + Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "", + }, true, nil +} + +func TestStoreDatabaseSettingDoesNotExposePassword(t *testing.T) { + store := &fakeDatabaseSettingsStore{} + handler := NewDatabaseSettingsHandler(store) + body := `{"kode_rs":"RS_BEKASI","nama":"RS Bekasi","host":"10.0.0.2","port":"3306","database":"his","username":"cpone","password":"top-secret"}` + request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(body)) + recorder := httptest.NewRecorder() + handler.Store(recorder, request) + + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + if store.setting.Password != "top-secret" { + t.Fatal("password was not passed to store") + } + if strings.Contains(recorder.Body.String(), "top-secret") || strings.Contains(recorder.Body.String(), `"password"`) { + t.Fatalf("response exposes password: %s", recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"has_password":true`) { + t.Fatalf("response missing has_password: %s", recorder.Body.String()) + } +} + +func TestStoreDatabaseSettingValidation(t *testing.T) { + handler := NewDatabaseSettingsHandler(&fakeDatabaseSettingsStore{}) + request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(`{"kode_rs":"invalid code"}`)) + recorder := httptest.NewRecorder() + handler.Store(recorder, request) + if recorder.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/http/handler.go b/internal/http/handler.go index 88267fb..741cda5 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -65,6 +65,48 @@ func NewHandler(repo CPONERepository) Handler { } } +func (h Handler) layananRepository(ctx context.Context) LayananRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.layananRepo +} + +func (h Handler) labRepository(ctx context.Context) LabRegistrationRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.labRepo +} + +func (h Handler) patientRepository(ctx context.Context) PatientRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.patientRepo +} + +func (h Handler) labResultRepository(ctx context.Context) LabResultRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.labResultRepo +} + +func (h Handler) paketRepository(ctx context.Context) PaketRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.paketRepo +} + +func (h Handler) tarifRepository(ctx context.Context) TarifKerjasamaRepository { + if repo, ok := selectedRepository(ctx); ok { + return repo + } + return h.tarifRepo +} + func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { layananIDs := parseLayananIDs(r) if len(layananIDs) == 0 { @@ -96,7 +138,7 @@ func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) { return } - rows, err := h.layananRepo.MappingHarga(r.Context(), repository.MappingHargaParams{ + rows, err := h.layananRepository(r.Context()).MappingHarga(r.Context(), repository.MappingHargaParams{ PeriodeTarifID: periodeTarifID, LayananIDs: layananIDs, Search: strings.TrimSpace(r.URL.Query().Get("search")), diff --git a/internal/http/lab_package.go b/internal/http/lab_package.go index c24a053..5c2d053 100644 --- a/internal/http/lab_package.go +++ b/internal/http/lab_package.go @@ -23,7 +23,7 @@ func (h Handler) AddLabPackage(w http.ResponseWriter, r *http.Request) { writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil) return } - outcome, err := h.labRepo.AddLabPackage(r.Context(), strings.ToUpper(regID), params) + outcome, err := h.labRepository(r.Context()).AddLabPackage(r.Context(), strings.ToUpper(regID), params) if err != nil { if validationMessage, ok := asValidationError(err); ok { writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil) diff --git a/internal/http/lab_registration.go b/internal/http/lab_registration.go index 30ac7f2..220c47b 100644 --- a/internal/http/lab_registration.go +++ b/internal/http/lab_registration.go @@ -42,7 +42,7 @@ func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) { return } - outcome, err := h.labRepo.RegisterLab(r.Context(), validated.Params) + outcome, err := h.labRepository(r.Context()).RegisterLab(r.Context(), validated.Params) if err != nil { var validationErr repository.ValidationError if errors.As(err, &validationErr) { diff --git a/internal/http/lab_result.go b/internal/http/lab_result.go index 2b40cdb..392482b 100644 --- a/internal/http/lab_result.go +++ b/internal/http/lab_result.go @@ -34,7 +34,7 @@ func (h Handler) GetLabResult(w http.ResponseWriter, r *http.Request) { return } - outcome, err := h.labResultRepo.GetLabResult(r.Context(), strings.ToUpper(labNumber)) + outcome, err := h.labResultRepository(r.Context()).GetLabResult(r.Context(), strings.ToUpper(labNumber)) if err != nil { var validationErr repository.ValidationError if errors.As(err, &validationErr) { diff --git a/internal/http/lab_services.go b/internal/http/lab_services.go index 4b90199..3fb0215 100644 --- a/internal/http/lab_services.go +++ b/internal/http/lab_services.go @@ -21,7 +21,7 @@ func (h Handler) AddLabServices(w http.ResponseWriter, r *http.Request) { return } - outcome, err := h.labRepo.AddLabServices(r.Context(), r.PathValue("regId"), params) + outcome, err := h.labRepository(r.Context()).AddLabServices(r.Context(), r.PathValue("regId"), params) if err != nil { if validationErr, ok := asValidationError(err); ok { writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr, nil) diff --git a/internal/http/medrec.go b/internal/http/medrec.go index bd9304d..dd4b159 100644 --- a/internal/http/medrec.go +++ b/internal/http/medrec.go @@ -33,7 +33,7 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) { return } - outcome, err := h.patientRepo.StoreMedrec(r.Context(), params) + outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params) if err != nil { var validationErr repository.ValidationError if errors.As(err, &validationErr) { diff --git a/internal/http/middleware.go b/internal/http/middleware.go index f021334..93c32a7 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -1,10 +1,22 @@ package http import ( + "context" + "errors" "net/http" "strings" + + "primaya-api/cpone-middleware/internal/databaseconfig" + "primaya-api/cpone-middleware/internal/repository" ) +type selectedRepositoryKey struct{} + +type DatabaseResolver interface { + DefaultCode() string + Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) +} + func BearerAuth(staticToken string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if bearerToken(r.Header.Get("Authorization")) != staticToken { @@ -20,6 +32,39 @@ func BearerAuth(staticToken string, next http.Handler) http.Handler { }) } +func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + code := strings.TrimSpace(r.Header.Get("X-RS-Code")) + if code == "" { + code = strings.TrimSpace(r.URL.Query().Get("kode_rs")) + } + if code == "" { + code = resolver.DefaultCode() + } + code = databaseconfig.NormalizeRSCode(code) + + repo, err := resolver.Resolve(r.Context(), code) + if err != nil { + if errors.Is(err, databaseconfig.ErrSettingNotFound) { + WriteJSON(w, http.StatusNotFound, Response{Success: false, + Message: "Setting database untuk kode RS " + code + " tidak ditemukan", Data: nil}) + return + } + WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false, + Message: "Database untuk kode RS " + code + " tidak dapat diakses", Data: nil}) + return + } + w.Header().Set("X-RS-Code", code) + ctx := context.WithValue(r.Context(), selectedRepositoryKey{}, repo) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func selectedRepository(ctx context.Context) (repository.MySQLLayananRepository, bool) { + repo, ok := ctx.Value(selectedRepositoryKey{}).(repository.MySQLLayananRepository) + return repo, ok +} + func bearerToken(header string) string { const prefix = "Bearer " if !strings.HasPrefix(header, prefix) { diff --git a/internal/http/middleware_database_test.go b/internal/http/middleware_database_test.go new file mode 100644 index 0000000..653588e --- /dev/null +++ b/internal/http/middleware_database_test.go @@ -0,0 +1,74 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "primaya-api/cpone-middleware/internal/databaseconfig" + "primaya-api/cpone-middleware/internal/repository" +) + +type fakeDatabaseResolver struct { + defaultCode string + resolvedCode string + err error +} + +func (f *fakeDatabaseResolver) DefaultCode() string { return f.defaultCode } +func (f *fakeDatabaseResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) { + f.resolvedCode = code + return repository.MySQLLayananRepository{}, f.err +} + +func TestSelectDatabasePriorityAndDefault(t *testing.T) { + tests := []struct { + name, target, header, want string + }{ + {name: "header", target: "/resource?kode_rs=QUERY", header: " rs_header ", want: "RS_HEADER"}, + {name: "query", target: "/resource?kode_rs=rs_query", want: "RS_QUERY"}, + {name: "default", target: "/resource", want: "AWALBROS_DEV"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := &fakeDatabaseResolver{defaultCode: "AWALBROS_DEV"} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := selectedRepository(r.Context()); !ok { + t.Fatal("selected repository missing from context") + } + w.WriteHeader(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, test.target, nil) + request.Header.Set("X-RS-Code", test.header) + recorder := httptest.NewRecorder() + SelectDatabase(resolver, next).ServeHTTP(recorder, request) + if recorder.Code != http.StatusNoContent || resolver.resolvedCode != test.want { + t.Fatalf("status=%d code=%q, want status=204 code=%q", recorder.Code, resolver.resolvedCode, test.want) + } + if recorder.Header().Get("X-RS-Code") != test.want { + t.Fatalf("response X-RS-Code = %q", recorder.Header().Get("X-RS-Code")) + } + }) + } +} + +func TestSelectDatabaseUnknownCode(t *testing.T) { + resolver := &fakeDatabaseResolver{defaultCode: "DEFAULT", err: databaseconfig.ErrSettingNotFound} + request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil) + recorder := httptest.NewRecorder() + SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("next handler should not be called") + })).ServeHTTP(recorder, request) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + + resolver.err = errors.New("connection refused") + recorder = httptest.NewRecorder() + SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(recorder, request) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/http/paket.go b/internal/http/paket.go index d41f849..9c70b64 100644 --- a/internal/http/paket.go +++ b/internal/http/paket.go @@ -37,7 +37,7 @@ func (h Handler) ListPaket(w http.ResponseWriter, r *http.Request) { return } - page, err := h.paketRepo.ListPaket(r.Context(), params) + page, err := h.paketRepository(r.Context()).ListPaket(r.Context(), params) if err != nil { WriteJSON(w, http.StatusInternalServerError, Response{ Success: false, Message: "internal server error", Data: nil, @@ -76,7 +76,7 @@ func (h Handler) ListPaket(w http.ResponseWriter, r *http.Request) { func (h Handler) GetPaket(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("id")) - paket, err := h.paketRepo.GetPaket(r.Context(), id) + paket, err := h.paketRepository(r.Context()).GetPaket(r.Context(), id) if err != nil { WriteJSON(w, http.StatusInternalServerError, Response{ Success: false, Message: "internal server error", Data: nil, diff --git a/internal/http/tarif_kerjasama.go b/internal/http/tarif_kerjasama.go index 1ef4823..f722d5e 100644 --- a/internal/http/tarif_kerjasama.go +++ b/internal/http/tarif_kerjasama.go @@ -37,7 +37,7 @@ func (h Handler) ListTarifKerjasama(w http.ResponseWriter, r *http.Request) { return } - page, err := h.tarifRepo.ListTarifKerjasama(r.Context(), params) + page, err := h.tarifRepository(r.Context()).ListTarifKerjasama(r.Context(), params) if err != nil { writeTarifKerjasamaInternalError(w) return @@ -72,7 +72,7 @@ func (h Handler) GetTarifKerjasama(w http.ResponseWriter, r *http.Request) { return } - detail, err := h.tarifRepo.GetTarifKerjasama(r.Context(), id, params) + detail, err := h.tarifRepository(r.Context()).GetTarifKerjasama(r.Context(), id, params) if err != nil { writeTarifKerjasamaInternalError(w) return