001package org.hl7.fhir.r4.utils.client;
002
003import okhttp3.Headers;
004import okhttp3.Request;
005import okhttp3.internal.http2.Header;
006import org.hl7.fhir.exceptions.FHIRException;
007import org.hl7.fhir.r4.model.*;
008import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
009import org.hl7.fhir.r4.utils.client.network.ByteUtils;
010import org.hl7.fhir.r4.utils.client.network.Client;
011import org.hl7.fhir.r4.utils.client.network.ResourceRequest;
012import org.hl7.fhir.utilities.ToolingClientLogger;
013import org.hl7.fhir.utilities.Utilities;
014
015import java.io.IOException;
016import java.net.URI;
017import java.net.URISyntaxException;
018import java.util.*;
019
020/**
021 * Very Simple RESTful client. This is purely for use in the standalone
022 * tools jar packages. It doesn't support many features, only what the tools
023 * need.
024 * <p>
025 * To use, initialize class and set base service URI as follows:
026 *
027 * <pre><code>
028 * FHIRSimpleClient fhirClient = new FHIRSimpleClient();
029 * fhirClient.initialize("http://my.fhir.domain/myServiceRoot");
030 * </code></pre>
031 * <p>
032 * Default Accept and Content-Type headers are application/fhir+xml and application/fhir+json.
033 * <p>
034 * These can be changed by invoking the following setter functions:
035 *
036 * <pre><code>
037 * setPreferredResourceFormat()
038 * setPreferredFeedFormat()
039 * </code></pre>
040 * <p>
041 * TODO Review all sad paths.
042 *
043 * @author Claude Nanjo
044 */
045public class FHIRToolingClient {
046
047  public static final String DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ssK";
048  public static final String DATE_FORMAT = "yyyy-MM-dd";
049  public static final String hostKey = "http.proxyHost";
050  public static final String portKey = "http.proxyPort";
051
052  private static final int TIMEOUT_NORMAL = 1500;
053  private static final int TIMEOUT_OPERATION = 30000;
054  private static final int TIMEOUT_ENTRY = 500;
055  private static final int TIMEOUT_OPERATION_LONG = 60000;
056  private static final int TIMEOUT_OPERATION_EXPAND = 120000;
057
058  private String base;
059  private ResourceAddress resourceAddress;
060  private ResourceFormat preferredResourceFormat;
061  private int maxResultSetSize = -1;//_count
062  private CapabilityStatement capabilities;
063  private Client client = new Client();
064  private ArrayList<Header> headers = new ArrayList<>();
065  private String username;
066  private String password;
067  private String userAgent;
068
069  //Pass endpoint for client - URI
070  public FHIRToolingClient(String baseServiceUrl, String userAgent) throws URISyntaxException {
071    preferredResourceFormat = ResourceFormat.RESOURCE_JSON;
072    this.userAgent = userAgent;
073    initialize(baseServiceUrl);
074  }
075
076  public void initialize(String baseServiceUrl) throws URISyntaxException {
077    base = baseServiceUrl;
078    resourceAddress = new ResourceAddress(baseServiceUrl);
079    this.maxResultSetSize = -1;
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(String vsUrl, Parameters expParams) {
368    Map<String,String> parameters = new HashMap<>();
369    parameters.put("url", vsUrl);
370  
371    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
372    try {
373      result = client.issueGetResourceRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand", parameters),
374        getPreferredResourceFormat(),
375        generateHeaders(),
376        "ValueSet/$expand?url=" + vsUrl,
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 ValueSet expandValueset(ValueSet source, Parameters expParams) {
389    Parameters p = expParams == null ? new Parameters() : expParams.copy();
390    p.addParameter().setName("valueSet").setResource(source);
391    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
392    try {
393      result = client.issuePostRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand"),
394        ByteUtils.resourceToByteArray(p, false, isJson(getPreferredResourceFormat())),
395        getPreferredResourceFormat(),
396        generateHeaders(),
397        "ValueSet/$expand?url=" + source.getUrl(),
398        TIMEOUT_OPERATION_EXPAND);
399      if (result.isUnsuccessfulRequest()) {
400        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
401      }
402    } catch (IOException e) {
403      e.printStackTrace();
404    }
405    return result == null ? null : (ValueSet) result.getPayload();
406  }
407
408  public Parameters lookupCode(Map<String, String> params) {
409    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
410    try {
411      result = client.issueGetResourceRequest(resourceAddress.resolveOperationUri(CodeSystem.class, "lookup", params),
412        getPreferredResourceFormat(),
413        generateHeaders(),
414        "CodeSystem/$lookup",
415        TIMEOUT_NORMAL);
416    } catch (IOException e) {
417      e.printStackTrace();
418    }
419    if (result.isUnsuccessfulRequest()) {
420      throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
421    }
422    return (Parameters) result.getPayload();
423  }
424
425  public ValueSet expandValueset(ValueSet source, Parameters expParams, Map<String, String> params) {
426    Parameters p = expParams == null ? new Parameters() : expParams.copy();
427    p.addParameter().setName("valueSet").setResource(source);
428    if (params != null) {
429      for (String n : params.keySet()) {
430        p.addParameter().setName(n).setValue(new StringType(params.get(n)));
431      }
432    }
433    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
434    try {
435      result = client.issuePostRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand", params),
436        ByteUtils.resourceToByteArray(p, false, isJson(getPreferredResourceFormat())),
437        getPreferredResourceFormat(),
438        generateHeaders(),
439        source == null ? "ValueSet/$expand" : "ValueSet/$expand?url=" + source.getUrl(),
440        TIMEOUT_OPERATION_EXPAND);
441      if (result.isUnsuccessfulRequest()) {
442        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
443      }
444    } catch (IOException e) {
445      e.printStackTrace();
446    }
447    return result == null ? null : (ValueSet) result.getPayload();
448  }
449
450  public String getAddress() {
451    return base;
452  }
453
454  public ConceptMap initializeClosure(String name) {
455    Parameters params = new Parameters();
456    params.addParameter().setName("name").setValue(new StringType(name));
457    ResourceRequest<Resource> result = null;
458    try {
459      result = client.issuePostRequest(resourceAddress.resolveOperationUri(null, "closure", new HashMap<String, String>()),
460        ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat())),
461        getPreferredResourceFormat(),
462        generateHeaders(),
463        "Closure?name=" + name,
464        TIMEOUT_NORMAL);
465      if (result.isUnsuccessfulRequest()) {
466        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
467      }
468    } catch (IOException e) {
469      e.printStackTrace();
470    }
471    return result == null ? null : (ConceptMap) result.getPayload();
472  }
473
474  public ConceptMap updateClosure(String name, Coding coding) {
475    Parameters params = new Parameters();
476    params.addParameter().setName("name").setValue(new StringType(name));
477    params.addParameter().setName("concept").setValue(coding);
478    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
479    try {
480      result = client.issuePostRequest(resourceAddress.resolveOperationUri(null, "closure", new HashMap<String, String>()),
481        ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat())),
482        getPreferredResourceFormat(),
483        generateHeaders(),
484        "UpdateClosure?name=" + name,
485        TIMEOUT_OPERATION);
486      if (result.isUnsuccessfulRequest()) {
487        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
488      }
489    } catch (IOException e) {
490      e.printStackTrace();
491    }
492    return result == null ? null : (ConceptMap) result.getPayload();
493  }
494
495  public String getUsername() {
496    return username;
497  }
498
499  public void setUsername(String username) {
500    this.username = username;
501  }
502
503  public String getPassword() {
504    return password;
505  }
506
507  public void setPassword(String password) {
508    this.password = password;
509  }
510
511  public long getTimeout() {
512    return client.getTimeout();
513  }
514
515  public void setTimeout(long timeout) {
516    client.setTimeout(timeout);
517  }
518
519  public ToolingClientLogger getLogger() {
520    return client.getLogger();
521  }
522
523  public void setLogger(ToolingClientLogger logger) {
524    client.setLogger(logger);
525  }
526
527  public int getRetryCount() {
528    return client.getRetryCount();
529  }
530
531  public void setRetryCount(int retryCount) {
532    client.setRetryCount(retryCount);
533  }
534
535  public void setClientHeaders(ArrayList<Header> headers) {
536    this.headers = headers;
537  }
538
539  private Headers generateHeaders() {
540    Headers.Builder builder = new Headers.Builder();
541    // Add basic auth header if it exists
542    if (basicAuthHeaderExists()) {
543      builder.add(getAuthorizationHeader().toString());
544    }
545    // Add any other headers
546    if(this.headers != null) {
547      this.headers.forEach(header -> builder.add(header.toString()));
548    }
549    if (!Utilities.noString(userAgent)) {
550      builder.add("User-Agent: "+userAgent);
551    }
552    return builder.build();
553  }
554
555  public boolean basicAuthHeaderExists() {
556    return (username != null) && (password != null);
557  }
558
559  public Header getAuthorizationHeader() {
560    String usernamePassword = username + ":" + password;
561    String base64usernamePassword = Base64.getEncoder().encodeToString(usernamePassword.getBytes());
562    return new Header("Authorization", "Basic " + base64usernamePassword);
563  }
564  
565  public String getUserAgent() {
566    return userAgent;
567  }
568
569  public void setUserAgent(String userAgent) {
570    this.userAgent = userAgent;
571  }
572
573  public String getServerVersion() {
574    checkCapabilities();
575    return capabilities == null ? null : capabilities.getSoftware().getVersion();
576  }
577}
578