` |
+
+
+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.
+ *
+ * - Separates camel case names with spaces
+ * - 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
+ *
+ *
+ * Does several things to transform nested class names to test scenario names.
+ *
+ * - Removes 'Test' from the end of class
+ * - Separates camel case names with spaces
+ * - 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.
+ *
+ */
+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