package repo import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // PatientData holds the response from the patient-data API. type PatientData struct { AccessionNumber string `json:"accession_number"` MedrecID string `json:"medrec_id"` RegID string `json:"reg_id"` PatientName string `json:"patient_name"` Modality string `json:"modality"` StudyDescription string `json:"study_description"` } // PatientRepo is an HTTP client for the external patient-data API. type PatientRepo struct { baseURL string endpoint string httpClient *http.Client authHeader string authToken string retryCount int retrySleep time.Duration } // NewPatientRepo creates a new PatientRepo. func NewPatientRepo(baseURL, endpoint, authHeader, authToken string, timeout time.Duration, retry int, retryBackoff time.Duration) *PatientRepo { return &PatientRepo{ baseURL: strings.TrimRight(baseURL, "/"), endpoint: endpoint, httpClient: &http.Client{ Timeout: timeout, }, authHeader: authHeader, authToken: authToken, retryCount: retry, retrySleep: retryBackoff, } } // ByAccessionNumber fetches patient data for the given accession number. // Returns (nil, nil) if: // - patient API is not configured (empty base URL) // - accession number is not found (HTTP 404) // This allows callers to gracefully degrade when patient info is unavailable. func (r *PatientRepo) ByAccessionNumber(ctx context.Context, acc string) (*PatientData, error) { if r.baseURL == "" { return nil, nil } u, err := url.Parse(r.baseURL + r.endpoint) if err != nil { return nil, fmt.Errorf("invalid patient API URL: %w", err) } u.RawQuery = url.Values{"accession_number": {acc}}.Encode() var lastErr error for attempt := 0; attempt <= r.retryCount; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(r.retrySleep): } } data, err := r.fetchPatientData(ctx, u.String(), acc) if err == nil { return data, nil } lastErr = err if !isRetryable(err) { return nil, lastErr } } return nil, lastErr } // fetchPatientData performs a single HTTP request to the patient API. func (r *PatientRepo) fetchPatientData(ctx context.Context, requestURL, acc string) (*PatientData, error) { req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if r.authHeader != "" && r.authToken != "" { req.Header.Set(r.authHeader, r.authToken) } resp, err := r.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("patient API request failed: %w", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: var data PatientData if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, fmt.Errorf("decode patient API response: %w", err) } return &data, nil case http.StatusNotFound: return nil, nil // not found is not an error — gracefully degrade default: body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("patient API returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } } // isRetryable returns true if the error is a transient network error // that might succeed on retry. func isRetryable(err error) bool { msg := err.Error() return strings.Contains(msg, "timeout") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "Temporary failure") }