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

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

View File

@@ -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 <your-database-settings-token>
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

View File

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

View File

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

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