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