134 lines
3.5 KiB
Go
134 lines
3.5 KiB
Go
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")
|
|
}
|