All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.hl7.fhir.r5.elementmodel.SHLParser Maven / Gradle / Ivy

package org.hl7.fhir.r5.elementmodel;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.UnknownHostException;
import java.text.ParseException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.DataFormatException;
import java.util.Date;
import java.util.zip.Inflater;

import org.hl7.fhir.exceptions.DefinitionException;
import org.hl7.fhir.exceptions.FHIRException;
import org.hl7.fhir.exceptions.FHIRFormatError;
import org.hl7.fhir.r5.context.IWorkerContext;
import org.hl7.fhir.r5.formats.IParser.OutputStyle;
import org.hl7.fhir.r5.test.utils.TestingUtilities;
import org.hl7.fhir.utilities.CommaSeparatedStringBuilder;
import org.hl7.fhir.utilities.FileUtilities;
import org.hl7.fhir.utilities.MarkedToMoveToAdjunctPackage;
import org.hl7.fhir.utilities.Utilities;
import org.hl7.fhir.utilities.VersionUtilities;
import org.hl7.fhir.utilities.http.HTTPResult;
import org.hl7.fhir.utilities.http.ManagedWebAccess;
import org.hl7.fhir.utilities.json.model.JsonArray;
import org.hl7.fhir.utilities.json.model.JsonElement;
import org.hl7.fhir.utilities.json.model.JsonElementType;
import org.hl7.fhir.utilities.json.model.JsonObject;
import org.hl7.fhir.utilities.json.model.JsonPrimitive;
import org.hl7.fhir.utilities.json.model.JsonProperty;
import org.hl7.fhir.utilities.validation.ValidationMessage;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType;

import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.DirectDecrypter;

/**
 * this class is actually a smart health link retreiver. 
 * It's going to parse the link, check it, and then return
 * 2 items, the link with validation information in an Element, and the 
 * parsed whatever that the link pointed to
 * 
 * Error locations in the first item are in the decoded JSON file the URL contains, or 0,0 if not in the json file
 * Error locations in the second item are in the decoded payload
 * 
 * @author grahame
 *
 */
@MarkedToMoveToAdjunctPackage
public class SHLParser extends ParserBase {
  private static boolean testMode;
  
  private boolean post = true;
  private String url = null;
  private byte[] key = null;
  private String ct;

  public SHLParser(IWorkerContext context) {
    super(context);
  }

