package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "api-service/internal/config" "api-service/pkg/logger" "github.com/mashingan/smapping" "github.com/tidwall/gjson" ) // SatuSehatService interface for SATUSEHAT operations type SatuSehatService interface { // Standard HTTP methods Get(ctx context.Context, endpoint string, result interface{}) error Post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error Put(ctx context.Context, endpoint string, payload interface{}, result interface{}) error Delete(ctx context.Context, endpoint string, result interface{}) error // Raw response methods GetRawResponse(ctx context.Context, endpoint string) (*SatuSehatResponDTO, error) PostRawResponse(ctx context.Context, endpoint string, payload interface{}) (*SatuSehatResponDTO, error) // FHIR specific methods PostBundle(ctx context.Context, bundle interface{}) (*SatuSehatResponDTO, error) GetPatientByNIK(ctx context.Context, nik string) (*SatuSehatResponDTO, error) GetPractitionerByNIK(ctx context.Context, nik string) (*SatuSehatResponDTO, error) GetResourceByID(ctx context.Context, resourceType, id string) (*SatuSehatResponDTO, error) // Token management RefreshToken(ctx context.Context) error IsTokenValid() bool GenerateToken(ctx context.Context, clientID, clientSecret string) (*SatuSehatResponDTO, error) } // SatuSehatService struct for SATUSEHAT service type SatuSehatServiceStruct struct { config config.SatuSehatConfig httpClient *http.Client token TokenDetail tokenMutex sync.RWMutex } // Token detail structure type TokenDetail struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` IssuedAt int64 `json:"issued_at"` OrganizationName string `json:"organization_name"` DeveloperEmail string `json:"developer.email"` ClientID string `json:"client_id"` ApplicationName string `json:"application_name"` Status string `json:"status"` ExpiryTime time.Time `json:"-"` } // Response structures type SatuSehatResponMentahDTO struct { StatusCode int `json:"status_code"` Success bool `json:"success"` Message string `json:"message"` Data interface{} `json:"data"` } type SatuSehatResponDTO struct { StatusCode int `json:"status_code"` Success bool `json:"success"` Message string `json:"message"` Data interface{} `json:"data"` Error *ErrorInfo `json:"error,omitempty"` } type ErrorInfo struct { Code string `json:"code"` Details string `json:"details"` } // Token methods func (t *TokenDetail) IsExpired() bool { if t.ExpiryTime.IsZero() { return true } return time.Now().UTC().After(t.ExpiryTime.Add(-5 * time.Minute)) } func (t *TokenDetail) SetExpired() { t.ExpiryTime = time.Time{} } // NewSatuSehatService creates a new SATUSEHAT service instance func NewSatuSehatService(cfg config.SatuSehatConfig) SatuSehatService { service := &SatuSehatServiceStruct{ config: cfg, httpClient: &http.Client{ Timeout: cfg.Timeout, }, } return service } // NewSatuSehatServiceFromConfig creates service from main config func NewSatuSehatServiceFromConfig(cfg *config.Config) SatuSehatService { return NewSatuSehatService(cfg.SatuSehat) } // NewSatuSehatServiceFromInterface creates service from interface (for backward compatibility) func NewSatuSehatServiceFromInterface(cfg interface{}) (SatuSehatService, error) { var satusehatConfig config.SatuSehatConfig // Try to map from interface err := smapping.FillStruct(&satusehatConfig, smapping.MapFields(&cfg)) if err != nil { return nil, fmt.Errorf("failed to map config: %w", err) } if satusehatConfig.Timeout == 0 { satusehatConfig.Timeout = 30 * time.Second } return NewSatuSehatService(satusehatConfig), nil } // SetHTTPClient allows custom http client configuration func (s *SatuSehatServiceStruct) SetHTTPClient(client *http.Client) { s.httpClient = client } // RefreshToken obtains new access token func (s *SatuSehatServiceStruct) RefreshToken(ctx context.Context) error { s.tokenMutex.Lock() defer s.tokenMutex.Unlock() // Double-check pattern if !s.token.IsExpired() { return nil } // Remove duplicate /oauth2/v1 from URL since AuthURL already contains it tokenURL := fmt.Sprintf("%s/accesstoken?grant_type=client_credentials", s.config.AuthURL) formData := fmt.Sprintf("client_id=%s&client_secret=%s", s.config.ClientID, s.config.ClientSecret) req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBufferString(formData)) if err != nil { return fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to execute token request: %w", err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return fmt.Errorf("failed to read token response: %w", err) } if res.StatusCode != 200 { // Log the error response for debugging fmt.Printf("DEBUG: Token request failed with status %d: %s\n", res.StatusCode, string(body)) return fmt.Errorf("token request failed with status %d: %s", res.StatusCode, string(body)) } // Debug: log the raw response for troubleshooting fmt.Printf("DEBUG: SATUSEHAT token response - Status: %d, Body: %s\n", res.StatusCode, string(body)) fmt.Printf("DEBUG: Request URL: %s\n", tokenURL) fmt.Printf("DEBUG: Request Headers: %+v\n", req.Header) return s.parseTokenResponse(body) } // parseTokenResponse parses token response from SATUSEHAT func (s *SatuSehatServiceStruct) parseTokenResponse(body []byte) error { // Debug: log the raw response for detailed analysis fmt.Printf("DEBUG: Raw token response body: %s\n", string(body)) result := gjson.ParseBytes(body) // Check if we have a valid access token accessToken := result.Get("access_token").String() if accessToken == "" { return fmt.Errorf("no access token found in response: %s", string(body)) } issuedAt := result.Get("issued_at").Int() expiresIn := result.Get("expires_in").Int() // Handle timestamp conversion (issued_at could be in milliseconds or seconds) var expiryTime time.Time if issuedAt > 1000000000000 { // If timestamp is in milliseconds expiryTime = time.Unix(issuedAt/1000, 0).Add(time.Duration(expiresIn) * time.Second) } else if issuedAt > 0 { // If timestamp is in seconds expiryTime = time.Unix(issuedAt, 0).Add(time.Duration(expiresIn) * time.Second) } else { // If no issued_at, use current time + expires_in expiryTime = time.Now().UTC().Add(time.Duration(expiresIn) * time.Second) } s.token = TokenDetail{ AccessToken: accessToken, TokenType: result.Get("token_type").String(), ExpiresIn: expiresIn, IssuedAt: issuedAt, OrganizationName: result.Get("organization_name").String(), DeveloperEmail: result.Get("developer\\.email").String(), ClientID: result.Get("client_id").String(), ApplicationName: result.Get("application_name").String(), Status: result.Get("status").String(), ExpiryTime: expiryTime, } logger.Info("SATUSEHAT token refreshed successfully", map[string]interface{}{ "expires_at": s.token.ExpiryTime, "organization": s.token.OrganizationName, "token_type": s.token.TokenType, "client_id": s.token.ClientID, }) return nil } // IsTokenValid checks if current token is valid func (s *SatuSehatServiceStruct) IsTokenValid() bool { s.tokenMutex.RLock() defer s.tokenMutex.RUnlock() return !s.token.IsExpired() } // ensureValidToken ensures we have a valid token func (s *SatuSehatServiceStruct) ensureValidToken(ctx context.Context) error { s.tokenMutex.RLock() needsRefresh := s.token.IsExpired() s.tokenMutex.RUnlock() if needsRefresh { return s.RefreshToken(ctx) } return nil } // prepareRequest prepares HTTP request with required headers func (s *SatuSehatServiceStruct) prepareRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, error) { // Ensure valid token if err := s.ensureValidToken(ctx); err != nil { return nil, fmt.Errorf("failed to ensure valid token: %w", err) } fullURL := s.config.BaseURL + endpoint req, err := http.NewRequestWithContext(ctx, method, fullURL, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set headers s.tokenMutex.RLock() token := s.token.AccessToken s.tokenMutex.RUnlock() req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) return req, nil } // processResponse processes response from SATUSEHAT API func (s *SatuSehatServiceStruct) processResponse(res *http.Response) (*SatuSehatResponDTO, error) { defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } // Create response resp := &SatuSehatResponDTO{ StatusCode: res.StatusCode, Success: res.StatusCode >= 200 && res.StatusCode < 300, } // Handle different status codes switch { case res.StatusCode == 401: s.tokenMutex.Lock() s.token.SetExpired() s.tokenMutex.Unlock() resp.Error = &ErrorInfo{ Code: "UNAUTHORIZED", Details: "Token expired or invalid", } resp.Message = "Unauthorized access" case res.StatusCode >= 400 && res.StatusCode < 500: resp.Error = &ErrorInfo{ Code: "CLIENT_ERROR", Details: string(body), } resp.Message = "Client error" case res.StatusCode >= 500: resp.Error = &ErrorInfo{ Code: "SERVER_ERROR", Details: string(body), } resp.Message = "Server error" default: resp.Message = "Success" } // Parse JSON response if successful if resp.Success && len(body) > 0 { var jsonData interface{} if err := json.Unmarshal(body, &jsonData); err != nil { // If JSON unmarshal fails, store as string resp.Data = string(body) } else { resp.Data = jsonData } } return resp, nil } // Get performs HTTP GET request func (s *SatuSehatServiceStruct) Get(ctx context.Context, endpoint string, result interface{}) error { resp, err := s.GetRawResponse(ctx, endpoint) if err != nil { return err } return mapToResult(resp, result) } // Post performs HTTP POST request func (s *SatuSehatServiceStruct) Post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error { resp, err := s.PostRawResponse(ctx, endpoint, payload) if err != nil { return err } return mapToResult(resp, result) } // Put performs HTTP PUT request func (s *SatuSehatServiceStruct) Put(ctx context.Context, endpoint string, payload interface{}, result interface{}) error { var buf bytes.Buffer if payload != nil { if err := json.NewEncoder(&buf).Encode(payload); err != nil { return fmt.Errorf("failed to encode payload: %w", err) } } req, err := s.prepareRequest(ctx, http.MethodPut, endpoint, &buf) if err != nil { return err } res, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to execute PUT request: %w", err) } resp, err := s.processResponse(res) if err != nil { return err } return mapToResult(resp, result) } // Delete performs HTTP DELETE request func (s *SatuSehatServiceStruct) Delete(ctx context.Context, endpoint string, result interface{}) error { req, err := s.prepareRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } res, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to execute DELETE request: %w", err) } resp, err := s.processResponse(res) if err != nil { return err } return mapToResult(resp, result) } // GetRawResponse returns raw response without mapping func (s *SatuSehatServiceStruct) GetRawResponse(ctx context.Context, endpoint string) (*SatuSehatResponDTO, error) { req, err := s.prepareRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } res, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute GET request: %w", err) } return s.processResponse(res) } // PostRawResponse returns raw response without mapping func (s *SatuSehatServiceStruct) PostRawResponse(ctx context.Context, endpoint string, payload interface{}) (*SatuSehatResponDTO, error) { var buf bytes.Buffer if payload != nil { if err := json.NewEncoder(&buf).Encode(payload); err != nil { return nil, fmt.Errorf("failed to encode payload: %w", err) } } req, err := s.prepareRequest(ctx, http.MethodPost, endpoint, &buf) if err != nil { return nil, err } res, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute POST request: %w", err) } return s.processResponse(res) } // FHIR-specific methods // PostBundle posts FHIR bundle to SATUSEHAT func (s *SatuSehatServiceStruct) PostBundle(ctx context.Context, bundle interface{}) (*SatuSehatResponDTO, error) { return s.PostRawResponse(ctx, "", bundle) } // GetPatientByNIK retrieves patient by NIK func (s *SatuSehatServiceStruct) GetPatientByNIK(ctx context.Context, nik string) (*SatuSehatResponDTO, error) { endpoint := fmt.Sprintf("/Patient?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik) return s.GetRawResponse(ctx, endpoint) } // GetPractitionerByNIK retrieves practitioner by NIK func (s *SatuSehatServiceStruct) GetPractitionerByNIK(ctx context.Context, nik string) (*SatuSehatResponDTO, error) { endpoint := fmt.Sprintf("/Practitioner?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik) return s.GetRawResponse(ctx, endpoint) } // GetResourceByID retrieves any FHIR resource by ID func (s *SatuSehatServiceStruct) GetResourceByID(ctx context.Context, resourceType, id string) (*SatuSehatResponDTO, error) { endpoint := fmt.Sprintf("/%s/%s", resourceType, id) return s.GetRawResponse(ctx, endpoint) } // GenerateToken generates a new access token with custom client credentials func (s *SatuSehatServiceStruct) GenerateToken(ctx context.Context, clientID, clientSecret string) (*SatuSehatResponDTO, error) { // Remove duplicate /oauth2/v1 from URL since AuthURL already contains it tokenURL := fmt.Sprintf("%s/accesstoken?grant_type=client_credentials", s.config.AuthURL) formData := fmt.Sprintf("client_id=%s&client_secret=%s", clientID, clientSecret) req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(formData)) if err != nil { return nil, fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute token request: %w", err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } // Process the response using the existing response processor resp := &SatuSehatResponDTO{ StatusCode: res.StatusCode, Success: res.StatusCode >= 200 && res.StatusCode < 300, } // Handle different status codes switch { case res.StatusCode == 401: resp.Error = &ErrorInfo{ Code: "UNAUTHORIZED", Details: "Invalid client credentials", } resp.Message = "Unauthorized access" case res.StatusCode >= 400 && res.StatusCode < 500: resp.Error = &ErrorInfo{ Code: "CLIENT_ERROR", Details: string(body), } resp.Message = "Client error" case res.StatusCode >= 500: resp.Error = &ErrorInfo{ Code: "SERVER_ERROR", Details: string(body), } resp.Message = "Server error" default: resp.Message = "Success" } // Parse JSON response if successful if resp.Success && len(body) > 0 { var jsonData interface{} if err := json.Unmarshal(body, &jsonData); err != nil { // If JSON unmarshal fails, store as string resp.Data = string(body) } else { resp.Data = jsonData } } return resp, nil } // Helper functions // mapToResult maps the final response to the result interface func mapToResult(resp *SatuSehatResponDTO, result interface{}) error { respBytes, err := json.Marshal(resp) if err != nil { return fmt.Errorf("failed to marshal final response: %w", err) } if err := json.Unmarshal(respBytes, result); err != nil { return fmt.Errorf("failed to unmarshal to result: %w", err) } return nil } // Backward compatibility functions func SatuSehatGetRequest(endpoint string, cfg interface{}) interface{} { service, err := NewSatuSehatServiceFromInterface(cfg) if err != nil { logger.Error("Failed to create SATUSEHAT service", map[string]interface{}{ "error": err.Error(), }) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := service.GetRawResponse(ctx, endpoint) if err != nil { logger.Error("Failed to get SATUSEHAT response", map[string]interface{}{ "error": err.Error(), }) return nil } return resp } func SatuSehatPostRequest(endpoint string, cfg interface{}, data interface{}) interface{} { service, err := NewSatuSehatServiceFromInterface(cfg) if err != nil { logger.Error("Failed to create SATUSEHAT service", map[string]interface{}{ "error": err.Error(), }) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := service.PostRawResponse(ctx, endpoint, data) if err != nil { logger.Error("Failed to post SATUSEHAT response", map[string]interface{}{ "error": err.Error(), }) return nil } return resp } // FHIR helper functions func SatuSehatGetPatient(nik string, cfg interface{}) interface{} { service, err := NewSatuSehatServiceFromInterface(cfg) if err != nil { logger.Error("Failed to create SATUSEHAT service", map[string]interface{}{ "error": err.Error(), }) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := service.GetPatientByNIK(ctx, nik) if err != nil { logger.Error("Failed to get patient", map[string]interface{}{ "error": err.Error(), "nik": nik, }) return nil } return resp } func SatuSehatGetPractitioner(nik string, cfg interface{}) interface{} { service, err := NewSatuSehatServiceFromInterface(cfg) if err != nil { logger.Error("Failed to create SATUSEHAT service", map[string]interface{}{ "error": err.Error(), }) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := service.GetPractitionerByNIK(ctx, nik) if err != nil { logger.Error("Failed to get practitioner", map[string]interface{}{ "error": err.Error(), "nik": nik, }) return nil } return resp } func SatuSehatPostBundle(bundle interface{}, cfg interface{}) interface{} { service, err := NewSatuSehatServiceFromInterface(cfg) if err != nil { logger.Error("Failed to create SATUSEHAT service", map[string]interface{}{ "error": err.Error(), }) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := service.PostBundle(ctx, bundle) if err != nil { logger.Error("Failed to post bundle", map[string]interface{}{ "error": err.Error(), }) return nil } return resp }