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