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_XML;
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    for (String n : params.keySet()) {
429      p.addParameter().setName(n).setValue(new StringType(params.get(n)));
430    }
431    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
432    try {
433      result = client.issuePostRequest(resourceAddress.resolveOperationUri(ValueSet.class, "expand", params),
434        ByteUtils.resourceToByteArray(p, false, isJson(getPreferredResourceFormat())),
435        getPreferredResourceFormat(),
436        generateHeaders(),
437        "ValueSet/$expand?url=" + source.getUrl(),
438        TIMEOUT_OPERATION_EXPAND);
439      if (result.isUnsuccessfulRequest()) {
440        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
441      }
442    } catch (IOException e) {
443      e.printStackTrace();
444    }
445    return result == null ? null : (ValueSet) result.getPayload();
446  }
447
448  public String getAddress() {
449    return base;
450  }
451
452  public ConceptMap initializeClosure(String name) {
453    Parameters params = new Parameters();
454    params.addParameter().setName("name").setValue(new StringType(name));
455    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        "Closure?name=" + name,
462        TIMEOUT_NORMAL);
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 ConceptMap updateClosure(String name, Coding coding) {
473    Parameters params = new Parameters();
474    params.addParameter().setName("name").setValue(new StringType(name));
475    params.addParameter().setName("concept").setValue(coding);
476    org.hl7.fhir.r4.utils.client.network.ResourceRequest<Resource> result = null;
477    try {
478      result = client.issuePostRequest(resourceAddress.resolveOperationUri(null, "closure", new HashMap<String, String>()),
479        ByteUtils.resourceToByteArray(params, false, isJson(getPreferredResourceFormat())),
480        getPreferredResourceFormat(),
481        generateHeaders(),
482        "UpdateClosure?name=" + name,
483        TIMEOUT_OPERATION);
484      if (result.isUnsuccessfulRequest()) {
485        throw new EFhirClientException("Server returned error code " + result.getHttpStatus(), (OperationOutcome) result.getPayload());
486      }
487    } catch (IOException e) {
488      e.printStackTrace();
489    }
490    return result == null ? null : (ConceptMap) result.getPayload();
491  }
492
493  public String getUsername() {
494    return username;
495  }
496
497  public void setUsername(String username) {
498    this.username = username;
499  }
500
501  public String getPassword() {
502    return password;
503  }
504
505  public void setPassword(String password) {
506    this.password = password;
507  }
508
509  public long getTimeout() {
510    return client.getTimeout();
511  }
512
513  public void setTimeout(long timeout) {
514    client.setTimeout(timeout);
515  }
516
517  public ToolingClientLogger getLogger() {
518    return client.getLogger();
519  }
520
521  public void setLogger(ToolingClientLogger logger) {
522    client.setLogger(logger);
523  }
524
525  public int getRetryCount() {
526    return client.getRetryCount();
527  }
528
529  public void setRetryCount(int retryCount) {
530    client.setRetryCount(retryCount);
531  }
532
533  public void setClientHeaders(ArrayList<Header> headers) {
534    this.headers = headers;
535  }
536
537  private Headers generateHeaders() {
538    Headers.Builder builder = new Headers.Builder();
539    // Add basic auth header if it exists
540    if (basicAuthHeaderExists()) {
541      builder.add(getAuthorizationHeader().toString());
542    }
543    // Add any other headers
544    if(this.headers != null) {
545      this.headers.forEach(header -> builder.add(header.toString()));
546    }
547    if (!Utilities.noString(userAgent)) {
548      builder.add("User-Agent: "+userAgent);
549    }
550    return builder.build();
551  }
552
553  public boolean basicAuthHeaderExists() {
554    return (username != null) && (password != null);
555  }
556
557  public Header getAuthorizationHeader() {
558    String usernamePassword = username + ":" + password;
559    String base64usernamePassword = Base64.getEncoder().encodeToString(usernamePassword.getBytes());
560    return new Header("Authorization", "Basic " + base64usernamePassword);
561  }
562  
563  public String getUserAgent() {
564    return userAgent;
565  }
566
567  public void setUserAgent(String userAgent) {
568    this.userAgent = userAgent;
569  }
570
571  public String getServerVersion() {
572    checkCapabilities();
573    return capabilities == null ? null : capabilities.getSoftware().getVersion();
574  }
575}
576