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