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