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}