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