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