001package org.hl7.fhir.dstu3.utils.client.network;
002
003import okhttp3.*;
004import org.apache.commons.lang3.StringUtils;
005import org.hl7.fhir.dstu3.formats.IParser;
006import org.hl7.fhir.dstu3.formats.JsonParser;
007import org.hl7.fhir.dstu3.formats.XmlParser;
008import org.hl7.fhir.dstu3.model.Bundle;
009import org.hl7.fhir.dstu3.model.OperationOutcome;
010import org.hl7.fhir.dstu3.model.Resource;
011import org.hl7.fhir.dstu3.utils.ResourceUtilities;
012import org.hl7.fhir.dstu3.utils.client.EFhirClientException;
013import org.hl7.fhir.dstu3.utils.client.ResourceFormat;
014import org.hl7.fhir.utilities.ToolingClientLogger;
015
016import javax.annotation.Nonnull;
017import java.io.IOException;
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.List;
021import java.util.Map;
022import java.util.concurrent.TimeUnit;
023
024public class FhirRequestBuilder {
025
026  protected static final String HTTP_PROXY_USER = "http.proxyUser";
027  protected static final String HTTP_PROXY_PASS = "http.proxyPassword";
028  protected static final String HEADER_PROXY_AUTH = "Proxy-Authorization";
029  protected static final String LOCATION_HEADER = "location";
030  protected static final String CONTENT_LOCATION_HEADER = "content-location";
031  protected static final String DEFAULT_CHARSET = "UTF-8";
032  /**
033   * The singleton instance of the HttpClient, used for all requests.
034   */
035  private static OkHttpClient okHttpClient;
036  private final Request.Builder httpRequest;
037  private String resourceFormat = null;
038  private Headers headers = null;
039  private String message = null;
040  private int retryCount = 1;
041  /**
042   * The timeout quantity. Used in combination with {@link FhirRequestBuilder#timeoutUnit}.
043   */
044  private long timeout = 5000;
045  /**
046   * Time unit for {@link FhirRequestBuilder#timeout}.
047   */
048  private TimeUnit timeoutUnit = TimeUnit.MILLISECONDS;
049  /**
050   * {@link ToolingClientLogger} for log output.
051   */
052  private ToolingClientLogger logger = null;
053
054  public FhirRequestBuilder(Request.Builder httpRequest) {
055    this.httpRequest = httpRequest;
056  }
057
058  /**
059   * Adds necessary default headers, formatting headers, and any passed in {@link Headers} to the passed in
060   * {@link okhttp3.Request.Builder}
061   *
062   * @param request {@link okhttp3.Request.Builder} to add headers to.
063   * @param format  Expected {@link Resource} format.
064   * @param headers Any additional {@link Headers} to add to the request.
065   */
066  protected static void formatHeaders(Request.Builder request, String format, Headers headers) {
067    addDefaultHeaders(request, headers);
068    if (format != null) addResourceFormatHeaders(request, format);
069    if (headers != null) addHeaders(request, headers);
070  }
071
072  /**
073   * Adds necessary headers for all REST requests.
074   * <li>User-Agent : hapi-fhir-tooling-client</li>
075   * <li>Accept-Charset : {@link FhirRequestBuilder#DEFAULT_CHARSET}</li>
076   *
077   * @param request {@link Request.Builder} to add default headers to.
078   */
079  protected static void addDefaultHeaders(Request.Builder request, Headers headers) {
080    if (headers == null || !headers.names().contains("User-Agent")) {
081      request.addHeader("User-Agent", "hapi-fhir-tooling-client");
082    }
083    request.addHeader("Accept-Charset", DEFAULT_CHARSET);
084  }
085
086  /**
087   * Adds necessary headers for the given resource format provided.
088   *
089   * @param request {@link Request.Builder} to add default headers to.
090   */
091  protected static void addResourceFormatHeaders(Request.Builder request, String format) {
092    request.addHeader("Accept", format);
093    request.addHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET);
094  }
095
096  /**
097   * Iterates through the passed in {@link Headers} and adds them to the provided {@link Request.Builder}.
098   *
099   * @param request {@link Request.Builder} to add headers to.
100   * @param headers {@link Headers} to add to request.
101   */
102  protected static void addHeaders(Request.Builder request, Headers headers) {
103    headers.forEach(header -> request.addHeader(header.getFirst(), header.getSecond()));
104  }
105
106  /**
107   * Returns true if any of the {@link org.hl7.fhir.dstu3.model.OperationOutcome.OperationOutcomeIssueComponent} within the
108   * provided {@link OperationOutcome} have an {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity} of
109   * {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity#ERROR} or
110   * {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity#FATAL}
111   *
112   * @param oo {@link OperationOutcome} to evaluate.
113   * @return {@link Boolean#TRUE} if an error exists.
114   */
115  protected static boolean hasError(OperationOutcome oo) {
116    return (oo.getIssue().stream()
117      .anyMatch(issue -> issue.getSeverity() == OperationOutcome.IssueSeverity.ERROR
118        || issue.getSeverity() == OperationOutcome.IssueSeverity.FATAL));
119  }
120
121  /**
122   * Extracts the 'location' header from the passes in {@link Headers}. If no value for 'location' exists, the
123   * value for 'content-location' is returned. If neither header exists, we return null.
124   *
125   * @param headers {@link Headers} to evaluate
126   * @return {@link String} header value, or null if no location headers are set.
127   */
128  protected static String getLocationHeader(Headers headers) {
129    Map<String, List<String>> headerMap = headers.toMultimap();
130    if (headerMap.containsKey(LOCATION_HEADER)) {
131      return headerMap.get(LOCATION_HEADER).get(0);
132    } else if (headerMap.containsKey(CONTENT_LOCATION_HEADER)) {
133      return headerMap.get(CONTENT_LOCATION_HEADER).get(0);
134    } else {
135      return null;
136    }
137  }
138
139  /**
140   * We only ever want to have one copy of the HttpClient kicking around at any given time. If we need to make changes
141   * to any configuration, such as proxy settings, timeout, caches, etc, we can do a per-call configuration through
142   * the {@link OkHttpClient#newBuilder()} method. That will return a builder that shares the same connection pool,
143   * dispatcher, and configuration with the original client.
144   * </p>
145   * The {@link OkHttpClient} uses the proxy auth properties set in the current system properties. The reason we don't
146   * set the proxy address and authentication explicitly, is due to the fact that this class is often used in conjunction
147   * with other http client tools which rely on the system.properties settings to determine proxy settings. It's easier
148   * to keep the method consistent across the board. ...for now.
149   *
150   * @return {@link OkHttpClient} instance
151   */
152  protected OkHttpClient getHttpClient() {
153    if (okHttpClient == null) {
154      okHttpClient = new OkHttpClient();
155    }
156
157    Authenticator proxyAuthenticator = getAuthenticator();
158
159    return okHttpClient.newBuilder()
160      .addInterceptor(new RetryInterceptor(retryCount))
161      .connectTimeout(timeout, timeoutUnit)
162      .writeTimeout(timeout, timeoutUnit)
163      .readTimeout(timeout, timeoutUnit)
164      .proxyAuthenticator(proxyAuthenticator)
165      .build();
166  }
167
168  @Nonnull
169  private static Authenticator getAuthenticator() {
170    return (route, response) -> {
171      final String httpProxyUser = System.getProperty(HTTP_PROXY_USER);
172      final String httpProxyPass = System.getProperty(HTTP_PROXY_PASS);
173      if (httpProxyUser != null && httpProxyPass != null) {
174        String credential = Credentials.basic(httpProxyUser, httpProxyPass);
175        return response.request().newBuilder()
176          .header(HEADER_PROXY_AUTH, credential)
177          .build();
178      }
179      return response.request().newBuilder().build();
180    };
181  }
182
183  public FhirRequestBuilder withResourceFormat(String resourceFormat) {
184    this.resourceFormat = resourceFormat;
185    return this;
186  }
187
188  public FhirRequestBuilder withHeaders(Headers headers) {
189    this.headers = headers;
190    return this;
191  }
192
193  public FhirRequestBuilder withMessage(String message) {
194    this.message = message;
195    return this;
196  }
197
198  public FhirRequestBuilder withRetryCount(int retryCount) {
199    this.retryCount = retryCount;
200    return this;
201  }
202
203  public FhirRequestBuilder withLogger(ToolingClientLogger logger) {
204    this.logger = logger;
205    return this;
206  }
207
208  public FhirRequestBuilder withTimeout(long timeout, TimeUnit unit) {
209    this.timeout = timeout;
210    this.timeoutUnit = unit;
211    return this;
212  }
213
214  protected Request buildRequest() {
215    return httpRequest.build();
216  }
217
218  public <T extends Resource> ResourceRequest<T> execute() throws IOException {
219    formatHeaders(httpRequest, resourceFormat, headers);
220    final Request request = httpRequest.build();
221    log(request.method(), request.url().toString(), request.headers(), request.body() != null ? request.body().toString().getBytes() : null);
222    Response response = getHttpClient().newCall(request).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    final Request request = httpRequest.build();
230    log(request.method(), request.url().toString(), request.headers(), request.body() != null ? request.body().toString().getBytes() : null);
231
232    Response response = getHttpClient().newCall(request).execute();
233    return unmarshalFeed(response, resourceFormat);
234  }
235
236  /**
237   * Unmarshalls a resource from the response stream.
238   */
239  @SuppressWarnings("unchecked")
240  protected <T extends Resource> T unmarshalReference(Response response, String format) {
241    T resource = null;
242    OperationOutcome error = null;
243
244    if (response.body() != null) {
245      try {
246        byte[] body = response.body().bytes();
247        log(response.code(), response.headers(), body);
248        resource = (T) getParser(format).parse(body);
249        if (resource instanceof OperationOutcome && hasError((OperationOutcome) resource)) {
250          error = (OperationOutcome) resource;
251        }
252      } catch (IOException ioe) {
253        throw new EFhirClientException("Error reading Http Response: " + ioe.getMessage(), ioe);
254      } catch (Exception e) {
255        throw new EFhirClientException("Error parsing response message: " + e.getMessage(), e);
256      }
257    }
258
259    if (error != null) {
260      throw new EFhirClientException("Error from server: " + ResourceUtilities.getErrorDescription(error), error);
261    }
262
263    return resource;
264  }
265
266  /**
267   * Unmarshalls Bundle from response stream.
268   */
269  protected Bundle unmarshalFeed(Response response, String format) {
270    Bundle feed = null;
271    OperationOutcome error = null;
272    try {
273      byte[] body = response.body().bytes();
274      log(response.code(), response.headers(), body);
275      String contentType = response.header("Content-Type");
276      if (body != null) {
277        if (contentType.contains(ResourceFormat.RESOURCE_XML.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", ioe);
290    } catch (Exception e) {
291      throw new EFhirClientException("Error parsing response message", e);
292    }
293    if (error != null) {
294      throw new EFhirClientException("Error from server: " + 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
321  /**
322   * Logs the given {@link Request}, using the current {@link ToolingClientLogger}. If the current
323   * {@link FhirRequestBuilder#logger} is null, no action is taken.
324   *
325   * @param method  HTTP request method
326   * @param url request URL
327   * @param requestHeaders {@link Headers} for request
328   * @param requestBody Byte array request
329   */
330  protected void log(String method, String url, Headers requestHeaders, byte[] requestBody) {
331    if (logger != null) {
332      List<String> headerList = new ArrayList<>(Collections.emptyList());
333      Map<String, List<String>> headerMap = requestHeaders.toMultimap();
334      headerMap.keySet().forEach(key -> headerMap.get(key).forEach(value -> headerList.add(key + ":" + value)));
335
336      logger.logRequest(method, url, headerList, requestBody);
337    }
338
339  }
340
341  /**
342   * Logs the given {@link Response}, using the current {@link ToolingClientLogger}. If the current
343   * {@link FhirRequestBuilder#logger} is null, no action is taken.
344   *
345   * @param responseCode    HTTP response code
346   * @param responseHeaders {@link Headers} from response
347   * @param responseBody    Byte array response
348   */
349  protected void log(int responseCode, Headers responseHeaders, byte[] responseBody) {
350    if (logger != null) {
351      List<String> headerList = new ArrayList<>(Collections.emptyList());
352      Map<String, List<String>> headerMap = responseHeaders.toMultimap();
353      headerMap.keySet().forEach(key -> headerMap.get(key).forEach(value -> headerList.add(key + ":" + value)));
354
355      try {
356        logger.logResponse(Integer.toString(responseCode), headerList, responseBody);
357      } catch (Exception e) {
358        System.out.println("Error parsing response body passed in to logger ->\n" + e.getLocalizedMessage());
359      }
360    }
361//    else { // TODO fix logs
362//      System.out.println("Call to log HTTP response with null ToolingClientLogger set... are you forgetting to " +
363//        "initialize your logger?");
364//    }
365  }
366}