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