
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}