275 lines
8.4 KiB
Go
275 lines
8.4 KiB
Go
package satusehat
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"service/internal/infrastructure/config"
|
|
"service/pkg/logger"
|
|
)
|
|
|
|
type client struct {
|
|
cfg config.SatuSehatConfig
|
|
httpClient *http.Client
|
|
tokenData map[string]interface{}
|
|
tokenExpiry time.Time
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewSatuSehatClient membuat instance baru dari SatuSehatClient.
|
|
func NewSatuSehatClient(cfg config.SatuSehatConfig) SatuSehatClient {
|
|
return &client{
|
|
cfg: cfg,
|
|
httpClient: &http.Client{Timeout: cfg.Timeout},
|
|
}
|
|
}
|
|
|
|
// GetAccessToken mengelola request token OAuth2 (Client Credentials) dan melakukan caching di memori.
|
|
func (c *client) GetAccessToken(ctx context.Context) (map[string]interface{}, error) {
|
|
c.mu.RLock()
|
|
// Cek apakah token masih valid (dengan buffer 1 menit untuk mencegah kegagalan)
|
|
if c.tokenData != nil && time.Now().Before(c.tokenExpiry) {
|
|
data := c.tokenData
|
|
c.mu.RUnlock()
|
|
return data, nil
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Double check setelah mendapat lock
|
|
if c.tokenData != nil && time.Now().Before(c.tokenExpiry) {
|
|
return c.tokenData, nil
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("client_id", c.cfg.ClientID)
|
|
data.Set("client_secret", c.cfg.ClientSecret)
|
|
// Sesuai standar OAuth2, grant_type disisipkan dalam body request
|
|
data.Set("grant_type", "client_credentials")
|
|
|
|
// Deteksi otomatis jika Auth URL di environment hanya berupa Base (tanpa /accesstoken)
|
|
authURL := strings.TrimRight(c.cfg.AuthURL, "/")
|
|
if !strings.HasSuffix(authURL, "accesstoken") {
|
|
authURL += "/accesstoken"
|
|
}
|
|
// Kemenkes juga sering mensyaratkan query parameter grant_type di URL
|
|
if !strings.Contains(authURL, "grant_type") {
|
|
authURL += "?grant_type=client_credentials"
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", authURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create auth request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Baca terlebih dahulu seluruh response body untuk memudahkan debugging jika gagal parse JSON
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read auth response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
logger.Default().Error("SatuSehat Auth Error", logger.Int("status", resp.StatusCode), logger.String("response", string(respBody)))
|
|
return nil, fmt.Errorf("SatuSehat Auth failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
logger.Default().Error("SatuSehat Auth Decode Error", logger.String("response", string(respBody)), logger.ErrorField(err))
|
|
return nil, fmt.Errorf("failed to decode auth response: %w", err)
|
|
}
|
|
|
|
if _, ok := result["access_token"].(string); !ok {
|
|
return nil, fmt.Errorf("access_token missing or invalid in response: %s", string(respBody))
|
|
}
|
|
|
|
c.tokenData = result
|
|
|
|
var expiresIn int
|
|
if expRaw, ok := result["expires_in"]; ok {
|
|
switch v := expRaw.(type) {
|
|
case string:
|
|
expiresIn, _ = strconv.Atoi(v)
|
|
case float64: // Pada interface{}, JSON number dibaca sebagai float64 oleh Go
|
|
expiresIn = int(v)
|
|
default:
|
|
expiresIn = 3599 // Fallback default 1 jam jika atribut tidak ada/tidak sesuai
|
|
}
|
|
} else {
|
|
expiresIn = 3599
|
|
}
|
|
|
|
// Simpan masa berlaku token dikurangi 60 detik sebagai safety margin
|
|
c.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
|
|
|
|
logger.Default().Info("🔑 SatuSehat Access Token refreshed successfully")
|
|
|
|
return c.tokenData, nil
|
|
}
|
|
|
|
// RefreshToken memaksa pengambilan token baru (melewati cache).
|
|
func (c *client) RefreshToken(ctx context.Context) (map[string]interface{}, error) {
|
|
c.mu.Lock()
|
|
c.tokenData = nil // Invalidasi cache token
|
|
c.mu.Unlock()
|
|
return c.GetAccessToken(ctx)
|
|
}
|
|
|
|
// do merupakan helper HTTP request secara general ke berbagai Base URL SatuSehat
|
|
func (c *client) do(ctx context.Context, baseURL, method, endpoint string, body interface{}) ([]byte, error) {
|
|
if !c.cfg.Enabled {
|
|
return nil, fmt.Errorf("SatuSehat integration is disabled in configuration")
|
|
}
|
|
|
|
authData, err := c.GetAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get SatuSehat access token: %w", err)
|
|
}
|
|
token, ok := authData["access_token"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("access token is missing in auth data")
|
|
}
|
|
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
endpointPath := strings.TrimLeft(endpoint, "/")
|
|
targetURL := fmt.Sprintf("%s/%s", baseURL, endpointPath)
|
|
|
|
// Marshal body terlebih dahulu agar bisa digunakan berulang kali jika butuh retry
|
|
var jsonBody []byte
|
|
if body != nil {
|
|
var err error
|
|
jsonBody, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
}
|
|
|
|
// Helper function untuk merakit ulang request (sangat berguna untuk skenario Retry)
|
|
buildReq := func(accessToken string) (*http.Request, error) {
|
|
var bodyReader io.Reader
|
|
if jsonBody != nil {
|
|
bodyReader = bytes.NewBuffer(jsonBody)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
if jsonBody != nil {
|
|
if method == "PATCH" {
|
|
req.Header.Set("Content-Type", "application/json-patch+json")
|
|
} else {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
req, err := buildReq(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// [RADAR] Logging Outgoing Request
|
|
logger.Default().Info("🛫 SatuSehat API Request Sent",
|
|
logger.String("target_url", targetURL),
|
|
logger.String("method", method),
|
|
)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http request failed: %w", err)
|
|
}
|
|
|
|
// [AUTO-RETRY LOGIC] Jika Kemenkes menjawab 401 Unauthorized, token mungkin expired/revoked
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
// Tutup body request pertama yang gagal agar tidak memory leak
|
|
resp.Body.Close()
|
|
|
|
logger.Default().Warn("🔄 Token Satu Sehat ditolak (401). Melakukan Auto-Refresh dan mengulang request...")
|
|
|
|
// 1. Refresh Token secara paksa
|
|
authData, err = c.RefreshToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to auto-refresh token for retry: %w", err)
|
|
}
|
|
token, ok = authData["access_token"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("access token is missing after auto-refresh")
|
|
}
|
|
|
|
// 2. Bangun ulang Request dengan token terbaru
|
|
req, err = buildReq(token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to rebuild retry request: %w", err)
|
|
}
|
|
|
|
// 3. Tembak ulang API Kemenkes
|
|
resp, err = c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http retry request failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Pastikan body response dari eksekusi terakhir (berhasil atau retry) selalu ditutup
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Tangani response status code error (seperti FHIROperationOutcome)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
logger.Default().Error("SatuSehat API Error",
|
|
logger.Int("status_code", resp.StatusCode),
|
|
logger.String("response", string(respBody)),
|
|
)
|
|
|
|
// Coba parse response body sebagai FHIR OperationOutcome untuk error yang lebih terbaca
|
|
var outcome map[string]interface{}
|
|
if err := json.Unmarshal(respBody, &outcome); err == nil && outcome["resourceType"] == "OperationOutcome" {
|
|
return nil, &ErrorOperationOutcome{
|
|
StatusCode: resp.StatusCode,
|
|
Outcome: outcome,
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("SatuSehat API Error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return respBody, nil
|
|
}
|
|
|
|
func (c *client) DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
|
return c.do(ctx, c.cfg.BaseURL, method, endpoint, body)
|
|
}
|
|
|
|
func (c *client) DoKFA(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
|
return c.do(ctx, c.cfg.KFAURL, method, endpoint, body)
|
|
}
|
|
|
|
func (c *client) DoConsent(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
|
return c.do(ctx, c.cfg.ConsentURL, method, endpoint, body)
|
|
}
|