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