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