Skip to main content

Overview

The ApiClient class is the foundational HTTP client used by all services. It handles HTTP POST requests, automatic authentication token injection, automatic retry on 401 errors, and comprehensive request/response logging.

Class Definition

package com.hl7client.client;

public class ApiClient

Constructor

public ApiClient(AuthRefresher authRefresher)
Creates a new ApiClient instance with custom timeout and cookie configuration.
authRefresher
AuthRefresher
required
The authentication refresher to use for automatic token renewal. Must not be null.
HTTP Client Configuration:
RequestConfig config = RequestConfig.custom()
    .setConnectTimeout(10_000)           // 10 seconds
    .setConnectionRequestTimeout(10_000)
    .setSocketTimeout(30_000)            // 30 seconds
    .setCookieSpec(CookieSpecs.STANDARD) // Cloudflare compatibility
    .build();
connectTimeout
int
10,000 ms - Maximum time to establish connection
connectionRequestTimeout
int
10,000 ms - Maximum time to obtain connection from pool
socketTimeout
int
30,000 ms - Maximum time waiting for data between packets
STANDARD - Handles Cloudflare cookies with 4-digit years and commas
Throws: NullPointerException if authRefresher is null Example:
AuthService authService = new AuthService();
ApiClient apiClient = new ApiClient(authService);
The cookie specification is set to STANDARD to prevent warnings when Cloudflare sets cookies with expiration dates containing commas and 4-digit years.

Methods

post

public ApiResponse post(
    String url,
    String body,
    Map<String, String> headers
)
Sends an HTTP POST request with automatic authentication and retry logic.
url
String
required
The target URL for the POST request
body
String
required
The request body (typically JSON). Can be null or empty.
headers
Map<String, String>
Additional HTTP headers. Can be null. Merged with default headers.
Returns: ApiResponse containing status code, response body, and headers Throws: RuntimeException if network/transport error occurs

Request Processing

1. Header Building

The buildHeaders() method constructs the final header map:
private Map<String, String> buildHeaders(Map<String, String> headers) {
    Map<String, String> finalHeaders = new HashMap<>();
    
    // 1. Set default headers
    finalHeaders.put("Content-Type", "application/json; charset=UTF-8");
    finalHeaders.put("Accept", "application/json");
    
    // 2. Merge custom headers (can override defaults)
    if (headers != null) {
        finalHeaders.putAll(headers);
    }
    
    // 3. Add Authorization header if authenticated and not already set
    if (SessionContext.isAuthenticated() && !finalHeaders.containsKey("Authorization")) {
        finalHeaders.put("Authorization", "Bearer " + SessionContext.getToken());
    }
    
    return finalHeaders;
}
Header Priority:
  1. Default headers (Content-Type, Accept)
  2. Custom headers (can override defaults)
  3. Authorization header (added automatically if missing)
Example:
// Example 1: Default headers only
apiClient.post(url, body, null);
// Headers: Content-Type, Accept, Authorization (auto-added)

// Example 2: Custom Authorization
Map<String, String> customHeaders = new HashMap<>();
customHeaders.put("Authorization", "Bearer custom-token");
apiClient.post(url, body, customHeaders);
// Headers: Content-Type, Accept, Authorization (custom value)

// Example 3: Additional headers
Map<String, String> extraHeaders = new HashMap<>();
extraHeaders.put("X-Request-ID", "12345");
apiClient.post(url, body, extraHeaders);
// Headers: Content-Type, Accept, Authorization (auto), X-Request-ID

2. Automatic 401 Retry Logic

When a request receives HTTP 401 (Unauthorized), the client automatically attempts to refresh the authentication token and retry:
private ApiResponse postInternal(String url, String body, 
                                 Map<String, String> headers, 
                                 boolean allowRetry) {
    // ... execute request ...
    
    int statusCode = response.getStatusLine().getStatusCode();
    
    // Automatic retry on 401
    if (statusCode == 401 && allowRetry && canRefresh(url)) {
        LOGGER.info("401 received, attempting auth refresh");
        authRefresher.refreshAuth();
        return postInternal(url, body, headers, false); // Retry once
    }
    
    return new ApiResponse(statusCode, responseBody, Collections.emptyMap());
}
Retry Conditions:
statusCode == 401
boolean
Response status is Unauthorized
allowRetry == true
boolean
This is the first attempt (prevents infinite loops)
canRefresh(url)
boolean
URL is not an auth endpoint (prevents recursive refresh)
canRefresh() Logic:
private boolean canRefresh(String url) {
    return SessionContext.isAuthenticated()
        && !url.contains("auth-login")
        && !url.contains("auth-refresh");
}
The retry mechanism only executes once. If the second request also returns 401, it will fail without further attempts.
Retry Flow Example:
Request 1: POST /hl7/elegibilidad
  → 401 Unauthorized
  → Trigger: authRefresher.refreshAuth()
  → Token updated in SessionContext
  
Request 2: POST /hl7/elegibilidad (with new token)
  → 200 OK
  → Return response

Logging Methods

The ApiClient provides comprehensive logging for debugging:

logRequest

