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}