  public List parse(InputStream inStream) throws IOException, FHIRFormatError, DefinitionException, FHIRException {
    byte[] content = FileUtilities.streamToBytes(inStream);
    
    List res = new ArrayList<>();
    ValidatedFragment shl = addNamedElement(res, "shl", "txt", content);
    String src = FileUtilities.bytesToString(content);
    
    if (src.startsWith("shlink:/")) {
      src = src.substring(8);
    } else if (src.contains("#shlink:/")) {
      String pfx = src.substring(0, src.indexOf("#shlink:/"));
      src = src.substring(src.indexOf("#shlink:/")+9);
      if (!Utilities.isAbsoluteUrlLinkable(pfx)) {        
        logError(shl.getErrors(), "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "if a prefix is present, it must be a URL, not "+pfx, IssueSeverity.ERROR);                
      }
    } else {
      logError(shl.getErrors(), "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "This content does not appear to be an Smart Health Link", IssueSeverity.ERROR);
      src = null;
    }
    if (src != null) {
      byte[] cntin = Base64.getUrlDecoder().decode(src);
      ValidatedFragment json = addNamedElement(res, "json", "json", cntin);
      JsonObject j = null;
      try {
        j = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(cntin);
      } catch (Exception e) {
        logError(json.getErrors(), "202-08-31", 1, 1, "shl.json", IssueType.STRUCTURE, "The JSON is not valid: "+e.getMessage(), IssueSeverity.ERROR);        
      }
      if (j != null) {
        byte[] cntout = org.hl7.fhir.utilities.json.parser.JsonParser.composeBytes(j, false);
        if (!Arrays.equals(cntin, cntout)) {
          logError(shl.getErrors(), "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "The JSON does not seem to be minified properly", IssueSeverity.ERROR);        
        }
        if (checkJson(json.getErrors(), j)) {
          HTTPResult cnt = null;
          if (post) {
            try {
              cnt = fetchManifest();
            } catch (UnknownHostException e) {
              logError(json.getErrors(), "202-08-31", 1, 1, "shl.json", IssueType.STRUCTURE, "The manifest could not be fetched because the host "+e.getMessage()+" is unknown", IssueSeverity.ERROR);
            } catch (Exception e) {
              logError(json.getErrors(), "202-08-31", 1, 1, "shl.json", IssueType.STRUCTURE, "The manifest could not be fetched: "+e.getMessage(), IssueSeverity.ERROR);
            }
            if (cnt != null) {
              if (cnt.getContentType() == null) {
                logError(json.getErrors(), "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.NOTFOUND, "The server did not return a Content-Type header - should be 'application/json'", IssueSeverity.WARNING);
              } else if (!"application/json".equals(cnt.getContentType())) {
                logError(json.getErrors(), "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.STRUCTURE, "The server returned the wrong Content-Type header '"+cnt.getContentType()+"' - must be 'application/json'", IssueSeverity.ERROR);
              }
              checkManifest(res, cnt);
            } 
          } else {
            try {
              cnt = fetchFile(url+"?recipient=FHIR%20Validator", "application/jose"); 
            } catch (Exception e) {
              logError(json.getErrors(), "202-08-31", 1, 1, "shl,json.url", IssueType.STRUCTURE, "The document could not be fetched: "+e.getMessage(), IssueSeverity.ERROR);
            }
            if (cnt != null) {
              if (cnt.getContentType() == null) {
                logError(json.getErrors(), "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.NOTFOUND, "The server did not return a Content-Type header - should be 'application/jose'", IssueSeverity.WARNING);
              } else if (!"application/json".equals(cnt.getContentType())) {
                logError(json.getErrors(), "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.STRUCTURE, "The server returned the wrong Content-Type header '"+cnt.getContentType()+"' - must be 'application/jose'", IssueSeverity.ERROR);
              }
              processContent(res, json.getErrors(), "shl.url.fetched()", "document", cnt.getContentAsString(), ct);
            } 
          }
        }
      }
    }
    return res;
  }
  

  private void checkManifest(List res, HTTPResult cnt) throws IOException {
    ValidatedFragment manifest = addNamedElement(res, "manifest", "json", cnt.getContent());
    
    if (!cnt.getContentType().equals("application/json")) {
      logError(manifest.getErrors(), "202-08-31", 1, 1, "manifest", IssueType.STRUCTURE, "The mime type should be application/json not "+cnt.getContentType(), IssueSeverity.ERROR);
    } else {
      JsonObject j = null;
      try {
        j = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(cnt.getContent());
      } catch (Exception e) {
        logError(manifest.getErrors(), "202-08-31", 1, 1, "manifest", IssueType.STRUCTURE, "The JSON is not valid: "+e.getMessage(), IssueSeverity.ERROR);        
      }
      if (j != null) {
        for (JsonProperty p : j.getProperties()) {
         if (!p.getName().equals("files")) {
            logError(manifest.getErrors(), "202-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "manifest."+p.getName(), 
                IssueType.STRUCTURE, "Unexpected property name "+p.getName(), IssueSeverity.WARNING);  
          }
        }
      }
      if (j.has("files")) {
        JsonElement f = j.get("files");
        if (f.isJsonArray()) {
          int i = 0;
          for (JsonElement e : f.asJsonArray()) {
            if (e.isJsonObject()) {
              processManifestEntry(res, manifest.getErrors(), e.asJsonObject(), "manifest.files["+i+"]", "files["+i+"]");
            } else {
              logError(manifest.getErrors(), "202-08-31", e.getStart().getLine(), e.getStart().getCol(), "manifest.files["+i+"]", 
                  IssueType.STRUCTURE, "files must be an object, not a "+f.type().name(), IssueSeverity.ERROR);  
            }
          }
        } else {
          logError(manifest.getErrors(), "202-08-31", f.getStart().getLine(), f.getStart().getCol(), "manifest.files", 
              IssueType.STRUCTURE, "files must be an array, not a "+f.type().name(), IssueSeverity.ERROR);  
        }
      } else {
        logError(manifest.getErrors(), "202-08-31", j.getStart().getLine(), j.getStart().getCol(), "manifest", 
            IssueType.STRUCTURE, "files not found", IssueSeverity.WARNING);  
      }
    }
  }

  private void processManifestEntry(List res, List errors, JsonObject j, String path, String name) throws FHIRFormatError, DefinitionException, FHIRException, IOException {
    for (JsonProperty p : j.getProperties()) {
      if (!Utilities.existsInList(p.getName(), "contentType", "location", "embedded")) {
        logError(errors, "202-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "manifest."+p.getName(), 
            IssueType.STRUCTURE, "Unexpected property "+p.getName(), IssueSeverity.WARNING);  
      }
    }
    JsonElement cte = j.get("contentType");
    JsonElement loce = j.get("location");
    JsonElement embe = j.get("embedded");
    String ct = null;
    if (cte == null) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path, IssueType.STRUCTURE, "contentType not found", IssueSeverity.ERROR);  
    } else if (!cte.isJsonString()) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path+".contentType", IssueType.STRUCTURE, "contentType must be a string not a "+cte.type().name(), IssueSeverity.ERROR);       
    } else { 
      ct = cte.asString();
      if (!Utilities.existsInList(ct, "application/smart-health-card", "application/smart-api-access", "application/fhir+json")) {
        logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path+".contentType", IssueType.STRUCTURE, "contentType must be one of application/smart-health-card, application/smart-api-access or application/fhir+json", IssueSeverity.ERROR);       
        ct = null;
      }
    }
    if (loce != null && !loce.isJsonString()) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path+".location", IssueType.STRUCTURE, "location must be a string not a "+loce.type().name(), IssueSeverity.ERROR);       
    } 
    if (embe != null && !embe.isJsonString()) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path+".embedded", IssueType.STRUCTURE, "embedded must be a string not a "+embe.type().name(), IssueSeverity.ERROR);       
    }
    if (loce == null && embe == null) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path, IssueType.STRUCTURE, "Found neither a location nor an embedded property", IssueSeverity.ERROR);  
    } else if (loce != null && embe != null) {
      logError(errors, "202-08-31", j.getStart().getLine(), j.getStart().getCol(), path, IssueType.STRUCTURE, "Found both a location nor an embedded property - only one can be present", IssueSeverity.ERROR);  
    } else if (ct != null) {
      if (embe != null) {
        processContent(res, errors, path+".embedded", name, embe.asString(), ct);
      } else if (loce != null) { // it will be, just removes a warning
        HTTPResult cnt = null;
        try {
          cnt = fetchFile(loce.asString(), "application/jose"); 
        } catch (Exception e) {
          logError(errors, "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "The document could not be fetched: "+e.getMessage(), IssueSeverity.ERROR);
        }
        if (cnt != null) {
          if (cnt.getContentType() == null) {
            logError(errors, "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.NOTFOUND, "The server did not return a Content-Type header - should be 'application/jose'", IssueSeverity.WARNING);
          } else if (!"application/json".equals(cnt.getContentType())) {
            logError(errors, "202-08-31", 1, 1, "shl.json.url.fetch()", IssueType.STRUCTURE, "The server returned the wrong Content-Type header '"+cnt.getContentType()+"' - must be 'application/jose'", IssueSeverity.ERROR);
          }
          processContent(res, errors, path+".url.fetch()", name, cnt.getContentAsString(), ct);            
        } 
      } 
    }
  }

  private void processContent(List res, List errors, String path, String name, String jose, String ct) throws FHIRFormatError, DefinitionException, FHIRException, IOException {
    ValidatedFragment bin = addNamedElement(res, "encrypted", "jose", FileUtilities.stringToBytes(jose));
    byte[] cnt = null;
    JWEObject jwe;
    try {
      jwe = JWEObject.parse(jose);
      jwe.decrypt(new DirectDecrypter(key));
      cnt = jwe.getPayload().toBytes();
    } catch (Exception e) {
      logError(bin.getErrors(), "202-08-31", 1, 1, path, IssueType.STRUCTURE, "Decruption failed: "+e.getMessage(), IssueSeverity.ERROR);    
    }
    if (cnt != null) {
      switch (ct) {
      case "application/smart-health-card":
        //a JSON file with a .verifiableCredential array containing SMART Health Card JWS strings, as specified by https://spec.smarthealth.cards#via-file-download.
        SHCParser shc = new SHCParser(context);
        res.addAll(shc.parse(new ByteArrayInputStream(cnt)));
        break;
      case "application/fhir+json": 
        ValidatedFragment doc = addNamedElement(res, name, "json", cnt);
        // a JSON file containing any FHIR resource (e.g., an individual resource or a Bundle of resources). Generally this format may not be tamper-proof.
        logError(doc.getErrors(), "202-08-31", 1, 1, name, IssueType.STRUCTURE, "Processing content of type 'application/smart-api-access' is not done yet", IssueSeverity.INFORMATION);
        break;
      case "application/smart-api-access":
        doc = addNamedElement(res, name, "api.json", cnt);
        // a JSON file with a SMART Access Token Response (see SMART App Launch). Two additional properties are defined:
        // aud Required string indicating the FHIR Server Base URL where this token can be used (e.g., "https://server.example.org/fhir")
        // query: Optional array of strings acting as hints to the client, indicating queries it might want to make (e.g., ["Coverage?patient=123&_tag=family-insurance"])
        logError(doc.getErrors(), "202-08-31", 1, 1, name, IssueType.STRUCTURE, "Processing content of type 'application/smart-api-access' is not done yet", IssueSeverity.INFORMATION);
        break;
      default: 
        doc = addNamedElement(res, name, "bin", cnt);
        logError(doc.getErrors(), "202-08-31", 1, 1, name, IssueType.STRUCTURE, "The Content-Type '"+ct+"' is not known", IssueSeverity.INFORMATION);
      }
    }
  }

  private ValidatedFragment addNamedElement(List res, String name, String type, byte[] content) {
    ValidatedFragment result = new ValidatedFragment(name, type, content, true);
    res.add(result);
    return result;
  }


  private HTTPResult fetchFile(String url, String ct) throws IOException {
    HTTPResult res = ManagedWebAccess.get(Arrays.asList("web"), url, ct);
    res.checkThrowException();
    return res;
  }
  
  private HTTPResult fetchManifest() throws IOException {
    if (testMode) {
      return new HTTPResult(url, 200, "OK", "application/json", FileUtilities.streamToBytes(TestingUtilities.loadTestResourceStream("validator", "shlink.manifest.json")));
    }

    JsonObject j = new JsonObject();
    j.add("recipient", "FHIR Validator");
    HTTPResult res = ManagedWebAccess.post(Arrays.asList("web"), url, org.hl7.fhir.utilities.json.parser.JsonParser.composeBytes(j), "application/json", "application/json");
    res.checkThrowException();
    return res;
  }

  private boolean checkJson(List errors, JsonObject j) {
    boolean ok = true;
    boolean fUrl = false;
    boolean fKey = false;
    boolean fCty = false;
    boolean hp = false;
    boolean hu = false;
    for (JsonProperty p : j.getProperties()) {
      switch (p.getName()) {
      case "url":
        fUrl = true;
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "url must be a string", IssueSeverity.ERROR);   
          ok = false;
        } else if (!Utilities.isAbsoluteUrlLinkable(p.getValue().asString())) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "url is not valid: "+p.getValue().asString(), IssueSeverity.ERROR);  
          ok = false;
        } else {
          url = p.getValue().asString();
        }
        break;
      case "key":
        fKey = true;
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "key must be a string", IssueSeverity.ERROR);   
          ok = false;
        } else if (p.getValue().asString().length() != 43) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "key must contain 43 chars", IssueSeverity.ERROR);  
          ok = false;
        } else {
          key = Base64.getUrlDecoder().decode(p.getValue().asString());
        }
        break;
      case "exp":
        if (!p.getValue().isJsonNumber()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "exp must be a number", IssueSeverity.ERROR);   
        } else if (!Utilities.isDecimal(p.getValue().asJsonNumber().getValue(), false)) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "exp must be a valid number", IssueSeverity.ERROR);   
        } else {
          String v = p.getValue().asJsonNumber().getValue();
          if (v.contains(".")) {
            v = v.substring(0, v.indexOf("."));
          }

          long epochSecs = Long.valueOf(v);
          LocalDateTime date = LocalDateTime.ofEpochSecond(epochSecs, 0, ZoneOffset.UTC);
          LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
          Duration duration = Duration.between(date, now);
        
          if (date.isBefore(now)) {          
            logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "The content has expired (by "+Utilities.describeDuration(duration)+")", IssueSeverity.WARNING);  
          }
        }
        break;
      case "flag":
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "flag must be a string", IssueSeverity.ERROR);   
        } else {
          String flag = p.getValue().asString();
          for (char c : flag.toCharArray()) {
            switch (c) {
            case 'L': // ok
              break;
            case 'P':
              hp = true;
              break;
            case 'U':
              hu = true;
              break;
            default:
              logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
                  IssueType.STRUCTURE, "Illegal Character "+c+" in flag", IssueSeverity.ERROR);   
            }
          }
          if (hu && hp) {
            logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
                IssueType.STRUCTURE, "Illegal combination in flag: both P and U are present", IssueSeverity.ERROR);               
          }
          if (hp) {
            logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
                IssueType.BUSINESSRULE, "The validator is unable to retrieve the content referred to by the URL because a password is required", IssueSeverity.INFORMATION);  
            ok = false;
          }
          if (hu) {
            post = false;
          }
        }
        break;
      case "label":
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "label must be a string", IssueSeverity.ERROR);   
        } else if (p.getValue().asString().length() > 80) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "label must be no longer than 80 chars", IssueSeverity.ERROR);   
        }
        break;
      case "cty" :
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "cty must be a string", IssueSeverity.ERROR);   
        } else if (!Utilities.existsInList(p.getValue().asString(), "application/smart-health-card", "application/smart-api-access", "application/fhir+json")) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "cty must be one of 'application/smart-health-card/, 'application/smart-api-access', 'application/fhir+json'", IssueSeverity.ERROR);   
        } else {
          ct = p.getValue().asString();
        }
        break;
      case "v":
        if (!p.getValue().isJsonString()) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "v must be a string", IssueSeverity.ERROR);   
        } else if (p.getValue().asString().length() <= 80) {
          logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
              IssueType.STRUCTURE, "if present, v must be '1'", IssueSeverity.ERROR);   
        }
        break;
      default:
        logError(errors, "202-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(), 
            IssueType.STRUCTURE, "Illegal property name "+p.getName(), IssueSeverity.ERROR);  
      }
    }
    if (hu && !fCty) {
      logError(errors, "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "Flag 'U' found, but no 'cty' header which is required for the U flag", IssueSeverity.ERROR);  
      ok = false;
    }
    if (!fUrl) {
      logError(errors, "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "No url found", IssueSeverity.ERROR);  
      ok = false;
    }
    if (!fKey) {
      logError(errors, "202-08-31", 1, 1, "shl", IssueType.STRUCTURE, "No key found", IssueSeverity.ERROR);  
      ok = false;
    }
    return ok;
  }

  public void compose(Element e, OutputStream destination, OutputStyle style, String base)  throws FHIRException, IOException {
    throw new FHIRFormatError("Writing resources is not supported for the SHL format");
    // because then we'd have to try to sign, and we're just not going to be doing that from the element model
  }

  public static boolean isTestMode() {
    return testMode;
  }

  public static void setTestMode(boolean testMode) {
    SHLParser.testMode = testMode;
  }


}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy