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