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