feat: add dynamic hospital database registry
This commit is contained in:
72
internal/http/database_settings.go
Normal file
72
internal/http/database_settings.go
Normal 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})
|
||||
}
|
||||
59
internal/http/database_settings_test.go
Normal file
59
internal/http/database_settings_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
74
internal/http/middleware_database_test.go
Normal file
74
internal/http/middleware_database_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user