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