feat: add dynamic hospital database registry

This commit is contained in:
2026-08-28 09:53:03 +07:00
parent 78c02356c5
commit a59b2c6443
19 changed files with 1055 additions and 75 deletions

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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")
}
})
}
}

View File

@@ -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})
}

View File

@@ -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())
}
}

View File

@@ -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")),

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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())
}
}

View File

@@ -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,

View File

@@ -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