001package org.hl7.fhir.r5.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.r5.formats.IParser;
013import org.hl7.fhir.r5.formats.JsonParser;
014import org.hl7.fhir.r5.formats.XmlParser;
015import org.hl7.fhir.r5.model.Bundle;
016import org.hl7.fhir.r5.model.OperationOutcome;
017import org.hl7.fhir.r5.model.Resource;
018import org.hl7.fhir.r5.utils.OperationOutcomeUtilities;
019import org.hl7.fhir.r5.utils.ResourceUtilities;
020import org.hl7.fhir.r5.utils.client.EFhirClientException;
021import org.hl7.fhir.r5.utils.client.ResourceFormat;
022import org.hl7.fhir.utilities.MimeType;
023import org.hl7.fhir.utilities.Utilities;
024import org.hl7.fhir.utilities.settings.FhirSettings;
025import org.hl7.fhir.utilities.xhtml.XhtmlUtils;
026
027import okhttp3.Authenticator;
028import okhttp3.Credentials;
029import okhttp3.Headers;
030import okhttp3.OkHttpClient;
031import okhttp3.Request;
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 {@link FhirRequestBuilder#timeoutUnit}.
053   */
054  private long timeout = 5000;
055  /**
056   * Time unit for {@link FhirRequestBuilder#timeout}.
057   */
058  private TimeUnit timeoutUnit = TimeUnit.MILLISECONDS;
059
060  /**
061   * {@link FhirLoggingInterceptor} for log output.
062   */
063  private FhirLoggingInterceptor logger = null;
064  private String source;
065
066  public FhirRequestBuilder(Request.Builder httpRequest, String source) {
067    this.httpRequest = httpRequest;
068    this.source = source;
069  }
070
071  /**
072   * Adds necessary default headers, formatting headers, and any passed in {@link Headers} to the passed in
073   * {@link okhttp3.Request.Builder}
074   *
075   * @param request {@link okhttp3.Request.Builder} to add headers to.
076   * @param format  Expected {@link Resource} format.
077   * @param headers Any additional {@link Headers} to add to the request.
078   */
079  protected static void formatHeaders(Request.Builder request, String format, Headers headers) {
080    addDefaultHeaders(request, headers);
081    if (format != null) addResourceFormatHeaders(request, format);
082    if (headers != null) addHeaders(request, headers);
083  }
084
085  /**
086   * Adds necessary headers for all REST requests.
087   * <li>User-Agent : hapi-fhir-tooling-client</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  }
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    if (Utilities.existsInList(request.getMethod$okhttp(), "POST", "PUT")) {
105      request.addHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET);
106    }
107  }
108
109  /**
110   * Iterates through the passed in {@link Headers} and adds them to the provided {@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 {@link org.hl7.fhir.r5.model.OperationOutcome.OperationOutcomeIssueComponent} within the
121   * provided {@link OperationOutcome} have an {@link org.hl7.fhir.r5.model.OperationOutcome.IssueSeverity} of
122   * {@link org.hl7.fhir.r5.model.OperationOutcome.IssueSeverity#ERROR} or
123   * {@link org.hl7.fhir.r5.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    OkHttpClient.Builder builder = okHttpClient.newBuilder();
177    if (logger != null) builder.addInterceptor(logger);
178    builder.addInterceptor(new RetryInterceptor(retryCount));
179    return builder.connectTimeout(timeout, timeoutUnit)
180      .writeTimeout(timeout, timeoutUnit)
181      .readTimeout(timeout, timeoutUnit)
182      .proxyAuthenticator(proxyAuthenticator)
183      .build();
184  }
185
186  @Nonnull
187  private static Authenticator getAuthenticator() {
188    return (route, response) -> {
189      final String httpProxyUser = System.getProperty(HTTP_PROXY_USER);
190      final String httpProxyPass = System.getProperty(HTTP_PROXY_PASS);
191      if (httpProxyUser != null && httpProxyPass != null) {
192        String credential = Credentials.basic(httpProxyUser, httpProxyPass);
193        return response.request().newBuilder()
194          .header(HEADER_PROXY_AUTH, credential)
195          .build();
196      }
197      return response.request().newBuilder().build();
198    };
199  }
200
201  public FhirRequestBuilder withResourceFormat(String resourceFormat) {
202    this.resourceFormat = resourceFormat;
203    return this;
204  }
205
206  public FhirRequestBuilder withHeaders(Headers headers) {
207    this.headers = headers;
208    return this;
209  }
210
211  public FhirRequestBuilder withMessage(String message) {
212    this.message = message;
213    return this;
214  }
215
216  public FhirRequestBuilder withRetryCount(int retryCount) {
217    this.retryCount = retryCount;
218    return this;
219  }
220
221  public FhirRequestBuilder withLogger(FhirLoggingInterceptor logger) {
222    this.logger = logger;
223    return this;
224  }
225
226  public FhirRequestBuilder withTimeout(long timeout, TimeUnit unit) {
227    this.timeout = timeout;
228    this.timeoutUnit = unit;
229    return this;
230  }
231
232  protected Request buildRequest() {
233    return httpRequest.build();
234  }
235
236  public <T extends Resource> ResourceRequest<T> execute() throws IOException {
237    formatHeaders(httpRequest, resourceFormat, headers);
238    Response response = getHttpClient().newCall(httpRequest.build()).execute();
239    T resource = unmarshalReference(response, resourceFormat, null);
240    return new ResourceRequest<T>(resource, response.code(), getLocationHeader(response.headers()));
241  }
242
243  public Bundle executeAsBatch() throws IOException {
244    formatHeaders(httpRequest, resourceFormat, null);
245    Response response = getHttpClient().newCall(httpRequest.build()).execute();
246    return unmarshalFeed(response, resourceFormat);
247  }
248
249  /**
250   * Unmarshalls a resource from the response stream.
251   */
252  @SuppressWarnings("unchecked")
253  protected <T extends Resource> T unmarshalReference(Response response, String format, String resourceType) {
254    int code = response.code();
255    boolean ok = code >= 200 && code < 300;
256    if (response.body() == null) {
257      if (!ok) {
258        throw new EFhirClientException(code, response.message());
259      } else {
260        return null;
261      }
262    }
263    String body;
264    
265    Resource resource = null;
266    try {
267      body = response.body().string();
268      String ct = response.header("Content-Type"); 
269      if (ct == null) {
270        if (ok) {
271          resource = getParser(format).parse(body);
272        } else {
273          System.out.println("Got error response with no Content-Type from "+source+" with status "+code);
274          System.out.println(body);
275          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
276        }
277      } else {
278        if (ct.contains(";")) {
279          ct = ct.substring(0, ct.indexOf(";"));
280        }
281        switch (ct) {
282        case "application/json":
283        case "application/fhir+json":
284          if (!format.contains("json")) {
285            System.out.println("Got json response expecting "+format+" from "+source+" with status "+code);            
286          }
287          resource = getParser(ResourceFormat.RESOURCE_JSON.getHeader()).parse(body);
288          break;
289        case "application/xml":
290        case "application/fhir+xml":
291        case "text/xml":
292          if (!format.contains("xml")) {
293            System.out.println("Got xml response expecting "+format+" from "+source+" with status "+code);            
294          }
295          resource = getParser(ResourceFormat.RESOURCE_XML.getHeader()).parse(body);
296          break;
297        case "text/plain":
298          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
299          break;
300        case "text/html" : 
301          resource = OperationOutcomeUtilities.outcomeFromTextError(XhtmlUtils.convertHtmlToText(response.body().string(), source));
302          break;
303        default: // not sure what else to do? 
304          System.out.println("Got content-type '"+ct+"' from "+source);
305          System.out.println(body);
306          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
307        }
308      }
309    } catch (IOException ioe) {
310      throw new EFhirClientException(0, "Error reading Http Response from "+source+":"+ioe.getMessage(), ioe);
311    } catch (Exception e) {
312      throw new EFhirClientException(0, "Error parsing response message from "+source+": "+e.getMessage(), e);
313    }
314    if (resource instanceof OperationOutcome && (!"OperationOutcome".equals(resourceType) || !ok)) {
315      OperationOutcome error = (OperationOutcome) resource;  
316      if (hasError((OperationOutcome) resource)) {
317        throw new EFhirClientException(0, "Error from "+source+": " + ResourceUtilities.getErrorDescription(error), error);
318      } else {
319        // umm, weird...
320        System.out.println("Got OperationOutcome with no error from "+source+" with status "+code);            
321        System.out.println(body);
322        return null;
323      }
324    }
325    if (resource == null) {
326      System.out.println("No resource from "+source+" with status "+code);   
327      System.out.println(body);         
328      return null; // shouldn't get here?
329    }
330    if (resourceType != null && !resource.fhirType().equals(resourceType)) {
331      throw new EFhirClientException(0, "Error parsing response message from "+source+": Found an "+resource.fhirType()+" looking for a "+resourceType);        
332    }
333    return (T) resource;
334  }
335
336  /**
337   * Unmarshalls Bundle from response stream.
338   */
339  protected Bundle unmarshalFeed(Response response, String format) {
340    return unmarshalReference(response, format, "Bundle");
341  }
342
343  /**
344   * Returns the appropriate parser based on the format type passed in. Defaults to XML parser if a blank format is
345   * provided...because reasons.
346   * <p>
347   * Currently supports only "json" and "xml" formats.
348   *
349   * @param format One of "json" or "xml".
350   * @return {@link JsonParser} or {@link XmlParser}
351   */
352  protected IParser getParser(String format) {
353    if (StringUtils.isBlank(format)) {
354      format = ResourceFormat.RESOURCE_XML.getHeader();
355    }
356    MimeType mt = new MimeType(format);
357    if (mt.getBase().equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader())) {
358      return new JsonParser();
359    } else if (mt.getBase().equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader())) {
360      return new XmlParser();
361    } else {
362      throw new EFhirClientException(0, "Invalid format: " + format);
363    }
364  }
365}