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