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