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