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