001package org.hl7.fhir.r4.utils.client.network; 002 003import okhttp3.*; 004import org.apache.commons.lang3.StringUtils; 005import org.hl7.fhir.r4.formats.IParser; 006import org.hl7.fhir.r4.formats.JsonParser; 007import org.hl7.fhir.r4.formats.XmlParser; 008import org.hl7.fhir.r4.model.Bundle; 009import org.hl7.fhir.r4.model.OperationOutcome; 010import org.hl7.fhir.r4.model.Resource; 011import org.hl7.fhir.r4.utils.ResourceUtilities; 012import org.hl7.fhir.r4.utils.client.EFhirClientException; 013import org.hl7.fhir.r4.utils.client.ResourceFormat; 014 015import java.io.IOException; 016import java.util.List; 017import java.util.Map; 018import java.util.concurrent.TimeUnit; 019 020public class FhirRequestBuilder { 021 022 protected static final String HTTP_PROXY_USER = "http.proxyUser"; 023 protected static final String HTTP_PROXY_PASS = "http.proxyPassword"; 024 protected static final String HEADER_PROXY_AUTH = "Proxy-Authorization"; 025 protected static final String LOCATION_HEADER = "location"; 026 protected static final String CONTENT_LOCATION_HEADER = "content-location"; 027 protected static final String DEFAULT_CHARSET = "UTF-8"; 028 /** 029 * The singleton instance of the HttpClient, used for all requests. 030 */ 031 private static OkHttpClient okHttpClient; 032 private final Request.Builder httpRequest; 033 private String resourceFormat = null; 034 private Headers headers = null; 035 private String message = null; 036 private int retryCount = 1; 037 /** 038 * The timeout quantity. Used in combination with {@link FhirRequestBuilder#timeoutUnit}. 039 */ 040 private long timeout = 5000; 041 /** 042 * Time unit for {@link FhirRequestBuilder#timeout}. 043 */ 044 private TimeUnit timeoutUnit = TimeUnit.MILLISECONDS; 045 046 /** 047 * {@link FhirLoggingInterceptor} for log output. 048 */ 049 private FhirLoggingInterceptor logger = null; 050 051 public FhirRequestBuilder(Request.Builder httpRequest) { 052 this.httpRequest = httpRequest; 053 } 054 055 /** 056 * Adds necessary default headers, formatting headers, and any passed in {@link Headers} to the passed in 057 * {@link okhttp3.Request.Builder} 058 * 059 * @param request {@link okhttp3.Request.Builder} to add headers to. 060 * @param format Expected {@link Resource} format. 061 * @param headers Any additional {@link Headers} to add to the request. 062 */ 063 protected static void formatHeaders(Request.Builder request, String format, Headers headers) { 064 addDefaultHeaders(request, headers); 065 if (format != null) addResourceFormatHeaders(request, format); 066 if (headers != null) addHeaders(request, headers); 067 } 068 069 /** 070 * Adds necessary headers for all REST requests. 071 * <li>User-Agent : hapi-fhir-tooling-client</li> 072 * <li>Accept-Charset : {@link FhirRequestBuilder#DEFAULT_CHARSET}</li> 073 * 074 * @param request {@link Request.Builder} to add default headers to. 075 */ 076 protected static void addDefaultHeaders(Request.Builder request, Headers headers) { 077 if (headers == null || !headers.names().contains("User-Agent")) { 078 request.addHeader("User-Agent", "hapi-fhir-tooling-client"); 079 } 080 request.addHeader("Accept-Charset", DEFAULT_CHARSET); 081 } 082 083 /** 084 * Adds necessary headers for the given resource format provided. 085 * 086 * @param request {@link Request.Builder} to add default headers to. 087 */ 088 protected static void addResourceFormatHeaders(Request.Builder request, String format) { 089 request.addHeader("Accept", format); 090 request.addHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET); 091 } 092 093 /** 094 * Iterates through the passed in {@link Headers} and adds them to the provided {@link Request.Builder}. 095 * 096 * @param request {@link Request.Builder} to add headers to. 097 * @param headers {@link Headers} to add to request. 098 */ 099 protected static void addHeaders(Request.Builder request, Headers headers) { 100 headers.forEach(header -> request.addHeader(header.getFirst(), header.getSecond())); 101 } 102 103 /** 104 * Returns true if any of the {@link org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent} within the 105 * provided {@link OperationOutcome} have an {@link org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity} of 106 * {@link org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity#ERROR} or 107 * {@link org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity#FATAL} 108 * 109 * @param oo {@link OperationOutcome} to evaluate. 110 * @return {@link Boolean#TRUE} if an error exists. 111 */ 112 protected static boolean hasError(OperationOutcome oo) { 113 return (oo.getIssue().stream() 114 .anyMatch(issue -> issue.getSeverity() == OperationOutcome.IssueSeverity.ERROR 115 || issue.getSeverity() == OperationOutcome.IssueSeverity.FATAL)); 116 } 117 118 /** 119 * Extracts the 'location' header from the passes in {@link Headers}. If no value for 'location' exists, the 120 * value for 'content-location' is returned. If neither header exists, we return null. 121 * 122 * @param headers {@link Headers} to evaluate 123 * @return {@link String} header value, or null if no location headers are set. 124 */ 125 protected static String getLocationHeader(Headers headers) { 126 Map<String, List<String>> headerMap = headers.toMultimap(); 127 if (headerMap.containsKey(LOCATION_HEADER)) { 128 return headerMap.get(LOCATION_HEADER).get(0); 129 } else if (headerMap.containsKey(CONTENT_LOCATION_HEADER)) { 130 return headerMap.get(CONTENT_LOCATION_HEADER).get(0); 131 } else { 132 return null; 133 } 134 } 135 136 /** 137 * We only ever want to have one copy of the HttpClient kicking around at any given time. If we need to make changes 138 * to any configuration, such as proxy settings, timeout, caches, etc, we can do a per-call configuration through 139 * the {@link OkHttpClient#newBuilder()} method. That will return a builder that shares the same connection pool, 140 * dispatcher, and configuration with the original client. 141 * </p> 142 * The {@link OkHttpClient} uses the proxy auth properties set in the current system properties. The reason we don't 143 * set the proxy address and authentication explicitly, is due to the fact that this class is often used in conjunction 144 * with other http client tools which rely on the system.properties settings to determine proxy settings. It's easier 145 * to keep the method consistent across the board. ...for now. 146 * 147 * @return {@link OkHttpClient} instance 148 */ 149 protected OkHttpClient getHttpClient() { 150 if (okHttpClient == null) { 151 okHttpClient = new OkHttpClient(); 152 } 153 154 Authenticator proxyAuthenticator = (route, response) -> { 155 String credential = Credentials.basic(System.getProperty(HTTP_PROXY_USER), System.getProperty(HTTP_PROXY_PASS)); 156 return response.request().newBuilder() 157 .header(HEADER_PROXY_AUTH, credential) 158 .build(); 159 }; 160 161 OkHttpClient.Builder builder = okHttpClient.newBuilder(); 162 if (logger != null) builder.addInterceptor(logger); 163 builder.addInterceptor(new RetryInterceptor(retryCount)); 164 165 return builder.connectTimeout(timeout, timeoutUnit) 166 .addInterceptor(new RetryInterceptor(retryCount)) 167 .connectTimeout(timeout, timeoutUnit) 168 .writeTimeout(timeout, timeoutUnit) 169 .readTimeout(timeout, timeoutUnit) 170 .proxyAuthenticator(proxyAuthenticator) 171 .build(); 172 } 173 174 public FhirRequestBuilder withResourceFormat(String resourceFormat) { 175 this.resourceFormat = resourceFormat; 176 return this; 177 } 178 179 public FhirRequestBuilder withHeaders(Headers headers) { 180 this.headers = headers; 181 return this; 182 } 183 184 public FhirRequestBuilder withMessage(String message) { 185 this.message = message; 186 return this; 187 } 188 189 public FhirRequestBuilder withRetryCount(int retryCount) { 190 this.retryCount = retryCount; 191 return this; 192 } 193 194 public FhirRequestBuilder withLogger(FhirLoggingInterceptor logger) { 195 this.logger = logger; 196 return this; 197 } 198 199 public FhirRequestBuilder withTimeout(long timeout, TimeUnit unit) { 200 this.timeout = timeout; 201 this.timeoutUnit = unit; 202 return this; 203 } 204 205 protected Request buildRequest() { 206 return httpRequest.build(); 207 } 208 209 public <T extends Resource> ResourceRequest<T> execute() throws IOException { 210 formatHeaders(httpRequest, resourceFormat, headers); 211 Response response = getHttpClient().newCall(httpRequest.build()).execute(); 212 T resource = unmarshalReference(response, resourceFormat); 213 return new ResourceRequest<T>(resource, response.code(), getLocationHeader(response.headers())); 214 } 215 216 public Bundle executeAsBatch() throws IOException { 217 formatHeaders(httpRequest, resourceFormat, null); 218 Response response = getHttpClient().newCall(httpRequest.build()).execute(); 219 return unmarshalFeed(response, resourceFormat); 220 } 221 222 /** 223 * Unmarshalls a resource from the response stream. 224 */ 225 @SuppressWarnings("unchecked") 226 protected <T extends Resource> T unmarshalReference(Response response, String format) { 227 T resource = null; 228 OperationOutcome error = null; 229 230 if (response.body() != null) { 231 try { 232 byte[] body = response.body().bytes(); 233 resource = (T) getParser(format).parse(body); 234 if (resource instanceof OperationOutcome && hasError((OperationOutcome) resource)) { 235 error = (OperationOutcome) resource; 236 } 237 } catch (IOException ioe) { 238 throw new EFhirClientException("Error reading Http Response: " + ioe.getMessage(), ioe); 239 } catch (Exception e) { 240 throw new EFhirClientException("Error parsing response message: " + e.getMessage(), e); 241 } 242 } 243 244 if (error != null) { 245 throw new EFhirClientException("Error from server: " + ResourceUtilities.getErrorDescription(error), error); 246 } 247 248 return resource; 249 } 250 251 /** 252 * Unmarshalls Bundle from response stream. 253 */ 254 protected Bundle unmarshalFeed(Response response, String format) { 255 Bundle feed = null; 256 OperationOutcome error = null; 257 try { 258 byte[] body = response.body().bytes(); 259 String contentType = response.header("Content-Type"); 260 if (body != null) { 261 if (contentType.contains(ResourceFormat.RESOURCE_XML.getHeader()) || contentType.contains("text/xml+fhir")) { 262 Resource rf = getParser(format).parse(body); 263 if (rf instanceof Bundle) 264 feed = (Bundle) rf; 265 else if (rf instanceof OperationOutcome && hasError((OperationOutcome) rf)) { 266 error = (OperationOutcome) rf; 267 } else { 268 throw new EFhirClientException("Error reading server response: a resource was returned instead"); 269 } 270 } 271 } 272 } catch (IOException ioe) { 273 throw new EFhirClientException("Error reading Http Response", ioe); 274 } catch (Exception e) { 275 throw new EFhirClientException("Error parsing response message", e); 276 } 277 if (error != null) { 278 throw new EFhirClientException("Error from server: " + ResourceUtilities.getErrorDescription(error), error); 279 } 280 return feed; 281 } 282 283 /** 284 * Returns the appropriate parser based on the format type passed in. Defaults to XML parser if a blank format is 285 * provided...because reasons. 286 * <p> 287 * Currently supports only "json" and "xml" formats. 288 * 289 * @param format One of "json" or "xml". 290 * @return {@link JsonParser} or {@link XmlParser} 291 */ 292 protected IParser getParser(String format) { 293 if (StringUtils.isBlank(format)) { 294 format = ResourceFormat.RESOURCE_XML.getHeader(); 295 } 296 if (format.equalsIgnoreCase("json") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader())) { 297 return new JsonParser(); 298 } else if (format.equalsIgnoreCase("xml") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader())) { 299 return new XmlParser(); 300 } else { 301 throw new EFhirClientException("Invalid format: " + format); 302 } 303 } 304}