diff --git a/.github.settings.xml b/.github.settings.xml new file mode 100644 index 0000000..1059768 --- /dev/null +++ b/.github.settings.xml @@ -0,0 +1,14 @@ + + + + nexus-snapshots + ${env.MAVEN_REPO_USERNAME} + ${env.MAVEN_REPO_PASSWORD} + + + nexus-releases + ${env.MAVEN_REPO_USERNAME} + ${env.MAVEN_REPO_PASSWORD} + + + diff --git a/.github/workflows/build_package.yml b/.github/workflows/build_package.yml index 622bd58..bea020c 100644 --- a/.github/workflows/build_package.yml +++ b/.github/workflows/build_package.yml @@ -1,6 +1,6 @@ name: Build Maven Package -on: [push] +on: [ push ] jobs: build: diff --git a/.github/workflows/changelog-update.yml b/.github/workflows/changelog-update.yml index e7e4401..74e3321 100644 --- a/.github/workflows/changelog-update.yml +++ b/.github/workflows/changelog-update.yml @@ -1,7 +1,7 @@ name: "Changelog update" on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + types: [ opened, synchronize, reopened, ready_for_review, labeled, unlabeled ] jobs: # Enforces the update of a changelog file on every pull request diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c33dea0..11ba499 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,47 +37,47 @@ jobs: # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Set up JDK 1.17 - uses: actions/setup-java@v1 - with: - java-version: 1.17 - settings-path: ${{ github.workspace }} + - name: Checkout repository + uses: actions/checkout@v2 + - name: Set up JDK 1.17 + uses: actions/setup-java@v1 + with: + java-version: 1.17 + settings-path: ${{ github.workspace }} - - name: Load local Maven repository cache - uses: actions/cache@v2 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- + - name: Load local Maven repository cache + uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - #- run: | - # make bootstrap - # make release + #- run: | + # make bootstrap + # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/generate-reports.yml b/.github/workflows/generate-reports.yml index f1f02bb..7590c21 100644 --- a/.github/workflows/generate-reports.yml +++ b/.github/workflows/generate-reports.yml @@ -5,7 +5,7 @@ name: Generate reports and API documentation on: release: - types: [created] + types: [ created ] push: branches: - development @@ -20,10 +20,10 @@ jobs: - name: Install git run: sudo apt-get install git - - name: Set up JDK 1.11 + - name: Set up JDK 1.17 uses: actions/setup-java@v1 with: - java-version: 1.11 + java-version: 1.17 server-id: github # Value of the distributionManagement/repository/id field of the pom.xml settings-path: ${{ github.workspace }} @@ -44,15 +44,15 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | - ${{ runner.os }}-maven- + ${{ runner.os }}-maven- - name: Maven generate reports run: mvn --activate-profiles !development-build,!release-build --settings .github.settings.xml site - name: Set up git run: | - git config --global user.email "support@qbic.zendesk.com" - git config --global user.name "JohnnyQ5" + git config --global user.email "support@qbic.zendesk.com" + git config --global user.name "JohnnyQ5" - name: Publish reports run: | diff --git a/.github/workflows/groovy_checkstyle.yml b/.github/workflows/groovy_checkstyle.yml index ea3df6a..2014e6f 100644 --- a/.github/workflows/groovy_checkstyle.yml +++ b/.github/workflows/groovy_checkstyle.yml @@ -1,6 +1,6 @@ name: Groovy Checkstyle -on: [push] +on: [ push ] jobs: build: diff --git a/.github/workflows/nexus-publish-release.yml b/.github/workflows/nexus-publish-release.yml index e4e669c..d1c4e72 100644 --- a/.github/workflows/nexus-publish-release.yml +++ b/.github/workflows/nexus-publish-release.yml @@ -6,7 +6,7 @@ name: Nexus Package on: release: - types: [created] + types: [ created ] jobs: build: @@ -14,30 +14,30 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.17 - uses: actions/setup-java@v1 - with: - java-version: 1.17 - server-id: github # Value of the distributionManagement/repository/id field of the pom.xml - settings-path: ${{ github.workspace }} - - - name: Load local Maven repository cache + - uses: actions/checkout@v2 + - name: Set up JDK 1.17 + uses: actions/setup-java@v1 + with: + java-version: 1.17 + server-id: github # Value of the distributionManagement/repository/id field of the pom.xml + settings-path: ${{ github.workspace }} + + - name: Load local Maven repository cache uses: actions/cache@v2 with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- - - name: Remove snapshot tags - run: mvn versions:set -DremoveSnapshot + - name: Remove snapshot tags + run: mvn versions:set -DremoveSnapshot - - name: Build with Maven + - name: Build with Maven run: mvn -B package --file pom.xml - - name: Publish artefact to QBiC Nexus Repository + - name: Publish artefact to QBiC Nexus Repository run: mvn --quiet --activate-profiles !development-build,release-build --settings $GITHUB_WORKSPACE/.github.settings.xml deploy env: - MAVEN_REPO_USERNAME: ${{ secrets.NEXUS_USERNAME }} - MAVEN_REPO_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} + MAVEN_REPO_USERNAME: ${{ secrets.NEXUS_USERNAME }} + MAVEN_REPO_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} diff --git a/.github/workflows/nexus-publish.yml b/.github/workflows/nexus-publish.yml index 90f710c..6879928 100644 --- a/.github/workflows/nexus-publish.yml +++ b/.github/workflows/nexus-publish.yml @@ -21,7 +21,7 @@ jobs: with: java-version: 1.17 settings-path: ${{ github.workspace }} - + - name: Load local Maven repository cache uses: actions/cache@v2 with: diff --git a/.github/workflows/pr_to_master_from_hotfix_release_only.yml b/.github/workflows/pr_to_master_from_hotfix_release_only.yml index 99e9096..a8ccce6 100644 --- a/.github/workflows/pr_to_master_from_hotfix_release_only.yml +++ b/.github/workflows/pr_to_master_from_hotfix_release_only.yml @@ -3,7 +3,7 @@ name: PR to master/main branch from patch/release branch only on: pull_request: branches: - - main + - main jobs: test: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 3407d3a..294f8a6 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -1,6 +1,6 @@ name: Run Maven Tests -on: [push] +on: [ push ] jobs: build: diff --git a/.gitignore b/.gitignore index 60958b9..f3c15cf 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ hs_err_pid* # misc *.DS_STORE +/target/ +/bin/ # IntelliJ -.idea \ No newline at end of file +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..87339e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +This project adheres to [Semantic Versioning](https://semver.org/). + +## 1.0.0 (2022-01-28) + +* Add first working implementation to report updates based on a point in time. +* The application fails when any problems arise retrieving samples + + diff --git a/HELP.md b/HELP.md index f057e44..cd8182c 100644 --- a/HELP.md +++ b/HELP.md @@ -1,11 +1,14 @@ # Read Me First + The following was discovered as part of building this project: -* The original package name 'life.qbic.spring-minimal-template' is invalid and this project uses 'life.qbic.springminimaltemplate' instead. +* The original package name 'life.qbic.spring-minimal-template' is invalid and this project uses ' + life.qbic.springminimaltemplate' instead. # Getting Started ### Reference Documentation + For further reference, please consider the following sections: * [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) diff --git a/README.md b/README.md index 1f89b10..3b99f11 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,43 @@ -# LIMS Sample Status Reporter +# LIMS Sample Status Reporter + [![CodeQL](https://github.com/qbicsoftware/sample-status-reporter/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/qbicsoftware/sample-status-reporter/actions/workflows/codeql-analysis.yml) [![Latest Release ](https://img.shields.io/github/v/release/qbicsoftware/sample-status-reporter.svg)](https://github.com/qbicsoftware/sample-status-reporter/releases) ![Groovy Language](https://img.shields.io/badge/language-groovy-blue.svg) -The LIMS Sample status reporter application is used to report changes in the status of a sample set in the LIMS environment to Sample Tracking Service. - -Overview: - -- [Requirements](#requirements) -- [Run the app](#run-the-app) -- [App structure](#app-structure) - -# Requirements - -To run this app, you need to have version 17 of a **Java JRE** or **JDK** installed (e.g. **Zulu**). +## System setup + +For this application to be run the following environment variables need to be set: + +| Name | Description | +|-------------------------------------|------------------------------------------------------------------------| +| `LAST_UPDATE_FILE` | A path to a persistent file. The last successful run is stored here. | +| `LIMS_PASSWORD` | The password to access the OpenBiS LIMS | +| `LIMS_SERVER_URL` | The URL to the OpenBiS LIMS API | +| `LIMS_USER` | The user to access the OpenBiS LIMS | +| `SAMPLE_TRACKING_AUTH_PASSWORD` | The password for the sample tracking user | +| `SAMPLE_TRACKING_AUTH_USER` | The username for the sample tracking service | +| `SAMPLE_TRACKING_LOCATION_ENDPOINT` | The endpoint to list all locations. This does not contain the base url | +| `SAMPLE_TRACKING_LOCATION_USER` | The sample tracking user currently using the application | +| `SAMPLE_TRACKING_URL` | The base URL for the sample tracking service | +| `USER_DB_DIALECT` | The database dialect of the user database | +| `USER_DB_DRIVER` | The database driver for the user database | +| `USER_DB_HOST` | The URL to the host of the user database containing the database name | +| `USER_DB_USER_NAME` | The database user name | +| `USER_DB_USER_PW` | The database user password | ## Run the app Checkout the latest code from `main` and run the Maven goal `spring-boot:run`: ``` -mvn spring-boot:run +mvn spring-boot:run [-Dspring-boot.run.arguments=[-hV],[-t=]] + -h, --help Show this help message and exit. + -t, --time-point= + Point in time from where to search for updates e.g. '2022-01-01T00:00:00Z'. + Defaults to the last successful run. + If never run successfully defaults to the same time yesterday. + -V, --version Print version information and exit. ``` -## App structure - -The preliminary app structure is outlined in this UML diagram: -![Bioinformatics Analysis Result Set ER](./docs/Spring%20Boot%20Starter%20Template%20UML.jpg) - - - diff --git a/pom.xml b/pom.xml index 0e28cdb..7f207e8 100644 --- a/pom.xml +++ b/pom.xml @@ -1,79 +1,257 @@ - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 2.5.6 - - - life.qbic - spring-minimal-template - 0.1.0-SNAPSHOT - spring-minimal-template - Demo project for Spring Boot - - 17 - 3.0.8 - - - - org.springframework.boot - spring-boot-starter - - - org.codehaus.groovy - groovy - + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.5.6 + + + life.qbic + sample-status-reporter + 1.0.0 + sample-status-reporter + Spring Boot based Sample Status Reporter application + + 17 + 3.0.8 + + + + org.springframework.boot + spring-boot-starter + + + org.codehaus.groovy + groovy + - - org.springframework.boot - spring-boot-starter-test - test - + + + org.codehaus.groovy + groovy-json + ${groovy.version} + - - org.spockframework - spock-core - 2.0-groovy-3.0 - test - + + org.springframework.boot + spring-boot-starter-test + test + - - org.spockframework - spock-spring - 2.0-groovy-3.0 - test - - + + org.spockframework + spock-core + 2.0-groovy-3.0 + test + - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.codehaus.gmavenplus - gmavenplus-plugin - 1.13.0 - - - - addSources - addTestSources - generateStubs - compile - generateTestStubs - compileTests - removeStubs - removeTestStubs - - - - - - + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.apache.commons + commons-dbcp2 + + + org.mariadb.jdbc + mariadb-java-client + + + mysql + mysql-connector-java + + + org.hibernate.validator + hibernate-validator + 6.2.0.Final + + + + javax.validation + validation-api + 2.0.1.Final + + + org.hibernate + hibernate-core + 5.6.0.Final + + + org.spockframework + spock-spring + 2.0-groovy-3.0 + test + + + + + life.qbic + openbis-api-barebone + 19.06.5 + + + + + info.picocli + picocli + 4.6.2 + + + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + org.springframework + spring-web + + + + life.qbic + openbis-api-marathon + 19.06.5 + + + + + true + nexus-releases + QBiC Releases + https://qbic-repo.qbic.uni-tuebingen.de/repository/maven-releases + + + false + nexus-snapshots + QBiC Snapshots + https://qbic-repo.qbic.uni-tuebingen.de/repository/maven-snapshots + + + dummytest + localhost:8080 + + + + + + true + always + fail + + + false + + maven-central + Maven central + https://repo.maven.apache.org/maven2 + + + + false + + + true + always + fail + + nexus-snapshots + QBiC Snapshots + https://qbic-repo.qbic.uni-tuebingen.de/repository/maven-snapshots + + + + true + always + fail + + + false + + nexus-releases + QBiC Releases + https://qbic-repo.qbic.uni-tuebingen.de/repository/maven-releases + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.0 + + + + addSources + addTestSources + generateStubs + compile + generateTestStubs + compileTests + removeStubs + removeTestStubs + + + + + + + ${project.basedir}/src/main/groovy + + **/*.groovy + + + + + + ${project.basedir}/src/test/groovy + + **/*.groovy + + + + + + + maven-surefire-plugin + 3.0.0-M5 + + + **/*Spec + + + + + org.hibernate.orm.tooling + hibernate-enhance-maven-plugin + 5.6.0.Final + + + + true + true + + + enhance + + + + + + diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 7b0d367..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/main/groovy/.DS_Store b/src/main/groovy/.DS_Store deleted file mode 100644 index f94f028..0000000 Binary files a/src/main/groovy/.DS_Store and /dev/null differ diff --git a/src/main/groovy/life/.DS_Store b/src/main/groovy/life/.DS_Store deleted file mode 100644 index e426088..0000000 Binary files a/src/main/groovy/life/.DS_Store and /dev/null differ diff --git a/src/main/groovy/life/qbic/.DS_Store b/src/main/groovy/life/qbic/.DS_Store deleted file mode 100644 index 73c441a..0000000 Binary files a/src/main/groovy/life/qbic/.DS_Store and /dev/null differ diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/QbicSampleStatusReporter.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/QbicSampleStatusReporter.groovy new file mode 100644 index 0000000..350b9a2 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/QbicSampleStatusReporter.groovy @@ -0,0 +1,39 @@ +package life.qbic.samplestatus.reporter + +import life.qbic.samplestatus.reporter.api.Location +import life.qbic.samplestatus.reporter.api.LocationService +import life.qbic.samplestatus.reporter.api.Person +import life.qbic.samplestatus.reporter.api.SampleTrackingService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +import java.time.Instant + +/** + * Reports a sample update to the sample-tracking-service + * @since 1.0.0 + */ +@Component +class QbicSampleStatusReporter implements SampleStatusReporter { + + @Autowired + private SampleTrackingService sampleTrackingService + + @Autowired + private LocationService locationService + + + @Override + void reportSampleStatusUpdate(SampleUpdate sampleUpdate) { + Location currentLocation = locationService.getCurrentLocation().orElseThrow({ + new RuntimeException("No current location could be determined.") + }) + String sampleCode = sampleUpdate.getSample().getSampleCode() + String status = sampleUpdate.getUpdatedStatus() + Instant updateTimepoint = sampleUpdate.getModificationDate() + Person responsiblePerson = locationService.getResponsiblePerson().orElseThrow({ + new RuntimeException("No responsible person for the update was determined.") + }) + sampleTrackingService.updateSampleLocation(sampleCode, currentLocation, status, updateTimepoint, responsiblePerson) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/ReporterApp.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/ReporterApp.groovy new file mode 100644 index 0000000..2604e72 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/ReporterApp.groovy @@ -0,0 +1,50 @@ +package life.qbic.samplestatus.reporter + +import life.qbic.samplestatus.reporter.api.LimsQueryService +import life.qbic.samplestatus.reporter.api.UpdateSearchService +import life.qbic.samplestatus.reporter.commands.ReportSinceInstant +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.CommandLineRunner +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.ApplicationContext +import picocli.CommandLine + +/** + * Main application class + * + *

