From 70505b17046a3d4dc06da09caa2b4dff0b3cdbfc Mon Sep 17 00:00:00 2001 From: dotasek Date: Tue, 20 Feb 2024 10:23:37 -0500 Subject: [PATCH 1/4] Bump sqlite to 3.45.1.0 (#1562) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 873eeb3f7..3f074f653 100644 --- a/pom.xml +++ b/pom.xml @@ -294,7 +294,7 @@ org.xerial sqlite-jdbc - 3.43.0.0 + 3.45.1.0 From e42079e482f9277859a72252d7ad01e8a6041ff8 Mon Sep 17 00:00:00 2001 From: dotasek Date: Tue, 20 Feb 2024 11:04:32 -0500 Subject: [PATCH 2/4] Restore commented method verification (#1566) --- .../fhir/r5/context/SimpleWorkerContextTests.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/context/SimpleWorkerContextTests.java b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/context/SimpleWorkerContextTests.java index bd11532e4..8e4523ea2 100644 --- a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/context/SimpleWorkerContextTests.java +++ b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/context/SimpleWorkerContextTests.java @@ -123,6 +123,16 @@ public class SimpleWorkerContextTests { } } + public class CodingMatcher implements ArgumentMatcher { + final private Coding left; + + CodingMatcher(Coding left) { this.left = left; } + + public boolean matches(Coding right) { + return left.equalsShallow(right); + } + } + public class ParametersMatcher implements ArgumentMatcher { final private Parameters left; @@ -187,7 +197,7 @@ public class SimpleWorkerContextTests { assertEquals(expectedValidationResult, actualValidationResult); - // Mockito.verify(valueSetCheckerSimple).validateCode("Coding", coding); + Mockito.verify(valueSetCheckerSimple).validateCode(eq("Coding"), argThat(new CodingMatcher(coding))); Mockito.verify(terminologyCache).getValidation(cacheToken); Mockito.verify(terminologyCache).cacheValidation(cacheToken, expectedValidationResult,false); } From 7761be619744ad4fbce2ab264784f8dbe6b03aaa Mon Sep 17 00:00:00 2001 From: Tim Prudhomme Date: Tue, 20 Feb 2024 15:25:12 -0500 Subject: [PATCH 3/4] Add TurtleGeneratorTests (#1528) * Add TurtleGeneratorTests for R5 * Finish TurtleGeneratorTests setup * Switch unit test to added example resource, disable others --- .../fhir/r5/test/TurtleGeneratorTests.java | 199 ++++++++++++++++++ .../examples/codesystem-contact-point-use.xml | 134 ++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/TurtleGeneratorTests.java create mode 100644 org.hl7.fhir.r5/src/test/resources/testUtilities/xml/examples/codesystem-contact-point-use.xml diff --git a/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/TurtleGeneratorTests.java b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/TurtleGeneratorTests.java new file mode 100644 index 000000000..7ac92346e --- /dev/null +++ b/org.hl7.fhir.r5/src/test/java/org/hl7/fhir/r5/test/TurtleGeneratorTests.java @@ -0,0 +1,199 @@ +package org.hl7.fhir.r5.test; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Properties; + +import org.fhir.ucum.UcumException; +import org.hl7.fhir.r5.conformance.profile.ProfileUtilities; +import org.hl7.fhir.r5.context.IWorkerContext; +import org.hl7.fhir.r5.elementmodel.Element; +import org.hl7.fhir.r5.elementmodel.Manager; +import org.hl7.fhir.r5.elementmodel.Manager.FhirFormat; +import org.hl7.fhir.r5.elementmodel.ResourceParser; +import org.hl7.fhir.r5.elementmodel.TurtleParser; +import org.hl7.fhir.r5.elementmodel.XmlParser; +import org.hl7.fhir.r5.formats.IParser.OutputStyle; +import org.hl7.fhir.r5.model.Resource; +import org.hl7.fhir.r5.test.utils.TestingUtilities; +import org.hl7.fhir.utilities.validation.ValidationMessage; + +import org.hl7.fhir.utilities.turtle.Turtle; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * TurtleGeneratorTests + * Generates turtle files from specified resources, including example "instances" + * Unit tests for the generated turtle files + * For generic RDF parsing tests, see `TurtleTests.java` + * For ShEx validation tests, see `ShExGeneratorTests.java` + * Author: Tim Prudhomme + */ +public class TurtleGeneratorTests { + + private static IWorkerContext workerContext; + private static ResourceParser resourceParser; + private static XmlParser xmlParser; + private static TurtleParser turtleParser; + + private static Path inputXmlDirectory; + private static Path outputTurtleDirectory; + + @BeforeAll + public static void setup() throws IOException { + workerContext = TestingUtilities.getSharedWorkerContext(); + resourceParser = new org.hl7.fhir.r5.elementmodel.ResourceParser(workerContext); + xmlParser = (XmlParser) Manager.makeParser(workerContext, FhirFormat.XML); + turtleParser = (TurtleParser) Manager.makeParser(workerContext, FhirFormat.TURTLE); + + // Temporary directory of files that should be discarded after testing + outputTurtleDirectory = FileSystems.getDefault().getPath(System.getProperty("java.io.tmpdir")); + + // Directory of XML files used for generating Turtle files + String currentDirectory = System.getProperty("user.dir"); + inputXmlDirectory = FileSystems.getDefault().getPath(currentDirectory, "src", "test", "resources", "testUtilities", "xml", "examples"); + } + + @Test + public void testExamples() throws IOException, UcumException { + var exampleInstanceName = "codesystem-contact-point-use"; + testInstanceGeneration(exampleInstanceName); + } + + @Disabled("TODO this doesn't pass due to existing issues in R5 RDF") + @Test + public void testProfiles() throws IOException, UcumException { + var profileName = "Encounter"; + testClassGeneration(profileName); + } + + @Disabled("Run manually for testing with XML resources generated from FHIR specification publishing library") + @Test + public void testPublishedExamples() throws IOException, UcumException { + inputXmlDirectory = getPublishedXmlDirectory(); + var exampleInstanceName = "codesystem-contact-point-use"; + testInstanceGeneration(exampleInstanceName); + } + + /* + * Generate a Turtle file from the name of an XML resource, then parse it + */ + private void testInstanceGeneration(String resourceName) throws IOException, UcumException { + // Generate Turtle + var generatedTurtleFilePath = generateTurtleFromResourceName(resourceName, inputXmlDirectory, outputTurtleDirectory); + // Try parsing again ("round-trip test") -- this only tests for valid RDF + parseGeneratedTurtle(generatedTurtleFilePath); + } + + /* + * Generate a Turtle file from the name of a profile, then parse it + */ + private void testClassGeneration(String profileName) throws IOException, UcumException { + var generatedTurtleFilePath = generateTurtleClassFromProfileName(profileName); + // Try parsing again ("round-trip test") -- this only tests for valid RDF + parseGeneratedTurtle(generatedTurtleFilePath); + } + + private void parseGeneratedTurtle(String generatedTurtleFilePath) throws IOException { + try ( + InputStream turtleStream = new FileInputStream(generatedTurtleFilePath); + ) { + var generatedTurtleString = new String(turtleStream.readAllBytes()); + Turtle ttl = new Turtle(); + ttl.parse(generatedTurtleString); + } + } + + /** + * Generate a Turtle version of a resource, given its name, input directory of its XML source, and output directory of the Turtle file + * @return the path of the generated Turtle file + */ + private String generateTurtleFromResourceName(String resourceName, Path inputXmlDirectory, Path outputTurtleDirectory) throws IOException, UcumException { + // Specify source xml path and destination turtle path + var xmlFilePath = inputXmlDirectory.resolve(resourceName + ".xml").toString(); + var turtleFilePath = outputTurtleDirectory.resolve(resourceName + ".ttl").toString(); + try ( + InputStream inputXmlStream = new FileInputStream(xmlFilePath); + OutputStream outputTurtleStream = new FileOutputStream(turtleFilePath); + ) { + // print out file names using string interpolation + System.out.println("Generating " + turtleFilePath); + generateTurtleFromXmlStream(inputXmlStream, outputTurtleStream); + return turtleFilePath; + } + } + + /** + * Generate a Turtle file from an XML resource + */ + private void generateTurtleFromXmlStream(InputStream xmlStream, OutputStream turtleStream) throws IOException, UcumException { + var errorList = new ArrayList(); + Element resourceElement = xmlParser.parseSingle(xmlStream, errorList); + turtleParser.compose(resourceElement, turtleStream, OutputStyle.PRETTY, null); + // Check errors + for (ValidationMessage m : errorList) { + System.out.println(m.getDisplay()); + } + } + + /** + * Generate a Turtle file from an org.hl7.fhir.r5.model.Resource profile + * @return the path of the generated Turtle file + */ + private String generateTurtleClassFromProfileName(String profileName) throws IOException, UcumException { + String resourceUri = ProfileUtilities.sdNs(profileName, null); + Resource resource = workerContext.fetchResource(Resource.class, resourceUri); + Element resourceElement = resourceParser.parse(resource); + var turtleFilePath = outputTurtleDirectory.resolve(profileName + ".ttl").toString(); + try (OutputStream outputStream = new FileOutputStream(turtleFilePath)) { + turtleParser.compose(resourceElement, outputStream, OutputStyle.PRETTY, null); + return turtleFilePath; + } + } + + /** + * Generate a Turtle file from a "test case" resource -- those only available on https://github.com/FHIR/fhir-test-cases/ + * @return the path of the generated Turtle file + */ + private String generateTurtleFromTestCaseResource(String resourceName) throws IOException, UcumException { + var turtleFilePath = outputTurtleDirectory.resolve(resourceName + ".ttl").toString(); + try ( + // Follows pattern in `TestingUtilities.java` + InputStream inputXmlStream = TestingUtilities.loadTestResourceStream("r5", resourceName + ".xml"); + OutputStream outputTurtleStream = new FileOutputStream(turtleFilePath); + ) { + generateTurtleFromXmlStream(inputXmlStream, outputTurtleStream); + return turtleFilePath; + } + } + + + /** + * This could be the "publish" directory of XML resources built using the FHIR specification publishing library. + * Use this for testing with other generated XML resources + */ + private static Path getPublishedXmlDirectory() throws IOException { + Properties properties = new Properties(); + String currentDirectory = System.getProperty("user.dir"); + // Add your directory path to "org.hl7.fhir.r5/src/test/resources/local.properties" + String localPropertiesPath = FileSystems.getDefault().getPath(currentDirectory, "src", "test", "resources", "local.properties").toString(); + try (FileInputStream input = new FileInputStream(localPropertiesPath)) { + properties.load(input); + } catch (IOException e) { + // You should create this local.properties file if it doesn't exist. It should already be listed in .gitignore. + e.printStackTrace(); + throw e; + } + var filePath = properties.getProperty("xmlResourceDirectory"); + return FileSystems.getDefault().getPath(filePath); + } +} diff --git a/org.hl7.fhir.r5/src/test/resources/testUtilities/xml/examples/codesystem-contact-point-use.xml b/org.hl7.fhir.r5/src/test/resources/testUtilities/xml/examples/codesystem-contact-point-use.xml new file mode 100644 index 000000000..f21d99431 --- /dev/null +++ b/org.hl7.fhir.r5/src/test/resources/testUtilities/xml/examples/codesystem-contact-point-use.xml @@ -0,0 +1,134 @@ + + + + + + + + + +
+

This case-sensitive code system + http://hl7.org/fhir/contact-point-use defines the following codes: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Code + + Display + + Definition +
home + + HomeA communication contact point at a home; attempted contacts for business purposes might intrude privacy and chances are one will contact family or other household members instead of the person one wishes to call. Typically used with urgent cases, or if no other contacts are available.
work + + WorkAn office contact point. First choice for business related contacts during business hours.
temp + + TempA temporary contact point. The period can provide more detailed information.
old + + OldThis contact point is no longer in use (or was never correct, but retained for records).
mobile + + MobileA telecommunication device that moves and stays with its owner. May have characteristics of all other use codes, suitable for urgent matters, not the first choice for routine business.
+
+
+ + + + + + + + + + + + + + + + + + + + + <status value="active"/> + <experimental value="false"/> + <date value="2023-10-03T22:51:29-04:00"/> + <publisher value="HL7 (FHIR Project)"/> + <contact> + <telecom> + <system value="url"/> + <value value="http://hl7.org/fhir"/> + </telecom> + <telecom> + <system value="email"/> + <value value="fhir@lists.hl7.org"/> + </telecom> + </contact> + <description value="Use of contact point."/> + <jurisdiction> + <coding> + <system value="http://unstats.un.org/unsd/methods/m49/m49.htm"/> + <code value="001"/> + <display value="World"/> + </coding> + </jurisdiction> + <caseSensitive value="true"/> + <valueSet value="http://hl7.org/fhir/ValueSet/contact-point-use"/> + <content value="complete"/> + <concept> + <code value="home"/> + <display value="Home"/> + <definition value="A communication contact point at a home; attempted contacts for business purposes might intrude privacy and chances are one will contact family or other household members instead of the person one wishes to call. Typically used with urgent cases, or if no other contacts are available."/> + </concept> + <concept> + <code value="work"/> + <display value="Work"/> + <definition value="An office contact point. First choice for business related contacts during business hours."/> + </concept> + <concept> + <code value="temp"/> + <display value="Temp"/> + <definition value="A temporary contact point. The period can provide more detailed information."/> + </concept> + <concept> + <code value="old"/> + <display value="Old"/> + <definition value="This contact point is no longer in use (or was never correct, but retained for records)."/> + </concept> + <concept> + <code value="mobile"/> + <display value="Mobile"/> + <definition value="A telecommunication device that moves and stays with its owner. May have characteristics of all other use codes, suitable for urgent matters, not the first choice for routine business."/> + </concept> +</CodeSystem> \ No newline at end of file From f1096a8ebdc200092fe144a2f115975e2d6149e2 Mon Sep 17 00:00:00 2001 From: Dylan Hall <dehall@mitre.org> Date: Tue, 20 Feb 2024 17:06:55 -0500 Subject: [PATCH 4/4] Introduce new validator cliContext option disableDefaultResourceFetcher (#1526) * introduce new cliContext option doNotFetchUnknownProfiles * rename parameter to disable-default-resource-fetcher * missed renaming a couple items * review feedback --- .../fhir/validation/cli/model/CliContext.java | 13 +++++ .../cli/services/ValidationService.java | 8 ++-- .../hl7/fhir/validation/cli/utils/Params.java | 3 ++ .../cli/services/ValidationServiceTest.java | 48 ++++++++++++++----- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/model/CliContext.java b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/model/CliContext.java index 445e8a59b..8b987f826 100644 --- a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/model/CliContext.java +++ b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/model/CliContext.java @@ -56,6 +56,8 @@ public class CliContext { private HtmlInMarkdownCheck htmlInMarkdownCheck = HtmlInMarkdownCheck.WARNING; @JsonProperty("allowDoubleQuotesInFHIRPath") private boolean allowDoubleQuotesInFHIRPath = false; + @JsonProperty("disableDefaultResourceFetcher") + private boolean disableDefaultResourceFetcher = false; @JsonProperty("checkIPSCodes") private boolean checkIPSCodes; @JsonProperty("langTransform") @@ -328,6 +330,17 @@ public class CliContext { this.allowDoubleQuotesInFHIRPath = allowDoubleQuotesInFHIRPath; } + @JsonProperty("disableDefaultResourceFetcher") + public boolean isDisableDefaultResourceFetcher() { + return disableDefaultResourceFetcher; + } + + @JsonProperty("disableDefaultResourceFetcher") + public CliContext setDisableDefaultResourceFetcher(boolean disableDefaultResourceFetcher) { + this.disableDefaultResourceFetcher = disableDefaultResourceFetcher; + return this; + } + @JsonProperty("checkIPSCodes") public boolean isCheckIPSCodes() { return checkIPSCodes; diff --git a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/services/ValidationService.java b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/services/ValidationService.java index deb7717aa..25cdc889b 100644 --- a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/services/ValidationService.java +++ b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/services/ValidationService.java @@ -524,9 +524,11 @@ public class ValidationService { validationEngine.setForPublication(cliContext.isForPublication()); validationEngine.setShowTimes(cliContext.isShowTimes()); validationEngine.setAllowExampleUrls(cliContext.isAllowExampleUrls()); - StandAloneValidatorFetcher fetcher = new StandAloneValidatorFetcher(validationEngine.getPcm(), validationEngine.getContext(), validationEngine); - validationEngine.setFetcher(fetcher); - validationEngine.getContext().setLocator(fetcher); + if (!cliContext.isDisableDefaultResourceFetcher()) { + StandAloneValidatorFetcher fetcher = new StandAloneValidatorFetcher(validationEngine.getPcm(), validationEngine.getContext(), validationEngine); + validationEngine.setFetcher(fetcher); + validationEngine.getContext().setLocator(fetcher); + } validationEngine.getBundleValidationRules().addAll(cliContext.getBundleValidationRules()); validationEngine.setJurisdiction(CodeSystemUtilities.readCoding(cliContext.getJurisdiction())); TerminologyCache.setNoCaching(cliContext.isNoInternalCaching()); diff --git a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/utils/Params.java b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/utils/Params.java index cd70055f4..52edd4542 100644 --- a/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/utils/Params.java +++ b/org.hl7.fhir.validation/src/main/java/org/hl7/fhir/validation/cli/utils/Params.java @@ -89,6 +89,7 @@ public class Params { public static final String SRC_LANG = "-src-lang"; public static final String TGT_LANG = "-tgt-lang"; public static final String ALLOW_DOUBLE_QUOTES = "-allow-double-quotes-in-fhirpath"; + public static final String DISABLE_DEFAULT_RESOURCE_FETCHER = "-disable-default-resource-fetcher"; public static final String CHECK_IPS_CODES = "-check-ips-codes"; public static final String BEST_PRACTICE = "-best-practice"; @@ -270,6 +271,8 @@ public class Params { cliContext.setNoExtensibleBindingMessages(true); } else if (args[i].equals(ALLOW_DOUBLE_QUOTES)) { cliContext.setAllowDoubleQuotesInFHIRPath(true); + } else if (args[i].equals(DISABLE_DEFAULT_RESOURCE_FETCHER)) { + cliContext.setDisableDefaultResourceFetcher(true); } else if (args[i].equals(CHECK_IPS_CODES)) { cliContext.setCheckIPSCodes(true); } else if (args[i].equals(NO_UNICODE_BIDI_CONTROL_CHARS)) { diff --git a/org.hl7.fhir.validation/src/test/java/org/hl7/fhir/validation/cli/services/ValidationServiceTest.java b/org.hl7.fhir.validation/src/test/java/org/hl7/fhir/validation/cli/services/ValidationServiceTest.java index 945c9b537..5346d78e8 100644 --- a/org.hl7.fhir.validation/src/test/java/org/hl7/fhir/validation/cli/services/ValidationServiceTest.java +++ b/org.hl7.fhir.validation/src/test/java/org/hl7/fhir/validation/cli/services/ValidationServiceTest.java @@ -10,8 +10,10 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.endsWith; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -236,17 +238,43 @@ class ValidationServiceTest { */ @Test public void buildValidationEngineTest() throws IOException, URISyntaxException { - - final org.hl7.fhir.utilities.TimeTracker timeTracker = mock(org.hl7.fhir.utilities.TimeTracker.class); - + final TimeTracker timeTracker = mock(TimeTracker.class); final SimpleWorkerContext workerContext = mock(SimpleWorkerContext.class); - final ValidationEngine validationEngine = mock(ValidationEngine.class); - when(validationEngine.getContext()).thenReturn(workerContext); + final ValidationEngine mockValidationEngine = mock(ValidationEngine.class); + when(mockValidationEngine.getContext()).thenReturn(workerContext); - final ValidationEngine.ValidationEngineBuilder validationEngineBuilder = mock(ValidationEngine.ValidationEngineBuilder.class);; + final ValidationEngine.ValidationEngineBuilder mockValidationEngineBuilder = mock(ValidationEngine.ValidationEngineBuilder.class);; + final ValidationService validationService = createFakeValidationService(mockValidationEngineBuilder, mockValidationEngine); - final ValidationService validationService = new ValidationService() { + CliContext cliContext = new CliContext(); + validationService.buildValidationEngine(cliContext, null, timeTracker); + + verify(mockValidationEngine).setFetcher(notNull()); + verify(mockValidationEngineBuilder).withUserAgent(eq("fhir/validator/" + VersionUtil.getVersion())); + } + + @Test + public void buildValidationEngineDisableDefaultResourceFetcherTest() throws IOException, URISyntaxException { + final TimeTracker timeTracker = mock(TimeTracker.class); + final SimpleWorkerContext workerContext = mock(SimpleWorkerContext.class); + + final ValidationEngine mockValidationEngine = mock(ValidationEngine.class); + when(mockValidationEngine.getContext()).thenReturn(workerContext); + + final ValidationEngine.ValidationEngineBuilder mockValidationEngineBuilder = mock(ValidationEngine.ValidationEngineBuilder.class);; + final ValidationService validationService = createFakeValidationService(mockValidationEngineBuilder, mockValidationEngine); + + CliContext cliContext = new CliContext(); + cliContext.setDisableDefaultResourceFetcher(true); + validationService.buildValidationEngine(cliContext, null, timeTracker); + + verify(mockValidationEngine, never()).setFetcher(any()); + verify(mockValidationEngineBuilder).withUserAgent(eq("fhir/validator/" + VersionUtil.getVersion())); + } + + private static ValidationService createFakeValidationService(ValidationEngine.ValidationEngineBuilder validationEngineBuilder, ValidationEngine validationEngine) { + return new ValidationService() { @Override protected ValidationEngine.ValidationEngineBuilder getValidationEngineBuilder() { when(validationEngineBuilder.withTHO(anyBoolean())).thenReturn(validationEngineBuilder); @@ -268,11 +296,5 @@ class ValidationServiceTest { //Don't care. Do nothing. } }; - - CliContext cliContext = new CliContext(); - - validationService.buildValidationEngine(cliContext, null, timeTracker); - - verify(validationEngineBuilder).withUserAgent(eq("fhir/validator/" + VersionUtil.getVersion())); } } \ No newline at end of file