diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..937e937 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Build and test + run: mvn -B -ntp verify + diff --git a/.gitignore b/.gitignore index 6d706b8..cada8c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,22 @@ +### General ### +Thumbs.db +.DS_Store + +### Build outputs ### +build/ target/ +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ +.micronaut/ + +### Gradle ### +.gradle + +### Maven ### pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup @@ -10,8 +28,31 @@ buildNumber.properties # https://maven.apache.org/wrapper/#usage-without-binary-jar .mvn/wrapper/maven-wrapper.jar -# Eclipse m2e generated files -# Eclipse Core -.project -# JDT-specific (Eclipse Java Development Tools) +### IntelliJ IDEA ### +.idea/ +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated .classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/README.md b/README.md index 1a2ad4a..180bca3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# mn-exemplar -A Sandbox to play with examples of different patterns in using Micronaut. +# Intention + +Exemplar application demonstrating my preferred practices for commandline applications using micronaut + + + + + + +## Micronaut 4.8.2 Documentation + +- [User Guide](https://docs.micronaut.io/4.8.2/guide/index.html) +- [API Reference](https://docs.micronaut.io/4.8.2/api/index.html) +- [Configuration Reference](https://docs.micronaut.io/4.8.2/guide/configurationreference.html) +- [Micronaut Guides](https://guides.micronaut.io/index.html) +--- + +- [Micronaut Maven Plugin documentation](https://micronaut-projects.github.io/micronaut-maven-plugin/latest/) +## Feature serialization-jackson documentation + +- [Micronaut Serialization Jackson Core documentation](https://micronaut-projects.github.io/micronaut-serialization/latest/guide/) + + +## Feature lombok documentation + +- [Micronaut Project Lombok documentation](https://docs.micronaut.io/latest/guide/index.html#lombok) + +- [https://projectlombok.org/features/all](https://projectlombok.org/features/all) + + +## Feature maven-enforcer-plugin documentation + +- [https://maven.apache.org/enforcer/maven-enforcer-plugin/](https://maven.apache.org/enforcer/maven-enforcer-plugin/) + + +## Feature validation documentation + +- [Micronaut Validation documentation](https://micronaut-projects.github.io/micronaut-validation/latest/guide/) + + diff --git a/cli-exemplar/PROJECT_PRACTICES_RULES.md b/cli-exemplar/PROJECT_PRACTICES_RULES.md new file mode 100644 index 0000000..6e5276b --- /dev/null +++ b/cli-exemplar/PROJECT_PRACTICES_RULES.md @@ -0,0 +1,229 @@ +# Project Practices Rules - MN4 CLI Exemplar + +This document outlines the project practices and conventions used in the mn4-cli-exemplar project, serving as a template for similar Micronaut CLI applications. + +## 1. Maven Build File Practices + +### 1.1 Project Structure and Parent +- **Use Micronaut Platform Parent**: Always inherit from `io.micronaut.platform:micronaut-parent` to ensure consistent dependency management and plugin versions +- **Version Alignment**: Keep `micronaut.version` property aligned with the parent version (currently 4.8.2) +- **Packaging Flexibility**: Use `${packaging}` property for flexible packaging (defaults to `jar`) + +```xml + + io.micronaut.platform + micronaut-parent + 4.8.2 + + + + jar + 4.8.2 + +``` + +### 1.2 Java Version Configuration +- **Target Java 21**: Use Java 21 as the target version for modern CLI applications +- **Consistent Version Properties**: Define `jdk.version`, `source.version`, and `release.version` consistently + +```xml + + 21 + 21 + 21 + +``` + +### 1.3 Dependency Management +- **Core Micronaut Dependencies**: Include essential Micronaut modules: + - `micronaut-picocli` for CLI functionality + - `micronaut-inject` for dependency injection + - `micronaut-serde-jackson` for JSON serialization + - `micronaut-validation` for input validation +- **Runtime Dependencies**: Use `runtime` scope for logging and YAML processing +- **Provided Dependencies**: Use `provided` scope for Lombok to avoid runtime inclusion +- **Custom Libraries**: Include project-specific libraries (e.g., `endeavour` for outcome handling) + +### 1.4 Annotation Processing Configuration +- **Comprehensive Processor Paths**: Configure all necessary annotation processors: + - Lombok for code generation + - Micronaut Inject Java for DI + - Picocli Codegen for CLI generation + - Micronaut Graal for native compilation + - Micronaut Serde Processor for serialization + - Micronaut Validation Processor for validation +- **Processor Exclusions**: Exclude conflicting dependencies in processor paths +- **Processing Configuration**: Set Micronaut processing group and module properties + +```xml + + + org.projectlombok + lombok + ${lombok.version} + + + + + -Amicronaut.processing.group=org.saltations.mn4 + -Amicronaut.processing.module=mn4-cli-exemplar + +``` + +### 1.5 Plugin Configuration +- **Micronaut Maven Plugin**: Use for application packaging and execution +- **Maven Enforcer Plugin**: Include but disable by default (set phase to `none`) +- **Compiler Plugin**: Configure with annotation processors and compiler arguments + +## 2. Java Library Usage Practices + +### 2.1 Core Framework Libraries +- **Micronaut Framework**: Use Micronaut 4.x for dependency injection, configuration, and HTTP client capabilities +- **Picocli Integration**: Leverage `micronaut-picocli` for seamless CLI command definition and execution +- **Validation**: Use `micronaut-validation` with Jakarta Validation API for input validation +- **Serialization**: Use `micronaut-serde-jackson` for JSON processing + +### 2.2 Utility Libraries +- **Lombok**: Use for reducing boilerplate code with `@Slf4j`, `@Getter`, etc. +- **Logback**: Use for logging with SLF4J API +- **SnakeYAML**: Use for YAML configuration processing (pin to safe version 2.3+) +- **JUnit 5**: Use for testing with Micronaut Test integration + +### 2.3 Custom Libraries +- **Endeavour Framework**: Use for consistent outcome handling with `Outcome` and `FailureType` patterns +- **HTTP Client**: Use Micronaut HTTP client with JDK implementation for external API calls + +### 2.4 Library Version Management +- **BOM Inheritance**: Rely on Micronaut parent for version management +- **Explicit Versions**: Only specify versions for libraries not managed by the parent (e.g., SnakeYAML) +- **Security Considerations**: Pin vulnerable dependencies to safe versions + +## 3. Command and Subcommand Coding Practices + +### 3.1 Command Structure +- **Root Command**: Implement `Callable>` for consistent return handling +- **Subcommands**: Define as separate classes implementing the same interface +- **Command Registration**: Register subcommands in the root command's `@Command` annotation + +```java +@Command(name = "mn4-cli-exemplar", + description = "Exemplar application of preferred best practices", + mixinStandardHelpOptions = true, + subcommands = { + TZCommand.class, + IPCommand.class + }) +public class Mn4CliExemplarCommand implements Callable> { + // Implementation +} +``` + +### 3.2 Application Context Management +- **Context Lifecycle**: Use try-with-resources for proper ApplicationContext management +- **Bean Creation**: Use `ctx.createBean()` for command instantiation +- **Factory Integration**: Use `MicronautFactory` for Picocli integration + +```java +try (ApplicationContext ctx = ApplicationContext.run()) { + var root = ctx.createBean(Mn4CliExemplarCommand.class); + var factory = new MicronautFactory(ctx); + var cmdLine = new CommandLine(root, factory); + // Execute commands +} +``` + +### 3.3 Custom Execution Strategy +- **Outcome-Based Execution**: Implement custom `IExecutionStrategy` for consistent outcome handling +- **Validation Integration**: Integrate Micronaut validation with command execution +- **Error Handling**: Use `Outcome` pattern for consistent success/failure handling +- **Help Request Handling**: Properly handle help requests before validation + +```java +@Singleton +public class RunAllStopOnError implements IExecutionStrategy { + // Custom execution logic with validation and outcome handling +} +``` + +### 3.4 Command Implementation Patterns +- **Outcome Return**: All commands must return `Outcome` for consistent error handling +- **Logging**: Use `@Slf4j` for logging in command classes +- **Validation**: Use Jakarta Validation annotations for input validation +- **Help Options**: Include `mixinStandardHelpOptions = true` for automatic help generation + +```java +@Slf4j +@Command(name = "tz", description = "Timezone query command") +public class TZCommand implements Callable> { + @Override + public Outcome call() { + return Outcomes.succeed(0); + } +} +``` + +### 3.5 Failure Handling +- **Failure Types**: Define custom `FailureType` enums for different failure scenarios +- **Outcome Creation**: Use `Outcomes.succeed()` and `Outcomes.fail()` for consistent outcome creation +- **Error Propagation**: Let the execution strategy handle error logging and exit codes + +```java +@Getter +public enum ExemplarFailureType implements FailureType { + GENERIC("generic-failure", "A generic failure occurred"), + GENERIC_EXCEPTION("generic-exception-failure", "An exception occurred: {0}"); +} +``` + +### 3.6 Testing Practices +- **Integration Testing**: Use `PicocliRunner` for command testing +- **Context Configuration**: Use appropriate environments (CLI, TEST) for testing +- **Output Verification**: Capture and verify command output in tests +- **Micronaut Test**: Leverage Micronaut Test framework for integration testing + +```java +@Test +public void testWithCommandLineOption() throws Exception { + try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) { + String[] args = new String[] { "-v" }; + Outcome outcome = PicocliRunner.call(Mn4CliExemplarCommand.class, ctx, args); + // Assertions + } +} +``` + +### 3.7 Configuration Management +- **YAML Configuration**: Use `application.yml` for application configuration +- **HTTP Client Configuration**: Configure external service endpoints and timeouts +- **Environment-Specific**: Use Micronaut environments for different deployment scenarios + +```yaml +micronaut: + application: + name: mn4-cli-exemplar + http: + services: + worldtimeapi: + url: "https://worldtimeapi.org/api" + client-configuration: + read-timeout: 10s +``` + +## 4. General Best Practices + +### 4.1 Code Organization +- **Package Structure**: Use clear package hierarchy (e.g., `org.saltations.mn4`) +- **Separation of Concerns**: Separate commands, services, and configuration +- **Dependency Injection**: Leverage Micronaut's DI for loose coupling + +### 4.2 Error Handling +- **Consistent Patterns**: Use `Outcome` pattern throughout the application +- **No Logging in Catch Blocks**: Delegate logging responsibility to callers [[memory:193440]] +- **Graceful Degradation**: Handle validation failures gracefully + +### 4.3 Documentation +- **Command Descriptions**: Provide clear descriptions for all commands and options +- **Code Comments**: Document complex logic and custom execution strategies +- **README**: Include setup and usage instructions + +This document serves as a comprehensive guide for maintaining consistency and best practices in Micronaut CLI applications following the patterns established in the mn4-cli-exemplar project. diff --git a/cli-exemplar/README.md b/cli-exemplar/README.md new file mode 100644 index 0000000..180bca3 --- /dev/null +++ b/cli-exemplar/README.md @@ -0,0 +1,40 @@ +# Intention + +Exemplar application demonstrating my preferred practices for commandline applications using micronaut + + + + + + +## Micronaut 4.8.2 Documentation + +- [User Guide](https://docs.micronaut.io/4.8.2/guide/index.html) +- [API Reference](https://docs.micronaut.io/4.8.2/api/index.html) +- [Configuration Reference](https://docs.micronaut.io/4.8.2/guide/configurationreference.html) +- [Micronaut Guides](https://guides.micronaut.io/index.html) +--- + +- [Micronaut Maven Plugin documentation](https://micronaut-projects.github.io/micronaut-maven-plugin/latest/) +## Feature serialization-jackson documentation + +- [Micronaut Serialization Jackson Core documentation](https://micronaut-projects.github.io/micronaut-serialization/latest/guide/) + + +## Feature lombok documentation + +- [Micronaut Project Lombok documentation](https://docs.micronaut.io/latest/guide/index.html#lombok) + +- [https://projectlombok.org/features/all](https://projectlombok.org/features/all) + + +## Feature maven-enforcer-plugin documentation + +- [https://maven.apache.org/enforcer/maven-enforcer-plugin/](https://maven.apache.org/enforcer/maven-enforcer-plugin/) + + +## Feature validation documentation + +- [Micronaut Validation documentation](https://micronaut-projects.github.io/micronaut-validation/latest/guide/) + + diff --git a/cli-exemplar/micronaut-cli.yml b/cli-exemplar/micronaut-cli.yml new file mode 100644 index 0000000..a0e34ae --- /dev/null +++ b/cli-exemplar/micronaut-cli.yml @@ -0,0 +1,6 @@ +applicationType: cli +defaultPackage: org.saltations.mn4 +testFramework: junit +sourceLanguage: java +buildTool: maven +features: [app-name, http-client-test, java, junit, logback, lombok, maven, maven-enforcer-plugin, picocli, picocli-java-application, picocli-junit, readme, serialization-jackson, shade, validation, yaml, yaml-build] diff --git a/cli-exemplar/mvnw b/cli-exemplar/mvnw new file mode 100755 index 0000000..8822887 --- /dev/null +++ b/cli-exemplar/mvnw @@ -0,0 +1,287 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.1.1 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="`/usr/libexec/java_home`"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + printf '%s' "$(cd "$basedir"; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname $0)") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) wrapperUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $wrapperUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + QUIET="--quiet" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + QUIET="" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" + fi + [ $? -eq 0 ] || rm -f "$wrapperJarPath" + elif command -v curl > /dev/null; then + QUIET="--silent" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + QUIET="" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L + fi + [ $? -eq 0 ] || rm -f "$wrapperJarPath" + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaSource="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=`cygpath --path --windows "$javaSource"` + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/cli-exemplar/mvnw.bat b/cli-exemplar/mvnw.bat new file mode 100644 index 0000000..1d7c59b --- /dev/null +++ b/cli-exemplar/mvnw.bat @@ -0,0 +1,187 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.1.1 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/cli-exemplar/pom.xml b/cli-exemplar/pom.xml new file mode 100644 index 0000000..02ca019 --- /dev/null +++ b/cli-exemplar/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + org.saltations.exemplar + cli-exemplar + ${packaging} + + + org.saltations.exemplar + exemplar-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + + jar + org.saltations.mn4.Mn4CliExemplarCommand + + + + + + io.micronaut.picocli + micronaut-picocli + compile + + + io.micronaut + micronaut-inject + compile + + + io.micronaut.serde + micronaut-serde-jackson + compile + + + io.micronaut.validation + micronaut-validation + compile + + + jakarta.validation + jakarta.validation-api + compile + + + ch.qos.logback + logback-classic + runtime + + + org.projectlombok + lombok + provided + + + io.micronaut + micronaut-http-client-jdk + compile + + + io.micronaut.test + micronaut-test-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + + + io.micronaut.maven + micronaut-maven-plugin + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + micronaut-enforce + none + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + + + org.projectlombok + lombok + ${lombok.version} + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + info.picocli + picocli-codegen + ${picocli.version} + + + io.micronaut + micronaut-graal + ${micronaut.core.version} + + + io.micronaut.serde + micronaut-serde-processor + ${micronaut.serialization.version} + + + io.micronaut + micronaut-inject + + + + + io.micronaut.validation + micronaut-validation-processor + ${micronaut.validation.version} + + + io.micronaut + micronaut-inject + + + + + + -Amicronaut.processing.group=org.saltations.mn4 + -Amicronaut.processing.module=cli-exemplar + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + + prepare-agent + + + + report + test + + report + + + + + + + + diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/ExemplarFailureType.java b/cli-exemplar/src/main/java/org/saltations/mn4/ExemplarFailureType.java new file mode 100644 index 0000000..f44ff20 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/ExemplarFailureType.java @@ -0,0 +1,29 @@ +package org.saltations.mn4; + +import lombok.Getter; +import org.saltations.endeavour.FailureType; + +@Getter +public enum ExemplarFailureType implements FailureType +{ + GENERIC("generic-failure", "A generic failure occurred"), + GENERIC_EXCEPTION("generic-exception-failure", "An exception occurred: {0}"); + + private final String title; + private final String template; + + ExemplarFailureType(String title, String template) { + this.title = title; + this.template = template; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public String getTemplate() { + return template; + } +} \ No newline at end of file diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/IPCommand.java b/cli-exemplar/src/main/java/org/saltations/mn4/IPCommand.java new file mode 100644 index 0000000..5b8b4d7 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/IPCommand.java @@ -0,0 +1,26 @@ +package org.saltations.mn4; + +import java.util.concurrent.Callable; + +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; + +import io.micronaut.core.annotation.Introspected; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; +import jakarta.inject.Singleton; + +@Slf4j +@Singleton +@Introspected +@Command(name = "ip", + description = "IP address lookup command", + mixinStandardHelpOptions = true) +public class IPCommand implements Callable> { + + @Override + public Result call() { + // business logic here + return Try.success(0); + } +} \ No newline at end of file diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/Mn4CliExemplarCommand.java b/cli-exemplar/src/main/java/org/saltations/mn4/Mn4CliExemplarCommand.java new file mode 100644 index 0000000..04b53a8 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/Mn4CliExemplarCommand.java @@ -0,0 +1,64 @@ +package org.saltations.mn4; + +import java.util.concurrent.Callable; + +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; + +import io.micronaut.configuration.picocli.MicronautFactory; +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.annotation.Introspected; +import jakarta.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Slf4j +@Singleton +@Introspected +@Command(name = "mn4-cli-exemplar", description = "Exemplar application of preferred best practices for micronaut commandline applications", mixinStandardHelpOptions = true, subcommands = { + TZCommand.class, + IPCommand.class +}) +public class Mn4CliExemplarCommand implements Callable> { + + @Option(names = { "-v", "--verbose" }, description = "...") + boolean verbose; + + public static void main(String... args) throws Exception { + int exitCode = run(args); + + System.exit(exitCode); + } + + public static int run(String... args) throws Exception { + int exitCode; + try (ApplicationContext ctx = ApplicationContext.run()) { + + var root = ctx.createBean(Mn4CliExemplarCommand.class); + + // Create a new CommandLine instance with the root command and the Micronaut + // factory + + var factory = new MicronautFactory(ctx); + var executionStrategy = factory.create(RunAllStopOnError.class); + + var cmdLine = new CommandLine(root, factory); + cmdLine.setExecutionStrategy(executionStrategy); + + exitCode = cmdLine.execute(args); + } + return exitCode; + } + + @Override + public Result call() { + // business logic here + if (verbose) { + System.out.println("Hi!"); + } + return Try.success(0); + } + +} diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/RunAllStopOnError.java b/cli-exemplar/src/main/java/org/saltations/mn4/RunAllStopOnError.java new file mode 100644 index 0000000..bbecf09 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/RunAllStopOnError.java @@ -0,0 +1,127 @@ + +package org.saltations.mn4; + +import java.util.ArrayList; +import java.util.concurrent.Callable; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.validation.validator.Validator; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine; +import picocli.CommandLine.ExitCode; +import picocli.CommandLine.IExecutionStrategy; + + +/** + * This is a custom execution strategy that will execute all commands in the chain, and stop on the first error. + * It is used to ensure that all commands in the chain are executed, and that the first error is propagated. + *

Note