The entry-point of the reporter application.

+ * + * @since 1.0.0 + */ +@SpringBootApplication +class ReporterApp implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(ReporterApp.class) + + @Autowired + ApplicationContext applicationContext + + static void main(String[] args) { + SpringApplication.run(ReporterApp.class, args) + } + + @Override + void run(String... args) throws Exception { + UpdateSearchService updateSearchService = applicationContext.getBean("lastUpdateSearch") + LimsQueryService limsQueryService = applicationContext.getBean("realLimsQueryService") + SampleStatusReporter statusReporter = applicationContext.getBean("qbicSampleStatusReporter") + + ReportSinceInstant reportSinceInstant = new ReportSinceInstant(limsQueryService, statusReporter, updateSearchService) + + int exitCode = 1 + try { + exitCode = new CommandLine(reportSinceInstant).execute(args) + } catch (Exception e) { + log.error(e.getMessage(), e) + } + System.exit(exitCode) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/Result.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/Result.groovy new file mode 100644 index 0000000..7662156 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/Result.groovy @@ -0,0 +1,144 @@ +package life.qbic.samplestatus.reporter + +import java.util.function.Consumer +import java.util.function.Function + +/** + * Class Result + * + *

This class introduces the Rust idiom to use Result objects to enforce the client code + * to apply some best practises to safe value access and proper exception handling.

+ * + *

Results can be used to wrap an actual value of type V or an exception of type + * E. An object of type Result can only contain either a value or an exception, not both in the + * same instance.

+ * + * To properly deal with Result objects, a good idiom using Java's enhanced switch statements + * looks like this: + * + *
+ *
+ * final Result<String, Exception> result =
+ *          new Result("Contains actual information")
+ * // Using the  {@link Function} interface
+ * Function function = switch (result) {*    case result.isOk(): yield Function<V, ?>{...}*    case result.isError(): yield Function<E, ?>{...}*}* function.apply(result)
+ *
+ * // or using the {@link Consumer} interface
+ * Consumer consumer = switch (result) {*   case result.isOk() : yield Consumer<V>{...}*   case result.isError() : yield Consumer<E>{...}*}* consumer.accept(result)
+ *
+ * // or using lambda expressions
+ * switch (result) {*   case result.isOk() : () -> {}*   case result.isError() : () -> {}*}* 
+ * @param the value of this result in case it is OK + * @param the type of error this result can hold + * @since 1.0.0 + */ +class Result { + + private final V value + private final E err + + /** + * Static constructor method for creating a result object instance of type V,E + * wrapping an actual value V. + * @param value the notorious value to get wrapped in a result object + * @return a new result object instance + */ + static Result of(V value) { + return new Result<>(value) + } + + /** + * Static constructor method for creating a result object instance of type V,E + * wrapping an error E. + * @param e the suspicious error to get wrapped in a result object + * @return a new result object instance + */ + static Result of(E e) { + return new Result<>(e) + } + + private Result(V value) { + this.value = value + this.err = null + } + + private Result(E exception) { + this.value = null + this.err = exception + } + + /** + * Access the wrapped value if present + * @return the wrapped value + * @throws NoSuchElementException if no value exists in the result object + */ + V getValue() throws NoSuchElementException { + if (!value) { + throw new NoSuchElementException("Result with error has no value.") + } + return value + } + + /** + * Access the wrapped error if present + * @return the wrapped exception + * @throws NoSuchElementException if no error exists in the result object + */ + E getError() throws NoSuchElementException { + if (!err) { + throw new NoSuchElementException("Result with value has no error.") + } + return err as E + } + + /** + * Returns true, if the result object contains an error. Is always the negation + * of {@link Result#isOk()}. + *

So {@link Result#isError()} == !{@link Result#isOk()}

+ * @return true, if the result object has an error, else false + */ + Boolean isError() { + return err as boolean + } + + /** + * Returns true, if the result object contains an error. Is always the negation + * of {@link Result#isError()}. + *

So {@link Result#isOk()} == !{@link Result#isError()}

+ * @return true, if the result object has a value, else false + */ + Boolean isOk() { + return value as boolean + } + + /** + *

Maps the current result object to a consumer function, that expects the same input + * type V as the result's value type and produces a result object + * of type U,E.

+ * + *

If the current result contains an error (and therefore has no value), the created result object + * will contain the error of type E of the input result object. + *

+ * + * @param function a function transforming data of type V to U + * @return a new result object instance of type U,E + */ + def Result map(Function function) { + Objects.requireNonNull(function) + switch (this) { + case { it.isError() }: return new Result(err as E); break + case { it.isOk() }: return apply(function, this.getValue() as V); break + default: throw new IllegalStateException("This should be unreachable. Result neither is ok nor error.") + } + } + + private static Result apply(Function function, V value) { + try { + Result processedResult = new Result<>(function.apply(value)) + return processedResult + } catch (Exception e) { + Result failureResult = new Result(e as E) + return failureResult + } + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/Sample.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/Sample.groovy new file mode 100644 index 0000000..085281c --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/Sample.groovy @@ -0,0 +1,23 @@ +package life.qbic.samplestatus.reporter + +import groovy.transform.EqualsAndHashCode + +/** + * Value Class Sample + * + * @since 1.0.0 + */ +@EqualsAndHashCode +class Sample { + + String sampleCode + + Sample(String sampleCode) { + this.sampleCode = sampleCode + } + + @Override + String toString() { + return "$sampleCode" + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/SampleStatusReporter.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/SampleStatusReporter.groovy new file mode 100644 index 0000000..00cdc08 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/SampleStatusReporter.groovy @@ -0,0 +1,14 @@ +package life.qbic.samplestatus.reporter + +/** + * Reports sample updates to the ecosystem + * + *

Classes implementing this interface will consume sample updates and propagate them to the persistence layer.

+ * + * @since 1.0.0 + */ +interface SampleStatusReporter { + + void reportSampleStatusUpdate(SampleUpdate sampleUpdate) + +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/SampleUpdate.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/SampleUpdate.groovy new file mode 100644 index 0000000..25b0836 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/SampleUpdate.groovy @@ -0,0 +1,22 @@ +package life.qbic.samplestatus.reporter + +import groovy.transform.EqualsAndHashCode + +import java.time.Instant + +/** + * Value Class SampleUpdate + * + * @since 1.0.0 + */ +@EqualsAndHashCode +class SampleUpdate { + Sample sample + String updatedStatus + Instant modificationDate + + @Override + String toString() { + return "{$sample, $updatedStatus, $modificationDate}" + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/Address.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/Address.groovy new file mode 100644 index 0000000..f21a8e1 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/Address.groovy @@ -0,0 +1,15 @@ +package life.qbic.samplestatus.reporter.api + +import groovy.transform.ToString + +@ToString +class Address { + + String affiliation + + String street + + String zipCode + + String country +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/LimsQueryService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/LimsQueryService.groovy new file mode 100644 index 0000000..17056f8 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/LimsQueryService.groovy @@ -0,0 +1,38 @@ +package life.qbic.samplestatus.reporter.api + +import life.qbic.samplestatus.reporter.Result +import life.qbic.samplestatus.reporter.SampleUpdate + +import java.time.Instant + +/** + * Interface LimsQueryService + * + *

Provides access to a laboratory information system and enables clients to submit simple + * sample queries.

+ * + * @since 1.0.0 + */ +interface LimsQueryService { + + /** + *

Requests updated samples from a LIMS, that have been updated since a provided + * time instant

+ * + *

For example if you want to search for the updated samples since the last hour, you can just + * define the instant method parameter like this:

+ * + *
+   *  Instant oneHourEarlier = Instant.now().minus(1, ChronoUnit.HOURS);
+   * 
+ * + *

Implementing classes need to return samples that have been updated + * later or equal to the provided time-point.

+ * + * @param updatedSince an instance that configures the query from which time-point in the past + * updated samples shall be listed. + * @return a list of updated samples + * @since 1.0.0 + */ + List> getUpdatedSamples(Instant updatedSince) +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/Location.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/Location.groovy new file mode 100644 index 0000000..8371758 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/Location.groovy @@ -0,0 +1,15 @@ +package life.qbic.samplestatus.reporter.api + +import groovy.transform.ToString + +@ToString +class Location { + String label + + String contactPerson + + String contactEmail + + Address address + +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/LocationService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/LocationService.groovy new file mode 100644 index 0000000..dd7c6a8 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/LocationService.groovy @@ -0,0 +1,23 @@ +package life.qbic.samplestatus.reporter.api + +/** + * Interface LocationService + * + *

Provides the current location of the configured LIMS.

+ * + * @since 1.0.0 + */ +interface LocationService { + /** + *

Returns an {@link Optional} of the current location the LIMS is configured for. Must return an + * object of type {@link Optional#empty} if no matching location was found.

+ * + * @return an {@link Optional} wrapping a matching current {@link Location} or empty, if none was found. + * @throws ServiceException in case the service cannot retrieve the location due to for example connection issues. + * Must not be thrown, if the internal query was successful but no matching location was found. Instead, return an {@link Optional#empty}. + * @since 1.0.0 + */ + Optional getCurrentLocation() throws ServiceException + + Optional getResponsiblePerson() throws ServiceException +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/Person.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/Person.groovy new file mode 100644 index 0000000..c18ceea --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/Person.groovy @@ -0,0 +1,106 @@ +package life.qbic.samplestatus.reporter.api + +import groovy.transform.ToString + +import javax.persistence.* + +/** + *

Represents a user responsible for a location

+ * + * @since 1.0.0 + */ +@ToString +@Entity +@Table(name = "person") +class Person { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + Integer id + + @Column(name = "user_id") + String userId + + @Column(name = "first_name") + String firstName + + @Column(name = "last_name") + String lastName + + @Column(name = "title") + String title + + @Column(name = "email") + String email + + @Column(name = "active") + Integer active + + Person() {} + + Person(String userId, String firstName, String lastName, String title, String email, Integer active) { + this.userId = userId + this.firstName = firstName + this.lastName = lastName + this.title = title + this.email = email + this.active = active + } + + Integer getId() { + return id + } + + void setId(Integer id) { + this.id = id + } + + String getUserId() { + return userId + } + + void setUserId(String userId) { + this.userId = userId + } + + String getFirstName() { + return firstName + } + + void setFirstName(String firstName) { + this.firstName = firstName + } + + String getLastName() { + return lastName + } + + void setLastName(String lastName) { + this.lastName = lastName + } + + String getTitle() { + return title + } + + void setTitle(String title) { + this.title = title + } + + String getEmail() { + return email + } + + void setEmail(String email) { + this.email = email + } + + Integer getActive() { + return active + } + + void setActive(Integer active) { + this.active = active + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/SampleTrackingService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/SampleTrackingService.groovy new file mode 100644 index 0000000..80efb28 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/SampleTrackingService.groovy @@ -0,0 +1,37 @@ +package life.qbic.samplestatus.reporter.api + +import life.qbic.samplestatus.reporter.services.SampleUpdateException + +import java.time.Instant + +/** + * Provides access to the sample tracking persistence layer. + * + *

This service provides fundamental access to sample-tracking persistence. + * It allows to retrieve information and stores information in the system.

+ * + * @since 1.0.0 + */ +interface SampleTrackingService { + + /** + * Retrieves the location associated with the userId provided. + * @param userId an identifier for a user in the sample tracking system + * @return the location that this user is associated with + * @since 1.0.0 + */ + Optional getLocationForUser(String userId) + + /** + * Updates a sample to a given location with a status set in the location. + * This information is stored on the persistence layer. + * @param sampleCode the code of the sample changing status or location + * @param location the new location with a sample status already set + * @param status sample status to be set. + * @param timestamp the point in time that will be recorded as time of this update + * @param responsiblePerson the person responsible for this update + * @throws SampleUpdateException in case the sample update was unsuccessful + * @since 1.0.0 + */ + void updateSampleLocation(String sampleCode, Location location, String status, Instant timestamp, Person responsiblePerson) throws SampleUpdateException +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/ServiceException.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/ServiceException.groovy new file mode 100644 index 0000000..e22bc7a --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/ServiceException.groovy @@ -0,0 +1,19 @@ +package life.qbic.samplestatus.reporter.api + +/** + * Class ServiceException + * + *

Shall be used, when unexpected exceptions occur during a service task execution, to indicate + * to the client, that some action might be advised here.

+ * + * @since 1.0.0 + */ +class ServiceException extends RuntimeException { + ServiceException() { + super() + } + + ServiceException(String message) { + super(message) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy new file mode 100644 index 0000000..716fb1c --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/api/UpdateSearchService.groovy @@ -0,0 +1,29 @@ +package life.qbic.samplestatus.reporter.api + +import java.time.Instant + +/** + * Interface UpdateSearchService + * + *

Provides the time point of the last update search

+ * + * @since 1.0.0 + */ +interface UpdateSearchService { + + /** + *

Returns the last update search time point.

+ * @return the last update search time point. + * + * @since 1.0.0 + */ + Optional getLastUpdateSearchTimePoint() + + /** + *

Saves the last search time-point persistently.

+ * @param lastSearchTimePoint the time-point of the last search + * + * @since 1.0.0 + */ + void saveLastSearchTimePoint(Instant lastSearchTimePoint) +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy new file mode 100644 index 0000000..dea0cd1 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstant.groovy @@ -0,0 +1,93 @@ +package life.qbic.samplestatus.reporter.commands + +import life.qbic.samplestatus.reporter.Result +import life.qbic.samplestatus.reporter.SampleStatusReporter +import life.qbic.samplestatus.reporter.SampleUpdate +import life.qbic.samplestatus.reporter.api.LimsQueryService +import life.qbic.samplestatus.reporter.api.UpdateSearchService +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import picocli.CommandLine + +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.stream.Collectors + +/** + * Command that runs the app for a given instant + * + * @since 1.0.0 + */ +@CommandLine.Command(name = "SampleStatusReporter", version = "1.0.0", mixinStandardHelpOptions = true) +class ReportSinceInstant implements Runnable { + + private static final Logger log = LoggerFactory.getLogger(ReportSinceInstant.class) + + @CommandLine.Option(names = ["-t", "--time-point"], description = "Point in time from where to search for updates e.g. '2022-01-01T00:00:00Z'.\nDefaults to the last successful run. \nIf never run successfully defaults to the same time yesterday.") + final Instant timePoint + + private final LimsQueryService limsQueryService + private final SampleStatusReporter statusReporter + private final UpdateSearchService updateSearchService + + + ReportSinceInstant(LimsQueryService limsQueryService, SampleStatusReporter statusReporter, UpdateSearchService updateSearchService) { + this.limsQueryService = limsQueryService + this.statusReporter = statusReporter + this.updateSearchService = updateSearchService + + if (!timePoint) { + timePoint = defaultTimePoint(updateSearchService) + } + } + + private static Instant defaultTimePoint(UpdateSearchService updateSearchService) { + return updateSearchService.getLastUpdateSearchTimePoint().orElse(Instant.now().minus(1, ChronoUnit.DAYS)) + } + + + /** + * When an object implementing interface {@code Runnable} is used + * to create a thread, starting the thread causes the object's + * {@code run} method to be called in that separately executing + * thread. + *

+ * The general contract of the method {@code run} is that it may + * take any action whatsoever. + * + * @see java.lang.Thread#run() + */ + @Override + void run() { + Instant executionTime = Instant.now() + List> updatedSamples + try { + log.info("Gathering updated samples since $timePoint ...") + updatedSamples = limsQueryService.getUpdatedSamples(getTimePoint()) + log.info("Found ${updatedSamples.size()} updated samples.") + } catch (Exception e) { + throw new RuntimeException("Could not report sample updates successfully.", e) + } + + def errors = updatedSamples.stream() + .filter(Result::isError) + .map(Result::getError).collect() + if (errors.size() > 0) { + def errorMessages = errors.stream().map(RuntimeException::getMessage).collect(Collectors.joining("\n\t")) + errors.forEach((Exception e) -> log.debug(e.getMessage(), e)) + throw new RuntimeException("Encountered ${errors.size()} errors retrieving updated samples: \n\t$errorMessages") + } + + try { + updatedSamples.stream() + .filter(Result::isOk) + .map(Result::getValue) + .peek(it -> log.info("\tUpdating $it")) + .forEach(statusReporter::reportSampleStatusUpdate) + log.info("Finished processing.") + } catch (Exception e) { + throw new RuntimeException("Could not report sample updates successfully.", e) + } + updateSearchService.saveLastSearchTimePoint(executionTime) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy new file mode 100644 index 0000000..6ad51ac --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/DtoParseException.groovy @@ -0,0 +1,12 @@ +package life.qbic.samplestatus.reporter.services + +/** + * Exception to be thrown when parsing a data transfer object fails. + * + * @since 1.0.0 + */ +class DtoParseException extends RuntimeException { + DtoParseException(String message) { + super(message) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy new file mode 100644 index 0000000..0a65deb --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/LastUpdateSearch.groovy @@ -0,0 +1,48 @@ +package life.qbic.samplestatus.reporter.services + +import life.qbic.samplestatus.reporter.api.UpdateSearchService +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +import javax.annotation.PostConstruct +import java.time.Instant + +@Component +class LastUpdateSearch implements UpdateSearchService { + + @Value('${service.last-update.file}') + String filePath + + private Instant lastSearch + + @PostConstruct + void init() { + // Read the file content + String lastSearchDate = new File(filePath).getText('UTF-8').trim() + + if (lastSearchDate) { + // Parse the first line as date with format "YYYY-mm-dd'T'HH:mm:ss.SSSZ" (ISO 8601) + // For example "2021-12-01T12:00:00.000Z" + lastSearch = Instant.parse(lastSearchDate) + } + } + + /** + * @inheritDocs + */ + @Override + Optional getLastUpdateSearchTimePoint() { + return Optional.ofNullable(this.lastSearch) + } + + /** + * @inheritDocs + */ + @Override + void saveLastSearchTimePoint(Instant lastSearchTimePoint) { + new File(filePath).withWriter { + it.write(lastSearchTimePoint.toString()) + lastSearch = lastSearchTimePoint + } + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy new file mode 100644 index 0000000..70bf77b --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/NcctLocationService.groovy @@ -0,0 +1,43 @@ +package life.qbic.samplestatus.reporter.services + +import life.qbic.samplestatus.reporter.api.Location +import life.qbic.samplestatus.reporter.api.LocationService +import life.qbic.samplestatus.reporter.api.Person +import life.qbic.samplestatus.reporter.api.SampleTrackingService +import life.qbic.samplestatus.reporter.services.users.UserService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +/** + * + * + *

Provides the current location of the configured LIMS. In this case the LIMS for the NCCT.

+ * + * @since 1.0.0 + */ +@Component +@ConfigurationProperties +class NcctLocationService implements LocationService { + + @Value('${service.sampletracking.location.user}') + private String userId + + @Autowired + private SampleTrackingService sampleTrackingService + + @Autowired + private UserService userService + + @Override + Optional getCurrentLocation() { + return sampleTrackingService.getLocationForUser(userId) + } + + @Override + Optional getResponsiblePerson() { + Optional responsiblePerson = userService.getPerson(userId) + return responsiblePerson + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy new file mode 100644 index 0000000..7f11136 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/QbicSampleTrackingService.groovy @@ -0,0 +1,201 @@ +package life.qbic.samplestatus.reporter.services + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import life.qbic.samplestatus.reporter.api.Address +import life.qbic.samplestatus.reporter.api.Location +import life.qbic.samplestatus.reporter.api.Person +import life.qbic.samplestatus.reporter.api.SampleTrackingService +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +import javax.annotation.PostConstruct +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +import static java.time.ZoneOffset.UTC + +/** + * + * This service provides fundamental access to sample-tracking persistence. It allows to retrieve information and stores information in the system. + * + * @since 1.0.0 + */ +@Component +@ConfigurationProperties +class QbicSampleTrackingService implements SampleTrackingService { + + @Value('${service.sampletracking.url}') + private String sampleTrackingBaseUrl + + @Value('${service.sampletracking.location.endpoint}') + private String locationEndpoint + + @Value('${service.sampletracking.auth.user}') + private String serviceUser + + @Value('${service.sampletracking.auth.password}') + private String servicePassword + + private String locationEndpointPath + + @PostConstruct + void initService() { + locationEndpointPath = sampleTrackingBaseUrl + locationEndpoint + } + + @Override + Optional getLocationForUser(String userId) { + URI requestURI = createUserLocationURI(userId) + HttpResponse response = requestLocation(requestURI) + return Optional.of(response.body()).flatMap(DtoMapper::parseLocationOfJson) + } + + @Override + void updateSampleLocation(String sampleCode, Location location, String status, Instant timestamp, Person responsiblePerson) throws SampleUpdateException { + String locationJson = DtoMapper.createJsonFromLocationWithStatus(location, status, responsiblePerson, timestamp) + HttpResponse response = requestSampleUpdate(createSampleUpdateURI(sampleCode), locationJson) + if (response.statusCode() != 200) { + throw new SampleUpdateException("Could not update $sampleCode to ${location.getLabel()} - ${response.statusCode()} : ${response.headers()}: ${response.body()}") + } + } + + private HttpResponse requestSampleUpdate(URI requestURI, String locationJson) { + HttpRequest request = HttpRequest + .newBuilder(requestURI) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(locationJson)) + .build() + HttpClient client = HttpClient.newBuilder() + .authenticator(getAuthenticator()) + .build() + return client.send(request, HttpResponse.BodyHandlers.ofString()) + } + + private URI createUserLocationURI(String userId) { + return URI.create("${this.locationEndpointPath}/${userId}") + } + + private URI createSampleUpdateURI(String sampleCode) { + return URI.create("${sampleTrackingBaseUrl}/samples/${sampleCode}/currentLocation/") + } + + private HttpResponse requestLocation(URI requestURI) { + HttpRequest request = HttpRequest.newBuilder().GET().uri(requestURI).build() + HttpClient client = HttpClient.newBuilder() + .authenticator(getAuthenticator()).build() + return client.send(request, HttpResponse.BodyHandlers.ofString()) + } + + private Authenticator getAuthenticator() { + return new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(serviceUser, servicePassword.toCharArray()) + } + } + } + + private static class DtoMapper { + + private static final LOCATION_NAME = "name" + private static final LOCATION_CONTACT = "responsible_person" + private static final LOCATION_CONTACT_EMAIL = "responsible_person_email" + private static final LOCATION_ADDRESS = "address" + private static final ADDRESS_AFFILIATION = "affiliation" + private static final ADDRESS_STREET = "street" + private static final ADDRESS_ZIP = "zip_code" + private static final ADDRESS_COUNTRY = "country" + + protected static Optional parseLocationOfJson(String putativeLocationJson) { + println putativeLocationJson + + List locationMaps = parseJsonToList(putativeLocationJson) + return locationMaps.stream().map(DtoMapper::convertMapToLocation).findFirst() + } + + protected static String createJsonFromLocationWithStatus(Location location, String status, Person responsiblePerson, Instant arrivalTime) { + Map locationMap = convertLocationToMap(location, responsiblePerson) + locationMap.put("arrival_date", mapToLocationDateTimeString(arrivalTime)) + locationMap.put("sample_status", status) + return JsonOutput.toJson(locationMap) + } + + private static String mapToLocationDateTimeString(Instant timestamp) { + // the pattern mentioned here is dictated by the data-model-lib location object + var dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'") + .withZone(ZoneId.from(UTC)) + return dateTimeFormatter.format(timestamp) + } + + private static List> parseJsonToList(String json) { + return new JsonSlurper().parseText(json) as ArrayList + } + + private static Location convertMapToLocation(Map locationMap) { + Location location = new Location() + location.label = locationMap.get(LOCATION_NAME) ?: "" + location.contactPerson = locationMap.get(LOCATION_CONTACT) ?: "" + location.contactEmail = locationMap.get(LOCATION_CONTACT_EMAIL) ?: "" + location.address = convertMapToAddress(locationMap.get(LOCATION_ADDRESS) as Map) + return location + } + + private static Address convertMapToAddress(Map addressMap) { + Address address = new Address() + address.affiliation = addressMap.get(ADDRESS_AFFILIATION) ?: "" + address.street = addressMap.get(ADDRESS_STREET) ?: "" + address.zipCode = addressMap.get(ADDRESS_ZIP) ?: "" + address.country = addressMap.get(ADDRESS_COUNTRY) ?: "" + return address + } + + /** + *
+     *{*     "name": "QBiC",
+     *     "responsible_person": "Tobias Koch",
+     *     "responsible_person_email": "tobias.koch@qbic.uni-tuebingen.de",
+     *     "address": {*         "affiliation": "QBiC",
+     *         "street": "Auf der Morgenstelle 10",
+     *         "zip_code": 72076,
+     *         "country": "Germany"
+     *}*}* 
+ * @param location + * @return a map representing the location + */ + private static Map convertLocationToMap(Location location, Person responsiblePerson) { + Map locationMap = [ + "name" : location.getLabel(), + "responsible_person" : responsiblePerson.getFirstName() + " " + responsiblePerson.getLastName(), + "responsible_person_email": responsiblePerson.getEmail(), + "address" : convertAddressToMap(location.getAddress()) + ] + return locationMap + } + + /** + *
+     *{*     "affiliation": "QBiC",
+     *     "street": "Auf der Morgenstelle 10",
+     *     "zip_code": 72076,
+     *     "country": "Germany"
+     *}* 
+ * @param address the address being converted to a map + * @return a map containing information about the address provided + */ + private static Map convertAddressToMap(Address address) { + Map addressMap = [ + "affiliation": address.getAffiliation(), + "street" : address.getStreet(), + "zip_code" : address.getZipCode(), + "country" : address.getCountry() + ] + return addressMap + } + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/RealLimsQueryService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/RealLimsQueryService.groovy new file mode 100644 index 0000000..cf9fd48 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/RealLimsQueryService.groovy @@ -0,0 +1,130 @@ +package life.qbic.samplestatus.reporter.services + +import ch.ethz.sis.openbis.generic.asapi.v3.IApplicationServerApi +import ch.ethz.sis.openbis.generic.asapi.v3.dto.common.search.SearchResult +import ch.ethz.sis.openbis.generic.asapi.v3.dto.sample.Sample +import ch.ethz.sis.openbis.generic.asapi.v3.dto.sample.fetchoptions.SampleFetchOptions +import ch.ethz.sis.openbis.generic.asapi.v3.dto.sample.search.SampleSearchCriteria +import ch.systemsx.cisd.common.spring.HttpInvokerUtils +import life.qbic.samplestatus.reporter.Result +import life.qbic.samplestatus.reporter.SampleUpdate +import life.qbic.samplestatus.reporter.api.LimsQueryService +import life.qbic.samplestatus.reporter.services.utils.SampleStatusMapper +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +import javax.annotation.PostConstruct +import java.time.Instant + +/** + * Class RealLimsQueryService + * + *

Implementation of the {@link LimsQueryService} interface. + * Enables the client to request sample information from the connected LIMS. + *

+ * + * @since 1.0.0 + */ +@Component +@ConfigurationProperties +class RealLimsQueryService implements LimsQueryService { + + private IApplicationServerApi openBisApplicationServerApi + + private final String sessionToken + + /** + * Main configuration constructor + * + *

This constructor is used by Spring to create an Singleton instance of the {@link RealLimsQueryService}.

+ *

Please note, that we need three constructor parameters, that must be configured in the applications property file.

+ * + *

We have the application property service.openbis.user that maps to the parameter openbisUser, + * then the property service.openbis.password that maps to the parameter openbisPassword + * and finally the property service.openbis.api-server-url that maps to the parameter applicationServerUrl.

+ * + *

We also let you configure the server timeout, which uses the application property service.openbis.server-timeout + * and maps it to the parameter serverTimeout. + * + * @param openbisUser the openBIS user id + * @param openbisPassword the openBIS user password + * @param applicationServerUrl the openBIS application server url + * @param serverTimeout the server connection timeout + */ + RealLimsQueryService(@Value('${service.openbis.lims.user.name}') String openbisUser, + @Value('${service.openbis.lims.user.password}') String openbisPassword, + @Value('${service.openbis.lims.server.api.url}') String applicationServerUrl, + @Value('${service.openbis.server.timeout}') Integer serverTimeout) { + this.openBisApplicationServerApi = HttpInvokerUtils.createServiceStub( + IApplicationServerApi.class, + applicationServerUrl + IApplicationServerApi.SERVICE_URL, serverTimeout) + sessionToken = this.openBisApplicationServerApi.login(openbisUser, openbisPassword) + } + + /** + *

This method is called by the Spring framework, after an instance of this class + * has been created.

+ *

We just check, if the authentication against openBIS has been successful + * which is the case, when there is a session token present.

+ */ + @PostConstruct + void init() { + if (!sessionToken) { + throw new AuthenticationException("Authentication against LIMS service failed.") + } + } + /** + * {@InheritDocs} + */ + @Override + List> getUpdatedSamples(Instant updatedSince) { + SampleSearchCriteria criteria = new SampleSearchCriteria() + // we make sure that the barcode is set, otherwise the sample is of no interest to us + criteria.withProperty("QBIC_BARCODE").thatContains("Q") + // only fetch latest samples + criteria.withModificationDate().thatIsLaterThanOrEqualTo(Date.from(updatedSince)) + + // we need to fetch properties, as the sample status (and barcode) is contained therein + SampleFetchOptions fetchOptions = new SampleFetchOptions() + fetchOptions.withProperties() + + SearchResult result = openBisApplicationServerApi.searchSamples(sessionToken, criteria, fetchOptions) + List> sampleUpdates = + result.getObjects().stream() + .map(this::createSampleUpdate) + .collect() + + return sampleUpdates + } + + private static Result createSampleUpdate(Sample limsSample) { + + Map properties = limsSample.getProperties() + String sampleBarcode = properties.get("QBIC_BARCODE") + + + life.qbic.samplestatus.reporter.Sample sample = new life.qbic.samplestatus.reporter.Sample(sampleBarcode) + + Date modificationDate = limsSample.getModificationDate() + Result updatedStatus = new SampleStatusMapper().apply(properties.get("SAMPLE_STATUS")) + + switch (updatedStatus) { + case { it.isOk() }: return Result.of(new SampleUpdate(sample: sample, updatedStatus: updatedStatus.getValue(), modificationDate: modificationDate.toInstant())); break + case { it.isError() }: return Result.of(updatedStatus.getError()); break + default: throw new RuntimeException("Result neither Ok nor Error. This is not expected!") + } + } + + /** + * Class AuthenticationException + * + *

Small authentication exception class that can be used to indicate authentication exceptions

+ */ + class AuthenticationException extends RuntimeException { + + AuthenticationException(String message) { + super(message) + } + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/SampleUpdateException.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/SampleUpdateException.groovy new file mode 100644 index 0000000..4a6b468 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/SampleUpdateException.groovy @@ -0,0 +1,12 @@ +package life.qbic.samplestatus.reporter.services + +/** + * Exception to be thrown when a sample update failed + * + * @since 1.0.0 + */ +class SampleUpdateException extends RuntimeException { + SampleUpdateException(String message) { + super(message) + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/users/QbicUserService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/QbicUserService.groovy new file mode 100644 index 0000000..d6024ce --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/QbicUserService.groovy @@ -0,0 +1,47 @@ +package life.qbic.samplestatus.reporter.services.users + + +import life.qbic.samplestatus.reporter.api.Person +import life.qbic.samplestatus.reporter.api.ServiceException +import life.qbic.samplestatus.reporter.services.users.database.SessionProvider +import life.qbic.samplestatus.reporter.services.users.database.UserDatabaseConfig +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import org.hibernate.Session +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +/** + * A user service providing interaction with QBiC users + * + * @since 1.0.0 + */ +@Component +class QbicUserService implements UserService { + + @Autowired + UserDatabaseConfig userServiceConfig + + @Autowired + SessionProvider connectionProvider + + static Logger logger = LogManager.getLogger(QbicUserService.class) + + Optional getPerson(String userId) { + return fetchPerson(userId) + } + + private Optional fetchPerson(String userId) { + try (Session session = connectionProvider.getCurrentSession()) { + session.beginTransaction() + var query = session.createQuery("FROM Person p WHERE p.userId = :user_id") + query.setParameter("user_id", userId) + var personsFound = query.getResultList() as List + return Optional.ofNullable(personsFound.first()) + } catch (Exception e) { + logger.error(e.getMessage(), e) + throw new ServiceException("Unable to execute person search for person with id = $userId.") + } + } +} + diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/users/UserService.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/UserService.groovy new file mode 100644 index 0000000..f942275 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/UserService.groovy @@ -0,0 +1,19 @@ +package life.qbic.samplestatus.reporter.services.users + +import life.qbic.samplestatus.reporter.api.Person + +/** + * Provides user information + * + * @since 1.0.0 + */ +interface UserService { + /** + * Retrieves user details for a provided userId from the persistence layer. + * @param userId the identifier of the user + * @return user details for a user found. + * @since 1.0.0 + */ + Optional getPerson(String userId) + +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/DatabaseSession.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/DatabaseSession.groovy new file mode 100644 index 0000000..8c89e00 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/DatabaseSession.groovy @@ -0,0 +1,70 @@ +package life.qbic.samplestatus.reporter.services.users.database + +import life.qbic.samplestatus.reporter.api.Person +import org.hibernate.Session +import org.hibernate.SessionFactory +import org.hibernate.cfg.Configuration +import org.hibernate.cfg.Environment +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +import javax.annotation.PostConstruct +import javax.annotation.PreDestroy + + +/** + * Creates a connection to the user database + * + * A class for setting up the connection to the user database. It should be used when data needs to be retrieved from the + * DB or written into it. + * + * @since: 1.0.0 + * @author: Jennifer Bödker + * + */ +@Singleton(lazy = true) +@Component +class DatabaseSession implements SessionProvider { + + private static final Logger log = LoggerFactory.getLogger(DatabaseSession.class) + + @Autowired + private UserDatabaseConfig userDatabaseConfig + + private SessionFactory sessionFactory + + @PostConstruct + void init() { + sessionFactory = initHibernate(userDatabaseConfig) + } + + private static SessionFactory initHibernate(UserDatabaseConfig dbConfig) { + var config = new Configuration() + var properties = new Properties() + println dbConfig.getDriver() + properties[Environment.DRIVER] = dbConfig.getDriver() + properties[Environment.URL] = dbConfig.getUrl() + properties[Environment.USER] = dbConfig.getUser() + properties[Environment.PASS] = dbConfig.getPassword() + properties[Environment.POOL_SIZE] = 1 + properties[Environment.DIALECT] = dbConfig.getSqlDialect() + properties[Environment.CURRENT_SESSION_CONTEXT_CLASS] = "thread" + + config.setProperties(properties) + + config.addAnnotatedClass(Person.class).buildSessionFactory() + } + + @Override + Session getCurrentSession() { + return sessionFactory.getCurrentSession() + } + + @PreDestroy + void destroy() { + log.debug("Closing session factory...") + sessionFactory.close() + } +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/SessionProvider.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/SessionProvider.groovy new file mode 100644 index 0000000..8820c6b --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/SessionProvider.groovy @@ -0,0 +1,12 @@ +package life.qbic.samplestatus.reporter.services.users.database + +import org.hibernate.Session + +/** + * Provides the ability to connect to a SQL resource + * + * @since: 1.0.0 + */ +interface SessionProvider { + Session getCurrentSession() +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/UserDatabaseConfig.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/UserDatabaseConfig.groovy new file mode 100644 index 0000000..8891a80 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/users/database/UserDatabaseConfig.groovy @@ -0,0 +1,28 @@ +package life.qbic.samplestatus.reporter.services.users.database + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +/** + * Configuration for the user database + * + * @since 1.0.0 + */ +@Component +@ConfigurationProperties(prefix = "databases.users") +class UserDatabaseConfig { + + @Value('${databases.users.user.name}') + String user + @Value('${databases.users.user.password}') + String password + @Value('${databases.users.database.url}') + String url + @Value('${databases.users.database.dialect}') + String sqlDialect + @Value('${databases.users.database.driver}') + String driver + + +} diff --git a/src/main/groovy/life/qbic/samplestatus/reporter/services/utils/SampleStatusMapper.groovy b/src/main/groovy/life/qbic/samplestatus/reporter/services/utils/SampleStatusMapper.groovy new file mode 100644 index 0000000..c83fa89 --- /dev/null +++ b/src/main/groovy/life/qbic/samplestatus/reporter/services/utils/SampleStatusMapper.groovy @@ -0,0 +1,63 @@ +package life.qbic.samplestatus.reporter.services.utils + +import life.qbic.samplestatus.reporter.Result + +import java.util.function.Function + +/** + * Class SampleStatusMapper + * + *

Takes a String value and tries to map it to a known sample status.

+ * + * @since 1.0.0 + */ +class SampleStatusMapper implements Function> { + + private static final String SAMPLE_RECEIVED = "SAMPLE_RECEIVED" + private static final String SAMPLE_QC_PASS = "SAMPLE_QC_PASS" + private static final String SAMPLE_QC_FAIL = "SAMPLE_QC_FAIL" + private static final String LIBRARY_PREP_FINISHED = "LIBRARY_PREP_FINISHED" + + /** + *

Tries to map a String value to a known sample status.

+ * @param s the String value you want to have mapped + * @return the mapped String value + */ + @Override + Result apply(String s) { + return mapSampleStatus(s) + } + + private Result mapSampleStatus(String statusString) { + if (statusString.isEmpty()) { + return Result.of(new MappingException("Status value is empty.")) + } + Result result + switch (statusString) { + case "SAMPLE_RECEIVED": + result = Result.of(SAMPLE_RECEIVED); break + case "QC_PASSED": + result = Result.of(SAMPLE_QC_PASS); break + case "QC_FAILED": + result = Result.of(SAMPLE_QC_FAIL); break + case "LIBRARY_PREP_FINISHED": + result = Result.of(LIBRARY_PREP_FINISHED); break + default: + result = Result.of(new MappingException("Cannot map unkown satus value: $statusString.")) + } + return result + } + + /** + * Class MappingException + *

Small mapping exception class that can be used when sample status mapping fails

+ */ + class MappingException extends RuntimeException { + + MappingException(String message) { + super(message) + } + } +} + + diff --git a/src/main/groovy/life/qbic/springminimaltemplate/AppConfig.groovy b/src/main/groovy/life/qbic/springminimaltemplate/AppConfig.groovy deleted file mode 100644 index 70c3187..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/AppConfig.groovy +++ /dev/null @@ -1,32 +0,0 @@ -package life.qbic.springminimaltemplate - -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.PropertySource - -/** - * Spring configuration class - * - *

Reads properties from a properties file and creates beans for the application.

- * - * @since 0.1.0 - */ -@Configuration -@PropertySource("application.properties") -class AppConfig { - - @Value('${messages.file}') - public String messagesFile - - @Bean - MessageService messageService() { - return new CodingPrayersMessageService(messagesFile) - } - - @Bean - NewsMedia newsMedia() { - return new DeveloperNews(messageService()) - } - -} diff --git a/src/main/groovy/life/qbic/springminimaltemplate/CodingPrayersMessageService.groovy b/src/main/groovy/life/qbic/springminimaltemplate/CodingPrayersMessageService.groovy deleted file mode 100644 index 0a001f3..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/CodingPrayersMessageService.groovy +++ /dev/null @@ -1,29 +0,0 @@ -package life.qbic.springminimaltemplate - -/** - * Example implementation of a {@link MessageService} - * - * @since 0.1.0 - */ -class CodingPrayersMessageService implements MessageService { - - private List messages - - CodingPrayersMessageService() { - this.messages = new ArrayList<>() - } - - CodingPrayersMessageService(String filePath) { - this.messages = readMessagesFromClassPath(filePath) - } - - @Override - String collectMessage() { - return messages.get(new Random().nextInt(messages.size())) - } - - private List readMessagesFromClassPath(String path) { - URL url = getClass().getClassLoader().getResource(path) - return url.readLines().each {it.trim()}.collect() - } -} diff --git a/src/main/groovy/life/qbic/springminimaltemplate/DeveloperNews.groovy b/src/main/groovy/life/qbic/springminimaltemplate/DeveloperNews.groovy deleted file mode 100644 index 6188959..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/DeveloperNews.groovy +++ /dev/null @@ -1,20 +0,0 @@ -package life.qbic.springminimaltemplate - -/** - * An example {@link NewsMedia} implementation for developer news. - * - * @since 0.1.0 - */ -class DeveloperNews implements NewsMedia{ - - private MessageService service - - DeveloperNews(MessageService service) { - this.service = service - } - - @Override - String getNews() { - return service.collectMessage() - } -} diff --git a/src/main/groovy/life/qbic/springminimaltemplate/MessageService.groovy b/src/main/groovy/life/qbic/springminimaltemplate/MessageService.groovy deleted file mode 100644 index eaadf3e..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/MessageService.groovy +++ /dev/null @@ -1,18 +0,0 @@ -package life.qbic.springminimaltemplate - -/** - * Small toy interface that represents message services - * - *

Message services shall provide access to received messages.

- * - * @since 0.1.0 - */ -interface MessageService { - - /** - * Collects the latest message - * @return the latest message - * @since 0.1.0 - */ - String collectMessage() -} diff --git a/src/main/groovy/life/qbic/springminimaltemplate/NewsMedia.groovy b/src/main/groovy/life/qbic/springminimaltemplate/NewsMedia.groovy deleted file mode 100644 index d335196..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/NewsMedia.groovy +++ /dev/null @@ -1,17 +0,0 @@ -package life.qbic.springminimaltemplate - -/** - * Example interface for a news media - * - * @since 0.1.0 - */ -interface NewsMedia { - - /** - * Returns latest news - * @return news stuff! - * @since 0.1.0 - */ - String getNews() - -} \ No newline at end of file diff --git a/src/main/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplication.groovy b/src/main/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplication.groovy deleted file mode 100644 index 58b6f65..0000000 --- a/src/main/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplication.groovy +++ /dev/null @@ -1,23 +0,0 @@ -package life.qbic.springminimaltemplate - -import org.springframework.boot.SpringApplication -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.context.annotation.AnnotationConfigApplicationContext - -@SpringBootApplication -class SpringMinimalTemplateApplication { - - static void main(String[] args) { - SpringApplication.run(SpringMinimalTemplateApplication, args) - - AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class) - - NewsMedia media = context.getBean("newsMedia", NewsMedia.class) - println "####################### Message of the day ##################" - println media.getNews() - println "##############################################################" - - context.close() - } - -} diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..e21a837 --- /dev/null +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,54 @@ +{ + "properties": [ + { + "name": "service.sampletracking.auth.password", + "type": "java.lang.String", + "description": "Description for service.sampletracking.auth.password." + }, + { + "name": "service.sampletracking.auth.user", + "type": "java.lang.String", + "description": "Description for service.sampletracking.auth.user." + }, + { + "name": "service.sampletracking.location.endpoint", + "type": "java.lang.String", + "description": "Description for service.sampletracking.location.endpoint." + }, + { + "name": "service.sampletracking.location.user", + "type": "java.lang.String", + "description": "Description for service.sampletracking.location.user." + }, + { + "name": "service.sampletracking.url", + "type": "java.lang.String", + "description": "Description for service.sampletracking.url." + }, + { + "name": "databases.users.database.host", + "type": "java.lang.String", + "description": "Description for databases.users.database.host." + }, + { + "name": "databases.users.database.name", + "type": "java.lang.String", + "description": "Description for databases.users.database.name." + }, + { + "name": "databases.users.database.port", + "type": "java.lang.String", + "description": "Description for databases.users.database.port." + }, + { + "name": "databases.users.user.name", + "type": "java.lang.String", + "description": "Description for databases.users.user.name." + }, + { + "name": "databases.users.user.password", + "type": "java.lang.String", + "description": "Description for databases.users.user.password." + } + ] +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 43f0696..7994301 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,19 @@ -messages.file=messages.txt +# Properties for the sample tracking service implementation +service.sampletracking.auth.password=${SAMPLE_TRACKING_AUTH_PASSWORD:astrongpassphrase!} +service.sampletracking.auth.user=${SAMPLE_TRACKING_AUTH_USER:qbic} +service.sampletracking.location.endpoint=${SAMPLE_TRACKING_LOCATION_ENDPOINT:/locations} +service.sampletracking.location.user=${SAMPLE_TRACKING_LOCATION_USER:my_email@example.com} +service.sampletracking.url=${SAMPLE_TRACKING_URL:http://localhost.de} +databases.users.database.dialect=${USER_DB_DIALECT:org.hibernate.dialect.MariaDBDialect} +databases.users.database.driver=${USER_DB_DRIVER:com.mysql.cj.jdbc.Driver} +databases.users.database.url=${USER_DB_HOST:localhost} +databases.users.user.name=${USER_DB_USER_NAME:myusername} +databases.users.user.password=${USER_DB_USER_PW:astrongpassphrase!} +# Properties for the openBIS LIMS application server +service.openbis.lims.server.api.url=${LIMS_SERVER_URL:} +service.openbis.lims.user.name=${LIMS_USER:} +service.openbis.lims.user.password=${LIMS_PASSWORD:} +# openBIS application server timeout in milliseconds +service.openbis.server.timeout=${OPENBIS_TIMEOUT:10000} +# File path to the text file that stores the date of the last search for updated samples +service.last-update.file=${LAST_UPDATE_FILE:last-updated.txt} diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..aeef889 --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,34 @@ + + + + + + + + + + %d %p %C{1.} [%t] %m%n + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/messages.txt b/src/main/resources/messages.txt deleted file mode 100644 index 76dab38..0000000 --- a/src/main/resources/messages.txt +++ /dev/null @@ -1,5 +0,0 @@ -Have you written unit tests yet? If not, do it! -Keep it simple! -Remember the single responsibility principle! -Clean architecture ftw! -Favor composition over inheritance! \ No newline at end of file diff --git a/src/test/groovy/life.qbic.samplestatus.reporter/ResultSpec.groovy b/src/test/groovy/life.qbic.samplestatus.reporter/ResultSpec.groovy new file mode 100644 index 0000000..caebc49 --- /dev/null +++ b/src/test/groovy/life.qbic.samplestatus.reporter/ResultSpec.groovy @@ -0,0 +1,166 @@ +package life.qbic.samplestatus.reporter + +import spock.lang.Specification + +import java.util.function.Function + +/** + * Tests for the {@link Result} class. + * + * @since 1.0.0 + */ +class ResultSpec extends Specification { + + def "Given a value, the result should be ok and return the wrapped value"() { + given: + Result stringResult = Result.of("My precious!") + + when: + String value = stringResult.getValue() + + then: + noExceptionThrown() + assert stringResult.isOk() + assert stringResult.isOk() == !stringResult.isError() + assert value == "My precious!" + } + + def "Given no value, the attempt to access the contained value must throw a NoSuchElementException"() { + given: + Result stringResult = Result.of(null) + + when: + stringResult.getValue() + + then: + thrown(NoSuchElementException.class) + } + + def "Given an error has been produced during result creation, the result object must contain an exception object"() { + given: + Result stringResult = Result.of(new RuntimeException("This went wrong!")) + + when: + Exception e = stringResult.getError() + + then: + noExceptionThrown() + e instanceof RuntimeException + stringResult.isError() + stringResult.isError() == !stringResult.isOk() + } + + def "Mapping of a result of value A with a function that consumes A and produces B, must return a result of type B"() { + given: + Result stringResult = Result.of("Awesome!") + + and: + Function ruler = ((String value) -> { return value.length() }) + + when: + Result result = stringResult.map(ruler) + + then: + result.isOk() + result.getValue() instanceof Integer + result.getValue() == 8 + } + + def "Test for result idiom with value"() { + given: + String actualValue = "A real value" + Result stringResult = Result.of(actualValue) + + and: + ExecutionDummy executionDummy = Mock(ExecutionDummy.class) + + when: + switch (stringResult) { + case { it.isOk() }: executionDummy.apply(stringResult.getValue()); break + case { it.isError() }: throw stringResult.getError(); break + } + + then: + 1 * executionDummy.apply(actualValue) + } + + def "Test for result idiom with exception"() { + given: + String exceptionMessage = "Iuh, that went south!" + Result stringResult = Result.of(new RuntimeException(exceptionMessage)) + + and: + ExecutionDummy executionDummy = new ExecutionDummy<>() + + when: + switch (stringResult) { + case { it.isOk() }: executionDummy.apply("Worked!"); break + case { it.isError() }: throw new RuntimeException("Error present"); break + } + + then: + thrown(RuntimeException.class) + stringResult.getError().getMessage() == exceptionMessage + } + + def "Mapping a function to a result with value must return the target result type with target value type and target value"() { + given: + // A six-char String + String message = "Hello!" + Result stringResult = Result.of(message) + + and: + Function lengthCalculator = (s) -> { s.length() } + + when: + Result lengthResult = stringResult.map(lengthCalculator) + + then: + lengthResult.isOk() + lengthResult.getValue() == message.length() // 6 + } + + def "Mapping a function to a result with error must return the target result type with target value type and input error"() { + given: + String message = "Failure!" + Result stringResult = Result.of(new RuntimeException(message)) + + and: + Function lengthCalculator = (s) -> { s.length() } + + when: + Result lengthResult = stringResult.map(lengthCalculator) + + then: + lengthResult.isError() + lengthResult.getError().getMessage() == message // the initial error message + } + + def "Given an exception in the mapped function passed to the result object, the final result object must contain this exception"() { + given: + String message = "We believe this might works..." + Result stringResult = Result.of(message) + + and: + Function lengthCalculator = (s) -> { s.notAvailableProperty } + + when: + Result lengthResult = stringResult.map(lengthCalculator) + + then: + noExceptionThrown() + lengthResult.isError() + lengthResult.getError() instanceof MissingPropertyException // the wrapped exception + } + + /** + * Small helper class for mocking executions + * @param + */ + class ExecutionDummy implements Function { + @Override + String apply(T t) { + return "worked!" + } + } +} diff --git a/src/test/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstantSpec.groovy b/src/test/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstantSpec.groovy new file mode 100644 index 0000000..91cd277 --- /dev/null +++ b/src/test/groovy/life/qbic/samplestatus/reporter/commands/ReportSinceInstantSpec.groovy @@ -0,0 +1,91 @@ +package life.qbic.samplestatus.reporter.commands + +import life.qbic.samplestatus.reporter.Result +import life.qbic.samplestatus.reporter.Sample +import life.qbic.samplestatus.reporter.SampleStatusReporter +import life.qbic.samplestatus.reporter.SampleUpdate +import life.qbic.samplestatus.reporter.api.LimsQueryService +import life.qbic.samplestatus.reporter.api.UpdateSearchService +import spock.lang.Specification + +import java.time.Instant + +class ReportSinceInstantSpec extends Specification { + + ReportSinceInstant underTest + LimsQueryService limsQueryService = Stub() + SampleStatusReporter statusReporter = Mock() + UpdateSearchService updateSearchService = Stub() + + def "when no samples were updated then no sample updates are triggered in the reporter"() { + when: "no samples were updated" + + limsQueryService.getUpdatedSamples(_ as Instant) >> [] + updateSearchService.getLastUpdateSearchTimePoint() >> Optional.empty() + + underTest = new ReportSinceInstant(limsQueryService, statusReporter, updateSearchService) + underTest.run() + then: "no sample updates are triggered in the reporter" + 0 * statusReporter.reportSampleStatusUpdate(_) + } + + def "given #n exceptions during sample update retrieval, when the reporter is run, then no updates are performed and the reporter fails"() { + + given: "#n exceptions during sample update retrieval" + List> updates = [] + for (i in 0..> updates + updateSearchService.getLastUpdateSearchTimePoint() >> Optional.empty() + + when: "when the reporter is run" + underTest = new ReportSinceInstant(limsQueryService, statusReporter, updateSearchService) + underTest.run() + + then: "then no updates are performed and the reporter fails" + 0 * statusReporter.reportSampleStatusUpdate(_) + thrown(RuntimeException) + + where: + n << [1, 2, 3, 33, 102] + + } + + def "given #n random sample updates, when the reporter is run, then all sample updates are triggered in the reporter"() { + given: "#n random sample updates" + List> updates = [] + for (i in 0..> updates + updateSearchService.getLastUpdateSearchTimePoint() >> Optional.empty() + + when: "the reporter is run" + underTest = new ReportSinceInstant(limsQueryService, statusReporter, updateSearchService) + underTest.run() + + then: "all sample updates are triggered in the reporter" + n * statusReporter.reportSampleStatusUpdate(_) + where: + n << [1, 2, 3, 33, 102] + } + + private static String generateFakeSampleCode() { + String code = "QABCD" + return code + } +} diff --git a/src/test/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplicationTests.groovy b/src/test/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplicationTests.groovy deleted file mode 100644 index 469064a..0000000 --- a/src/test/groovy/life/qbic/springminimaltemplate/SpringMinimalTemplateApplicationTests.groovy +++ /dev/null @@ -1,26 +0,0 @@ -package life.qbic.springminimaltemplate - -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import spock.lang.Specification - -@SpringBootTest -class SpringMinimalTemplateApplicationTests extends Specification { - - @Test - void contextLoads() { - } - - @Autowired - private MessageService messageService - - def "autowired works"() { - when: - String messages = messageService.collectMessage() - println(messages) - then: - messages != null - } - -}