
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}