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