Files
dicom-iso/internal/repo/patient.go
2026-06-05 08:11:44 +07:00

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