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