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