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