001package org.hl7.fhir.r4.utils.client;
002
003import java.io.IOException;
004import java.net.URI;
005import java.net.URISyntaxException;
006import java.util.ArrayList;
007import java.util.Base64;
008import java.util.HashMap;
009import java.util.Map;
010
011import org.hl7.fhir.exceptions.FHIRException;
012import org.hl7.fhir.r4.model.Bundle;
013import org.hl7.fhir.r4.model.CapabilityStatement;
014import org.hl7.fhir.r4.model.CodeSystem;
015import org.hl7.fhir.r4.model.Coding;
016import org.hl7.fhir.r4.model.ConceptMap;
017import org.hl7.fhir.r4.model.OperationOutcome;
018import org.hl7.fhir.r4.model.Parameters;
019import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
020import org.hl7.fhir.r4.model.PrimitiveType;
021import org.hl7.fhir.r4.model.Resource;
022import org.hl7.fhir.r4.model.StringType;
023import org.hl7.fhir.r4.model.TerminologyCapabilities;
024import org.hl7.fhir.r4.model.ValueSet;
025import org.hl7.fhir.r4.utils.client.network.ByteUtils;
026import org.hl7.fhir.r4.utils.client.network.Client;
027import org.hl7.fhir.r4.utils.client.network.ResourceRequest;
028import org.hl7.fhir.utilities.ToolingClientLogger;
029import org.hl7.fhir.utilities.Utilities;
030
031import okhttp3.Headers;
032import okhttp3.internal.http2.Header;
033
034/**
035 * Very Simple RESTful client. This is purely for use in the standalone tools
036 * jar packages. It doesn't support many features, only what the tools need.
037 * <p>
038 * To use, initialize class and set base service URI as follows:
039 *
040 * <pre>
041 * <code>
042 * FHIRSimpleClient fhirClient = new FHIRSimpleClient();
043 * fhirClient.initialize("http://my.fhir.domain/myServiceRoot");
044 * </code>
045 * </pre>
046 * <p>
047 * Default Accept and Content-Type headers are application/fhir+xml and
048 * application/fhir+json.
049 * <p>
050 * These can be changed by invoking the following setter functions:
051 *
052 * <pre>
053 * <code>
054 * setPreferredResourceFormat()
055 * setPreferredFeedFormat()
056 * </code>
057 * </pre>
058 * <p>
059 * TODO Review all sad paths.
060 *
061 * @author Claude Nanjo
062 */
063public class FHIRToolingClient {
064
065  public static final String DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ssK";
066  public static final String DATE_FORMAT = "yyyy-MM-dd";
067  public static final String hostKey = "http.proxyHost";
068  public static final String portKey = "http.proxyPort";
069
070  private static final int TIMEOUT_NORMAL = 1500;
071  private static final int TIMEOUT_OPERATION = 30000;
072  private static final int TIMEOUT_ENTRY = 500;
073  private static final int TIMEOUT_OPERATION_LONG = 60000;
074  private static final int TIMEOUT_OPERATION_EXPAND = 120000;
075
076  private String base;
077  private ResourceAddress resourceAddress;
078  private ResourceFormat preferredResourceFormat;
079  private int maxResultSetSize = -1;// _count
080  private CapabilityStatement capabilities;
081  private Client client = new Client();
082  private ArrayList<Header> headers = new ArrayList<>();
083  private String username;
084  private String password;
085  private String userAgent;
086  private String acceptLang;
087
088  // Pass endpoint for client - URI
089  public FHIRToolingClient(String baseServiceUrl, String userAgent) throws URISyntaxException {
090    preferredResourceFormat = ResourceFormat.RESOURCE_JSON;
091    this.userAgent = userAgent;
092    initialize(baseServiceUrl);
093  }
094
095  public void initialize(String baseServiceUrl) throws URISyntaxException {
096    base = baseServiceUrl;
097    client.setBase(base);
098    resourceAddress = new ResourceAddress(baseServiceUrl);
099    this.maxResultSetSize = -1;
100  }
101
102  public Client getClient() {
103    return client;
104  }
105
106  public void setClient(Client client) {
107    this.client = client;
108  }
109
110  private void checkCapabilities() {
111    try {
112      capabilities = getCapabilitiesStatementQuick();
113    } catch (Throwable e) {
114    }
115  }
116
117  public String getPreferredResourceFormat() {
118    return preferredResourceFormat.getHeader();
119  }
120
121  public void setPreferredResourceFormat(ResourceFormat resourceFormat) {
122    preferredResourceFormat = resourceFormat;
123  }
124
125  public int getMaximumRecordCount() {
126    return maxResultSetSize;
127  }
128
129  public void setMaximumRecordCount(int maxResultSetSize) {
130    this.maxResultSetSize = maxResultSetSize;
131  }
132
133  public TerminologyCapabilities getTerminologyCapabilities() {
134    TerminologyCapabilities capabilities = null;
135    try {
136      capabilities = (TerminologyCapabilities) client.issueGetResourceRequest(resourceAddress.resolveMetadataTxCaps(),
137          getPreferredResourceFormat(), generateHeaders(), "TerminologyCapabilities", TIMEOUT_NORMAL).getReference();
138    } catch (Exception e) {
139      throw new FHIRException("Error fetching the server's terminology capabilities", e);
140    }
141    return capabilities;
142  }
143
144  public CapabilityStatement getCapabilitiesStatement() {
145    CapabilityStatement conformance = null;
146    try {
147      conformance = (CapabilityStatement) client.issueGetResourceRequest(resourceAddress.resolveMetadataUri(false),
148          getPreferredResourceFormat(), generateHeaders(), "CapabilitiesStatement", TIMEOUT_NORMAL).getReference();
149    } catch (Exception e) {
150      throw new FHIRException("Error fetching the server's conformance statement", e);
151    }
152    return conformance;
153  }
154
155  public CapabilityStatement getCapabilitiesStatementQuick() throws EFhirClientException {
156    if (capabilities != null)
157      return capabilities;
158    try {
159      capabilities = (CapabilityStatement) client.issueGetResourceRequest(resourceAddress.resolveMetadataUri(true),
160          getPreferredResourceFormat(), generateHeaders(), "CapabilitiesStatement-Quick", TIMEOUT_NORMAL)
161          .getReference();
162    } catch (Exception e) {
163      throw new FHIRException("Error fetching the server's capability statement: " + e.getMessage(), e);
164    }
165    return capabilities;
166  }
167
168  public <T extends Resource> T read(Class<T> resourceClass, String id) {// TODO Change this to AddressableResource
169    ResourceRequest<T> result = null;
170    try {
171      result = client.issueGetResourceRequest(resourceAddress.resolveGetUriFromResourceClassAndId(resourceClass, id),
172          getPreferredResourceFormat(), generateHeaders(), "Read " + resourceClass.getName() + "/" + id,
173          TIMEOUT_NORMAL);
174      if (result.isUnsuccessfulRequest()) {
175        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
176            (OperationOutcome) result.getPayload());
177      }
178    } catch (Exception e) {
179      throw new FHIRException(e);
180    }
181    return result.getPayload();
182  }
183
184  public <T extends Resource> T vread(Class<T> resourceClass, String id, String version) {
185    ResourceRequest<T> result = null;
186    try {
187      result = client.issueGetResourceRequest(
188          resourceAddress.resolveGetUriFromResourceClassAndIdAndVersion(resourceClass, id, version),
189          getPreferredResourceFormat(), generateHeaders(),
190          "VRead " + resourceClass.getName() + "/" + id + "/?_history/" + version, TIMEOUT_NORMAL);
191      if (result.isUnsuccessfulRequest()) {
192        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
193            (OperationOutcome) result.getPayload());
194      }
195    } catch (Exception e) {
196      throw new FHIRException("Error trying to read this version of the resource", e);
197    }
198    return result.getPayload();
199  }
200
201  public <T extends Resource> T getCanonical(Class<T> resourceClass, String canonicalURL) {
202    ResourceRequest<T> result = null;
203    try {
204      result = client.issueGetResourceRequest(
205          resourceAddress.resolveGetUriFromResourceClassAndCanonical(resourceClass, canonicalURL),
206          getPreferredResourceFormat(), generateHeaders(), "Read " + resourceClass.getName() + "?url=" + canonicalURL,
207          TIMEOUT_NORMAL);
208      if (result.isUnsuccessfulRequest()) {
209        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
210            (OperationOutcome) result.getPayload());
211      }
212    } catch (Exception e) {
213      handleException("An error has occurred while trying to read this version of the resource", e);
214    }
215    Bundle bnd = (Bundle) result.getPayload();
216    if (bnd.getEntry().size() == 0)
217      throw new EFhirClientException("No matching resource found for canonical URL '" + canonicalURL + "'");
218    if (bnd.getEntry().size() > 1)
219      throw new EFhirClientException("Multiple matching resources found for canonical URL '" + canonicalURL + "'");
220    return (T) bnd.getEntry().get(0).getResource();
221  }
222
223  public Resource update(Resource resource) {
224    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
225    try {
226      result = client.issuePutRequest(
227          resourceAddress.resolveGetUriFromResourceClassAndId(resource.getClass(), resource.getId()),
228          ByteUtils.resourceToByteArray(resource, false, isJson(getPreferredResourceFormat())),
229          getPreferredResourceFormat(), generateHeaders(), "Update " + resource.fhirType() + "/" + resource.getId(),
230          TIMEOUT_OPERATION);
231      if (result.isUnsuccessfulRequest()) {
232        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
233            (OperationOutcome) result.getPayload());
234      }
235    } catch (Exception e) {
236      throw new EFhirClientException("An error has occurred while trying to update this resource", e);
237    }
238    // TODO oe 26.1.2015 could be made nicer if only OperationOutcome locationheader
239    // is returned with an operationOutcome would be returned (and not the resource
240    // also) we make another read
241    try {
242      OperationOutcome operationOutcome = (OperationOutcome) result.getPayload();
243      ResourceAddress.ResourceVersionedIdentifier resVersionedIdentifier = ResourceAddress
244          .parseCreateLocation(result.getLocation());
245      return this.vread(resource.getClass(), resVersionedIdentifier.getId(), resVersionedIdentifier.getVersionId());
246    } catch (ClassCastException e) {
247      // if we fall throught we have the correct type already in the create
248    }
249
250    return result.getPayload();
251  }
252
253  public <T extends Resource> T update(Class<T> resourceClass, T resource, String id) {
254    ResourceRequest<T> result = null;
255    try {
256      result = client.issuePutRequest(resourceAddress.resolveGetUriFromResourceClassAndId(resourceClass, id),
257          ByteUtils.resourceToByteArray(resource, false, isJson(getPreferredResourceFormat())),
258          getPreferredResourceFormat(), generateHeaders(), "Update " + resource.fhirType() + "/" + id,
259          TIMEOUT_OPERATION);
260      if (result.isUnsuccessfulRequest()) {
261        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
262            (OperationOutcome) result.getPayload());
263      }
264    } catch (Exception e) {
265      throw new EFhirClientException("An error has occurred while trying to update this resource", e);
266    }
267    // TODO oe 26.1.2015 could be made nicer if only OperationOutcome locationheader
268    // is returned with an operationOutcome would be returned (and not the resource
269    // also) we make another read
270    try {
271      OperationOutcome operationOutcome = (OperationOutcome) result.getPayload();
272      ResourceAddress.ResourceVersionedIdentifier resVersionedIdentifier = ResourceAddress
273          .parseCreateLocation(result.getLocation());
274      return this.vread(resourceClass, resVersionedIdentifier.getId(), resVersionedIdentifier.getVersionId());
275    } catch (ClassCastException e) {
276      // if we fall through we have the correct type already in the create
277    }
278
279    return result.getPayload();
280  }
281
282  public <T extends Resource> Parameters operateType(Class<T> resourceClass, String name, Parameters params) throws IOException {
283    boolean complex = false;
284    for (ParametersParameterComponent p : params.getParameter())
285      complex = complex || !(p.getValue() instanceof PrimitiveType);
286    String ps = "";
287    if (!complex)
288      for (ParametersParameterComponent p : params.getParameter())
289        if (p.getValue() instanceof PrimitiveType)
290          ps += p.getName() + "=" + Utilities.encodeUri(((PrimitiveType) p.getValue()).asStringValue()) + "&";
291    ResourceRequest<T> result;
292    URI url = resourceAddress.resolveOperationURLFromClass(resourceClass, name, ps);
293    if (complex) {
294      byte[] body = ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat()));
295      result = client.issuePostRequest(url, body, getPreferredResourceFormat(), generateHeaders(),
296          "POST " + resourceClass.getName() + "/$" + name, TIMEOUT_OPERATION_LONG);
297    } else {
298      result = client.issueGetResourceRequest(url, getPreferredResourceFormat(), generateHeaders(),
299          "GET " + resourceClass.getName() + "/$" + name, TIMEOUT_OPERATION_LONG);
300    }
301    if (result.isUnsuccessfulRequest()) {
302      throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
303          (OperationOutcome) result.getPayload());
304    }
305    if (result.getPayload() instanceof Parameters) {
306      return (Parameters) result.getPayload();
307    } else {
308      Parameters p_out = new Parameters();
309      p_out.addParameter().setName("return").setResource(result.getPayload());
310      return p_out;
311    }
312  }
313
314  public Bundle transaction(Bundle batch) {
315    Bundle transactionResult = null;
316    try {
317      transactionResult = client.postBatchRequest(resourceAddress.getBaseServiceUri(),
318          ByteUtils.resourceToByteArray(batch, false, isJson(getPreferredResourceFormat())),
319          getPreferredResourceFormat(), "transaction", TIMEOUT_OPERATION + (TIMEOUT_ENTRY * batch.getEntry().size()));
320    } catch (Exception e) {
321      handleException("An error occurred trying to process this transaction request", e);
322    }
323    return transactionResult;
324  }
325
326  @SuppressWarnings("unchecked")
327  public <T extends Resource> OperationOutcome validate(Class<T> resourceClass, T resource, String id) {
328    ResourceRequest<T> result = null;
329    try {
330      result = client.issuePostRequest(resourceAddress.resolveValidateUri(resourceClass, id),
331          ByteUtils.resourceToByteArray(resource, false, isJson(getPreferredResourceFormat())),
332          getPreferredResourceFormat(), generateHeaders(),
333          "POST " + resourceClass.getName() + (id != null ? "/" + id : "") + "/$validate", TIMEOUT_OPERATION_LONG);
334      if (result.isUnsuccessfulRequest()) {
335        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
336            (OperationOutcome) result.getPayload());
337      }
338    } catch (Exception e) {
339      handleException("An error has occurred while trying to validate this resource", e);
340    }
341    return (OperationOutcome) result.getPayload();
342  }
343
344  /**
345   * Helper method to prevent nesting of previously thrown EFhirClientExceptions
346   *
347   * @param e
348   * @throws EFhirClientException
349   */
350  protected void handleException(String message, Exception e) throws EFhirClientException {
351    if (e instanceof EFhirClientException) {
352      throw (EFhirClientException) e;
353    } else {
354      throw new EFhirClientException(message, e);
355    }
356  }
357
358  /**
359   * Helper method to determine whether desired resource representation is Json or
360   * XML.
361   *
362   * @param format
363   * @return
364   */
365  protected boolean isJson(String format) {
366    boolean isJson = false;
367    if (format.toLowerCase().contains("json")) {
368      isJson = true;
369    }
370    return isJson;
371  }
372
373  public Bundle fetchFeed(String url) {
374    Bundle feed = null;
375    try {
376      feed = client.issueGetFeedRequest(new URI(url), getPreferredResourceFormat());
377    } catch (Exception e) {
378      handleException("An error has occurred while trying to retrieve history since last update", e);
379    }
380    return feed;
381  }
382
383  public ValueSet expandValueset(String vsUrl, Parameters expParams) {
384    Map<String, String> parameters = new HashMap<>();
385    parameters.put("url", vsUrl);
386
387    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
388      try {
389        result = client.issueGetResourceRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand", parameters),
390            getPreferredResourceFormat(), generateHeaders(), "ValueSet/$expand?url=" + vsUrl, TIMEOUT_OPERATION_EXPAND);
391      } catch (IOException e) {
392        throw new FHIRException(e);
393      }
394      if (result.isUnsuccessfulRequest()) {
395        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
396            (OperationOutcome) result.getPayload());
397      }
398    return result == null ? null : (ValueSet) result.getPayload();
399  }
400
401  public ValueSet expandValueset(ValueSet source, Parameters expParams) {
402    Parameters p = expParams == null ? new Parameters() : expParams.copy();
403    p.addParameter().setName("valueSet").setResource(source);
404    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
405    try {
406      result = client.issuePostRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand"),
407          ByteUtils.resourceToByteArray(p, false, isJson(getPreferredResourceFormat())), getPreferredResourceFormat(),
408          generateHeaders(), "ValueSet/$expand?url=" + source.getUrl(), TIMEOUT_OPERATION_EXPAND);
409      if (result.isUnsuccessfulRequest()) {
410        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
411            (OperationOutcome) result.getPayload());
412      }
413    } catch (IOException e) {
414      throw new FHIRException(e);
415    }
416    return result == null ? null : (ValueSet) result.getPayload();
417  }
418
419  public Parameters lookupCode(Map<String, String> params) {
420    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
421    try {
422      result = client.issueGetResourceRequest(resourceAddress.resolveOperationUri(CodeSystem.class, "lookup", params),
423          getPreferredResourceFormat(), generateHeaders(), "CodeSystem/$lookup", TIMEOUT_NORMAL);
424    } catch (IOException e) {
425      throw new FHIRException(e);
426    }
427    if (result.isUnsuccessfulRequest()) {
428      throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
429          (OperationOutcome) result.getPayload());
430    }
431    return (Parameters) result.getPayload();
432  }
433
434  public ValueSet expandValueset(ValueSet source, Parameters expParams, Map<String, String> params) {
435    Parameters p = expParams == null ? new Parameters() : expParams.copy();
436    p.addParameter().setName("valueSet").setResource(source);
437    if (params != null) {
438      for (String n : params.keySet()) {
439        p.addParameter().setName(n).setValue(new StringType(params.get(n)));
440      }
441    }
442    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
443    try {
444      result = client.issuePostRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand", params),
445          ByteUtils.resourceToByteArray(p, false, isJson(getPreferredResourceFormat())), getPreferredResourceFormat(),
446          generateHeaders(), source == null ? "ValueSet/$expand" : "ValueSet/$expand?url=" + source.getUrl(),
447          TIMEOUT_OPERATION_EXPAND);
448      if (result.isUnsuccessfulRequest()) {
449        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
450            (OperationOutcome) result.getPayload());
451      }
452    } catch (IOException e) {
453      throw new FHIRException(e);
454    }
455    return result == null ? null : (ValueSet) result.getPayload();
456  }
457
458  public String getAddress() {
459    return base;
460  }
461
462  public ConceptMap initializeClosure(String name) {
463    Parameters params = new Parameters();
464    params.addParameter().setName("name").setValue(new StringType(name));
465    ResourceRequest<Resource> result = null;
466    try {
467      result = client.issuePostRequest(
468          resourceAddress.resolveOperationUri(null, "closure", new HashMap<String, String>()),
469          ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat())),
470          getPreferredResourceFormat(), generateHeaders(), "Closure?name=" + name, TIMEOUT_NORMAL);
471      if (result.isUnsuccessfulRequest()) {
472        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
473            (OperationOutcome) result.getPayload());
474      }
475    } catch (IOException e) {
476      throw new FHIRException(e);
477    }
478    return result == null ? null : (ConceptMap) result.getPayload();
479  }
480
481  public ConceptMap updateClosure(String name, Coding coding) {
482    Parameters params = new Parameters();
483    params.addParameter().setName("name").setValue(new StringType(name));
484    params.addParameter().setName("concept").setValue(coding);
485    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
486    try {
487      result = client.issuePostRequest(
488          resourceAddress.resolveOperationUri(null, "closure", new HashMap<String, String>()),
489          ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat())),
490          getPreferredResourceFormat(), generateHeaders(), "UpdateClosure?name=" + name, TIMEOUT_OPERATION);
491      if (result.isUnsuccessfulRequest()) {
492        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(),
493            (OperationOutcome) result.getPayload());
494      }
495    } catch (IOException e) {
496      throw new FHIRException(e);
497    }
498    return result == null ? null : (ConceptMap) result.getPayload();
499  }
500
501  public String getUsername() {
502    return username;
503  }
504
505  public void setUsername(String username) {
506    this.username = username;
507  }
508
509  public String getPassword() {
510    return password;
511  }
512
513  public void setPassword(String password) {
514    this.password = password;
515  }
516
517  public long getTimeout() {
518    return client.getTimeout();
519  }
520
521  public void setTimeout(long timeout) {
522    client.setTimeout(timeout);
523  }
524
525  public ToolingClientLogger getLogger() {
526    return client.getLogger();
527  }
528
529  public void setLogger(ToolingClientLogger logger) {
530    client.setLogger(logger);
531  }
532
533  public int getRetryCount() {
534    return client.getRetryCount();
535  }
536
537  public void setRetryCount(int retryCount) {
538    client.setRetryCount(retryCount);
539  }
540
541  public void setClientHeaders(ArrayList<Header> headers) {
542    this.headers = headers;
543  }
544
545  private Headers generateHeaders() {
546    Headers.Builder builder = new Headers.Builder();
547    // Add basic auth header if it exists
548    if (basicAuthHeaderExists()) {
549      builder.add(getAuthorizationHeader().toString());
550    }
551    // Add any other headers
552    if (this.headers != null) {
553      this.headers.forEach(header -> builder.add(header.toString()));
554    }
555    if (!Utilities.noString(userAgent)) {
556      builder.add("User-Agent: " + userAgent);
557    }
558
559    if (!Utilities.noString(acceptLang)) {
560      builder.add("Accept-Language: "+acceptLang);
561    }
562    
563    return builder.build();
564  }
565
566  public boolean basicAuthHeaderExists() {
567    return (username != null) && (password != null);
568  }
569
570  public Header getAuthorizationHeader() {
571    String usernamePassword = username + ":" + password;
572    String base64usernamePassword = Base64.getEncoder().encodeToString(usernamePassword.getBytes());
573    return new Header("Authorization", "Basic " + base64usernamePassword);
574  }
575
576  public String getUserAgent() {
577    return userAgent;
578  }
579
580  public void setUserAgent(String userAgent) {
581    this.userAgent = userAgent;
582  }
583
584  public String getServerVersion() {
585    checkCapabilities();
586    return capabilities == null ? null : capabilities.getSoftware().getVersion();
587  }
588
589  public void setLanguage(String lang) {
590    this.acceptLang = lang;
591  }
592}