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