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 java.io.IOException;
017import java.util.ArrayList;
018import java.util.Collections;
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   * {@link ToolingClientLogger} for log output.
050   */
051  private ToolingClientLogger logger = null;
052
053  public FhirRequestBuilder(Request.Builder httpRequest) {
054    this.httpRequest = httpRequest;
055  }
056
057  /**
058   * Adds necessary default headers, formatting headers, and any passed in {@link Headers} to the passed in
059   * {@link okhttp3.Request.Builder}
060   *
061   * @param request {@link okhttp3.Request.Builder} to add headers to.
062   * @param format  Expected {@link Resource} format.
063   * @param headers Any additional {@link Headers} to add to the request.
064   */
065  protected static void formatHeaders(Request.Builder request, String format, Headers headers) {
066    addDefaultHeaders(request, headers);
067    if (format != null) addResourceFormatHeaders(request, format);
068    if (headers != null) addHeaders(request, headers);
069  }
070
071  /**
072   * Adds necessary headers for all REST requests.
073   * <li>User-Agent : hapi-fhir-tooling-client</li>
074   * <li>Accept-Charset : {@link FhirRequestBuilder#DEFAULT_CHARSET}</li>
075   *
076   * @param request {@link Request.Builder} to add default headers to.
077   */
078  protected static void addDefaultHeaders(Request.Builder request, Headers headers) {
079    if (headers == null || !headers.names().contains("User-Agent")) {
080      request.addHeader("User-Agent", "hapi-fhir-tooling-client");
081    }
082    request.addHeader("Accept-Charset", DEFAULT_CHARSET);
083  }
084
085  /**
086   * Adds necessary headers for the given resource format provided.
087   *
088   * @param request {@link Request.Builder} to add default headers to.
089   */
090  protected static void addResourceFormatHeaders(Request.Builder request, String format) {
091    request.addHeader("Accept", format);
092    request.addHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET);
093  }
094
095  /**
096   * Iterates through the passed in {@link Headers} and adds them to the provided {@link Request.Builder}.
097   *
098   * @param request {@link Request.Builder} to add headers to.
099   * @param headers {@link Headers} to add to request.
100   */
101  protected static void addHeaders(Request.Builder request, Headers headers) {
102    headers.forEach(header -> request.addHeader(header.getFirst(), header.getSecond()));
103  }
104
105  /**
106   * Returns true if any of the {@link org.hl7.fhir.dstu3.model.OperationOutcome.OperationOutcomeIssueComponent} within the
107   * provided {@link OperationOutcome} have an {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity} of
108   * {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity#ERROR} or
109   * {@link org.hl7.fhir.dstu3.model.OperationOutcome.IssueSeverity#FATAL}
110   *
111   * @param oo {@link OperationOutcome} to evaluate.
112   * @return {@link Boolean#TRUE} if an error exists.
113   */
114  protected static boolean hasError(OperationOutcome oo) {
115    return (oo.getIssue().stream()
116      .anyMatch(issue -> issue.getSeverity() == OperationOutcome.IssueSeverity.ERROR
117        || issue.getSeverity() == OperationOutcome.IssueSeverity.FATAL));
118  }
119
120  /**
121   * Extracts the 'location' header from the passes in {@link Headers}. If no value for 'location' exists, the
122   * value for 'content-location' is returned. If neither header exists, we return null.
123   *
124   * @param headers {@link Headers} to evaluate
125   * @return {@link String} header value, or null if no location headers are set.
126   */
127  protected static String getLocationHeader(Headers headers) {
128    Map<String, List<String>> headerMap = headers.toMultimap();
129    if (headerMap.containsKey(LOCATION_HEADER)) {
130      return headerMap.get(LOCATION_HEADER).get(0);
131    } else if (headerMap.containsKey(CONTENT_LOCATION_HEADER)) {
132      return headerMap.get(CONTENT_LOCATION_HEADER).get(0);
133    } else {
134      return null;
135    }
136  }
137
138  /**
139   * We only ever want to have one copy of the HttpClient kicking around at any given time. If we need to make changes
140   * to any configuration, such as proxy settings, timeout, caches, etc, we can do a per-call configuration through
141   * the {@link OkHttpClient#newBuilder()} method. That will return a builder that shares the same connection pool,
142   * dispatcher, and configuration with the original client.
143   * </p>
144   * The {@link OkHttpClient} uses the proxy auth properties set in the current system properties. The reason we don't
145   * set the proxy address and authentication explicitly, is due to the fact that this class is often used in conjunction
146   * with other http client tools which rely on the system.properties settings to determine proxy settings. It's easier
147   * to keep the method consistent across the board. ...for now.
148   *
149   * @return {@link OkHttpClient} instance
150   */
151  protected OkHttpClient getHttpClient() {
152    if (okHttpClient == null) {
153      okHttpClient = new OkHttpClient();
154    }
155
156    Authenticator proxyAuthenticator = (route, response) -> {
157      String credential = Credentials.basic(System.getProperty(HTTP_PROXY_USER), System.getProperty(HTTP_PROXY_PASS));
158      return response.request().newBuilder()
159        .header(HEADER_PROXY_AUTH, credential)
160        .build();
161    };
162
163    return okHttpClient.newBuilder()
164      .addInterceptor(new RetryInterceptor(retryCount))
165      .connectTimeout(timeout, timeoutUnit)
166      .writeTimeout(timeout, timeoutUnit)
167      .readTimeout(timeout, timeoutUnit)
168      .proxyAuthenticator(proxyAuthenticator)
169      .build();
170  }
171
172  public FhirRequestBuilder withResourceFormat(String resourceFormat) {
173    this.resourceFormat = resourceFormat;
174    return this;
175  }
176
177  public FhirRequestBuilder withHeaders(Headers headers) {
178    this.headers = headers;
179    return this;
180  }
181
182  public FhirRequestBuilder withMessage(String message) {
183    this.message = message;
184    return this;
185  }
186
187  public FhirRequestBuilder withRetryCount(int retryCount) {
188    this.retryCount = retryCount;
189    return this;
190  }
191
192  public FhirRequestBuilder withLogger(ToolingClientLogger logger) {
193    this.logger = logger;
194    return this;
195  }
196
197  public FhirRequestBuilder withTimeout(long timeout, TimeUnit unit) {
198    this.timeout = timeout;
199    this.timeoutUnit = unit;
200    return this;
201  }
202
203  protected Request buildRequest() {
204    return httpRequest.build();
205  }
206
207  public <T extends Resource> ResourceRequest<T> execute() throws IOException {
208    formatHeaders(httpRequest, resourceFormat, headers);
209    final Request request = httpRequest.build();
210    log(request.method(), request.url().toString(), request.headers(), request.body() != null ? request.body().toString().getBytes() : null);
211    Response response = getHttpClient().newCall(request).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    final Request request = httpRequest.build();
219    log(request.method(), request.url().toString(), request.headers(), request.body() != null ? request.body().toString().getBytes() : null);
220
221    Response response = getHttpClient().newCall(request).execute();
222    return unmarshalFeed(response, resourceFormat);
223  }
224
225  /**
226   * Unmarshalls a resource from the response stream.
227   */
228  @SuppressWarnings("unchecked")
229  protected <T extends Resource> T unmarshalReference(Response response, String format) {
230    T resource = null;
231    OperationOutcome error = null;
232
233    if (response.body() != null) {
234      try {
235        byte[] body = response.body().bytes();
236        log(response.code(), response.headers(), body);
237        resource = (T) getParser(format).parse(body);
238        if (resource instanceof OperationOutcome && hasError((OperationOutcome) resource)) {
239          error = (OperationOutcome) resource;
240        }
241      } catch (IOException ioe) {
242        throw new EFhirClientException("Error reading Http Response: " + ioe.getMessage(), ioe);
243      } catch (Exception e) {
244        throw new EFhirClientException("Error parsing response message: " + e.getMessage(), e);
245      }
246    }
247
248    if (error != null) {
249      throw new EFhirClientException("Error from server: " + ResourceUtilities.getErrorDescription(error), error);
250    }
251
252    return resource;
253  }
254
255  /**
256   * Unmarshalls Bundle from response stream.
257   */
258  protected Bundle unmarshalFeed(Response response, String format) {
259    Bundle feed = null;
260    OperationOutcome error = null;
261    try {
262      byte[] body = response.body().bytes();
263      log(response.code(), response.headers(), body);
264      String contentType = response.header("Content-Type");
265      if (body != null) {
266        if (contentType.contains(ResourceFormat.RESOURCE_XML.getHeader()) || contentType.contains("text/xml+fhir")) {
267          Resource rf = getParser(format).parse(body);
268          if (rf instanceof Bundle)
269            feed = (Bundle) rf;
270          else if (rf instanceof OperationOutcome && hasError((OperationOutcome) rf)) {
271            error = (OperationOutcome) rf;
272          } else {
273            throw new EFhirClientException("Error reading server response: a resource was returned instead");
274          }
275        }
276      }
277    } catch (IOException ioe) {
278      throw new EFhirClientException("Error reading Http Response", ioe);
279    } catch (Exception e) {
280      throw new EFhirClientException("Error parsing response message", e);
281    }
282    if (error != null) {
283      throw new EFhirClientException("Error from server: " + ResourceUtilities.getErrorDescription(error), error);
284    }
285    return feed;
286  }
287
288  /**
289   * Returns the appropriate parser based on the format type passed in. Defaults to XML parser if a blank format is
290   * provided...because reasons.
291   * <p>
292   * Currently supports only "json" and "xml" formats.
293   *
294   * @param format One of "json" or "xml".
295   * @return {@link JsonParser} or {@link XmlParser}
296   */
297  protected IParser getParser(String format) {
298    if (StringUtils.isBlank(format)) {
299      format = ResourceFormat.RESOURCE_XML.getHeader();
300    }
301    if (format.equalsIgnoreCase("json") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader())) {
302      return new JsonParser();
303    } else if (format.equalsIgnoreCase("xml") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader())) {
304      return new XmlParser();
305    } else {
306      throw new EFhirClientException("Invalid format: " + format);
307    }
308  }
309
310  /**
311   * Logs the given {@link Request}, using the current {@link ToolingClientLogger}. If the current
312   * {@link FhirRequestBuilder#logger} is null, no action is taken.
313   *
314   * @param method  HTTP request method
315   * @param url request URL
316   * @param requestHeaders {@link Headers} for request
317   * @param requestBody Byte array request
318   */
319  protected void log(String method, String url, Headers requestHeaders, byte[] requestBody) {
320    if (logger != null) {
321      List<String> headerList = new ArrayList<>(Collections.emptyList());
322      Map<String, List<String>> headerMap = requestHeaders.toMultimap();
323      headerMap.keySet().forEach(key -> headerMap.get(key).forEach(value -> headerList.add(key + ":" + value)));
324
325      logger.logRequest(method, url, headerList, requestBody);
326    }
327
328  }
329
330  /**
331   * Logs the given {@link Response}, using the current {@link ToolingClientLogger}. If the current
332   * {@link FhirRequestBuilder#logger} is null, no action is taken.
333   *
334   * @param responseCode    HTTP response code
335   * @param responseHeaders {@link Headers} from response
336   * @param responseBody    Byte array response
337   */
338  protected void log(int responseCode, Headers responseHeaders, byte[] responseBody) {
339    if (logger != null) {
340      List<String> headerList = new ArrayList<>(Collections.emptyList());
341      Map<String, List<String>> headerMap = responseHeaders.toMultimap();
342      headerMap.keySet().forEach(key -> headerMap.get(key).forEach(value -> headerList.add(key + ":" + value)));
343
344      try {
345        logger.logResponse(Integer.toString(responseCode), headerList, responseBody);
346      } catch (Exception e) {
347        System.out.println("Error parsing response body passed in to logger ->\n" + e.getLocalizedMessage());
348      }
349    }
350//    else { // TODO fix logs
351//      System.out.println("Call to log HTTP response with null ToolingClientLogger set... are you forgetting to " +
352//        "initialize your logger?");
353//    }
354  }
355}