+ * This should only be used for commands and subcommands that implement Callable>. + */ + +@Slf4j +@Introspected +@Singleton +public class RunAllStopOnError implements IExecutionStrategy { + + private final Validator validator; + + @Inject + public RunAllStopOnError(Validator validator) { + this.validator = validator; + } + + @Override + public int execute(CommandLine.ParseResult pr) throws CommandLine.ExecutionException { + + var helpExit = CommandLine.executeHelpRequest(pr); + + if (helpExit != null) + { + return ExitCode.OK; + } + + var cmdChain = pr.asCommandLineList(); + + // Validate each populated command object in the command chain before execution. + + Result validationResult = null; + + for (var cmdLine : cmdChain) { + + var userObj = cmdLine.getCommandSpec().userObject(); + + // Bail if the command class does not implements Callable + + if (!(userObj instanceof Callable)) { + log.error("Command class for {} does not implement Callable", cmdLine.getCommandSpec().name()); + return ExitCode.SOFTWARE; + } + + // Bail if the user object can be validated and is not valid + // Perform bean validation if a Validator is available on the classpath + + try { + // Call validator.validate(userObj) + + var violations = validator.validate(userObj); + + if (!violations.isEmpty()) { + + var message = violations.stream() + .map(v -> v.getPropertyPath() + " " + v.getMessage()) + .collect(Collectors.joining(", ")); + + log.error("Validation errors for {}: {}", cmdLine.getCommandSpec().name(), message); + return ExitCode.SOFTWARE; + } + + } catch (Exception e) { + log.error("Error validating command class for {}: {}", cmdLine.getCommandSpec().name(), e); + return ExitCode.SOFTWARE; + } + } + + // Execute the command chain and handle the results + + var results = new ArrayList>(); + + for (var cmdObj : cmdChain) { + + var result = cmdObj.getCommandSpec().userObject(); + + // Execute the command and check if it returns an Outcome object + + try { + + @SuppressWarnings("unchecked") + var outcome = ((Callable>) result).call(); + + // Bail if the command does not return an Outcome object + if (!(outcome instanceof Result)) { + log.error("Command does not return an operating result of Result type"); + return ExitCode.SOFTWARE; + } + + // Evaluate the outcome + + var operatingResult = ((Result) outcome); + results.add(operatingResult); + + } catch (Exception e) { + log.error("Error checking command return type for {}: {}", cmdObj.getCommandSpec().name(), e.getMessage()); + return ExitCode.SOFTWARE; + } + } + + return ExitCode.OK; + } + +} \ No newline at end of file diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/TZCommand.java b/cli-exemplar/src/main/java/org/saltations/mn4/TZCommand.java new file mode 100644 index 0000000..34608e2 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/TZCommand.java @@ -0,0 +1,26 @@ +package org.saltations.mn4; + +import java.util.concurrent.Callable; + +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; + +import io.micronaut.core.annotation.Introspected; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; +import jakarta.inject.Singleton; + +@Slf4j +@Singleton +@Introspected +@Command(name = "tz", + description = "WorldTimeAPI Timezone query command", + mixinStandardHelpOptions = true) +public class TZCommand implements Callable> { + + + @Override + public Result call() { + return Try.success(0); + } +} \ No newline at end of file diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/TZListTimezonesCommand.java b/cli-exemplar/src/main/java/org/saltations/mn4/TZListTimezonesCommand.java new file mode 100644 index 0000000..05663ff --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/TZListTimezonesCommand.java @@ -0,0 +1,34 @@ +package org.saltations.mn4; + +import java.util.concurrent.Callable; + +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; + +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; + +@Slf4j +@Command(name = "ls", + description = "List Timezones", + mixinStandardHelpOptions = true) +public class TZListTimezonesCommand implements Callable> +{ + @Inject + private WorldTimeApiClient client; + + @Override + public Result call() { + // business logic here + var timezones = client.listTimezones(); + + System.out.println("Timezones:"); + + for (String timezone : timezones) { + System.out.println(timezone); + } + + return Try.success(0); + } +} diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/TimezoneService.java b/cli-exemplar/src/main/java/org/saltations/mn4/TimezoneService.java new file mode 100644 index 0000000..9f979e6 --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/TimezoneService.java @@ -0,0 +1,29 @@ +package org.saltations.mn4; + +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class TimezoneService { + + private final WorldTimeApiClient client; + + @Inject + public TimezoneService(WorldTimeApiClient client) { + this.client = client; + } + + public List getAllTimezones() { + return client.listTimezones(); + } + + public Map getTime(String area, String location) { + return client.getTimeForLocation(area, location); + } +} diff --git a/cli-exemplar/src/main/java/org/saltations/mn4/WorldTimeApiClient.java b/cli-exemplar/src/main/java/org/saltations/mn4/WorldTimeApiClient.java new file mode 100644 index 0000000..ce0719e --- /dev/null +++ b/cli-exemplar/src/main/java/org/saltations/mn4/WorldTimeApiClient.java @@ -0,0 +1,22 @@ +package org.saltations.mn4; + + +import io.micronaut.http.annotation.Get; +import io.micronaut.http.client.annotation.Client; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; +import java.util.Map; + +@Client("https://worldtimeapi.org/api") +public interface WorldTimeApiClient { + + @Get("/timezone") + List listTimezones(); + + @Get("/timezone/{area}") + List listLocationsInArea(@NotBlank String area); + + @Get("/timezone/{area}/{location}") + Map getTimeForLocation(@NotBlank String area, @NotBlank String location); +} diff --git a/cli-exemplar/src/main/resources/application.yml b/cli-exemplar/src/main/resources/application.yml new file mode 100644 index 0000000..c5b6850 --- /dev/null +++ b/cli-exemplar/src/main/resources/application.yml @@ -0,0 +1,15 @@ +micronaut: + application: + name: mn4-cli-exemplar + http: + services: + worldtimeapi: + url: "https://worldtimeapi.org/api" + client-configuration: + http-version: HTTP_2 + read-timeout: 10s + client: + default-client: + type: jdk + read-timeout: 5s + connection-timeout: 5s diff --git a/cli-exemplar/src/main/resources/logback.xml b/cli-exemplar/src/main/resources/logback.xml new file mode 100644 index 0000000..2d77bda --- /dev/null +++ b/cli-exemplar/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + diff --git a/cli-exemplar/src/test/java/org/saltations/mn4/Mn4CliExemplarCommandTest.java b/cli-exemplar/src/test/java/org/saltations/mn4/Mn4CliExemplarCommandTest.java new file mode 100644 index 0000000..19b6e2c --- /dev/null +++ b/cli-exemplar/src/test/java/org/saltations/mn4/Mn4CliExemplarCommandTest.java @@ -0,0 +1,149 @@ +package org.saltations.mn4; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class Mn4CliExemplarCommandTest { + + private ByteArrayOutputStream outContent; + private ByteArrayOutputStream errContent; + private PrintStream originalOut; + private PrintStream originalErr; + + @BeforeEach + void setUp() { + outContent = new ByteArrayOutputStream(); + errContent = new ByteArrayOutputStream(); + originalOut = System.out; + originalErr = System.err; + System.setOut(new PrintStream(outContent)); + System.setErr(new PrintStream(errContent)); + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + System.setErr(originalErr); + } + + @Test + public void testMainCommandWithoutOptions() throws Exception { + + int exitCode = Mn4CliExemplarCommand.run(); + + assertEquals(0, exitCode); + } + + @Test + public void testMainCommandWithVerboseOption() throws Exception { + String[] args = new String[] { "-v" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + assertTrue(outContent.toString().contains("Hi!")); + } + + @Test + public void testMainCommandWithLongVerboseOption() throws Exception { + String[] args = new String[] { "--verbose" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + assertTrue(outContent.toString().contains("Hi!")); + } + + @Test + public void testHelpOption() throws Exception { + + var args = new String[] { "--help" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + assertTrue(outContent.toString().contains("mn4-cli-exemplar")); + assertTrue(outContent.toString().contains("Usage:")); + } + + @Test + public void testTZSubcommand() throws Exception { + String[] args = new String[] { "tz" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + } + + @Test + public void testTZSubcommandWithHelp() throws Exception { + String[] args = new String[] { "tz", "--help" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + assertTrue(outContent.toString().contains("tz")); + assertTrue(outContent.toString().contains("WorldTimeAPI Timezone query command")); + } + + @Test + public void testIPSubcommand() throws Exception { + String[] args = new String[] { "ip" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + } + + @Test + public void testIPSubcommandWithHelp() throws Exception { + String[] args = new String[] { "ip", "--help" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + assertTrue(outContent.toString().contains("ip")); + assertTrue(outContent.toString().contains("IP address lookup command")); + } + + @Test + public void testInvalidSubcommand() throws Exception { + String[] args = new String[] { "invalid" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + // Should return non-zero exit code for invalid subcommand + assertTrue(exitCode != 0); + } + + @Test + public void testInvalidOption() throws Exception { + String[] args = new String[] { "--invalid-option" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + // Should return non-zero exit code for invalid option + assertTrue(exitCode != 0); + } + + @Test + public void testMultipleOptions() throws Exception { + String[] args = new String[] { "-v", "--help" }; + + int exitCode = Mn4CliExemplarCommand.run(args); + + assertEquals(0, exitCode); + // Help should be displayed, but verbose output should not appear + assertTrue(outContent.toString().contains("Usage:")); + assertFalse(outContent.toString().contains("Hi!")); + } + +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..62072b5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,203 @@ + + + 4.0.0 + + + org.saltations.exemplar + exemplar-parent + 1.0.0-SNAPSHOT + pom + + Micronaut Exemplar Parent + Parent project for Micronaut Exemplar modules. These are intended to be used as a starting point for new Micronaut projects. + + + io.micronaut.platform + micronaut-parent + 4.8.2 + + + + + cli-exemplar + rest-exemplar + + + + scm:git:https://github.com/jmochel/endeavour.git + scm:git:https://github.com/jmochel/endeavour.git + https://github.com/jmochel/endeavour + HEAD + + + + + + 21 + 21 + UTF-8 + + + 1.18.36 + 1.5.12 + 5.11.3 + 3.12.0 + 33.3.1-jre + 4.7.7 + 4.8.2 + + + 3.2.5 + 0.8.12 + + + + + + + central + https://repo.maven.apache.org/maven2 + + + github + GitHub Packages - jmochel/endeavour + https://maven.pkg.github.com/jmochel/endeavour + + + + + + + + + org.projectlombok + lombok + ${lombok.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + + + org.assertj + assertj-core + ${assertj.version} + + + com.google.guava + guava + ${guava.version} + + + info.picocli + picocli + ${picocli.version} + + + org.yaml + snakeyaml + 2.3 + + + org.saltations.endeavour + endeavour + 0.3.0 + + + + + + + org.saltations.endeavour + endeavour + 0.2.0 + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + **/*Test.java + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + prepare-agent + + + + report + test + + report + + + + + + + + org.apache.maven.plugins + maven-release-plugin + 3.0.1 + + v@{project.version} + release + true + clean verify + -DskipTests=false -B -ntp + true + [release] + + + + + + org.codehaus.mojo + versions-maven-plugin + 2.17.1 + + + + + + + + github + GitHub Packages + https://maven.pkg.github.com/jmochel/endeavour + + + github + GitHub Packages + https://maven.pkg.github.com/jmochel/endeavour + + + + diff --git a/rest-exemplar/Readme.md b/rest-exemplar/Readme.md new file mode 100644 index 0000000..718d9de --- /dev/null +++ b/rest-exemplar/Readme.md @@ -0,0 +1,617 @@ +Structure and Design +--------------------- + +# 0. Problem Domain + +## Events + +* find person +* add person +* add person to course as student +* add person to course as auditing +* add person to court as instructor +* add point of contact to person +* remove point of contact from person +* get current class list +* remove person from course +* change person from auditor to student +* change person from student to auditor +* add course +* remove course + +## Date Model: Entities and VOs + +Each course is an entity, and it is composed of one of more sessions. +Each course, of course, has associated with it people who are participating in the course, +and each session has associated people who participated in the course. +Each person has is composed of a set of points of contact. + +# 1. Project Structure + +The Separation of concerns and naming of layers is intended to be consistent with Clean Architecture + +```text +/src/java/ + /main/org/../mre/ + /app - Application wide classes, such as the actual app or micro-service entry points + /common - Common code in classes for the entire application or micro-service + /core - core classes used everywhere (exceptions, values) and not specific to the business domain + /domain - common classes used in modeling entities,, aggregates, and value objects + /application - common classes used in modeling application specific business logic, such as use cases, services, etc. + /infrastructure - common classes used in modeling infrastructure, such as identity management, logging, etc. + /presentation - common classes used in modeling entry points into the system, such as controllers and event handlers + /domain - project wide business domain objects (entities, aggregates, value objects) + /feature1 + Feature1Factory + Feature1Service + Feature1Event1 + Feature1Event2 + Feature1Controller + Feature1UseCase1 + Feature1UseCase2 +``` + +# Implementation Notes + +## Domain modeling + +Domain package contains the entities, aggregates, and value objects make up the +data and basic business aggregates. + +Entities are modeled using a pattern that has an interface for the concept being modeled, a core class that +implements that interface and an entity that inherits from that core class and adds the entity identity and other specific +metadata to the entity. + +For example: +```text + Course (interface) + String getName + void setName(String name) + String getDescription + void setDescription(String description) + + CourseCore implements Course + - name : string + - description: string + + CourseEntity extends CourseCore + - id : uuid + - createdAt : datetime + - updatedAt : datetime +``` + +This allows CourseCore to be used as an input object for a controller while being easily +mapped to an entity and back again. + + + + + + + + +### REST Controllers + +Should always ensure that the domain model and the domain business logic are not leaked _as-is_ to the interfaces. + +* So what should you expose in the REST controller? +* + +How should you group what you expose in the REST controller? + +* Group/Namespacing of APIs - Teams/groups/business units generally group APIs together and make the grouping evident in the API URI +* Bounded Context `/)` - For example, `v1/ payments` +* API Product Boundary using + * Aggregate + * Service + +How should you name what you expose in the REST controller? + + + + +### Message Consumers + + +## Layering/Packaging + +* Does your clean architecture based app needs a separate application layer? + * _No_ if it is basically managing CRUD operations, then it is not necessary. A controller could call the repository directly without losing maintainability. + * _Maybe Not_ if there is business logic but it is handled inn the domain model;, then it may not be necessary. The domain model could be called directly from the controller. + * _Defintely_ if it goes outside the normal CRUD operations AND the business logic is not in the domain model. This is where the application layer comes in. It is the orchestrator of the domain model and the data access layer. +* Is it OK to have a Mapper being used in the Controller ? + * It is a stretch. If it is a simple mapping to and from the incoming request or response, then it is OK. But if it is a complex mapping, then it should be in the application layer. +* Consider having the crud operations in a CRUD Controller (just uses repo) and the business logic in a Business Controller (uses services or use cases). + +# Micronaut Exemplar + + + + +For review +------------ +[Project Structure](https://wkrzywiec.medium.com/ports-adapters-architecture-on-example-19cab9e93be7) +[Project Structure](https://medium.com/unil-ci-software-engineering/clean-ddd-lessons-project-structure-and-naming-conventions-00d0b9c57610) +[Onion Architecture](https://www.infoq.com/articles/architecture-onion-architecture/) + + +[Gradle Test Suites](https://blog.gradle.org/introducing-test-suites) + +[TOML Cheatsheet](https://quickref.me/toml.html) + +[Night Config](https://github.com/TheElectronWill/night-config/wiki/Configurations) + +[Try Paradigm](https://blog.softwaremill.com/exceptions-no-just-try-them-off-497984eb470d) + +**Entity** +: A trackable object in the domain that is defined by its identity. + +**Value Object** +: An object That has no identity and is defined by its attributes + +**Domain Event** +: An event that represents a change in the domain + +**Aggregate/Aggregate Root** +: A cluster of domain objects (entities and value objects) that can be treated as a single unit based on rules for +consistency. It typically encapsulates the group of objects and manages their behavior. Access to internal objects is +typically only possible through the aggregate route in order to ensure the integrity and consistency of the data. + +**Service** +: An object that perform a specific task for the application. What distinguishes the different types of services are the roles and the language. + +Questions to ask are: +* If we remove this, will it affect the execution of my domain model? If yes, it is Probably a domain service +* If we remove this, will it affect the execution of my application? If yes, it is Probably an infrastructure service +* If we remove this, will the customer be able to talk to us? If no, it is an application service. + + +**Infrastructure Service** +: An object that embodies (typically as verbs) activities that fulfill the infrastructure concerns, such as sending emails and logging meaningful data + +A good example is a notification service implementation. An infrastructure service does not make business decisions. In the domain layer, +you define an interface with actions we want to have such as `sendEmailAboutLoan` and in the service we implement it. + +Crosscutting concerns that often involve infrastructure services + +* [ ] Logging +* [ ] Configuration +* [ ] Security +* [ ] Monitoring +* [ ] Tracing +* [ ] Resilience + +**Domain Service** +: A stateless object that embodies (typically as verbs) and/or operates upon domain concepts. Common examples of domain services are `Use Cases` + +Domain services tend to be very granular. They contain domain logic (business decisions) that can't naturally be placed in +an entity or value object. Domain service methods can have other domain elements as parameters and return values. Domain service +implementations can also have infrastructure services as parameters or part of their construction. Domain services should be used +cautiously as they can lead to an anemic domain model. + +> When a significant process or verb in the domain is not the natural responsibility of an Aggregate, Entity or Value Object +> add an operation to the model as a standalone interface declared as a Service. Define the interface in terms of the language +> of the model and make sure the operation name is part of the ubiquitous language. Make the Service stateless. + +**Application Service** +: A service that provides a hosting environment for the execution of domain logic and serves as a gateway to expose the +functionality of the domain to clients as an API. + +* Operates on scalar types or DTOs, transforming them into domain types. +* Application service objects are "command" objects (GET, PUT, etc..) +* Typically responsible for fetching input data from outside of the domain +* Returns information about a result of the action, listens for an answer and decides if an event or message should be sent. +* Application services declared dependencies on infrastructure services required to execute domain logic. + +**Factory** +: A domain service that handles the beginning of a domain object's life. Answers the question: How do I make this? + +> Whenever there is **exposed complexity** in creating or reconstituting an object from another medium, the factory is a likely option + +**Repository** +: A domain service that handles the persistence of domain objects. Answers the question: How do I store and retrieve this? + +This really is a domain service. The interface is defined within the domain. + +**Controller** +: An application service that handles HTTP requests and responses. + +**Presenters** +: An object that formats data for the view + +You can notice a pattern in most code bases that adhere to such a guideline. Their execution flow goes as follows: + +Prepare all information needed for a business operation: load participating entities from the database and retrieve any required data from other external sources. + +Execute the operation. The operation consists of one or more business decisions made by the domain model. Those decisions result in either changing the model’s state, generating some artifacts (amountWithCommission value in the sample above), or both. + +Apply the results of the operation to the outside world. + + +The question to ask is - is sending an email an important domain concept? Is Email an entity in your domain? If so, you may have an interface for an email sender defined in your domain layer, with a separated implementation in the infrastructure layer, + + + +Project structure +----------------- +A mixture of package by feature and package by layer + +Pros + +* Higher modularity; +* Easier code navigation; +* Higher level of abstraction; +* Separates both features and layers; +* More readable and maintainable structure; +* More cohesion; +* Much easier to scale; +* Less chance to accidentally modify unrelated classes or files; +* Much easier to add or remove application features; +* And much more reusable modules. + + + +Functional Requirements +======================= + +Acceptance Criteria +------------------- +Acceptance Criteria are a way of describing your functional requirements in a Given/When/Then format. + +1. Directly translate into Given/When/Then format: + * When: A candidate Requests an ability to be assessed + * Then: The candidates ability is assessed +2. Scrutinize the pre-conditions - After this first round of interrogation, this will immediately flesh out the happy path to look like this. + * Given: There is an expert to assess Ability ‘A’ + * And: There is a pre-registered candidate ‘123’ + * When: Candidate ‘123’ requests to be assessed in Ability ‘A’ + * Then: He is assessed by the ‘expert’ randomly generating a number between 0–10 + These then fan out to additional ACs +3. Scrutinize the outcomes +4. Bring it to the team +5. Estimation +6. Move to your implementation board. + + +The Hard Thing : Naming +======================== + +# Functional naming + +A “FUNCTIONAL NAME” describes an object’s purpose and tends to create a mental image of the object in the operator’s mind + +# GUIDELINES: + +1. Functional names are brief, simple, and familiar to the operator. +2. Don’t use similar sounding words that can be confused with other equipment. +3. If possible, avoid acronyms. +4. Do not use tradenames, brands, or trademarks. (If a tradename, brand, or trademark is already in use and is clearly understood by the operators, consider it as an exception to this guide.) +5. A functional name for a system or component describes the purpose of the system or component. +6. A functional name for a display or instrumentation describes what is being measured or displayed. +7. A functional name for a control indicates the purpose of the control. +8. Do not use the same name for two separate systems or components within a workplace. +9. In facilities with identical systems or units, use identical functional names with unit or system identifiers (for example three identical systems could be labeled A, B, and C). + +In the case of naming, clarity is more important than shortness + +A name should clearly without ambiguity indicate what the object is or what a function does +Variables are nouns + +* created_at rather than created + +Function is an action so the name should contain at least one verb and should allow the code +to read as a sentence + +* `write(filename)` means write to file +* `reloadTableData()` means to reload table data +* `isBirthDay()` means checking if today is birth day +* `getAddress()` means getting the address +* `setAddress(address)` means setting an address + +Use the same verb for the same action + +A function + +* Answers a question or provides information +* Changes the state of the object +* Executes a task and returns the result + +Make your code short, concise and read as interesting stories + + +CA Layers +From the inside to he outside + +Entities encapsulate Enterprise wide business rules and data + +Use Case this layer contains application specific business rules + +Interface Adapters The software in this layer is a set of adapters that convert data from the format most convenient for the use cases and entities, +to the format most convenient for some external agency such as the Database or the Web + +Frameworks and Drivers.composed of frameworks and tools such as the Database, the Web Framework, etc. +Generally you don’t write much code in this layer other than glue code that communicates to the next circle inwards + +Source code dependencies always point inwards. As you move inwards the level of abstraction increases. The outermost circle is low level concrete detail. +As you move inwards the software grows more abstract, and encapsulates higher level policies. The innermost circle is the most general + +What gets composed into this +---------------------------- + +Advantages of Proper Architecture +* Testable +* Maintainable +* Changeable +* Easy to Develop +* Easy to Deploy +* Independent + + + +Key elements of the Clean Architecture are: Separation of concerns, Dependency Rule, and Boundaries +* Each part of the application should be independent of each other +* The Dependency Rule: Source code dependencies must point inwards only +* Boundaries: The software is separated into layers with each layer having a specific responsibility + +* Entities - least subject to changes when the application evolves +* Use Cases - contains application specific business rules +* Interface Adapters - contains the adapters that convert data from the format most convenient for the use cases and entities, to the format most convenient for some external agency such as the Database or the Web + +Controllers, Presenters, Views, and Gateways are all examples of Interface Adapters. +They are the input and output mechanisms of the use cases and entities. + +Controller is an adapter that converts HTTP requests and responses to method calls, and vice versa. + + + +```text +/com + /exemplar + /core + /infrastructure + + /app + App.java + /feature + /feature1 + Feature1Controller.java + Feature1Service.java + Feature1Repo.java + /feature2 + Feature2Controller.java + Feature2Service.java + Feature2Repo.java + /app + App.java + /feature + /feature1 + Feature1Controller.java + Feature1Service.java + Feature1Repo.java + /feature2 + Feature2Controller.java + Feature2Service.java + Feature2Repo.java +``` + +Clean Architecture Structure + +```text +/src + /main + /java + /com + /exemplar + /somapp + /core -- hexagon inside. Should not depend on infrastructure + /model + /person -- aggregate root + /place -- aggregate root + /port + /usecase + /infrastructure -- hexagon outside +``` + +CRUD Layers +=========== + +| | | | +|-------------------------------------------------------------------------------|----------------------------------------------|--------------------------------------------| +| **Controller** (Isolated Test and Integration Tests) | **Service** (Isolated Tests) | **Repo** (Live Test) | +| `POST /resources (Req) : Resp>) : Resp` | `create(List): Result` | `insert(List): Result` | +| `PUT /resources/1 (Req): Resp>) : Resp` | `replace(List): Result` | `update(List): Result` | +| `PATCH /resources/1 (Req): Resp>) : Resp` | `modify(List): Result` | `update(List): Result` | +| `GET /resources/1 (): Resp): Resp` | `find(List): Result` | `find(List): Result` | +| `DELETE /resources/1 (): Resp): Resp` | `delte(List): Result` | `delete(List): Result` | +| `GET /resources?fld1=val,fld2val&sort=+fld1,-fld2&fields=fld1,fld2` | `find(Query): Result` | `find(Query): Result` | + + +REST Nouns, Verbs, protocols +=========== + +## Nouns (Resources) +Plurals for Aggregates and Standalone entities + +Sub collections for VOs + +/users/{userId}/phones +/users/{userId}/phones/1 +/users/{userId}/emails + +For example, the process of setting up a new customer in a banking domain can be modeled as a resource. CRUD is just a minimal business process applicable to almost any resource. This allows us to model business processes as true resources which can be tracked in their own right. + +It is very important to distinguish between resources in REST API and domain entities in a domain driven design. Domain driven design applies to the implementation side of things (including API implementation) while resources in REST API drive the API design and contract. API resource selection should not depend on the underlying domain implementation details + +Use of PUT for complex state transitions can lead to synchronous cruddy CRUD + + +Controller Layer +=========== + +The controller layer is responsible for + +1. Authentication/Authorization of operations + 2. In general permissions look like `app-id.resource-id.permission` so we could have `exemplar.person.read` or `exemplar.person.write` + 3. APIs should stick to component specific permissions without resource extension to avoid the complexity of too many fine grained permissions. For the majority of use cases, restricting access for specific API endpoints using read or write is sufficient. +2. Mapping incoming values such as JSON into the appropriate domain objects +1. Providing the correct REST semantics POST,PUT,PATCH,GET, and DELETE +1. Exposing the domain model in a way that reflects the nouns and verbs of the domain. + +The EntityControllerBase provides us with CRUD semantics for most of the domain and CRUD semantics alone are +usually insufficient for a real world application + +The default entity controller will expose the following CRUD REST operations + + +* PUT Replace resource(s) + * POST /users + * BODY single prototype + * RESP 200 or 201 + * PAYLOAD create resource + * POST /users (single prototype in body) + * BODY multiple prototypes + * RESP 200 or 201 + * PAYLOAD create resources + * PUT /users/1 + * BODY single resource + * RESP 200 or 201 + * PAYLOAD updated resource + * PUT /users + * BODY multiple resource + * RESP 207 + * PAYLOAD multiple updated resource + * PUT /users/1?name=changed&age=21 + * BODY updated resource + * RESP 200 or 201 + * PAYLOAD updated resource + +* PATCH Modify resource(s) + * BODY RFC 7396 Merge Patch + * RESP 200 or 201 + * PAYLOAD updated resource + +* GET Fetch resource(s) + * GET /users/1 - Fetch User 1 + * GET /users - Fetch many + +* DELETE Delete a resource + * DELETE /users/1 + * RESP 200 or 201 + +* HEAD fetch meta-info as a header + * HEAD /users + +* OPTIONS Fetch all verbs that are allowed for the endpoint + * OPTIONS /users + + +### Service Layer + +The service layer is responsible for + +1. Business operations in the language of the business domain. +2. Transactional boundaries +3. Providing the business domain semantics + +### Repo Layer + +1. X Count of all +2. X Insert one +2. X Insert many +3. X Update one +4. X Update many +5. X Exists by id +5. X Find one by id +6. X Find many by ids +7. Find many by criteria +7. X Delete by id +9. X Delete by ids +10. X Delete all + +#### Resource Naming + +Use domain language + +Use plural form and kebab case i.e. `../shipped-orders/{id}` + +Identify sub resources via path segments i.e. `/resources/{resource-id}/sub-resources/{sub-resource-id}` + + + +Keep URLs verb free. Instead of thinking of actions (verbs), it’s often helpful to think about putting a message in a letter box: e.g., instead of having the verb cancel in the url, think of sending a message to cancel an order to the cancellations letter box on the server side + +#### Error Codes +The following are the common error code returns + +* 400 Bad Request Typically due to client error, such as a malformed, request, syntax, invalid, syntax, or invalid request +* 401 Unauthorized – actually Unauthenticated. +* 403 Forbidden – actually unauthorized. User is not authorized to perform this operation on that resource +* 404 Not found – this week we returned on any path for endpoints when it is logically expected, that resource would be returned +* 405 Method not allowed – typically you tried to do something like a delete against the URL. That does not support it. +* 409 Conflict + +#### `POST .../[resource]/` +* With JSON Body containing all attributes needed to create the resource (No metadata like id) + * snake_case names for json attributes +* Returns + * 200 and instance of the resource + * ??? 201 , no response payload, link in header + +#### `PUT ../[resource]/{id}` +* With JSON Body containing all attributes used to replace the resource (including id) +* Returns 200 and updated instance of the resource + +#### `PATCH ../[resource]/{id}` +* With JSON Body containing [JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7386) +* Content Type of "application/merge-patch+json" +* Returns 200 and updated instance of the resource + +#### `GET ../[resource]/{id}` +* With no request payload, only query parameters +* Content Type of "application/json" +* Returns + * 200 and updated instance of the resource + * 404 If resource does not exist + +#### `GET ../[resource]?q=xxx&fields=id,name,description&sort=+id,-name +* With no request payload, only query parameters +* Content Type of "application/json" +* Returns + * 200 and a collection of the resources + * 200 if collection empty + * 404 if collection does not exist (for named collections) + +## To Do + +* Example of GUIDs as resource ids. +* Add optimistic locking support (PUT) +* Add example of POST returning link in HEADER for created resource [zalando](https://opensource.zalando.com/restful-api-guidelines/#http-requests) +* Add example of POST of multiple elements returning a 207 [zalando](https://opensource.zalando.com/restful-api-guidelines/#http-requests) +* Add example of Async GET call +* Add example Async POST creation call + * Examples of POST and POLL + * POST and Callback + * +* Add example Async PUT call +* Add example Async PATCH call +* Add example PATCH using [JSON Patch](https://tools.ietf.org/html/rfc6902) +* Add example sophisticated query , filter and search +* Add swagger or other does for Standard client and server errors, e.g. 401 (unauthenticated), 403 (unauthorized), 404 (not found), 500 (internal server error), or 503 (service unavailable) +* Add example 7807 problem display controller , including languages +* + +### 2. Error handling + +References+ + +https://gaetanopiazzolla.github.io/java/2023/03/05/java-exception-patterns.html +https://www.freecodecamp.org/news/write-better-java-code-pattern-matching-sealed-classes/ +https://softwaremill.com/functional-error-handling-with-java-17/ +https://softwareengineering.stackexchange.com/questions/339088/pattern-for-a-method-call-outcome + +https://github.com/armtuk/java-functional-adapter/blob/develop/src/main/java/com/plexq/functional/Success.java diff --git a/rest-exemplar/architecture-and-project-layout.md b/rest-exemplar/architecture-and-project-layout.md new file mode 100644 index 0000000..37ddb5d --- /dev/null +++ b/rest-exemplar/architecture-and-project-layout.md @@ -0,0 +1,69 @@ +# Semi-Clean Architecture and Folder Layout + +## Clean Architecture Layers + +From inner to outer layers we have : + +- Domain +- Application +- Controllers/Engines +- Infrastructure +- Configuration + +### Domain Layer (Enterprise Business Logic and Entitie) + +- Defines enterprise wide business logic + - Exposes Enteprise Wide Domain Services and/or Use Cases (very rare) + - Exposes Aggregates, Entities, Value Objects + +### Application Layer (Application Business logic) + +- Uses Domain Layer +- Implements and exposes Application Domain Services and/or Use Cases +- Exposes needed infrastructure Ports (expectations) used by Application Domain Services amd/or Use Cases such as + - Repository Port (e.g. `PersonRepoPort`) + - External Service Ports (e.g. `DirectMessengerPort`) + +### Controllers/Engines Layer + +- Uses Application Layer and Domain layers +- Implements Engines that connect outside world to application + - REST Controllers and corresponding objects + - Scripting Engines and corresponding objects + - Event Handlers and corresponding objects +- Defines Ports used only in this layer (e.g. `ObjectMapperPort`) + +### Infrastructure Layer + +- Implements Application Layer defined ports (i.e. `PostgresPersonRepo` and `SlackDirectMessenger`) +- Implements Controller/Engine defined ports (i.e. `JSONObjectMapper` or `YAMLObjectMapper` ) + +### Configuration + +- Defines the wiring of the infrastructure implementations to the defined ports + +## Feature Folders Layering + +The architectural layering shows up in two different folder structures in the project. +The top level source folders would be + + +- app +- common + - application + - core + - domain + - infra +- domain +- infrastructure +- people +- locations +- user + - NewUserController + - NewUserService + - UserLoginController + - UserLoginService + - UserRepoPort + - PostgresUserRepo + - CreateNewUserRequest + - CreateNewUserResponse diff --git a/rest-exemplar/domain-model.puml b/rest-exemplar/domain-model.puml new file mode 100644 index 0000000..cf2fd42 --- /dev/null +++ b/rest-exemplar/domain-model.puml @@ -0,0 +1,47 @@ +@startuml +interface Place { + + String getName() + + void setName(String name) + + String getCountry() + + void setCountry(String country) + + String getCity() + + void setCity(String city) + + String getStreet() + + void setStreet(String street) + + String getZip() + + void setZip(String zip) + + String getNumber() + + void setNumber(String number) +} +@enduml + +@startuml +title Success Scenario +participant Subscriber as sub +participant Publisher as pub +sub -> pub: subscribe() +pub -> sub: onSubscribe() +sub -> pub: request(n) +pub -> sub: onNext(1) +pub -> sub: onNext(2) +pub -> sub: onNext(n) +pub -> sub: onComplete() +@enduml + +@startuml +title Error Scenario +participant Subscriber as sub +participant Publisher as pub +sub -> pub: subscribe() +pub -> sub: onSubscribe() +sub -> pub: request(n) +pub -> sub: onError() +@enduml + +@startuml +title Project Reactor +participant Flux +participant Mono +Flux -> Subscriber: subscribe() +Mono -> Subscriber: subscribe() +@enduml diff --git a/rest-exemplar/micronaut-cli.yml b/rest-exemplar/micronaut-cli.yml new file mode 100644 index 0000000..6e6aeff --- /dev/null +++ b/rest-exemplar/micronaut-cli.yml @@ -0,0 +1,6 @@ +applicationType: default +defaultPackage: org.saltations.mre +testFramework: junit +sourceLanguage: java +buildTool: gradle +features: [app-name, data, data-jdbc, gradle, http-client, java, java-application, jdbc-hikari, junit, liquibase, logback, lombok, management, micronaut-aot, micronaut-build, micronaut-http-validation, micronaut-test-rest-assured, mysql, netty-server, openapi, openrewrite, problem-json, reactor, reactor-http-client, readme, retry, serialization-jackson, shade, swagger-ui, testcontainers, validation, yaml, yaml-build] diff --git a/rest-exemplar/openapi.properties b/rest-exemplar/openapi.properties new file mode 100644 index 0000000..c4a67c9 --- /dev/null +++ b/rest-exemplar/openapi.properties @@ -0,0 +1,6 @@ +swagger-ui.enabled=true +redoc.enabled=false +rapidoc.enabled=false +rapidoc.bg-color=#14191f +rapidoc.text-color=#aec2e0 +rapidoc.sort-endpoints-by=method diff --git a/rest-exemplar/pom.xml b/rest-exemplar/pom.xml new file mode 100644 index 0000000..133670a --- /dev/null +++ b/rest-exemplar/pom.xml @@ -0,0 +1,432 @@ + + + 4.0.0 + + org.saltations.exemplar + rest-exemplar + jar + + + org.saltations.exemplar + exemplar-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + + 3.13.0 + 3.5.2 + org.saltations.mre.app.MNRestExemplarApp + + + + + + + + jakarta.validation + jakarta.validation-api + + + jakarta.persistence + jakarta.persistence-api + + + + + io.micronaut + micronaut-http-client + + + io.micronaut + micronaut-http-server-netty + + + io.micronaut + micronaut-management + + + io.micronaut + micronaut-retry + + + + + io.micronaut.data + micronaut-data-jdbc + + + + + io.micronaut.sql + micronaut-jdbc-hikari + + + + + io.micronaut.liquibase + micronaut-liquibase + + + + + io.micronaut.problem + micronaut-problem-json + + + + + io.micronaut.reactor + micronaut-reactor + + + io.micronaut.reactor + micronaut-reactor-http-client + + + + + io.micronaut.serde + micronaut-serde-jackson + + + + + io.micronaut.validation + micronaut-validation + + + + + io.micronaut.openapi + micronaut-openapi-annotations + provided + + + io.swagger.core.v3 + swagger-annotations + + + + + org.postgresql + postgresql + runtime + + + + + com.github.java-json-tools + json-patch + 1.13 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + ch.qos.logback + logback-classic + runtime + + + + + org.yaml + snakeyaml + runtime + + + + + org.projectlombok + lombok + provided + + + + + org.mapstruct + mapstruct + 1.5.5.Final + + + + + com.leakyabstractions + result + 0.15.0.0 + + + org.javatuples + javatuples + 1.2 + + + com.google.guava + guava + 32.1.3-jre + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + io.micronaut.test + micronaut-test-junit5 + test + + + io.micronaut.test + micronaut-test-rest-assured + test + + + io.rest-assured + json-schema-validator + test + + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + testcontainers + test + + + + + org.awaitility + awaitility + 4.2.0 + test + + + io.projectreactor + reactor-test + test + + + com.tngtech.archunit + archunit-junit5 + 1.3.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + io.micronaut + micronaut-http-validation + + + io.micronaut.serde + micronaut-serde-processor + + + io.micronaut + micronaut-inject + + + + + io.micronaut.validation + micronaut-validation-processor + + + io.micronaut + micronaut-inject + + + + + io.micronaut.data + micronaut-data-processor + + + io.micronaut.openapi + micronaut-openapi + + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + -Amicronaut.processing.group=org.saltations.mre + -Amicronaut.processing.module=rest-exemplar + + + + + test-compile + + testCompile + + + + + org.projectlombok + lombok + ${lombok.version} + + + io.micronaut + micronaut-inject-java + + + io.micronaut + micronaut-http-validation + + + io.micronaut.serde + micronaut-serde-processor + + + io.micronaut.data + micronaut-data-processor + + + io.micronaut.openapi + micronaut-openapi + + + io.micronaut.validation + micronaut-validation-processor + + + io.micronaut + micronaut-inject + + + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + -Amicronaut.processing.group=org.saltations.mre + -Amicronaut.processing.module=rest-exemplar + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + **/*Test.java + + + + + + io.micronaut.maven + micronaut-maven-plugin + 4.6.3 + + aot-${project.packaging}.properties + + + + + run + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + ${exec.mainClass} + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + \ No newline at end of file diff --git a/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarApp.java b/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarApp.java new file mode 100644 index 0000000..35ef128 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarApp.java @@ -0,0 +1,30 @@ +package org.saltations.mre.app; + +import io.micronaut.runtime.Micronaut; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; + +@OpenAPIDefinition( + info = @Info( + title = "mn4-rest-exemplar", + description = "API for a Micronaut exemplar application", + contact = @Contact(name = "Jim Mochel", email = "jmochel@saltations.org"), + version = "0.0.1" + ), + extensions = @Extension( + properties = { + @ExtensionProperty(name="api-id", value = "exemplar"), + @ExtensionProperty(name="audience", value = "company-internal") + } + ) +) +public class MNRestExemplarApp +{ + public static void main(String[] args) + { + Micronaut.run(MNRestExemplarApp.class, args); + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarController.java b/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarController.java new file mode 100644 index 0000000..d6c77f5 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/app/MNRestExemplarController.java @@ -0,0 +1,15 @@ +package org.saltations.mre.app; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; + +@Controller("/mn-rest-exemplar") +public class MNRestExemplarController +{ + @SuppressWarnings("SameReturnValue") + @Get(produces = "text/json") + public String index() + { + return "{}"; + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/app/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/app/package-info.java new file mode 100644 index 0000000..8c19bd8 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/app/package-info.java @@ -0,0 +1,9 @@ +/** + * This package contains the main class for the application as well as any app services specific to the application + * rather than the business logic within the application. + *

+ * The classes you will find here are typically the application services that allow us to manage the application + * logic (Monitoring, auditing, security, logging, etc.) For the application as a whole + */ + +package org.saltations.mre.app; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotCreateEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotCreateEntity.java new file mode 100644 index 0000000..37a4d4d --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotCreateEntity.java @@ -0,0 +1,33 @@ +package org.saltations.mre.common.application; + +import java.text.MessageFormat; + +import io.micronaut.http.HttpStatus; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import org.saltations.mre.common.core.errors.DomainProblemBase; + +/** + * Denotes the failure to create an entity of a given type from a prototype + */ + +@Serdeable +public class CannotCreateEntity extends DomainProblemBase +{ + private static final String PROBLEM_TYPE = "cannot-create-entity"; + + private static final String TITLE_TEMPLATE = "Cannot create {0}"; + + public CannotCreateEntity(String resourceTypeName, Object prototype) + { + super(PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot create a {0} with contents {1}", resourceTypeName, prototype.toString()); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + + public CannotCreateEntity(Throwable e, String resourceTypeName, Object prototype) + { + super(e, PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot create a {0} with contents {1}", resourceTypeName, prototype.toString()); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotDeleteEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotDeleteEntity.java new file mode 100644 index 0000000..7ba66df --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotDeleteEntity.java @@ -0,0 +1,33 @@ +package org.saltations.mre.common.application; + +import io.micronaut.http.HttpStatus; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import org.saltations.mre.common.core.errors.DomainProblemBase; + +import java.text.MessageFormat; + +/** + * Denotes the failure to delete an entity of a given type from a prototype + */ + +@Serdeable +public class CannotDeleteEntity extends DomainProblemBase +{ + private static final String PROBLEM_TYPE = "cannot-delete-entity"; + + private static final String TITLE_TEMPLATE = "Cannot delete {0}"; + + public CannotDeleteEntity(String resourceTypeName, Object id) + { + super(PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot delete a {0} with id {1}", resourceTypeName, id.toString()); + statusType(new HttpStatusType(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + public CannotDeleteEntity(Throwable e, String resourceTypeName, Object id) + { + super(e, PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot delete a {0} with id {1}", resourceTypeName, id.toString()); + statusType(new HttpStatusType(HttpStatus.INTERNAL_SERVER_ERROR)); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotFindEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotFindEntity.java new file mode 100644 index 0000000..28b5bdd --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotFindEntity.java @@ -0,0 +1,33 @@ +package org.saltations.mre.common.application; + +import java.text.MessageFormat; + +import io.micronaut.http.HttpStatus; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import org.saltations.mre.common.core.errors.DomainProblemBase; + +/** + * Denotes the failure to create an entity of a given type from a prototype + */ + +@Serdeable +public class CannotFindEntity extends DomainProblemBase +{ + private static final String PROBLEM_TYPE = "cannot-find-entity"; + + private static final String TITLE_TEMPLATE = "Cannot find {0}"; + + public CannotFindEntity(String resourceTypeName, Object id) + { + super(PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot find a {0} with id {1}", resourceTypeName, id.toString()); + statusType(new HttpStatusType(HttpStatus.NOT_FOUND)); + } + + public CannotFindEntity(Throwable e, String resourceTypeName, Object id) + { + super(e, PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot find a {0} with id {1}", resourceTypeName, id.toString()); + statusType(new HttpStatusType(HttpStatus.NOT_FOUND)); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotPatchEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotPatchEntity.java new file mode 100644 index 0000000..50eada8 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotPatchEntity.java @@ -0,0 +1,34 @@ +package org.saltations.mre.common.application; + +import io.micronaut.http.HttpStatus; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import org.saltations.mre.common.core.errors.DomainProblemBase; + +import java.text.MessageFormat; + +/** + * Denotes the failure to patch an entity of a given type from a patch + */ + +@Serdeable +public class CannotPatchEntity extends DomainProblemBase +{ + private static final String PROBLEM_TYPE = "cannot-patch-entity"; + + private static final String TITLE_TEMPLATE = "Cannot patch {0}"; + + public CannotPatchEntity(String resourceTypeName, Object id) + { + super(PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot patch a {0} with id {1}", resourceTypeName, id); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + + public CannotPatchEntity(Throwable e, String resourceTypeName, Object id) + { + super(e, PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot patch a {0} with id {1}", resourceTypeName, id); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotUpdateEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotUpdateEntity.java new file mode 100644 index 0000000..b789389 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CannotUpdateEntity.java @@ -0,0 +1,33 @@ +package org.saltations.mre.common.application; + +import java.text.MessageFormat; + +import io.micronaut.http.HttpStatus; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import org.saltations.mre.common.core.errors.DomainProblemBase; + +/** + * Denotes the failure to create an entity of a given type from a prototype + */ + +@Serdeable +public class CannotUpdateEntity extends DomainProblemBase +{ + private static final String PROBLEM_TYPE = "cannot-update-entity"; + + private static final String TITLE_TEMPLATE = "Cannot update {0}"; + + public CannotUpdateEntity(String resourceTypeName, Object prototype) + { + super(PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot update a {0} with contents {1}", resourceTypeName, prototype.toString()); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + + public CannotUpdateEntity(Throwable e, String resourceTypeName, Object prototype) + { + super(e, PROBLEM_TYPE, MessageFormat.format(TITLE_TEMPLATE, resourceTypeName),"Cannot update a {0} with contents {1}", resourceTypeName, prototype.toString()); + statusType(new HttpStatusType(HttpStatus.BAD_REQUEST)); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepo.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepo.java new file mode 100644 index 0000000..f000009 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepo.java @@ -0,0 +1,14 @@ +package org.saltations.mre.common.application; + +import io.micronaut.data.repository.CrudRepository; + +/** + * Minimum contract for a repository that provides CRUD operations for entities of type E. + * + * @param Type of the entity identifier. + * @param Class of the entity. + */ + +public interface CrudEntityRepo extends CrudRepository +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepoFoundation.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepoFoundation.java new file mode 100644 index 0000000..d79a31a --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityRepoFoundation.java @@ -0,0 +1,18 @@ +package org.saltations.mre.common.application; + +import java.util.List; + +import io.micronaut.core.annotation.NonNull; +import org.saltations.mre.common.domain.Entity; + +/** + * Foundation (provides some default functionality) repository for entities of type E. + * + * @param Type of the entity identifier . + * @param Class of the entity. + */ + +public abstract class CrudEntityRepoFoundation> implements CrudEntityRepo +{ + // Intentionally empty: rely on CrudRepository default methods like findAllById and deleteAllById +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityService.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityService.java new file mode 100644 index 0000000..c2cdba3 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityService.java @@ -0,0 +1,107 @@ +package org.saltations.mre.common.application; + +import java.util.Optional; + +import io.micronaut.core.annotation.NonNull; +import jakarta.transaction.Transactional; +import org.saltations.endeavour.FailureDescription; +import org.saltations.endeavour.Result; +import org.saltations.mre.common.domain.Entity; +import org.saltations.mre.common.domain.EntityMapper; + +/** + * Minimum contract for the application business logic (service) that provides CRUD operations on entities of type E + *

+ * This is at the application layer so we use this CRUD style of service only when persisting business Creating, Reading and Updating + * an entity is part of the application business logic. For example, when the creation of the entity is part of an exposed API. + *

+ * The primary reason for having the create , update and delete logic here is to maintain consistency for those operations and + * consistent transaction boundaries + * + * @param Type of the entity identifier . + * @param Interface of the core business concept the entity represents + * @param Class of the core object the entity represents + * @param Class of the entity. + */ + +public interface CrudEntityService, + ER extends CrudEntityRepo, + EM extends EntityMapper> +{ + /** + * Provides the entity name of the entity managed by the service. + * Intended to be used primarily in generating error messages + */ + + String getEntityName(); + + /** + * Checks existence of entity for a given id. + * + * @param id Identifier. Not null. + * + * @return Mono for the boolean result. + */ + @NonNull + Boolean exists(ID id); + + /** + * Checks non-existence of entity for a given id. + * + * @param id Identifier. Not null. + * + * @return the boolean result. + */ + @NonNull + default Boolean doesNotExist(ID id) + { + return !exists(id); + } + + /** + * Find the entity by its identifier + * + * @param id Identifier. Not null. + * + * @return Possible result. {@link java.util.Optional#empty()} if no entity matching the id is find. + */ + + Optional find(ID id); + + /** + * Creates an entity of type E from the prototype object. + * + * @param prototype Prototype object that contains the attributes necessary to create an entity of type E. Valid and not null. + * + * @return Populated entity of type E + * + * @throws CannotCreateEntity if the entity could not be created from the prototype + */ + + @Transactional(Transactional.TxType.REQUIRED) + Result create(C prototype); + + /** + * Updates an entity of type E with the contents of the given entity. + * + * @param update is the entity with the modified values and the ID of the entity to be modified. Valid and not null. + * + * @return updated entity. + * + * @throws CannotUpdateEntity If the entity could not be updated for any reason + */ + + @Transactional(Transactional.TxType.REQUIRED) + E update(E update) throws CannotUpdateEntity; + + /** + * Deletes an entity of type E with the given ID. + * + * @param id is the unique identifier for the entity + * + * @throws CannotDeleteEntity If the entity could not be deleted for any reason + */ + + @Transactional(Transactional.TxType.REQUIRED) + void delete(ID id) throws CannotDeleteEntity; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityServiceFoundation.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityServiceFoundation.java new file mode 100644 index 0000000..c508c4b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudEntityServiceFoundation.java @@ -0,0 +1,126 @@ +package org.saltations.mre.common.application; + +import java.util.Optional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.validation.validator.Validator; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.saltations.endeavour.Result; +import org.saltations.endeavour.Try; +import org.saltations.mre.common.domain.Entity; +import org.saltations.mre.common.domain.EntityMapper; + +/** + * Foundation (provides some default functionality) service for creating, finding, replacing, patching and deleting entities + * + * @param Type of the entity identifier . + * @param Interface of the core business concept + * @param Class of the core object + * @param Class of the entity. + * @param Type of the entity repository used by the service + * @param Type of the entity mapper used by the service + */ + +public abstract class CrudEntityServiceFoundation, + ER extends CrudEntityRepo, + EM extends EntityMapper> implements CrudEntityService +{ + private final Class entityClass; + + private final ER entityRepo; + + private final EntityMapper entityMapper; + + private final ObjectMapper jacksonMapper; + + private final Validator validator; + + /** + * Primary constructor + * + * @param entityClass Type of the entity + * @param entityRepo Repository for persistence of entities + */ + + public CrudEntityServiceFoundation(Class entityClass, ER entityRepo, EntityMapper entityMapper, Validator validator) + { + this.entityRepo = entityRepo; + this.entityClass = entityClass; + this.entityMapper = entityMapper; + this.validator = validator; + + this.jacksonMapper = new ObjectMapper(); + this.jacksonMapper.registerModule(new JavaTimeModule()); + } + + @Override + public String getEntityName() + { + return entityClass.getSimpleName(); + } + + @Override + public @NonNull Boolean exists(@NotNull ID id) + { + return entityRepo.existsById(id); + } + + @Override + public Optional find(@NotNull ID id) + { + return entityRepo.findById(id); + } + + @Transactional(Transactional.TxType.REQUIRED) + @Override + public Result create(@NotNull @Valid C prototype) + { + E created; + + try + { + var toBeCreated = entityMapper.createEntity(prototype); + + created = entityRepo.save(toBeCreated); + } + catch (Exception e) + { + return Try.typedFailure(CrudFailure.CANNOT_CREATE, getEntityName()); + + } + + return Try.success(created); + } + + @Transactional(Transactional.TxType.REQUIRED) + @Override + public E update(@NotNull @Valid E update) throws CannotUpdateEntity + { + try + { + return entityRepo.update(update); + } + catch (Exception e) + { + throw new CannotUpdateEntity(e, getEntityName(), update); + } + } + + @Transactional(Transactional.TxType.REQUIRED) + @Override + public void delete(@NotNull ID id) throws CannotDeleteEntity + { + try + { + entityRepo.deleteById(id); + } + catch (Exception e) + { + throw new CannotDeleteEntity(e, getEntityName(), id); + } + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudFailure.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudFailure.java new file mode 100644 index 0000000..d077f64 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/CrudFailure.java @@ -0,0 +1,29 @@ +package org.saltations.mre.common.application; + +import lombok.AllArgsConstructor; +import lombok.experimental.Accessors; +import org.saltations.endeavour.FailureType; + + +@Accessors(fluent = true) +@AllArgsConstructor +public enum CrudFailure implements FailureType +{ + GENERAL("Uncategorized error",""), + CANNOT_CREATE("Unable to create an entity", "{} entity could not be created from [{}]"),; + + private final String title; + private final String template; + + @Override + public String getTitle() + { + return title; + } + + @Override + public String getTemplate() + { + return template; + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/application/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/common/application/package-info.java new file mode 100644 index 0000000..3f118ca --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/application/package-info.java @@ -0,0 +1,5 @@ +/** + * + */ + +package org.saltations.mre.common.application; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/annotations/StdEmailAddress.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/annotations/StdEmailAddress.java new file mode 100644 index 0000000..4c9dd9f --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/annotations/StdEmailAddress.java @@ -0,0 +1,21 @@ +package org.saltations.mre.common.core.annotations; + + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Meta annotation for email data type. + */ + +@Email +@Size(max = 320) +@Inherited +@Retention(RetentionPolicy.RUNTIME) +public @interface StdEmailAddress +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainException.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainException.java new file mode 100644 index 0000000..cf37b62 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainException.java @@ -0,0 +1,36 @@ +package org.saltations.mre.common.core.errors; + +import java.util.UUID; + +import io.micronaut.serde.annotation.Serdeable; +import lombok.Getter; + +/** + * A business domain error. This is an unchecked exception that indicates that an exceptional event has happened. + * This exists separately from the Outcomes that are used to indicate the result of a business operation. + * They may be carried by the Outcomes but the two should not have any dependencies on each other. + */ + +@Serdeable +public class DomainException extends FormattedUncheckedException +{ + private static final long serialVersionUID = 1L; + + /** + * Tracer id for the exception. This is used to track the exception through the system from generation to where it is logged. + */ + + @Getter + private UUID traceId = UUID.randomUUID(); + + public DomainException(String msg, Object... args) + { + super(msg, args); + } + + public DomainException(Throwable e, String msg, Object... args) + { + super(e, msg, args); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblem.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblem.java new file mode 100644 index 0000000..5f4457c --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblem.java @@ -0,0 +1,21 @@ +package org.saltations.mre.common.core.errors; + +import java.net.URI; +import java.util.Map; + +/** + * Standard exception interface. This exists so that Domain specific exceptions thrown from the services where the + * controller can be mapped to the standard {@link org.zalando.problem.ThrowableProblem} without using the + * {@code ThrowableProblem} class as a base for all domain specific errors. + */ + +public interface DomainProblem +{ + URI expandType(URI problemTypeRootURI); + + String title(); + + String detail(); + + Map extensionPropertiesByName(); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblemBase.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblemBase.java new file mode 100644 index 0000000..fd31c7b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/DomainProblemBase.java @@ -0,0 +1,153 @@ +package org.saltations.mre.common.core.errors; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.problem.HttpStatusType; +import io.micronaut.serde.annotation.Serdeable; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +import static java.text.MessageFormat.format; + +/** + * Extendable base class that represents an internal error. Contains the elements needed to map to a RFC 7807 Problem. + *

+ * RFC 7807 provides a problem format that looks like this + *

+ *
+ *    HTTP/1.1 403 Forbidden
+ *    Content-Type: application/problem+json
+ *    Content-Language: en
+ *
+ *    {
+ *     "type": "https://example.com/probs/out-of-credit",
+ *     "title": "You do not have enough credit.",
+ *     "detail": "Your current balance is 30, but that costs 50."
+ *    }
+ * 
+ * RFC 7807 allows for the addition of properties such as + *
+ *    HTTP/1.1 403 Forbidden
+ *    Content-Type: application/problem+json
+ *    Content-Language: en
+ *
+ *    {
+ *     "type": "https://example.com/probs/out-of-credit",
+ *     "title": "You do not have enough credit.",
+ *     "detail": "Your current balance is 30, but that costs 50.",
+ *     "trace_id": "specific-id",
+ *      "account" : 126811
+ *    }
+ * 
+ *

+ * This base class will also generate a unique {@code trace_id} property for all instances of the problem + *

+ * + * @implNote This base class does not store the 'type' member from RFC 7807. It generates the 'type' member from + * the contents of the 'title' property. + */ + +@Getter +@Setter +@Accessors(fluent = true) +@Serdeable +public class DomainProblemBase extends Exception implements DomainProblem +{ + /** + * A short, human-readable summary of the problem type. + *

+ * This should not change from occurrence to + * occurrence of the problem except for the purposes of localization. + * + * @see RFC 7807. + */ + + private final String title; + + /** + * A human-readable explanation specific to this occurrence of the problem. + * + * @see RFC 7807. + */ + + private final String detail; + + /** + * Maps to 'status' which is the HTTP status code generated by the origin server for this occurrence of the problem. + * + * @see RFC 7807. + */ + + private HttpStatusType statusType; + + /** + * Extension properties + */ + + private final Map extensionPropertiesByName = new HashMap<>(); + + /** + * @param problemType endpoint suffix used to create the 'type' member from RFC 7807 + * @param title a short, human-readable summary of the problem type + * @param detailTemplate a template using the argument and formatting conventions of {@link java.text.MessageFormat} + * @param args 0 or more variable arguments for formatting in the detail template + */ + + public DomainProblemBase(String problemType, String title, String detailTemplate, Object...args) + { + super(title + ":" + format(detailTemplate, args)); + this.title = title; + this.detail = format(detailTemplate, args); + + extensionPropertiesByName.put("trace_id", UUID.randomUUID().toString()); + } + + /** + * @param cause exception that is the root cause of the problem + * @param problemType endpoint suffix used to create the 'type' member from RFC 7807 + * @param title a short, human-readable summary of the problem type + * @param detailTemplate a template using the argument and formatting conventions of {@link java.text.MessageFormat} + * @param args 0 or more variable arguments for formatting in the detail template + */ + + public DomainProblemBase(Throwable cause, String problemType, String title, String detailTemplate, Object...args) + { + super(title + ":" + format(detailTemplate, args), cause); + this.title = title; + this.detail = format(detailTemplate, args); + + extensionPropertiesByName.put("trace_id", UUID.randomUUID().toString()); + extensionPropertiesByName.put("problem-type", problemType); + } + + /** + * Expands a given root URI with a path parameter of {@code problem-type} + *

+ * The root URI must contain a path parameter with the name {@code problem-type} so that this + *

+ *
+     * https://example.com/probs/{problem-type}
+     * 
+ *

+ * expands to something like + *

+ *
+     * https://example.com/probs/validation-error
+     * 
+ * + * @param problemTypeRootURI the root of the problem endpoint with a path parameter of {@code problem-type} + * + * @return Expanded URI. + */ + + public URI expandType(URI problemTypeRootURI) + { + return UriBuilder.of(problemTypeRootURI).expand(extensionPropertiesByName); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/FormattedUncheckedException.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/FormattedUncheckedException.java new file mode 100644 index 0000000..f4f8130 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/errors/FormattedUncheckedException.java @@ -0,0 +1,45 @@ +package org.saltations.mre.common.core.errors; + +import io.micronaut.serde.annotation.Serdeable; +import lombok.Getter; +import org.slf4j.helpers.MessageFormatter; + +/** + * A runtime exception that allows the user to pass in messages with parameters. Messages and parameters are + * done using {@link org.slf4j.helpers.MessageFormatter} format strings. + */ + +@Getter +@Serdeable +public class FormattedUncheckedException extends RuntimeException +{ + /** + * An exception really should have a serialization version. + */ + + private static final long serialVersionUID = 1L; + + + /** + * Constructor that takes {@link org.slf4j.helpers.MessageFormatter} format strings and parameters + * + * @param msg Formatting message. Uses {@link org.slf4j.helpers.MessageFormatter#format} notation. + * @param args Objects as message parameters + */ + + public FormattedUncheckedException(String msg, Object... args) { + super(MessageFormatter.basicArrayFormat(msg, args)); + } + + /** + * Constructor that takes {@link org.slf4j.helpers.MessageFormatter} format strings and parameters + * + * @param e Root cause exception. Non-null. + * @param msg Formatting message. Uses {@link org.slf4j.helpers.MessageFormatter#format} notation. + * @param args Objects as message parameters + */ + + public FormattedUncheckedException(Throwable e, String msg, Object... args) { + super(MessageFormatter.basicArrayFormat(msg, args), e); + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/core/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/common/core/package-info.java new file mode 100644 index 0000000..9d0452e --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/core/package-info.java @@ -0,0 +1,8 @@ +/** + * Contains the underpinning classes. + *

+ * Such things as standardized control, basic exception types, base data types, annotations, that sort of thing + * These classes should only be dependent on other classes within the core package or external libraries. + */ + +package org.saltations.mre.common.core; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/domain/Entity.java b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/Entity.java new file mode 100644 index 0000000..2db4ed3 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/Entity.java @@ -0,0 +1,14 @@ +package org.saltations.mre.common.domain; + +/** + * Minimum contract for an entity with a unique identifier of the specified type. + * + * @param Type of the identifier + */ + +public interface Entity +{ + ID getId(); + + void setId(ID id); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/domain/EntityMapper.java b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/EntityMapper.java new file mode 100644 index 0000000..3d0142a --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/EntityMapper.java @@ -0,0 +1,36 @@ +package org.saltations.mre.common.domain; + +import org.mapstruct.MappingTarget; + +/** + * Defines the minimum contract for standard mapping functionality between core objects, and their corresponding entities + * + * @param Class of the core business item + * @param Class of the entity. + */ + +public interface EntityMapper +{ + /** + * Maps a (Core) prototype to an Entity. + * + * @param proto prototype with core attributes to create an Entity. + * + * @return Valid Entity + */ + + @SuppressWarnings("EmptyMethod") + E createEntity(C proto); + + /** + * Patches the entity with non-null values from the patch object + * + * @param patch object with core attributes used to update the entity. + * @param entity object to be updated + * + * @return Patched entity + */ + + @SuppressWarnings("EmptyMethod") + E patchEntity(C patch, @MappingTarget E entity); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/domain/HasChangeDates.java b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/HasChangeDates.java new file mode 100644 index 0000000..2e61fcc --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/HasChangeDates.java @@ -0,0 +1,26 @@ +package org.saltations.mre.common.domain; + +import java.time.OffsetDateTime; + +import lombok.NonNull; + +/** + * Minimum contract for an entity with basic . + *

+ * TODO Summary(ends with '.',third person[gets the X, not Get X],do not use @link) ${NAME} represents xxx OR ${NAME} does xxxx. + * + *

TODO Description(1 lines sentences,) References generic parameters with {@code } and uses 'b','em', dl, ul, ol tags + * + */ + +public interface HasChangeDates +{ + OffsetDateTime getCreated(); + OffsetDateTime getUpdated(); + + default boolean notUpdatedSince(@NonNull OffsetDateTime since) + { + return getUpdated().isAfter(since); + } + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/domain/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/package-info.java new file mode 100644 index 0000000..7765fe8 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/domain/package-info.java @@ -0,0 +1,11 @@ +/** + * + * This package contains code underpinning the domain model for the MRE application + *

+ * this includes space classes forThis includes base classes or interfaces for Entities, entity repositories, + * entity services, and entity controllers. + * + */ + +package org.saltations.mre.common.domain; + diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/infra/PlaceSetter.java b/rest-exemplar/src/main/java/org/saltations/mre/common/infra/PlaceSetter.java new file mode 100644 index 0000000..c25a5b3 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/infra/PlaceSetter.java @@ -0,0 +1,5 @@ +package org.saltations.mre.common.infra; + +public class PlaceSetter +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/infra/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/common/infra/package-info.java new file mode 100644 index 0000000..be76eae --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/infra/package-info.java @@ -0,0 +1 @@ +package org.saltations.mre.common.infra; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/common/package-info.java new file mode 100644 index 0000000..49fa41b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/package-info.java @@ -0,0 +1,13 @@ +/** + * Contains the common code used throughout the project codebase. + * The subdirectories represent the layers that are used throughout. + *

+ *
core
core classes and interfaces used throughout the entire entire code base
+ *
domain
common classes and interfaces used in modeling domain objects, such as entities
+ *
application
common classes used and modeling application, business logic, such as use cases and services
+ *
infrastructure
common classes and interfaces used in modeling infrastructure such as third-party services, logging, identity , etc
+ *
presentation
common classes and interfaces used in modeling presentation layer code, such as controllers, event handlers, etc.
+ *
+ */ + +package org.saltations.mre.common; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/EntityController.java b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/EntityController.java new file mode 100644 index 0000000..0afbe40 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/EntityController.java @@ -0,0 +1,30 @@ +package org.saltations.mre.common.presentation; + +import org.saltations.mre.common.domain.Entity; +import org.saltations.mre.common.domain.EntityMapper; +import org.saltations.mre.common.application.CrudEntityRepo; +import org.saltations.mre.common.application.CrudEntityService; + +/** + * Minimum contract for common functionality used within a controller that allows operations on an entity + */ + +public interface EntityController, + ER extends CrudEntityRepo, + EM extends EntityMapper, + ES extends CrudEntityService> +{ + /** + * Provides the resource name for the entity managed by the controller. + * This is intended to be used primarily in generating error messages + */ + + default String getEntityName() + { + return getEntityClass().getSimpleName(); + } + + Class getEntityClass(); + + ES getEntityService(); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/ProblemSchema.java b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/ProblemSchema.java new file mode 100644 index 0000000..cc17d3a --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/ProblemSchema.java @@ -0,0 +1,65 @@ +package org.saltations.mre.common.presentation; + +import java.net.URI; +import java.util.UUID; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +/** + * POJO used for specifying RFC 7807 Problem schema in the OpenAPI docs. + */ + +@Data +@Serdeable +@Introspected +@NoArgsConstructor +@Accessors(fluent = true) +@Schema(name = "Problem", description = "Standard error reporting details corresponding to RFC 7807") +public class ProblemSchema +{ + @Nullable + @Schema(name = "type", + description = "An absolute URI that identifies the problem type. " + + "When dereferenced,it SHOULD provide human-readable documentation for the problem type" + + " (e.g., using HTML)", + defaultValue = "about:blank", + example = "https://zalando.github.io/problem/constraint-violation" + ) + private URI type; + + @Nullable + @Schema( + description = "A short, summary of the problem type. Written in english and readable for engineers " + + "(usually not suited for non technical stakeholders and not localized)", + example = "Service Unavailable" + ) + private String title; + + @Schema( + description = "The HTTP status code generated by the origin server for this occurrence of the problem", + minimum = "100", maximum = "600", exclusiveMaximum = true, + example = "403" + ) + private Integer status; + + @Nullable + @Schema( + description = "A human readable explanation specific to this occurrence of the problem", + example = "Connection to database timed out" + ) + private String detail; + + @Nullable + @Schema( + description = "Additional property with unique identifier for the instance of the problem." + + "Logged by the server so someone can link the log to the REST Call", + example = "9508f49c-2f80-11ed-a261-0242ac120002" + ) + private UUID traceId; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/RestCrudEntityControllerFoundation.java b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/RestCrudEntityControllerFoundation.java new file mode 100644 index 0000000..29be750 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/RestCrudEntityControllerFoundation.java @@ -0,0 +1,336 @@ +package org.saltations.mre.common.presentation; + +import java.net.URI; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Patch; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Put; +import io.micronaut.validation.validator.Validator; +import io.micronaut.web.router.RouteBuilder; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.saltations.endeavour.Failure; +import org.saltations.endeavour.FailureType; +import org.saltations.mre.common.application.CannotFindEntity; +import org.saltations.mre.common.application.CannotPatchEntity; +import org.saltations.mre.common.application.CrudEntityRepo; +import org.saltations.mre.common.application.CrudEntityService; +import org.saltations.mre.common.application.CrudFailure; +import org.saltations.mre.common.core.errors.DomainProblemBase; +import org.saltations.mre.common.domain.Entity; +import org.saltations.mre.common.domain.EntityMapper; +import org.zalando.problem.Problem; +import org.zalando.problem.Status; +import org.zalando.problem.ThrowableProblem; +import reactor.core.publisher.Mono; + +/** + * Foundation (provides some default functionality) controller for basic CRUD operations on entities of type E + * + * @param Type of the entity identifier . + * @param Interface of the core business concept + * @param Class of the core object + * @param Class of the entity + * @param Type of the entity repository used by the controller + * @param Type of the entity mapper used by the controller + * @param Type of the entity service used by the controller + */ + +@Slf4j +public class RestCrudEntityControllerFoundation, + ER extends CrudEntityRepo, + EM extends EntityMapper, + ES extends CrudEntityService> + implements EntityController +{ + private final RouteBuilder.UriNamingStrategy uriNaming; + + @Getter + private final Class entityClass; + + @Getter + private final ES entityService; + + @Getter + private final ER entityRepo; + + @Getter + private final EM entityMapper; + + private final Validator validator; + + @Getter + private final ObjectMapper jsonMapper; + + public RestCrudEntityControllerFoundation(RouteBuilder.UriNamingStrategy uriNaming, Class entityClass, ES entityService, ER entityRepo, EM entityMapper, Validator validator) + { + this.uriNaming = uriNaming; + this.entityClass = entityClass; + this.entityService = entityService; + this.entityRepo = entityRepo; + this.entityMapper = entityMapper; + this.validator = validator; + + this.jsonMapper = new ObjectMapper(); + this.jsonMapper.registerModule(new JavaTimeModule()); + } + + /** + * Get for given id + * + * @param id the identifier for the resource. Not null. + * @return populated resource + */ + + @Get("/{id}") + public Mono> get(@NotNull ID id) + { + E found; + + try + { + found = this.entityService.find(id).orElseThrow(() -> new CannotFindEntity(getEntityName(), id)); + } + catch (DomainProblemBase e) + { + throw createThrowableProblem(e); + } + + return Mono.just(HttpResponse.ok(found)); + } + + /** + * + * Create from provided payload + * + * @param toBeCreated DTO containing the info needed to create an resource + * + * @return populated resource + */ + + @Post + @ApiResponse(responseCode = "201", + description = "Successfully created", + content = @Content(mediaType = MediaType.APPLICATION_JSON) + ) + @ApiResponse(responseCode = "400", + description = "Malformed request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_PROBLEM, schema = @Schema(allOf = ProblemSchema.class)) + ) + public Mono> create(@NotNull @Valid @Body final C toBeCreated) throws ThrowableProblem + { + // Part of the service contract is to return only and outcome , so no exceptions are thrown + var result = entityService.create(toBeCreated); + + result.ifFailure(failure -> log.error("Failure: {}", failure)); + + return Mono.just(created((E) result.get())); + } + + + + private Status convert(FailureType failureType) + { + return switch ( (CrudFailure) failureType) + { + case CANNOT_CREATE -> Status.BAD_REQUEST; + case GENERAL -> Status.INTERNAL_SERVER_ERROR; + }; + } + + + + private void logAndThrowFailure(Failure failure) throws ThrowableProblem + { + var status = convert(failure.getType()); + + if (failure.getCause() == null) + { + log.error("Failure: {}", failure); + throw Problem.builder() + .withTitle(failure.getTitle()) + .withDetail(failure.getDetail()) + .withStatus(status) + .build(); + } + } + + /** + * + * Replace with provided payload + * + * @param id the identifier for the resource. Not null. + * @param replacement Payload resource to be used to replace the id'd resource + * + * @return populated resource + */ + + @Put("/{id}") + public Mono> replace(@NotNull ID id, @NotNull @Valid @Body E replacement) + { + E replaced; + + try + { + if (entityService.doesNotExist(id)) + { + throw new CannotFindEntity(getEntityName(), id); + } + + replaced = entityService.update(replacement); + } + catch (DomainProblemBase e) + { + throw createThrowableProblem(e); + } + + return Mono.just(HttpResponse.ok(replaced)); + + } + + /** + * Modify using a JSON Merge Patch + * + *

Uses JSON Merge Patch to do partial updates of the + * identified resource including explicit nulls. + * + * @param id the identifier for the resource. Not null. + * @param mergePatchAsString the string containing the RFC 7386 JSON merge Patch. + * + * @return Patched resource + * @throws org.saltations.mre.common.application.CannotPatchEntity if TODO ? + */ + + @Patch(value = "/{id}", consumes = {"application/merge-patch+json"}) + public Mono> patch(@NotNull ID id, @NotNull @NotBlank @Body String mergePatchAsString) + throws CannotPatchEntity + { + E patched; + + try + { + if (entityService.doesNotExist(id)) + { + throw new CannotFindEntity(getEntityName(), id); + } + + // Take the incoming patch and overlay it on top of the retrieved entity. + + var mergePatch = jsonMapper.readValue(mergePatchAsString, JsonMergePatch.class); + var retrieved = entityRepo.findById(id).orElseThrow(); + var retrievedAsJsonNode = jsonMapper.readTree(jsonMapper.writeValueAsString(retrieved)); + var updatedEntityAsString = jsonMapper.writeValueAsString(mergePatch.apply(retrievedAsJsonNode)); + + patched = jsonMapper.readValue(updatedEntityAsString, entityClass); + + // We will not save the updated entity if the patch puts it into an invalid state + + var violations = validator.validate(patched); + + if (!violations.isEmpty()) + { + throw new ConstraintViolationException(violations); + } + } + catch (DomainProblemBase e) + { + throw createThrowableProblem(e); + } + catch (Exception e) + { + throw new CannotPatchEntity(e, getEntityName(), (Long) id); + } + + return Mono.just(HttpResponse.ok(patched)); + } + + /** + * Delete for id + * + * @param id the identifier for the resource. Not null. + */ + + @Delete("/{id}") + public HttpResponse delete(@NotNull ID id) + { + try + { + entityService.delete(id); + } + catch (DomainProblemBase e) + { + throw createThrowableProblem(e); + } + + return HttpResponse.ok(); + } + + + @NonNull + private MutableHttpResponse created(@NonNull E entity) { + return HttpResponse + .created(entity) + .headers(headers -> headers.location(resolveLocationWithID(entity.getId()))); + } + + private URI resolveLocationWithID(ID id) + { + var base = uriNaming.resolveUri(this.getClass()); + + return URI.create(base + "/" + id); + } + + private URI resolveLocationWith(String suffix) + { + var base = uriNaming.resolveUri(this.getClass()); + + return URI.create(base + "/" + suffix); + } + + private URI resolveLocationWithID(E entity) + { + return resolveLocationWithID(entity.getId()); + } + + public ThrowableProblem createThrowableProblem(@NotNull DomainProblemBase e) + { + var builder = Problem.builder() + .withTitle(e.title()) + .withStatus(e.statusType()) + .withDetail(e.detail()); + + // Add the type + + builder.withType(createType(e)); + + // Add the properties + + e.extensionPropertiesByName().entrySet().forEach(entry -> builder.with(entry.getKey(), entry.getValue())); + + return builder.build(); + } + + private URI createType(DomainProblemBase e) + { + return URI.create("https://localhost/probs/" + e.getClass().getSimpleName().replaceAll("([A-Z]+)([A-Z][a-z])", "$1-$2").replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase()); + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/StdController.java b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/StdController.java new file mode 100644 index 0000000..e8010a9 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/common/presentation/StdController.java @@ -0,0 +1,50 @@ +package org.saltations.mre.common.presentation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.scheduling.annotation.ExecuteOn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Meta annotation for standard controllers. Used to specify common controller behavior such as standard error responses + */ + +@Inherited +@Documented +@Retention(RUNTIME) +@Target(ElementType.TYPE) +@ExecuteOn(TaskExecutors.IO) +@ApiResponse(responseCode = "400", + description = "Malformed request could not be understood by the server due to " + + "malformed syntax. The client SHOULD NOT repeat the request without modifications.", + content = @Content(mediaType = "application/problem+json",schema = @Schema(allOf = ProblemSchema.class)) +) +@ApiResponse(responseCode = "401", + description = "Unauthorized. The request requires an authenticated user. User is either unauthenticated OR" + + " someone forgot to put an Authorization Header with a bearer access token in it.", + content = @Content(mediaType = "application/problem+json",schema = @Schema(allOf = ProblemSchema.class)) +) +@ApiResponse(responseCode = "403", + description = "Forbidden. User is authenticated but does not have sufficient " + + "permissions to perform the operation for this resource.", + content = @Content(mediaType = "application/problem+json",schema = @Schema(allOf = ProblemSchema.class)) +) +@ApiResponse(responseCode = "415", + description = "Unsupported media type. You have provided something other than JSON as a media type.", + content = @Content(mediaType = "application/problem+json",schema = @Schema(allOf = ProblemSchema.class)) +) +@ApiResponse(responseCode = "500", + description = "Some other error.", + content = @Content(mediaType = "application/problem+json",schema = @Schema(allOf = ProblemSchema.class)) +) +public @interface StdController { +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/Course.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/Course.java new file mode 100644 index 0000000..6ce4f0f --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/Course.java @@ -0,0 +1,15 @@ +package org.saltations.mre.domain; + +import io.micronaut.core.annotation.Introspected; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * TODO Summary(ends with '.',third person[gets the X, not Get X],do not use @link) Course represents xxx OR Course does xxxx. + * + *

TODO Description(1 lines sentences,) References generic parameters with {@code } and uses 'b','em', dl, ul, ol tags + */ +@Introspected +@Schema(name = "Course", description = "Represents a course's basic info") +public interface Course +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/CourseCore.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/CourseCore.java new file mode 100644 index 0000000..87fabca --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/CourseCore.java @@ -0,0 +1,60 @@ +package org.saltations.mre.domain; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.serde.config.naming.SnakeCaseStrategy; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * TODO Summary(ends with '.',third person[gets the X, not Get X],do not use @link) CourseCore represents xxx OR CourseCore does xxxx. + * + *

TODO Description(1 lines sentences,) References generic parameters with {@code } and uses 'b','em', dl, ul, ol tags + */ + +@Introspected +@Getter +@Setter +@ToString +@Serdeable(naming = SnakeCaseStrategy.class) +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder(builderMethodName = "of", buildMethodName = "done", toBuilder = true) +@Schema(name = "CourseCore", description = "Represents a courses basic info", allOf = Course.class) +public class CourseCore implements Course +{ + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("name") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String name; + + @NotNull + @NotBlank + @Size(max = 200) + @JsonProperty("description") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 200)}) + private String description; + + @NotNull + @JsonProperty("start_date") + @Setter(onParam_={@NotNull}) + private LocalDate startDate; + + @NotNull + @JsonProperty("end_date") + @Setter(onParam_={@NotNull}) + private LocalDate endDate; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/Person.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/Person.java new file mode 100644 index 0000000..8de8c12 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/Person.java @@ -0,0 +1,38 @@ +package org.saltations.mre.domain; + + +import io.micronaut.core.annotation.Introspected; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.saltations.mre.common.core.annotations.StdEmailAddress; + +/** + * Interface with the core attributes describing a person. + */ + +@Introspected +@Schema(name = "Person", description = "Represents a person's basic contact info") +public interface Person +{ + @Schema(description = "The age of the person", example = "14") + Integer getAge(); + + void setAge(@NotNull @Min(12L) Integer age); + + @Schema(description = "The first name of the person", example = "James") + String getFirstName(); + + void setFirstName(@NotNull @NotBlank @Size(max = 50) String firstName); + + @Schema(description = "The last name of the person", example = "Cricket") + String getLastName(); + + void setLastName(@NotNull @NotBlank @Size(max = 50) String lastName); + + @Schema(description = "Email address", example = "jmochel@landschneckt.org") + String getEmailAddress(); + void setEmailAddress(@NotNull @NotBlank @StdEmailAddress String emailAddress); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonCore.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonCore.java new file mode 100644 index 0000000..e284b8b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonCore.java @@ -0,0 +1,60 @@ +package org.saltations.mre.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.serde.config.naming.SnakeCaseStrategy; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import org.saltations.mre.common.core.annotations.StdEmailAddress; + +/** + * Core object for a Person + */ + +@Introspected +@Getter +@Setter +@ToString +@Serdeable(naming = SnakeCaseStrategy.class) +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder(builderMethodName = "of", buildMethodName = "done", toBuilder = true) +@Schema(name = "PersonCore", description = "Represents a person's basic contact info", allOf = Person.class) +public class PersonCore implements Person +{ + @NotNull + @Min(value = 12L) + @Setter(onParam_={@NotNull,@Min(value = 12L)}) + private Integer age; + + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("first_name") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String firstName; + + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("last_name") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String lastName; + + @NotNull + @NotBlank + @StdEmailAddress + @JsonProperty("email_address") + @Setter(onParam_={@NotNull,@NotBlank,@StdEmailAddress}) + private String emailAddress; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonEntity.java new file mode 100644 index 0000000..74fd3d9 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonEntity.java @@ -0,0 +1,46 @@ +package org.saltations.mre.domain; + +import java.time.OffsetDateTime; + +import io.micronaut.data.annotation.DateCreated; +import io.micronaut.data.annotation.DateUpdated; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.serde.config.naming.SnakeCaseStrategy; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.With; +import lombok.experimental.SuperBuilder; +import org.saltations.mre.common.domain.Entity; +import org.saltations.mre.common.domain.HasChangeDates; + +/** + * Identifiable, persistable Person + */ + +@Getter +@Setter +@With() +@ToString(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +@MappedEntity("person") +@Serdeable(naming = SnakeCaseStrategy.class) +@SuperBuilder(builderMethodName = "of", buildMethodName = "done", toBuilder = true) +public class PersonEntity extends PersonCore implements Entity, HasChangeDates +{ + @Id + @GeneratedValue + private Long id; + + @DateCreated + private OffsetDateTime created; + + @DateUpdated + private OffsetDateTime updated; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonMapper.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonMapper.java new file mode 100644 index 0000000..36e8901 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PersonMapper.java @@ -0,0 +1,72 @@ +package org.saltations.mre.domain; + +import java.util.List; + +import jakarta.inject.Singleton; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.saltations.mre.common.domain.EntityMapper; + + +@Singleton +@Mapper(componentModel = "jsr330") +public interface PersonMapper extends EntityMapper +{ + /** + * Creates a copy of a PersonCore + * + * @param source core object to be copied + * + * @return Valid PersonCore + */ + + PersonCore copyCore(PersonCore source); + + /** + * Maps a (PersonCore) prototype to an entity. + * + * @param proto prototype with core attributes to create an PersonEntity. + * + * @return Valid PersonEntity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + PersonEntity createEntity(PersonCore proto); + + /** + * Maps a list of (PersonCore) prototypes to a list of entities. + * + * @param protos prototypes with core attributes to create an PersonEntity. + * + * @return List of valid PersonEntity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + List createEntities(List protos); + + /** + * Patches the entity with non-null values from the patch object + * + * @param patch core object with core attributes used to update the entity. + * @param entity object to be updated + * + * @return Patched entity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + @Mapping(target = "withId", ignore = true) + @Mapping(target = "withCreated", ignore = true) + @Mapping(target = "withUpdated", ignore = true) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + PersonEntity patchEntity(PersonCore patch, @MappingTarget PersonEntity entity); + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/Place.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/Place.java new file mode 100644 index 0000000..7bada8b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/Place.java @@ -0,0 +1,42 @@ +package org.saltations.mre.domain; + + +import io.micronaut.core.annotation.Introspected; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Interface with the core attributes describing a place. + */ + +@Introspected +@Schema(name = "Place", description = "Represents a place's basic info") +public interface Place +{ + @Schema(description = "The name of the place", example = "Boston City Hall") + String getName(); + + void setName(@NotNull @NotBlank @Size(max = 50) String name); + + @Schema(description = "The address #1 of the place", example = "77 Mass Ave") + String getStreet1(); + + void setStreet1(@NotNull @NotBlank @Size(max = 50) String street1); + + @Schema(description = "The street address #2 of the place", example = "Ste 33") + String getStreet2(); + + void setStreet2(@NotNull @NotBlank @Size(max = 50) String street2); + + @Schema(description = "The city of the place", example = "Boston City Hall") + String getCity(); + + void setCity(@NotNull @NotBlank @Size(max = 50) String city); + + @Schema(description = "The state of the place", example = "MA") + USState getState(); + + void setState(@NotNull @NotBlank @Size(max = 2) USState state); +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceCore.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceCore.java new file mode 100644 index 0000000..fad68a5 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceCore.java @@ -0,0 +1,64 @@ +package org.saltations.mre.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.serde.config.naming.SnakeCaseStrategy; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * Core object for a Place + */ + +@Introspected +@Getter +@Setter +@ToString +@Serdeable(naming = SnakeCaseStrategy.class) +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder(builderMethodName = "of", buildMethodName = "done", toBuilder = true) +@Schema(name = "PlaceCore", description = "Represents a place's basic info", allOf = Place.class) +public class PlaceCore implements Place +{ + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("name") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String name; + + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("street1") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String street1; + + @Size(max = 50) + @JsonProperty("street2") + @Setter(onParam_={@Size(max = 50)}) + private String street2; + + @NotNull + @NotBlank + @Size(max = 50) + @JsonProperty("city") + @Setter(onParam_={@NotNull,@NotBlank,@Size(max = 50)}) + private String city; + + @NotNull + @JsonProperty("state") + @Setter(onParam_={@NotNull}) + private USState state; + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceEntity.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceEntity.java new file mode 100644 index 0000000..72073eb --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceEntity.java @@ -0,0 +1,46 @@ +package org.saltations.mre.domain; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import io.micronaut.data.annotation.AutoPopulated; +import io.micronaut.data.annotation.DateCreated; +import io.micronaut.data.annotation.DateUpdated; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.serde.config.naming.SnakeCaseStrategy; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.With; +import lombok.experimental.SuperBuilder; +import org.saltations.mre.common.domain.Entity; + +/** + * Identifiable, persistable Person + */ + +@Getter +@Setter +@With() +@ToString(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +@MappedEntity("place") +@Serdeable(naming = SnakeCaseStrategy.class) +@SuperBuilder(builderMethodName = "of", buildMethodName = "done", toBuilder = true) +public class PlaceEntity extends PlaceCore implements Entity +{ + @Id + @AutoPopulated + private UUID id; + + @DateCreated + private OffsetDateTime created; + + @DateUpdated + private OffsetDateTime updated; +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceMapper.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceMapper.java new file mode 100644 index 0000000..a64082e --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/PlaceMapper.java @@ -0,0 +1,72 @@ +package org.saltations.mre.domain; + +import java.util.List; + +import jakarta.inject.Singleton; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.saltations.mre.common.domain.EntityMapper; + + +@Singleton +@Mapper(componentModel = "jsr330") +public interface PlaceMapper extends EntityMapper +{ + /** + * Creates a copy of a PlaceCore + * + * @param source core object to be copied + * + * @return Valid PlaceCore + */ + + PlaceCore copyCore(PlaceCore source); + + /** + * Maps a (PlaceCore) prototype to an entity. + * + * @param proto prototype with core attributes to create an PlaceEntity. + * + * @return Valid PlaceEntity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + PlaceEntity createEntity(PlaceCore proto); + + /** + * Maps a list of (PlaceCore) prototypes to a list of entities. + * + * @param protos prototypes with core attributes to create an PlaceEntity. + * + * @return List of valid PlaceEntity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + List createEntities(List protos); + + /** + * Patches the entity with non-null values from the patch object + * + * @param patch core object with core attributes used to update the entity. + * @param entity object to be updated + * + * @return Patched entity + */ + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + @Mapping(target = "withId", ignore = true) + @Mapping(target = "withCreated", ignore = true) + @Mapping(target = "withUpdated", ignore = true) + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + PlaceEntity patchEntity(PlaceCore patch, @MappingTarget PlaceEntity entity); + +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/USState.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/USState.java new file mode 100644 index 0000000..56db8e0 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/USState.java @@ -0,0 +1,6 @@ +package org.saltations.mre.domain; + +public enum USState +{ + MA,NH,ME,NY +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/domain/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/domain/package-info.java new file mode 100644 index 0000000..eb5aa59 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/domain/package-info.java @@ -0,0 +1,8 @@ +/** + * This package contains the classes needed to model the project wide domain. this includes entities value, objects, etc. that are used throughout the entire project. + *

+ * Representations of the domain's relatively rich business model. Includes aggregates, entities, and value objects of the project's domain. + * you would only expect to see this code change when one of the project wide entities, value objects, or aggregates would change. + */ + +package org.saltations.mre.domain; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDController.java b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDController.java new file mode 100644 index 0000000..c915789 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDController.java @@ -0,0 +1,43 @@ +package org.saltations.mre.people; + +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.validation.validator.Validator; +import io.micronaut.web.router.RouteBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.saltations.mre.domain.Person; +import org.saltations.mre.domain.PersonCore; +import org.saltations.mre.domain.PersonEntity; +import org.saltations.mre.common.presentation.RestCrudEntityControllerFoundation; +import org.saltations.mre.common.presentation.StdController; +import org.saltations.mre.domain.PersonMapper; + + +/** + * Provides REST access to the Person entity + */ + +@Slf4j +@StdController +@Controller( + value = "/people/1", + consumes = MediaType.APPLICATION_JSON, + produces = {MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON_PROBLEM } +) +@Tag(name="Persons", description = "People's names and contact info") +public class PersonCRUDController extends RestCrudEntityControllerFoundation +{ + @Inject + public PersonCRUDController(RouteBuilder.UriNamingStrategy uriNaming, PersonCRUDService entityService, PersonRepo entityRepo, PersonMapper entityMapper, Validator validator) + { + super(uriNaming, PersonEntity.class, entityService, entityRepo, entityMapper, validator); + } + + @Override + public String getEntityName() + { + return "person"; + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDService.java b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDService.java new file mode 100644 index 0000000..aeb6364 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonCRUDService.java @@ -0,0 +1,20 @@ +package org.saltations.mre.people; + +import io.micronaut.validation.validator.Validator; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.saltations.mre.domain.Person; +import org.saltations.mre.domain.PersonCore; +import org.saltations.mre.domain.PersonEntity; +import org.saltations.mre.common.application.CrudEntityServiceFoundation; +import org.saltations.mre.domain.PersonMapper; + +@Singleton +public class PersonCRUDService extends CrudEntityServiceFoundation +{ + @Inject + public PersonCRUDService(PersonRepo repo, PersonMapper mapper, Validator validator) + { + super(PersonEntity.class, repo, mapper, validator); + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/people/PersonRepo.java b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonRepo.java new file mode 100644 index 0000000..35e8d09 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/people/PersonRepo.java @@ -0,0 +1,15 @@ +package org.saltations.mre.people; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import org.saltations.mre.domain.PersonEntity; +import io.micronaut.data.repository.CrudRepository; + +/** + * Repository for the Person entity + */ + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PersonRepo extends CrudRepository +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/people/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/people/package-info.java new file mode 100644 index 0000000..298ebb8 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/people/package-info.java @@ -0,0 +1,6 @@ +/** + * Contains a vertical slice of people related features. This includes presentation classes, + * localized business logic, and domain classes and objects that are only specific to implementing people related work + */ + +package org.saltations.mre.people; diff --git a/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDController.java b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDController.java new file mode 100644 index 0000000..124b86b --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDController.java @@ -0,0 +1,40 @@ +package org.saltations.mre.places; + +import java.util.UUID; + +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.validation.validator.Validator; +import io.micronaut.web.router.RouteBuilder; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.saltations.mre.common.presentation.RestCrudEntityControllerFoundation; +import org.saltations.mre.common.presentation.StdController; +import org.saltations.mre.domain.Place; +import org.saltations.mre.domain.PlaceCore; +import org.saltations.mre.domain.PlaceEntity; +import org.saltations.mre.domain.PlaceMapper; + +/** + * Provides REST access to the Place entity + */ + +@Slf4j +@StdController +@Controller(value = "/places", produces = MediaType.APPLICATION_JSON, consumes = MediaType.APPLICATION_JSON) +@Tag(name="Places", description = "Place info") +public class PlaceCRUDController extends RestCrudEntityControllerFoundation +{ + @Inject + public PlaceCRUDController(RouteBuilder.UriNamingStrategy uriNaming, PlaceCRUDService entityService, PlaceRepo entityRepo, PlaceMapper entityMapper, Validator validator) + { + super(uriNaming, PlaceEntity.class, entityService, entityRepo, entityMapper, validator); + } + + @Override + public String getEntityName() + { + return "place"; + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDService.java b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDService.java new file mode 100644 index 0000000..d847aa0 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceCRUDService.java @@ -0,0 +1,22 @@ +package org.saltations.mre.places; + +import java.util.UUID; + +import io.micronaut.validation.validator.Validator; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.saltations.mre.common.application.CrudEntityServiceFoundation; +import org.saltations.mre.domain.Place; +import org.saltations.mre.domain.PlaceCore; +import org.saltations.mre.domain.PlaceEntity; +import org.saltations.mre.domain.PlaceMapper; + +@Singleton +public class PlaceCRUDService extends CrudEntityServiceFoundation +{ + @Inject + public PlaceCRUDService(PlaceRepo repo, PlaceMapper mapper, Validator validator) + { + super(PlaceEntity.class, repo, mapper, validator); + } +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceRepo.java b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceRepo.java new file mode 100644 index 0000000..715792d --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/places/PlaceRepo.java @@ -0,0 +1,13 @@ +package org.saltations.mre.places; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import org.saltations.mre.domain.PlaceEntity; +import io.micronaut.data.repository.CrudRepository; + +import java.util.UUID; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PlaceRepo extends CrudRepository +{ +} diff --git a/rest-exemplar/src/main/java/org/saltations/mre/places/package-info.java b/rest-exemplar/src/main/java/org/saltations/mre/places/package-info.java new file mode 100644 index 0000000..7e1e8a9 --- /dev/null +++ b/rest-exemplar/src/main/java/org/saltations/mre/places/package-info.java @@ -0,0 +1,6 @@ +/** + * Contains a vertical slice of place related features. This includes presentation classes, + * localized business logic, and domain classes and objects that are only specific to implementing place related work + */ + +package org.saltations.mre.places; diff --git a/rest-exemplar/src/main/resources/application.yml b/rest-exemplar/src/main/resources/application.yml new file mode 100644 index 0000000..e710d0d --- /dev/null +++ b/rest-exemplar/src/main/resources/application.yml @@ -0,0 +1,65 @@ +micronaut: + application: + name: MNRestExemplar + router: + static-resources: + swagger: + paths: classpath:META-INF/swagger + mapping: /swagger/** + swagger-ui: + paths: classpath:META-INF/swagger/views/swagger-ui + mapping: /swagger-ui/** + codec: + json: + additional-types: 'application/problem+json' + +netty: + default: + allocator: + max-order: 3 + +problem: + enabled: true + stack-trace: false + +liquibase: + datasources: + default: + enabled: true + change-log: classpath:db/liquibase-changelog.xml + +datasources: + default: +# url: jdbc:mysql://localhost:3306/db +# username: root +# password: '' + driverClassName: org.postgresql.Driver + dialect: POSTGRES + schema-generate: NONE + +endpoints: + health: + enabled: true + sensitive: false # TODO Change to make secure + details-visible: ANONYMOUS + info: + enabled: true + sensitive: false # TODO Change to make secure + build: + enabled: true + routes: + enabled: true + sensitive: false # TODO Change to make secure + refresh: + enabled: false + sensitive: false # TODO Change to make secure + loggers: + enabled: true + sensitive: false # TODO Change to make secure + write-sensitive: false +# metrics: +# enabled: true # TODO Change to make secure +# sensitive: false + liquibase: + enabled: true + sensitive: false diff --git a/rest-exemplar/src/main/resources/db/changelog/01-schema.xml b/rest-exemplar/src/main/resources/db/changelog/01-schema.xml new file mode 100644 index 0000000..2023304 --- /dev/null +++ b/rest-exemplar/src/main/resources/db/changelog/01-schema.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rest-exemplar/src/main/resources/db/liquibase-changelog.xml b/rest-exemplar/src/main/resources/db/liquibase-changelog.xml new file mode 100644 index 0000000..2b784c3 --- /dev/null +++ b/rest-exemplar/src/main/resources/db/liquibase-changelog.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/rest-exemplar/src/main/resources/logback.xml b/rest-exemplar/src/main/resources/logback.xml new file mode 100644 index 0000000..94d2a1a --- /dev/null +++ b/rest-exemplar/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n + + + + + + + + + + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/app/MNRestExemplarAppTest.java b/rest-exemplar/src/test/java/org/saltations/mre/app/MNRestExemplarAppTest.java new file mode 100644 index 0000000..711cd77 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/app/MNRestExemplarAppTest.java @@ -0,0 +1,179 @@ +package org.saltations.mre.app; + +import java.time.ZonedDateTime; +import java.util.List; + +import io.micronaut.http.HttpStatus; +import io.micronaut.context.ApplicationContext; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import lombok.Data; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(application = MNRestExemplarApp.class) +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MNRestExemplarAppTest +{ + @Inject + ApplicationContext applicationContext; + + @Inject + RequestSpecification spec; + + @Test + @Order(1) + void isRunning() + { + assertTrue(applicationContext.isRunning()); + } + + @Test + @Order(2) + final void isHealthy() + { + spec. + when(). + get("/health"). + then(). + statusCode(HttpStatus.OK.getCode()). + body("status", is("UP")); + } + + @Test + @Order(3) + final void suppliesInfo() + { + var result = spec. + when(). + get("/info"). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().asString(); + } + + @Test + @Order(7) + final void suppliesRoutes() + { + var result = spec. + when(). + get("/routes"). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().asString(); + + assertNotEquals("", result, "Response should be non-null"); + } + + @Test + @Order(8) + final void suppliesLoggers() + { + var result = spec. + when(). + get("/loggers"). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().asString(); + + assertNotEquals("", result, "Response should be non-null"); + } + + @Test + @Order(10) + final void canCheckSpecificLoggers() + { + spec. + when(). + get("/loggers/io.micronaut.http"). + then(). + statusCode(HttpStatus.OK.getCode()). + body("effectiveLevel", is("INFO")); + } + + @Test + @Order(12) + final void canChangeLoggers() + { + spec. + when(). + contentType(ContentType.JSON). + body("{ \"configuredLevel\": \"ERROR\" }"). + post("/loggers/io.micronaut.http"). + then(). + statusCode(HttpStatus.OK.getCode()); + + spec. + when(). + get("/loggers/io.micronaut.http"). + then(). + statusCode(HttpStatus.OK.getCode()). + body("configuredLevel", is("ERROR")); + } + + + @Test + @Order(20) + final void canCheckLiquibaseChangelog() + { + var result = spec. + when(). + get("/liquibase"). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(new TypeRef>() {} + ); + + assertNotNull(result); + } + + @Data + @Serdeable + static class LiquibaseReport { + + private String name; + + private List changeSets; + + } + + @Data + @Serdeable + static class ChangeSet { + + private String author; + private String changeLog; + private String comments; + private ZonedDateTime dateExecuted; + private String deploymentId; + private String description; + private String execType; + private String id; + private String storedChangeLog; + private String checksum; + private Integer orderExecuted; + private String tag; + private List labels; + private List contexts; + + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonApplicationLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonApplicationLayer.java new file mode 100644 index 0000000..988cdd5 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonApplicationLayer.java @@ -0,0 +1,41 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.saltations.mre.architecture.CommonDomainLayer.areCommonDomainAndBelow; + +@AnalyzeClasses(packages = "org.saltations.mre.common.application", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CommonApplicationLayer +{ + static final DescribedPredicate areCommonApplication = resideInAPackage( + "org.saltations.mre.common.application.." + ); + + static final DescribedPredicate areCommonApplicationDependencies = resideInAnyPackage( + "com.fasterxml.jackson..", + "org.saltations.endeavour" + ); + + static final DescribedPredicate areCommonApplicationAndBelow = areCommonApplication + .or(areCommonApplicationDependencies) + .or(areCommonDomainAndBelow); + + @ArchTest + static final ArchRule should_only_depend_on_itself_and_common_domain_and_below = + classes() + .should() + .onlyDependOnClassesThat(areCommonApplicationAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonCoreLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonCoreLayer.java new file mode 100644 index 0000000..1119a95 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonCoreLayer.java @@ -0,0 +1,36 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@AnalyzeClasses(packages = "org.saltations.mre.common.core", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CommonCoreLayer +{ + static final DescribedPredicate areCommonCore = resideInAPackage("org.saltations.mre.common.core.."); + + static final DescribedPredicate areCrosscuttingDependencies = resideInAnyPackage("", "java..", "javax..", "jakarta..", // Jakarta annotations are across cutting concern that is easily managed and changed. + "org.slf4j..", // Logging is crosscutting concern that is easily managed if it needs to be replaced + "lombok..", // Bean annotations. Easily changed out if necessary. + "io.micronaut.." // FIXIT Move out of core dependencies. + ); + + static final DescribedPredicate areCommonCoreAndBelow = areCommonCore.or(areCrosscuttingDependencies); + + + @ArchTest + static final ArchRule common_core_should_only_depend_on_standard_libs_and_crosscutting_libs = classes().should() + .onlyDependOnClassesThat(areCommonCoreAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDependenciesPointDown.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDependenciesPointDown.java new file mode 100644 index 0000000..3790ff3 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDependenciesPointDown.java @@ -0,0 +1,17 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.library.DependencyRules.NO_CLASSES_SHOULD_DEPEND_UPPER_PACKAGES; + +@AnalyzeClasses(packages = "org.saltations.mre.common..", importOptions = {ImportOption.DoNotIncludeTests.class}) +public class CommonDependenciesPointDown +{ + @ArchTest + static final ArchRule no_access_to_upper_package = NO_CLASSES_SHOULD_DEPEND_UPPER_PACKAGES; + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDomainLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDomainLayer.java new file mode 100644 index 0000000..5f9d45b --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonDomainLayer.java @@ -0,0 +1,43 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.saltations.mre.architecture.CommonCoreLayer.areCommonCoreAndBelow; + +@AnalyzeClasses(packages = "org.saltations.mre.common.domain", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CommonDomainLayer +{ + static final DescribedPredicate areCommonDomain = resideInAPackage( + "org.saltations.mre.common.domain.." + ); + + static final DescribedPredicate areCommonDomainDependencies = resideInAnyPackage( + "org.mapstruct..", + "com.fasterxml.jackson..", // FIXIT Do we really need these dependencies at this level ? + "io.swagger..", // FIXIT Do we really need swagger annotations here ? + "io.micronaut.context.." // FIXIT Do we really need these dependencies at this level ? + ); + + static final DescribedPredicate areCommonDomainAndBelow = areCommonDomain + .or(areCommonDomainDependencies) + .or(areCommonCoreAndBelow); + + @ArchTest + static final ArchRule should_only_depend_on_itself_and_common_core_and_below = + classes() + .should() + .onlyDependOnClassesThat(areCommonDomainAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonInfraLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonInfraLayer.java new file mode 100644 index 0000000..1159dee --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonInfraLayer.java @@ -0,0 +1,40 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.saltations.mre.architecture.CommonDomainLayer.areCommonDomainAndBelow; + +@AnalyzeClasses(packages = "org.saltations.mre.common.infra", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CommonInfraLayer +{ + static final DescribedPredicate areCommonInfra = resideInAPackage( + "org.saltations.mre.common.infra.." + ); + + static final DescribedPredicate areCommonInfraDependencies = resideInAnyPackage( + "" + ); + + static final DescribedPredicate areCommonInfraAndBelow = areCommonInfra + .or(areCommonInfraDependencies) + .or(areCommonDomainAndBelow); + + @ArchTest + static final ArchRule should_only_depend_on_itself_and_common_application_and_below = + classes() + .should() + .onlyDependOnClassesThat(areCommonInfraAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonPresentationLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonPresentationLayer.java new file mode 100644 index 0000000..3e9cb40 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/CommonPresentationLayer.java @@ -0,0 +1,43 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.saltations.mre.architecture.CommonApplicationLayer.areCommonApplicationAndBelow; + +@AnalyzeClasses(packages = "org.saltations.mre.common.presentation", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class CommonPresentationLayer +{ + static final DescribedPredicate areCommonPresentation = resideInAPackage( + "org.saltations.mre.common.presentation.." + ); + + static final DescribedPredicate areCommonPresentationDependencies = resideInAnyPackage( + "io.swagger..", // TODO review of this needs to be in the _common_ presentation layer + "org.zalando.problem..", // TODO review of this needs to be in the _common_ presentation layer + "com.github.fge..", // TODO review of this needs to be in the _common_ presentation layer + "reactor.core.." // TODO review of this needs to be in the _common_ presentation layer + ); + + static final DescribedPredicate areCommonPresentationAndBelow = areCommonPresentation + .or(areCommonPresentationDependencies) + .or(areCommonApplicationAndBelow); + + @ArchTest + static final ArchRule should_only_depend_on_itself_and_common_application_and_below = + classes() + .should() + .onlyDependOnClassesThat(areCommonPresentationAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/Feature.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/Feature.java new file mode 100644 index 0000000..bc54f2b --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/Feature.java @@ -0,0 +1,64 @@ +package org.saltations.mre.architecture; + +import java.util.List; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleNameEndingWith; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class Feature +{ + public static final String ROOT_PACKAGE = "org.saltations.mre"; + + private final JavaClasses projectClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(ROOT_PACKAGE + ".."); + + static List getDomainNames() + { + return List.of("people","places"); + } + + @Order(10) + @ParameterizedTest(name ="{index} => {0} feature controllers depend on domain logic and domain model") + @MethodSource("getDomainNames") + void controllers_depend_on_feature_code_and_infra_layer_and_presentation_layer_and_below(String domainName) { + + var areFeatureLayer = resideInAPackage(ROOT_PACKAGE + "." + domainName + ".."); + var areFeatureControllers = areFeatureLayer.and(simpleNameEndingWith("Controller")); + + classes() + .that(areFeatureControllers) + .should() + .onlyDependOnClassesThat(areFeatureLayer.or(ProjectDomainLayer.areProjectDomainAndBelow).or(CommonPresentationLayer.areCommonPresentationAndBelow)) + .check(projectClasses); + } + + @Order(20) + @ParameterizedTest(name ="{index} => {0} application logic depends on domain logic and domain model") + @MethodSource("getDomainNames") + void services_depend_on_feature_code_and_infra_layer_and_presentation_layer_and_below(String domainName) { + + var areFeatureLayer = resideInAPackage(ROOT_PACKAGE + "." + domainName + ".."); + var areFeatureApplicationLayer = areFeatureLayer.and(simpleNameEndingWith("Service").or(simpleNameEndingWith("UseCase"))); + + classes() + .that(areFeatureApplicationLayer) + .should() + .onlyDependOnClassesThat(areFeatureLayer.or(ProjectDomainLayer.areProjectDomainAndBelow).or(CommonApplicationLayer.areCommonApplicationAndBelow)) + .check(projectClasses); + } + + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/architecture/ProjectDomainLayer.java b/rest-exemplar/src/test/java/org/saltations/mre/architecture/ProjectDomainLayer.java new file mode 100644 index 0000000..c0b2186 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/architecture/ProjectDomainLayer.java @@ -0,0 +1,40 @@ +package org.saltations.mre.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.saltations.mre.architecture.CommonDomainLayer.areCommonDomainAndBelow; + +@AnalyzeClasses(packages = "org.saltations.mre.domain", importOptions = {ImportOption.DoNotIncludeTests.class}) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ProjectDomainLayer +{ + static final DescribedPredicate areProjectDomain = resideInAPackage( + "org.saltations.mre.domain" + ); + + static final DescribedPredicate areProjectDomainDependencies = resideInAnyPackage( + "" + ); + + static final DescribedPredicate areProjectDomainAndBelow = areProjectDomain + .or(areProjectDomainDependencies) + .or(areCommonDomainAndBelow); + + @ArchTest + static final ArchRule should_only_depend_on_itself_and_common_domain_layer_and_below = + classes() + .should() + .onlyDependOnClassesThat(areProjectDomainAndBelow); + +} + diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonCRUDControllerTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonCRUDControllerTest.java new file mode 100644 index 0000000..8b721a3 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonCRUDControllerTest.java @@ -0,0 +1,242 @@ +package org.saltations.mre.domain.people; + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.micronaut.http.HttpStatus; +import io.micronaut.serde.ObjectMapper; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PersonMapper; +import org.saltations.mre.domain.PersonEntity; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@MicronautTest +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class Model1PersonCRUDControllerTest +{ + public static final String RESOURCE_ENDPOINT = "/people/1/"; + public static final String VALID_JSON_MERGE_PATCH_STRING = "{ \"first_name\" : \"Srinivas\", \"last_name\" : null }"; + public static final Class ENTITY_CLASS = PersonEntity.class; + + @Inject + private PersonOracle oracle; + + @Inject + private PersonMapper modelMapper; + + @Inject + private RequestSpecification spec; + + @Inject + private ObjectMapper objMapper; + + @Test + @Order(2) + void canCreateReadReplaceAndDelete() + throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + + assertNotNull(created); + oracle.hasSameCoreContent(proto, created); + + + // Read + + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(created, retrieved); + + // Replace + + var altered = oracle.refurbishCore(); + var updatePayload = objMapper.writeValueAsString(modelMapper.patchEntity(altered, retrieved)); + + var replaced = spec. + when(). + contentType(ContentType.JSON). + body(updatePayload). + put(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(altered, replaced); + + // Delete + + spec. + when(). + contentType(ContentType.JSON). + delete(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()); + + //@formatter:on + } + + @Test + @Order(4) + void canPatch() throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + assertNotNull(created); + oracle.hasSameCoreContent(proto, created); + + // Read + + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(created, retrieved); + + // Replace + + var jacksonMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + jacksonMapper.registerModule(new JavaTimeModule()); + + // Patch with valid values + + var refurb = oracle.refurbishCore(); + var patch = jacksonMapper.writeValueAsString(refurb); + + var patched = spec. + when(). + contentType(ContentType.JSON). + body(patch). + log().all(). + patch(RESOURCE_ENDPOINT + created.getId()). + then(). + log().all(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(refurb, patched); + //@formatter:on + } + + + @Test + @Order(20) + void whenCreatingResourceWithIncorrectInputReturnsValidProblemDetails() + { + //@formatter:off + var created = spec. + when(). + contentType(ContentType.JSON). + body("{}"). + post(RESOURCE_ENDPOINT). + then(). + log().all(). + statusCode(HttpStatus.BAD_REQUEST.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-create-constraint-violations.schema.json")); + //@formatter:on + } + + @Test + @Order(22) + void whenGettingNonexistentResourceReturnsProblemDetails() + { + //@formatter:off + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get(RESOURCE_ENDPOINT + 274). + then(). + statusCode(HttpStatus.NOT_FOUND.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-find-resource.schema.json")); + //@formatter:on + } + + @Test + @Order(24) + void whenReplacingResourceWithIncorrectInputReturnsValidProblemDetails() throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + // Replace + + var alteredCore = oracle.refurbishCore(); + + //noinspection DataFlowIssue + alteredCore.setAge(0); // Set age to an invalid value + + var updatePayload = objMapper.writeValueAsString(alteredCore); + + spec. + when(). + contentType(ContentType.JSON). + body(updatePayload). + put(RESOURCE_ENDPOINT + created.getId()). + then(). + log().all(). + statusCode(HttpStatus.BAD_REQUEST.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-create-constraint-violations.schema.json")); + //@formatter:on + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonMapperTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonMapperTest.java new file mode 100644 index 0000000..d9b663e --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonMapperTest.java @@ -0,0 +1,70 @@ +package org.saltations.mre.domain.people; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PersonMapper; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit test for the PersonMapper + */ + +@Testcontainers +@MicronautTest(transactional = false) +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class Model1PersonMapperTest +{ + @Inject + private PersonOracle oracle; + + @Inject + private PersonMapper mapper; + + @Test + @Order(2) + void canCreateAnEntityFromAPrototype() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + } + + @Test + @Order(4) + void canPatchAnEntityFromAPrototype() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + + var patch = oracle.refurbishCore(); + var updated = mapper.patchEntity(patch, created); + + oracle.hasSameCoreContent(patch, updated); + } + + @Test + void doesNotPatchNulls() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + + var patch = oracle.refurbishCore(); + patch.setLastName(null); + + var updated = mapper.patchEntity(patch, created); + assertEquals(prototype.getLastName(), updated.getLastName(), "LastName was left untouched"); + } + + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonRepoTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonRepoTest.java new file mode 100644 index 0000000..a409397 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/Model1PersonRepoTest.java @@ -0,0 +1,128 @@ +package org.saltations.mre.domain.people; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import io.micronaut.data.runtime.criteria.RuntimeCriteriaBuilder; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PersonMapper; +import org.saltations.mre.people.PersonRepo; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@SuppressWarnings("ClassHasNoToStringMethod") +@MicronautTest(transactional = false) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +class Model1PersonRepoTest +{ + @Inject + private PersonOracle oracle; + + @Inject + private PersonMapper mapper; + + @Inject + private PersonRepo repo; + + @Inject + private RuntimeCriteriaBuilder runtimeCriteriaBuilder; + + @BeforeEach + public void cleanDB() + { + repo.deleteAll(); + } + + @Test + @Order(2) + void canInsertFindAndDeleteAnEntity() + { + var prototype = mapper.createEntity(oracle.coreExemplar()); + + // Save and validate + + var saved = repo.save(prototype); + oracle.hasSameCoreContent(prototype, saved); + + // Retrieve and validate + + assertTrue(repo.existsById(saved.getId()),"It does exist"); + var retrieved = repo.findById(saved.getId()).orElseThrow(); + oracle.hasSameCoreContent(saved, retrieved); + + repo.deleteById(saved.getId()); + assertFalse(repo.existsById(saved.getId()),"Should have been deleted"); + } + + @Test + @Order(4) + void canUpdateAnEntity() + { + // Given a saved entity + + var saved = repo.save(mapper.createEntity(oracle.coreExemplar())); + + // When updated + + var update = mapper.patchEntity(oracle.refurbishCore(), saved); + var updated = repo.update(update); + + // Then + + oracle.hasSameCoreContent(update, updated); + } + + @Test + @Order(6) + void canInsertAndUpdateACollection() + { + var protos = oracle.coreExemplars(1,20); + + var saved = repo.saveAll(mapper.createEntities(protos)); + assertEquals(protos.size(), saved.size(), "Created the expected amount"); + + var modified = saved.stream().map(x -> { + var modifiedCore = oracle.refurbishCore(x); + return mapper.patchEntity(modifiedCore, x); + } + ).collect(Collectors.toList()); + + var updated = repo.updateAll(modified); + assertEquals(modified.size(), updated.size(), "Updated the expected amount"); + + IntStream.range(0,20).forEach(i -> oracle.hasSameCoreContent(modified.get(i), updated.get(i))); + } + + @Test + @Order(8) + void canInsertAndFindACollectionByIds() + { + var protos = oracle.coreExemplars(1,20); + + var saved = repo.saveAll(mapper.createEntities(protos)); + assertEquals(protos.size(), saved.size(), "Created the expected amount"); + + var ids = saved.stream().map(e -> e.getId()).collect(Collectors.toList()); + + var retrieved = repo.findAllById(ids); + assertEquals(ids.size(), retrieved.size(), "Retrieved the expected amount"); + + IntStream.range(0,20).forEach(i -> oracle.hasSameCoreContent(saved.get(i), retrieved.get(i))); + + repo.deleteAllById(ids); + assertEquals(0, repo.count(),"They should be gone"); + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/people/PersonOracle.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/PersonOracle.java new file mode 100644 index 0000000..adcf5aa --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/people/PersonOracle.java @@ -0,0 +1,66 @@ +package org.saltations.mre.domain.people; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.saltations.mre.domain.PersonMapper; +import org.saltations.mre.domain.Person; +import org.saltations.mre.domain.PersonCore; +import org.saltations.mre.domain.PersonEntity; +import org.saltations.mre.fixtures.EntityOracleBase; + +/** + * Provides exemplars of Person cores and entities. + */ + +@Singleton +public class PersonOracle extends EntityOracleBase +{ + private final PersonMapper mapper; + + @Inject + public PersonOracle(PersonMapper mapper) + { + super(PersonCore.class, PersonEntity.class, Person.class, 1L); + this.mapper = mapper; + } + + @Override + public PersonCore coreExemplar(long sharedInitialValue, int offset) + { + int currIndex = (int) sharedInitialValue + offset; + + return PersonCore.of() + .age(12 + currIndex) + .firstName("Samuel") + .lastName("Clemens") + .emailAddress("shmoil" + currIndex + "@agiga.com") + .done(); + } + + @Override + public PersonEntity entityExemplar(long sharedInitialValue, int offset) + { + var currIndex = initialSharedValue + offset; + + var core = coreExemplar(initialSharedValue, offset); + var entity = mapper.createEntity(core); + + entity.setId(currIndex); + + return entity; + } + + @Override + public PersonCore refurbishCore(PersonCore original) + { + var refurb = mapper.copyCore(original); + + refurb.setAge(original.getAge()+1); + refurb.setFirstName(original.getFirstName()+"A"); + refurb.setLastName(original.getLastName()+"B"); + refurb.setEmailAddress("mod"+ original.getEmailAddress()); + + return original; + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceCRUDControllerTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceCRUDControllerTest.java new file mode 100644 index 0000000..b05044d --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceCRUDControllerTest.java @@ -0,0 +1,245 @@ +package org.saltations.mre.domain.places; + +import java.util.UUID; + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.micronaut.http.HttpStatus; +import io.micronaut.serde.ObjectMapper; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PlaceMapper; +import org.saltations.mre.domain.PlaceEntity; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@MicronautTest +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class Model1PlaceCRUDControllerTest +{ + public static final String RESOURCE_ENDPOINT = "/places/"; + public static final Class ENTITY_CLASS = PlaceEntity.class; + + + @Inject + private PlaceOracle oracle; + + @Inject + private PlaceMapper modelMapper; + + @Inject + private RequestSpecification spec; + + @Inject + private ObjectMapper objMapper; + + + /** + * Confirms that the patched resource matches the {@code VALID_JSON_MERGE_PATCH_STRING} + */ + + + @Test + @Order(2) + void canCreateReadReplaceAndDelete() + throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + + assertNotNull(created); + oracle.hasSameCoreContent(proto, created); + + // Read + + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(created, retrieved); + + // Replace + + var altered = oracle.refurbishCore(); + var updatePayload = objMapper.writeValueAsString(modelMapper.patchEntity(altered, retrieved)); + + var replaced = spec. + when(). + contentType(ContentType.JSON). + body(updatePayload). + put(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(altered, replaced); + + // Delete + + spec. + when(). + contentType(ContentType.JSON). + delete(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()); + + //@formatter:on + } + + @Test + @Order(4) + void canPatch() throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + assertNotNull(created); + oracle.hasSameCoreContent(proto, created); + + // Read + + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(created, retrieved); + + // Patch with valid values + + var jacksonMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + jacksonMapper.registerModule(new JavaTimeModule()); + + var refurb = oracle.refurbishCore(); + var patch = jacksonMapper.writeValueAsString(refurb); + + var patched = spec. + when(). + contentType(ContentType.JSON). + body(patch). + patch(RESOURCE_ENDPOINT + created.getId()). + then(). + statusCode(HttpStatus.OK.getCode()). + extract().as(ENTITY_CLASS); + + oracle.hasSameCoreContent(refurb, patched); + //@formatter:on + } + + + @Test + @Order(20) + void whenCreatingResourceWithIncorrectInputReturnsValidProblemDetails() + { + //@formatter:off + var created = spec. + when(). + contentType(ContentType.JSON). + body("{}"). + post(RESOURCE_ENDPOINT). + then(). + log().all(). + statusCode(HttpStatus.BAD_REQUEST.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-create-constraint-violations.schema.json")); + //@formatter:on + } + + @Test + @Order(22) + void whenGettingNonexistentResourceReturnsProblemDetails() + { + var id = new UUID(11111L,22222L); + + //@formatter:off + var retrieved = spec. + when(). + contentType(ContentType.JSON). + get("/places/" + id). + then(). + statusCode(HttpStatus.NOT_FOUND.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-find-resource.schema.json")); + //@formatter:on + } + + @Test + @Order(24) + void whenReplacingResourceWithIncorrectInputReturnsValidProblemDetails() throws Exception + { + //@formatter:off + // Create + + var proto = oracle.coreExemplar(); + var protoPayload = objMapper.writeValueAsString(proto); + + var created = spec. + when(). + contentType(ContentType.JSON). + body(protoPayload). + post(RESOURCE_ENDPOINT). + then(). + statusCode(HttpStatus.CREATED.getCode()). + extract().as(ENTITY_CLASS); + + // Replace + + var alteredCore = oracle.refurbishCore(); + alteredCore.setName(null); + + var updatePayload = objMapper.writeValueAsString(alteredCore); + + spec. + when(). + contentType(ContentType.JSON). + body(updatePayload). + put(RESOURCE_ENDPOINT + created.getId()). + then(). + log().all(). + statusCode(HttpStatus.BAD_REQUEST.getCode()). + assertThat().body(matchesJsonSchemaInClasspath("json-schema/cannot-create-constraint-violations.schema.json")); + //@formatter:on + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceMapperTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceMapperTest.java new file mode 100644 index 0000000..ab502e6 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceMapperTest.java @@ -0,0 +1,71 @@ +package org.saltations.mre.domain.places; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PlaceMapper; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit test for the PlaceMapper + */ + +@Testcontainers +@MicronautTest(transactional = false) +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class Model1PlaceMapperTest +{ + @Inject + private PlaceOracle oracle; + + @Inject + private PlaceMapper mapper; + + @Test + @Order(2) + void canCreateAnEntityFromAPrototype() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + } + + @Test + @Order(4) + void canPatchAnEntityFromAPrototype() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + + var patch = oracle.refurbishCore(); + var updated = mapper.patchEntity(patch, created); + + oracle.hasSameCoreContent(patch, updated); + } + + @Test + @Order(6) + void doesNotPatchNulls() + { + var prototype = oracle.coreExemplar(); + var created = mapper.createEntity(prototype); + oracle.hasSameCoreContent(prototype, created); + + var patch = oracle.refurbishCore(); + patch.setCity(null); + + var updated = mapper.patchEntity(patch, created); + assertEquals(prototype.getCity(), updated.getCity(), "City was left untouched"); + } + + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceRepoTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceRepoTest.java new file mode 100644 index 0000000..99cbe2b --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceRepoTest.java @@ -0,0 +1,123 @@ +package org.saltations.mre.domain.places; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.domain.PlaceMapper; +import org.saltations.mre.places.PlaceRepo; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@SuppressWarnings("ClassHasNoToStringMethod") +@MicronautTest(transactional = false) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +class Model1PlaceRepoTest +{ + @Inject + private PlaceOracle oracle; + + @Inject + private PlaceMapper mapper; + + @Inject + private PlaceRepo repo; + + @BeforeEach + public void cleanDB() + { + repo.deleteAll(); + } + + @Test + @Order(2) + void canInsertFindAndDeleteAnEntity() + { + var prototype = mapper.createEntity(oracle.coreExemplar()); + + // Save and validate + + var saved = repo.save(prototype); + oracle.hasSameCoreContent(prototype, saved); + + // Retrieve and validate + + assertTrue(repo.existsById(saved.getId()),"It does exist"); + var retrieved = repo.findById(saved.getId()).orElseThrow(); + oracle.hasSameCoreContent(saved, retrieved); + + repo.deleteById(saved.getId()); + assertFalse(repo.existsById(saved.getId()),"Should have been deleted"); + } + + @Test + @Order(4) + void canUpdateAnEntity() + { + // Given a saved entity + + var saved = repo.save(mapper.createEntity(oracle.coreExemplar())); + + // When updated + + var update = mapper.patchEntity(oracle.refurbishCore(), saved); + var updated = repo.update(update); + + // Then + + oracle.hasSameCoreContent(update, updated); + } + + @Test + @Order(6) + void canInsertAndUpdateACollection() + { + var protos = oracle.coreExemplars(1,20); + + var saved = repo.saveAll(mapper.createEntities(protos)); + assertEquals(protos.size(), saved.size(), "Created the expected amount"); + + var modified = saved.stream().map(x -> { + var modifiedCore = oracle.refurbishCore(x); + return mapper.patchEntity(modifiedCore, x); + } + ).collect(Collectors.toList()); + + var updated = repo.updateAll(modified); + assertEquals(modified.size(), updated.size(), "Updated the expected amount"); + + IntStream.range(0,20).forEach(i -> oracle.hasSameCoreContent(modified.get(i), updated.get(i))); + } + + @Test + @Order(8) + void canInsertAndFindACollectionByIds() + { + var protos = oracle.coreExemplars(1,20); + + var saved = repo.saveAll(mapper.createEntities(protos)); + assertEquals(protos.size(), saved.size(), "Created the expected amount"); + + var ids = saved.stream().map(e -> e.getId()).collect(Collectors.toList()); + + var retrieved = repo.findAllById(ids); + assertEquals(ids.size(), retrieved.size(), "Retrieved the expected amount"); + + IntStream.range(0,20).forEach(i -> oracle.hasSameCoreContent(saved.get(i), retrieved.get(i))); + + repo.deleteAllById(ids); + assertEquals(0, repo.count(),"They should be gone"); + } +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceServiceTest.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceServiceTest.java new file mode 100644 index 0000000..f83a5c4 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/Model1PlaceServiceTest.java @@ -0,0 +1,74 @@ +package org.saltations.mre.domain.places; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.saltations.mre.common.application.CannotCreateEntity; +import org.saltations.mre.common.application.CannotDeleteEntity; +import org.saltations.mre.common.application.CannotUpdateEntity; +import org.saltations.mre.domain.PlaceMapper; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; +import org.saltations.mre.places.PlaceCRUDService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class Model1PlaceServiceTest +{ + @Inject + private PlaceOracle oracle; + + @Inject + private PlaceMapper modelMapper; + + @Inject + private PlaceCRUDService service; + + @Test + @Order(2) + void canCreateReadUpdateAndDelete() throws CannotCreateEntity, CannotUpdateEntity, CannotDeleteEntity + { + // Save + + var prototype = oracle.coreExemplar(); + var result = service.create(prototype); + var saved = result.get(); + + assertNotNull(saved); + assertNotNull(saved.getId()); + oracle.hasSameCoreContent(prototype, saved); + + // Read + + var retrieved = service.find(saved.getId()).orElseThrow(); + oracle.hasSameCoreContent(saved, retrieved); + assertEquals(saved.getId(), retrieved.getId()); + + // Update + + var alteredCore = oracle.refurbishCore(); + var modified = modelMapper.patchEntity(alteredCore, retrieved); + service.update(modified); + + var updated = service.find(saved.getId()).orElseThrow(); + oracle.hasSameCoreContent(alteredCore, updated); + + // Delete + + service.delete(saved.getId()); + var possible = service.find(saved.getId()); + assertTrue(possible.isEmpty()); + } + + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/domain/places/PlaceOracle.java b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/PlaceOracle.java new file mode 100644 index 0000000..2517cbb --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/domain/places/PlaceOracle.java @@ -0,0 +1,72 @@ +package org.saltations.mre.domain.places; + +import java.util.UUID; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.saltations.mre.domain.PlaceMapper; +import org.saltations.mre.domain.Place; +import org.saltations.mre.domain.PlaceCore; +import org.saltations.mre.domain.PlaceEntity; +import org.saltations.mre.domain.USState; +import org.saltations.mre.fixtures.EntityOracleBase; + +/** + * Provides exemplars of Place cores and entities. + */ + +@Singleton +public class PlaceOracle extends EntityOracleBase +{ + private static final long UUID_MOST_SIGNIFICANT_LONG = 0xffff_ffff_ffff_ffffL; + private final PlaceMapper mapper; + + @Inject + public PlaceOracle(PlaceMapper mapper) + { + super(PlaceCore.class, PlaceEntity.class, Place.class, 1L); + this.mapper = mapper; + } + + @Override + public PlaceCore coreExemplar(long sharedInitialValue, int offset) + { + int currIndex = (int) sharedInitialValue + offset; + + return PlaceCore.of() + .name("City Hall #" + currIndex) + .street1(currIndex + " Mass Ave") + .street2("Suite 1" + currIndex) + .city("Boston") + .state(USState.MA) + .done(); + } + + @Override + public PlaceEntity entityExemplar(long sharedInitialValue, int offset) + { + var currIndex = initialSharedValue + offset; + + var core = coreExemplar(initialSharedValue, offset); + var entity = mapper.createEntity(core); + + entity.setId(new UUID(UUID_MOST_SIGNIFICANT_LONG, currIndex)); + + return entity; + } + + @Override + public PlaceCore refurbishCore(PlaceCore original) + { + var refurb = mapper.copyCore(original); + + refurb.setName(original.getName()+11); + refurb.setCity("los Angeles"); + refurb.setStreet1(original.getStreet1()+"B"); + refurb.setStreet2(original.getStreet2()+"C"); + refurb.setState(USState.NH); + + return original; + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/errors/DomainExceptionTest.java b/rest-exemplar/src/test/java/org/saltations/mre/errors/DomainExceptionTest.java new file mode 100644 index 0000000..c27ce67 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/errors/DomainExceptionTest.java @@ -0,0 +1,47 @@ +package org.saltations.mre.errors; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.Test; +import org.saltations.mre.common.core.errors.DomainException; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +class DomainExceptionTest { + + @Test + void domainExceptionWithMessage() { + var exception = new DomainException("Error occurred"); + assertEquals("Error occurred", exception.getMessage()); + } + + @Test + void domainExceptionWithMessageAndArgs() { + var exception = new DomainException("Error: {}", "details"); + assertEquals("Error: details", exception.getMessage()); + } + + @Test + void domainExceptionWithThrowableAndMessage() { + var cause = new RuntimeException("Cause"); + DomainException exception = new DomainException(cause, "Error occurred"); + assertEquals("Error occurred", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + + @Test + void domainExceptionWithThrowableMessageAndArgs() { + var cause = new RuntimeException("Cause"); + var exception = new DomainException(cause, "Error: {}", "details"); + assertEquals("Error: details", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + + @Test + void domainExceptionTraceIdIsNotNull() { + var exception = new DomainException("Error occurred"); + assertNotNull(exception.getTraceId()); + } +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/errors/FormattedUncheckedExceptionTest.java b/rest-exemplar/src/test/java/org/saltations/mre/errors/FormattedUncheckedExceptionTest.java new file mode 100644 index 0000000..df40394 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/errors/FormattedUncheckedExceptionTest.java @@ -0,0 +1,39 @@ +package org.saltations.mre.errors; + +import java.io.IOException; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.Test; +import org.saltations.mre.common.core.errors.FormattedUncheckedException; +import org.saltations.mre.fixtures.ReplaceBDDCamelCase; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayNameGeneration(ReplaceBDDCamelCase.class) +class FormattedUncheckedExceptionTest +{ + @Test + void exceptionMessageIsFormattedCorrectly() { + String message = "Error: {} occurred at {}"; + String param1 = "NullPointerException"; + String param2 = "line 42"; + + FormattedUncheckedException exception = new FormattedUncheckedException(message, param1, param2); + + assertEquals("Error: NullPointerException occurred at line 42", exception.getMessage()); + } + + @Test + void exceptionWithCauseHasFormattedMessage() + { + String message = "Error: {} occurred at {}"; + String param1 = "IOException"; + String param2 = "line 24"; + Throwable cause = new IOException("File not found"); + FormattedUncheckedException exception = new FormattedUncheckedException(cause, message, param1, param2); + + assertEquals("Error: IOException occurred at line 24", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracle.java b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracle.java new file mode 100644 index 0000000..2f054d3 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracle.java @@ -0,0 +1,162 @@ +package org.saltations.mre.fixtures; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import io.micronaut.core.beans.BeanProperty; + +/** + * Minimum contract for an Oracle that produces core objects and entities that implement the core interface (IC) + * + * @param Interface of the core domain item being represented + * @param Class of the core domain item + * @param Class of the persistable domain entity. + * Contains all the same data as C but supports additional entity specific meta-data. + */ + +public interface EntityOracle +{ + /** + * Provides the initial shared value for all objects of class T + */ + + long getInitialSharedValue(); + + /** + * Returns a core exemplar of class C based on a shared value with an offset. + *

+ * The shared value is the start of the numeric name space of all the objects of class C and E. This allows us to + * create related groups of domain objects that have a consistent results for each group of objects. + * The created object should have the same data when it's created with the same initial value and offset + *

+ * @param sharedInitialValue Initial shared value for all objects of the class + * @param offset Offset used to compose data for this specific object. + * + * @return A populated core object. + */ + + C coreExemplar(long sharedInitialValue, int offset); + + /** + * Returns a prototype entity object of class E based on a shared value with an offset. + *

+ * The shared value is the start of the numeric name space of all the objects of class E or E. This allows us to + * create related groups of domain objects that have a consistent results for each group of objects. + * The created object should have the same data when it's created with the same initial value and offset + *

+ * @param sharedInitialValue Initial shared value for all objects of the class + * @param offset Offset used to compose data for this specific object. + * + * @return A populated object of type T. + */ + + E entityExemplar(long sharedInitialValue, int offset); + + /** + * Returns a copy of the original object with all content attributes (not ids) changed + * + * @param original Original object of class T + * + * @return An object of class T with all content attributes different from the original. + */ + + C refurbishCore(C original); + + /** + * Confirms that the exemplars have the same core values + */ + + void hasSameCoreContent(IC expected, IC actual); + + /** + * Returns a core object populated with all the data needed to create an entity + */ + + default C coreExemplar(int offset) + { + return coreExemplar(getInitialSharedValue(), offset); + } + + /** + * Returns a core prototype for the default offset of 0 + */ + + default C coreExemplar() + { + return coreExemplar(0); + } + + /** + * Returns a list of core prototypes with offsets from the given start to given end + */ + + default List coreExemplars(int startOffset, int endOffset) + { + return IntStream.rangeClosed(startOffset, endOffset) + .mapToObj(i -> coreExemplar(i)) + .collect(Collectors.toList()); + } + + /** + * Returns an entity exemplar + */ + + default E entityExemplar(int offset) + { + return entityExemplar(getInitialSharedValue(), offset); + } + + /** + * Returns an entity prototype for the default offset of 0 + */ + + default E entityExemplar() + { + return entityExemplar(0); + } + + /** + * Returns a list of entity prototypes with offsets from the given start to given end + */ + + default List entityExemplars(int startOffset, int endOffset) + { + return IntStream.rangeClosed(startOffset, endOffset) + .mapToObj(i -> entityExemplar(i)) + .collect(Collectors.toList()); + } + + + /** + * Returns a refurbished copy of a core object created from the given offset + */ + + default C refurbishCore(int offset) + { + return refurbishCore(coreExemplar(offset)); + } + + /** + * Returns a refurbished copy of the default core prototype + */ + + default C refurbishCore() + { + return refurbishCore(coreExemplar()); + } + + /** + * Confirms that the provided exemplar has the same core content as a default core prototype + */ + default void hasSameCoreContentAsPrototype(IC actual) + { + hasSameCoreContent(coreExemplar(), actual); + } + + /** + * Extracts the list of core bean properties + */ + + List> extractCoreProperties(); +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracleBase.java b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracleBase.java new file mode 100644 index 0000000..8a8683d --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/EntityOracleBase.java @@ -0,0 +1,81 @@ +package org.saltations.mre.fixtures; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.beans.BeanIntrospection; +import io.micronaut.core.beans.BeanProperty; +import lombok.Getter; +import org.javatuples.Pair; +import org.junit.jupiter.api.function.Executable; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Generates example core objects and entities that implement the core interface (IC) + * + * @param Interface of the core business item being represented + * @param Class of the business item + * @param Class of the persistable business item entity. Contains all the same data as C but supports additional + * entity specific meta-data (especially the Id). + */ + +@SuppressWarnings("ClassHasNoToStringMethod") +public abstract class EntityOracleBase + implements EntityOracle +{ + @Getter + private final Class coreClass; + + @Getter + private final Class entityClass; + + @Getter + private final Class coreInterfaceClass; + + @Getter + protected final long initialSharedValue; + + private final Collection> beanProperties; + + public EntityOracleBase(Class coreClass, Class entityClass, Class coreInterfaceClass, long initialSharedValue) + { + this.coreClass = coreClass; + this.entityClass = entityClass; + this.coreInterfaceClass = coreInterfaceClass; + this.initialSharedValue = initialSharedValue; + + BeanIntrospection introspection = BeanIntrospection.getIntrospection(this.coreInterfaceClass); + this.beanProperties = introspection.getBeanProperties(); + } + + @Override + public void hasSameCoreContent(IC expected, IC actual) + { + List assertions = new ArrayList<>(); + + for (BeanProperty property : beanProperties) + { + assertions.add(() -> assertEquals(property.get(expected), + property.get(actual), property.getName())); + } + + assertAll(coreInterfaceClass.getSimpleName(), assertions); + } + + @Override + public List> extractCoreProperties() + { + return new ArrayList<>(beanProperties); + } + + private Pair,BeanProperty> pairUp(String annotationName, BeanProperty beanProperty) + { + return Pair.with(beanProperty.getAnnotation(annotationName), beanProperty); + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/fixtures/Oracle.java b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/Oracle.java new file mode 100644 index 0000000..495f6f7 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/Oracle.java @@ -0,0 +1,116 @@ +package org.saltations.mre.fixtures; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Minimum contract for an Oracle that provides exemplar objects of class T created from an initial shared value and an offset. + *

+ * Domain Glossary + *

+ *
Initial Shared Value
+ *
common numeric value that all objects of the class use as a starting name space for its values. So if Person was + * a domain object, the PersonOracle would use the same starting Initial value for all generated Persons. + *
+ *
Offset
+ *
numeric offset from the initial shared value for that particular prototype being generated.
+ *
Refurb
+ *
A prototype that has had all of its attributes modified from the original prototype
+ *
+ */ + +public interface Oracle +{ + /** + * Returns a prototype object of class T based on a shared value with an offset. + *

+ * The shared value is the start of the numeric name space of all the objects of class T. This allows us to + * create related groups of domain objects that have a consistent results for each group of objects. + * The created object should have the same data when it's created with the same initial value and offset + *

+ * @param sharedInitialValue Initial shared value for all objects of the class + * @param offset Offset used to compose data for this specific object. + * + * @return A populated object of type T. + */ + + T exemplar(int sharedInitialValue, int offset); + + /** + * Provides the initial shared value for all objects of class T + */ + + int getInitialSharedValue(); + + /** + * Returns a prototype object of class T for the given offset + */ + + default T exemplar(int offset) + { + return exemplar(getInitialSharedValue(), offset); + } + + /** + * Returns a list of prototype objects with offsets from the given start to given end + */ + + default List exemplars(int startOffset, int endOffset) + { + return IntStream.rangeClosed(startOffset, endOffset) + .mapToObj(i -> exemplar(i)) + .collect(Collectors.toList()); + } + + /** + * Returns a prototype object of class T with a default offset of 0. + */ + + default T exemplar() + { + return exemplar(0); + } + + /** + * Returns a copy of the original object with all content attributes (not ids) changed + * + * @param original Original object of class T + * + * @return An object of class T with all content attributes different from the original. + */ + + T refurbish(T original); + + /** + * Returns a refurbished copy of the prototype object created from the given offset + */ + + default T refurbished(int offset) + { + return refurbish(exemplar(offset)); + } + + /** + * Returns a refurbished copy of the default prototype object + */ + + default T refurbished() + { + return refurbish(exemplar()); + } + + /** + * Confirms that the objects have the same core data + * + * @param expected Object containing the expected values + * @param actual Object containing the actual values + */ + + void hasSameContent(T expected, T actual); + + default void hasSameContentAsDefaultExemplar(T actual) + { + hasSameContent(exemplar(), actual); + } +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/fixtures/OracleFoundation.java b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/OracleFoundation.java new file mode 100644 index 0000000..04d3487 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/OracleFoundation.java @@ -0,0 +1,25 @@ +package org.saltations.mre.fixtures; + +import lombok.Getter; + +/** + * Foundation (provides some default functionality) for an Oracle, typically used for testing + * + * @param Type Class of the exemplar + */ + +@Getter +@SuppressWarnings("ClassHasNoToStringMethod") +public abstract class OracleFoundation implements Oracle +{ + private final Class clazz; + + private final int initialSharedValue; + + public OracleFoundation(Class clazz, int initialSharedValue) + { + this.clazz = clazz; + this.initialSharedValue = initialSharedValue; + } + +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/fixtures/ReplaceBDDCamelCase.java b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/ReplaceBDDCamelCase.java new file mode 100644 index 0000000..f9481cd --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/fixtures/ReplaceBDDCamelCase.java @@ -0,0 +1,64 @@ +package org.saltations.mre.fixtures; + +import org.junit.jupiter.api.DisplayNameGenerator; + +import java.lang.reflect.Method; + +/** + * JUnit 5 Test Display Name generator + *

+ * Does several things to transform test method names to test names. + *

    + *
  1. Separates camel case names with spaces
  2. + *
  3. Replace BDD key words with all caps versions. i.e. 'GIVEN','WHEN','THEN'. 'given' Is only uppercase when + * it is the first word of the test method name
  4. + *
+ *

+ * Does several things to transform nested class names to test scenario names. + *

    + *
  1. Removes 'Test' from the end of class
  2. + *
  3. Separates camel case names with spaces
  4. + *
  5. Replace BDD key words with all caps versions. i.e. 'AND','GIVEN','WHEN','THEN'. 'given' and 'and' are only uppercased + * when they are the first word in the name of the class.
  6. + *
+ */ +public class ReplaceBDDCamelCase extends DisplayNameGenerator.Standard +{ + @Override + public String generateDisplayNameForClass(Class testClass) { + + return splitCamelCase(testClass.getSimpleName().replaceAll("[Tt]est$","")); + } + + @Override + public String generateDisplayNameForNestedClass(Class nestedClass) { + + return splitCamelCase(nestedClass.getSimpleName().replaceAll("[Tt]est$","")) + .toLowerCase() + .replaceAll("^and", "AND ") + .replaceAll("^given", "GIVEN ") + .replaceAll(" when ", "WHEN ") + .replaceAll(" then ", " THEN ") + .replaceAll(" ", " ") + .trim(); + } + + @Override + public String generateDisplayNameForMethod(Class testClass, Method testMethod) { + return splitCamelCase(testMethod.getName()) + .toLowerCase() + .replaceAll("^given", "GIVEN ") + .replaceAll("when ", "WHEN ") + .replaceAll(" then ", " THEN ") + .replaceAll(" ", " ") + .trim(); + } + + private String splitCamelCase(String incoming) + { + return incoming.replaceAll("([A-Z][a-z]+)", " $1") + .replaceAll("([A-Z][A-Z]+)", " $1") + .replaceAll("([A-Z][a-z]+)", "$1 ") + .trim(); + } +} diff --git a/rest-exemplar/src/test/java/org/saltations/mre/package-info.java b/rest-exemplar/src/test/java/org/saltations/mre/package-info.java new file mode 100644 index 0000000..dc7c514 --- /dev/null +++ b/rest-exemplar/src/test/java/org/saltations/mre/package-info.java @@ -0,0 +1 @@ +package org.saltations.mre; \ No newline at end of file diff --git a/rest-exemplar/src/test/resources/application-test.yml b/rest-exemplar/src/test/resources/application-test.yml new file mode 100644 index 0000000..4254cf1 --- /dev/null +++ b/rest-exemplar/src/test/resources/application-test.yml @@ -0,0 +1,4 @@ +datasources: + default: + url: jdbc:tc:postgresql:15:///db + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver diff --git a/rest-exemplar/src/test/resources/json-schema/cannot-create-constraint-violations.schema.json b/rest-exemplar/src/test/resources/json-schema/cannot-create-constraint-violations.schema.json new file mode 100644 index 0000000..5ba720f --- /dev/null +++ b/rest-exemplar/src/test/resources/json-schema/cannot-create-constraint-violations.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for Root", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "number" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "field", + "message" + ] + } + } + }, + "required": [ + "type", + "title", + "status", + "violations" + ] +} \ No newline at end of file diff --git a/rest-exemplar/src/test/resources/json-schema/cannot-find-resource.schema.json b/rest-exemplar/src/test/resources/json-schema/cannot-find-resource.schema.json new file mode 100644 index 0000000..4091b89 --- /dev/null +++ b/rest-exemplar/src/test/resources/json-schema/cannot-find-resource.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for Root", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "number" + }, + "detail": { + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "trace_id": { + "type": "string" + } + }, + "required": [ + "trace_id" + ] + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "parameters" + ] +} \ No newline at end of file