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