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