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"
@@ -19,9 +18,18 @@ type Config struct {
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) {
@@ -38,9 +46,18 @@ func Load(path string) (Config, error) {
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