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