001package org.hl7.fhir.r5.utils.client.network;
002
003import java.io.IOException;
004import java.util.ArrayList;
005import java.util.List;
006import java.util.concurrent.TimeUnit;
007
008
009import org.apache.commons.lang3.StringUtils;
010import org.hl7.fhir.r5.formats.IParser;
011import org.hl7.fhir.r5.formats.JsonParser;
012import org.hl7.fhir.r5.formats.XmlParser;
013import org.hl7.fhir.r5.model.Bundle;
014import org.hl7.fhir.r5.model.OperationOutcome;
015import org.hl7.fhir.r5.model.Resource;
016import org.hl7.fhir.r5.utils.OperationOutcomeUtilities;
017import org.hl7.fhir.r5.utils.ResourceUtilities;
018import org.hl7.fhir.r5.utils.client.EFhirClientException;
019import org.hl7.fhir.r5.utils.client.ResourceFormat;
020import org.hl7.fhir.utilities.MimeType;
021import org.hl7.fhir.utilities.ToolingClientLogger;
022import org.hl7.fhir.utilities.Utilities;
023import org.hl7.fhir.utilities.http.*;
024import org.hl7.fhir.utilities.xhtml.XhtmlUtils;
025
026public class FhirRequestBuilder {
027
028  protected static final String DEFAULT_CHARSET = "UTF-8";
029
030  protected static final String LOCATION_HEADER = "location";
031  protected static final String CONTENT_LOCATION_HEADER = "content-location";
032
033  private final HTTPRequest httpRequest;
034  private String resourceFormat = null;
035  private Iterable<HTTPHeader> headers = null;
036  private String message = null;
037  private int retryCount = 1;
038  /**
039   * The timeout quantity. Used in combination with {@link FhirRequestBuilder#timeoutUnit}.
040   */
041  private long timeout = 5000;
042  /**
043   * Time unit for {@link FhirRequestBuilder#timeout}.
044   */
045  private TimeUnit timeoutUnit = TimeUnit.MILLISECONDS;
046
047  /**
048   * {@link ToolingClientLogger} for log output.
049   */
050  private ToolingClientLogger logger = null;
051
052  private String source;
053
054  public FhirRequestBuilder(HTTPRequest httpRequest, String source) {
055    this.source = source;
056    this.httpRequest = httpRequest;
057  }
058
059  /**
060   * Adds necessary default headers, formatting headers, and any passed in {@link HTTPHeader}s to the passed in
061   * {@link HTTPRequest}
062   *
063   * @param request {@link HTTPRequest} to add headers to.
064   * @param format  Expected {@link Resource} format.
065   * @param headers Any additional {@link HTTPHeader}s to add to the request.
066   */
067  protected static HTTPRequest formatHeaders(HTTPRequest request, String format, Iterable<HTTPHeader> headers) {
068    List<HTTPHeader> allHeaders = new ArrayList<>();
069    request.getHeaders().forEach(allHeaders::add);
070
071    if (format != null) getResourceFormatHeaders(request, format).forEach(allHeaders::add);
072    if (headers != null) headers.forEach(allHeaders::add);
073    return request.withHeaders(allHeaders);
074  }
075
076  /**
077   * Adds necessary headers for the given resource format provided.
078   *
079   * @param httpRequest {@link HTTPRequest} to add default headers to.
080   * @param format     Expected {@link Resource} format.
081   */
082  protected static Iterable<HTTPHeader> getResourceFormatHeaders(HTTPRequest httpRequest, String format) {
083    List<HTTPHeader> headers = new ArrayList<>();
084    headers.add(new HTTPHeader("Accept", format));
085    if (httpRequest.getMethod() == HTTPRequest.HttpMethod.PUT
086        || httpRequest.getMethod() ==  HTTPRequest.HttpMethod.POST
087      || httpRequest.getMethod() == HTTPRequest.HttpMethod.PATCH
088    ) {
089      headers.add( new HTTPHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET));
090    }
091    return headers;
092  }
093
094  /**
095   * Returns true if any of the {@link OperationOutcome.OperationOutcomeIssueComponent} within the
096   * provided {@link OperationOutcome} have an {@link OperationOutcome.IssueSeverity} of
097   * {@link OperationOutcome.IssueSeverity#ERROR} or
098   * {@link OperationOutcome.IssueSeverity#FATAL}
099   *
100   * @param oo {@link OperationOutcome} to evaluate.
101   * @return {@link Boolean#TRUE} if an error exists.
102   */
103  protected static boolean hasError(OperationOutcome oo) {
104    return (oo.getIssue().stream()
105      .anyMatch(issue -> issue.getSeverity() == OperationOutcome.IssueSeverity.ERROR
106        || issue.getSeverity() == OperationOutcome.IssueSeverity.FATAL));
107  }
108
109  protected ManagedFhirWebAccessor getManagedWebAccessor() {
110    return ManagedWebAccess.fhirAccessor().withRetries(retryCount).withTimeout(timeout, timeoutUnit).withLogger(logger);
111  }
112
113  public FhirRequestBuilder withResourceFormat(String resourceFormat) {
114    this.resourceFormat = resourceFormat;
115    return this;
116  }
117
118  public FhirRequestBuilder withHeaders(Iterable<HTTPHeader> headers) {
119    this.headers = headers;
120    return this;
121  }
122
123  public FhirRequestBuilder withMessage(String message) {
124    this.message = message;
125    return this;
126  }
127
128  public FhirRequestBuilder withRetryCount(int retryCount) {
129    this.retryCount = retryCount;
130    return this;
131  }
132
133  public FhirRequestBuilder withLogger(ToolingClientLogger logger) {
134    this.logger = logger;
135    return this;
136  }
137
138  public FhirRequestBuilder withTimeout(long timeout, TimeUnit unit) {
139    this.timeout = timeout;
140    this.timeoutUnit = unit;
141    return this;
142  }
143
144  public <T extends Resource> ResourceRequest<T> execute() throws IOException {
145    HTTPRequest requestWithHeaders = formatHeaders(httpRequest, resourceFormat, headers);
146    HTTPResult response = getManagedWebAccessor().httpCall(requestWithHeaders);//getHttpClient().newCall(httpRequest.build()).execute();
147    T resource = unmarshalReference(response, resourceFormat, null);
148    return new ResourceRequest<T>(resource, response.getCode(), getLocationHeader(response.getHeaders()));
149  }
150
151  public Bundle executeAsBatch() throws IOException {
152    HTTPRequest requestWithHeaders = formatHeaders(httpRequest, resourceFormat, null);
153    HTTPResult response = getManagedWebAccessor().httpCall(requestWithHeaders);
154    return unmarshalFeed(response, resourceFormat);
155  }
156
157  /**
158   * Unmarshalls a resource from the response stream.
159   */
160  @SuppressWarnings("unchecked")
161  protected <T extends Resource> T unmarshalReference(HTTPResult response, String format, String resourceType) {
162    int code = response.getCode();
163    boolean ok = code >= 200 && code < 300;
164    if (response.getContent() == null) {
165      if (!ok) {
166        if (Utilities.noString(response.getMessage())) {
167          throw new EFhirClientException(code, response.getMessagefromCode());
168        } else {
169          throw new EFhirClientException(code, response.getMessage());
170        }
171      } else {
172        return null;
173      }
174    }
175    String body;
176    
177    Resource resource = null;
178    try {
179      body = response.getContentAsString();
180      String contentType = HTTPHeaderUtil.getSingleHeader(response.getHeaders(), "Content-Type");
181      if (contentType == null) {
182        if (ok) {
183          resource = getParser(format).parse(body);
184        } else {
185          System.out.println("Got error response with no Content-Type from "+source+" with status "+code);
186          System.out.println(body);
187          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
188        }
189      } else {
190        if (contentType.contains(";")) {
191          contentType = contentType.substring(0, contentType.indexOf(";"));
192        }
193        switch (contentType) {
194        case "application/json":
195        case "application/fhir+json":
196          if (!format.contains("json")) {
197            System.out.println("Got json response expecting "+format+" from "+source+" with status "+code);            
198          }
199          resource = getParser(ResourceFormat.RESOURCE_JSON.getHeader()).parse(body);
200          break;
201        case "application/xml":
202        case "application/fhir+xml":
203        case "text/xml":
204          if (!format.contains("xml")) {
205            System.out.println("Got xml response expecting "+format+" from "+source+" with status "+code);            
206          }
207          resource = getParser(ResourceFormat.RESOURCE_XML.getHeader()).parse(body);
208          break;
209        case "text/plain":
210          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
211          break;
212        case "text/html" : 
213          resource = OperationOutcomeUtilities.outcomeFromTextError(XhtmlUtils.convertHtmlToText(response.getContentAsString(), source));
214          break;
215        default: // not sure what else to do? 
216          System.out.println("Got content-type '"+contentType+"' from "+source);
217          System.out.println(body);
218          resource = OperationOutcomeUtilities.outcomeFromTextError(body);
219        }
220      }
221    } catch (IOException ioe) {
222      throw new EFhirClientException(0, "Error reading Http Response from "+source+":"+ioe.getMessage(), ioe);
223    } catch (Exception e) {
224      throw new EFhirClientException(0, "Error parsing response message from "+source+": "+e.getMessage(), e);
225    }
226    if (resource instanceof OperationOutcome && (!"OperationOutcome".equals(resourceType) || !ok)) {
227      OperationOutcome error = (OperationOutcome) resource;  
228      if (hasError((OperationOutcome) resource)) {
229        throw new EFhirClientException(0, "Error from "+source+": " + ResourceUtilities.getErrorDescription(error), error);
230      } else {
231        // umm, weird...
232        System.out.println("Got OperationOutcome with no error from "+source+" with status "+code);            
233        System.out.println(body);
234        return null;
235      }
236    }
237    if (resource == null) {
238      System.out.println("No resource from "+source+" with status "+code);   
239      System.out.println(body);         
240      return null; // shouldn't get here?
241    }
242    if (resourceType != null && !resource.fhirType().equals(resourceType)) {
243      throw new EFhirClientException(0, "Error parsing response message from "+source+": Found an "+resource.fhirType()+" looking for a "+resourceType);        
244    }
245    return (T) resource;
246  }
247
248  /**
249   * Unmarshalls Bundle from response stream.
250   */
251  protected Bundle unmarshalFeed(HTTPResult response, String format) {
252    return unmarshalReference(response, format, "Bundle");
253  }
254
255  /**
256   * Returns the appropriate parser based on the format type passed in. Defaults to XML parser if a blank format is
257   * provided...because reasons.
258   * <p/>
259   * Currently supports only "json" and "xml" formats.
260   *
261   * @param format One of "json" or "xml".
262   * @return {@link JsonParser} or {@link XmlParser}
263   */
264  protected IParser getParser(String format) {
265    if (StringUtils.isBlank(format)) {
266      format = ResourceFormat.RESOURCE_XML.getHeader();
267    }
268    MimeType mt = new MimeType(format);
269    if (mt.getBase().equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader())) {
270      return new JsonParser();
271    } else if (mt.getBase().equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader())) {
272      return new XmlParser();
273    } else {
274      throw new EFhirClientException(0, "Invalid format: " + format);
275    }
276  }
277
278  /**
279   * Extracts the 'location' header from the passed headers. If no value for 'location' exists, the
280   * value for 'content-location' is returned. If neither header exists, we return null.
281   *
282   * @param headers Headers to search for 'location' or 'content-location'.
283   */
284  protected static String getLocationHeader(Iterable<HTTPHeader> headers) {
285    String locationHeader = HTTPHeaderUtil.getSingleHeader(headers, LOCATION_HEADER);
286
287    if (locationHeader != null) {
288      return locationHeader;
289    }
290    return HTTPHeaderUtil.getSingleHeader(headers, CONTENT_LOCATION_HEADER);
291  }
292}