private void logRequest(HttpPost post, String body) {
    System.out.println("=== REQUEST ===");
    System.out.println("URL: " + post.getURI());
    System.out.println("Method: POST");
    System.out.println("Headers: " + Arrays.toString(post.getAllHeaders()));
    System.out.println("Body: " + body);
    System.out.println("==============");
}
Example Output:
=== REQUEST ===
URL: https://api.example.com/hl7/elegibilidad
Method: POST
Headers: [Content-Type: application/json; charset=UTF-8, Authorization: Bearer eyJ...]
Body: {"dniAfiliado":"12345678",...}
==============

logResponse

private void logResponse(int status, String body) {
    System.out.println("=== RESPONSE ===");
    System.out.println("Status: " + status);
    System.out.println("Body: " + body);
    System.out.println("================");
}
Example Output:
=== RESPONSE ===
Status: 200
Body: {"rechaCabecera":0,...}
================

logAsCurl

private void logAsCurl(HttpPost post, String body) {
    StringBuilder curl = new StringBuilder("curl -X POST '").append(post.getURI()).append("'");
    
    for (org.apache.http.Header h : post.getAllHeaders()) {
        curl.append(" \\
  -H '").append(h.getName())
            .append(": ").append(h.getValue()).append("'");
    }
    
    if (body != null && !body.trim().isEmpty()) {
        curl.append(" \\
  --data '").append(body.replace("'", "'\\''")).append("'");
    }
    
    System.out.println("=== CURL ===");
    System.out.println(curl);
    System.out.println("============");
}
Example Output:
=== CURL ===
curl -X POST 'https://api.example.com/hl7/elegibilidad' \
  -H 'Content-Type: application/json; charset=UTF-8' \
  -H 'Authorization: Bearer eyJ...' \
  --data '{"dniAfiliado":"12345678"}'
============
The CURL output properly escapes single quotes in the body by replacing ' with '\''.

close

public void close() throws IOException
Closes the underlying HTTP client and releases resources. Best Practice:
ApiClient apiClient = new ApiClient(authService);
try {
    // Use apiClient
} finally {
    apiClient.close();
}
In the current application architecture, ApiClient instances are typically long-lived and shared. Closing the client will prevent further requests.

ApiResponse Structure

The post() method returns an ApiResponse object:
public final class ApiResponse {
    private final int statusCode;
    private final String body;
    private final Map<String, String> headers;
    
    public int getStatusCode() { ... }
    public String getBody() { ... }
    public Map<String, String> getHeaders() { ... }
    public boolean isHttpError() { return statusCode >= 400; }
}
statusCode
int
HTTP status code (200, 401, 500, etc.)
body
String
Response body as a string (typically JSON)
headers
Map<String, String>
Response headers (currently always empty in implementation)
isHttpError()
boolean
Returns true if status code is 400 or higher

Usage Examples

Basic POST Request

ApiClient apiClient = new ApiClient(authService);

String url = "https://api.example.com/endpoint";
String body = "{\"key\":\"value\"}";

ApiResponse response = apiClient.post(url, body, null);

if (response.isHttpError()) {
    System.err.println("HTTP Error: " + response.getStatusCode());
} else {
    System.out.println("Success: " + response.getBody());
}

Custom Headers

Map<String, String> headers = new HashMap<>();
headers.put("X-Request-ID", UUID.randomUUID().toString());
headers.put("X-Client-Version", "1.0.0");

ApiResponse response = apiClient.post(url, body, headers);

Handling 401 with Automatic Retry

// First request with expired token → 401
// Client automatically calls authRefresher.refreshAuth()
// Second request with new token → 200
ApiResponse response = apiClient.post(url, body, null);

// If second request also fails with 401, no further retry
if (response.getStatusCode() == 401) {
    System.err.println("Authentication failed even after refresh");
    // User needs to log in again
}

Error Handling

Transport Errors

try {
    ApiResponse response = apiClient.post(url, body, null);
} catch (RuntimeException e) {
    // Network error, timeout, or connection failure
    System.err.println("Transport error: " + e.getMessage());
    // Typically contains IOException as cause
}
Common transport errors:
  • Connection timeout (10 seconds)
  • Socket timeout (30 seconds)
  • DNS resolution failure
  • Network unreachable

HTTP Errors

ApiResponse response = apiClient.post(url, body, null);

if (response.isHttpError()) {
    switch (response.getStatusCode()) {
        case 400:
            System.err.println("Bad request: " + response.getBody());
            break;
        case 401:
            System.err.println("Unauthorized (retry failed)");
            break;
        case 403:
            System.err.println("Forbidden");
            break;
        case 500:
            System.err.println("Server error");
            break;
        default:
            System.err.println("HTTP error " + response.getStatusCode());
    }
}

Thread Safety

ApiClient uses Apache CloseableHttpClient, which is thread-safe. Multiple threads can safely call post() concurrently. However, the automatic retry logic depends on SessionContext, which is a global singleton and not thread-safe.


Configuration

Timeout Customization

To customize timeouts, modify the constructor:
RequestConfig config = RequestConfig.custom()
    .setConnectTimeout(15_000)     // 15 seconds
    .setSocketTimeout(60_000)      // 60 seconds
    .setCookieSpec(CookieSpecs.STANDARD)
    .build();

this.httpClient = HttpClients.custom()
    .setDefaultRequestConfig(config)
    .build();

Disabling Logging

To disable request/response logging:
private void logRequest(HttpPost post, String body) {
    // Comment out or remove System.out.println calls
}
Logging includes sensitive data like authentication tokens and request bodies. Ensure logs are properly secured in production environments.

Build docs developers (and LLMs) love