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