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