diff --git a/README.md b/README.md
index 2df73f7..e0c19e4 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,9 @@ The service requires Java 17 or later.
You can run your application in dev mode that enables live coding using:
```shell script
./gradlew quarkusDev
+
+# Or to run without performing tests
+./gradlew quarkusDev -x test
```
> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/.
diff --git a/build.gradle.kts b/build.gradle.kts
index 89d5f7d..683d07e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -18,6 +18,7 @@ dependencies {
implementation("io.quarkus:quarkus-jdbc-postgresql")
implementation("io.quarkus:quarkus-arc")
implementation("io.quarkus:quarkus-resteasy-reactive")
+ implementation ("jakarta.validation:jakarta.validation-api:3.0.2")
testImplementation("io.quarkus:quarkus-junit5")
testImplementation("io.rest-assured:rest-assured")
}
diff --git a/detail.md b/detail.md
new file mode 100644
index 0000000..0444389
--- /dev/null
+++ b/detail.md
@@ -0,0 +1,233 @@
+# archive-service
+
+Details of the functionality of the archive-service endpoints.
+
+------------------------------------------------------------------------------------------
+Example resources suitable for **minimal** testing (Mandatory properties only).
+#### Example Simple Observation
+```xml
+
+ 123456
+ e-merlin
+ science
+ auri
+
+```
+#### Example Derived Observation
+```xml
+
+ 999
+ e-merlin
+ science
+ auri
+ anyURI
+
+```
+------------------------------------------------------------------------------------------
+### REST API details
+Endpoints available for interaction with the archive-service.
+
+#### Retrieving observations
+
+
+ GET
/observations
(Returns either all of the observations OR a paginated subset if optional page and size parameters supplied)
+
+##### Parameters
+
+> | name | type | data type | description |
+> |------|----------|-----------|--------------------------------------------------------------------------------|
+> | page | optional | integer | The page index, zero-indexed |
+> | size | optional | integer | The number of observations to return for each page, must be greater than zero. |
+
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|-------------------|------------------------------------------|
+> | `200` | `application/xml` | `Returned successfully` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+
+
+##### Example cURL
+
+> ```
+> curl -X 'GET' -H 'accept: application/xml' 'http://localhost:8080/observations'
+> ```
+
+
+
+
+ GET
/observations/{observationId}
(Returns an Observation with the supplied ID, if found)
+
+##### Parameters
+
+> | name | type | data type | description |
+> |---------------|-----------|-----------|---------------------------------------------------------------------|
+> | observationId | required | String | The unique identifier of a specific Observation (Simple or Derived) |
+
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|-------------------|-----------------------------------------------|
+> | `201` | `application/xml` | `Observation found and returned successfully` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+> | `404` | `text/plain` | Observation not found |
+
+##### Example cURL
+
+> ```
+> curl -X 'GET' 'http://localhost:8080/observations/23456' -H 'accept: application/xml'
+> ```
+
+
+
+
+ GET
/observations/collection/{collectionId}
(Returns all observations for the supplied collectionId, if found)
+
+##### Parameters
+
+> | name | type | data type | description |
+> |--------------|----------|-----------|--------------------------------------------------------------------------------|
+> | collectionId | required | String | The unique identifier of a specific collection |
+> | page | optional | integer | The page index, zero-indexed |
+> | size | optional | integer | The number of observations to return for each page, must be greater than zero. |
+
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|-------------------|-------------------------------------------------------------------------------|
+> | `201` | `application/xml` | `List of Observation (Simple and/or Derived) found and returned successfully` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+
+##### Example cURL
+
+> ```
+> curl -X 'GET' 'http://localhost:8080/observations/23456' -H 'accept: application/xml'
+> ```
+
+
+
+------------------------------------------------------------------------------------------
+
+#### Adding new Observations
+
+
+ POST
/observations/add
(Add a new observation)
+
+##### Responses
+
+> | http code | content-type | response |
+> |---------------|-------------------|-----------------------------------------------------------------|
+> | `201` | `application/xml` | `Observation added successfully, body contains new Observation` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+
+##### Example cURL
+
+> ```
+> curl -v --header "Content-Type: application/xml" -T observation1.xml http://localhost:8080/observations/add
+> ```
+
+
+
+
+ POST
/observations/derived/add
(Add a new derived observation)
+
+##### Responses
+
+> | http code | content-type | response |
+> |---------------|-------------------|------------------------------------------------------------------------|
+> | `201` | `application/xml` | `Observation added successfully, body contains new DerivedObservation` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+
+##### Example cURL
+
+> ```
+> curl -v --header "Content-Type: application/xml" -T observation1.xml http://localhost:8080/observations/derived/add
+> ```
+
+
+
+------------------------------------------------------------------------------------------
+
+#### Updating observations
+
+
+ PUT
/observations/update/{observationId}
(Updates an observation (Simple or Derived) with the same observationId)
+
+##### Parameters
+
+> | name | type | data type | description |
+> |---------------|-----------|-----------|-----------------------------------------------------------|
+> | observationId | required | String | The unique identifier of a specific observation to update |
+
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|-------------------|------------------------------------------|
+> | `200` | `application/xml` | `Observation updated successfully` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+> | `404` | `text/plain` | Observation not found |
+
+##### Example cURL
+
+> ```
+> curl -v --header "Content-Type: application/xml" -T observation123.xml http://localhost:8080/observations/update/123
+> ```
+
+
+
+------------------------------------------------------------------------------------------
+
+#### Deleting Observations
+
+
+ GET
/observations/{observationId}
(Delete an Observation with the supplied ID, if found)
+
+##### Parameters
+
+> | name | type | data type | description |
+> |---------------|-----------|-----------|---------------------------------------------------------------------|
+> | observationId | required | String | The unique identifier of a specific Observation (Simple or Derived) |
+
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|-------------------|------------------------------------------|
+> | `204` | `application/xml` | `Observation deleted` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+> | `404` | `text/plain` | Observation not found |
+
+##### Example cURL
+
+> ```
+> curl -X 'DELETE' 'http://localhost:8080/observations/delete/123' -H 'accept: */*'
+> ```
+
+
+
+------------------------------------------------------------------------------------------
+
+#### Retrieving collections
+
+
+ GET
/observations/collections
(Returns the names of all the collections as a TSV (Tab Separated List))
+
+##### Responses
+
+> | http code | content-type | response |
+> |-----------|--------------|------------------------------------------|
+> | `200` | `text/plain` | `Returned successfully` |
+> | `400` | `text/plain` | `{"code":"400","message":"Bad Request"}` |
+
+
+##### Example cURL
+
+> ```
+> curl -X 'GET' -H 'accept: application/xml' 'http://localhost:8080/observations/collections'
+> ```
+
+
\ No newline at end of file
diff --git a/src/main/java/org/uksrc/archive/GreetingResource.java b/src/main/java/org/uksrc/archive/GreetingResource.java
deleted file mode 100644
index 38b6385..0000000
--- a/src/main/java/org/uksrc/archive/GreetingResource.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.uksrc.archive;
-
-import jakarta.ws.rs.GET;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.core.MediaType;
-
-@Path("/hello")
-public class GreetingResource {
-
- @GET
- @Produces(MediaType.TEXT_PLAIN)
- public String hello() {
- return "Hello from RESTEasy Reactive";
- }
-}
diff --git a/src/main/java/org/uksrc/archive/ObservationResource.java b/src/main/java/org/uksrc/archive/ObservationResource.java
index 1fbafc4..07b1c6d 100644
--- a/src/main/java/org/uksrc/archive/ObservationResource.java
+++ b/src/main/java/org/uksrc/archive/ObservationResource.java
@@ -5,31 +5,434 @@
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
-import jakarta.ws.rs.Consumes;
-import jakarta.ws.rs.POST;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.*;
import org.eclipse.microprofile.openapi.annotations.Operation;
+import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn;
+import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
+import org.eclipse.microprofile.openapi.annotations.media.Content;
+import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
+import org.eclipse.microprofile.openapi.annotations.parameters.Parameters;
+import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
+import org.hibernate.PropertyValueException;
+import org.ivoa.dm.caom2.caom2.DerivedObservation;
import org.ivoa.dm.caom2.caom2.Observation;
+import org.ivoa.dm.caom2.caom2.SimpleObservation;
+import jakarta.validation.constraints.NotNull;
+import org.uksrc.archive.utils.ObservationListWrapper;
+import java.util.*;
-@Produces(MediaType.APPLICATION_JSON)
-@Path("/observation")
-public class ObservationResource {
+@Path("/observations")
+public class ObservationResource {
@PersistenceContext
protected EntityManager em; // exists for the application lifetime no need to close
@POST
- @Operation(summary = "create a new Observation")
- @Consumes(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Create a new Observation", description = "Creates a new observation in the database, note the supplied ID needs to be unique.")
+ @RequestBody(
+ description = "XML representation of the Observation",
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML,
+ schema = @Schema(implementation = SimpleObservation.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "201",
+ description = "Observation created successfully",
+ content = @Content(schema = @Schema(implementation = SimpleObservation.class))
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Invalid input"
+ )
+ @Consumes(MediaType.APPLICATION_XML)
+ @Produces(MediaType.APPLICATION_XML)
+ @Transactional
+ public Response addObservation(SimpleObservation observation) {
+ return submitObservation(observation);
+ }
+
+ @POST
+ @Path("/derived/")
+ @Operation(summary = "Create a new Derived Observation", description = "Create a DERIVED observation in the database, note ID must be unique across all observations.")
+ @RequestBody(
+ description = "XML representation of the Derived Observation",
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML,
+ schema = @Schema(implementation = DerivedObservation.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "201",
+ description = "Observation created successfully",
+ content = @Content(schema = @Schema(implementation = DerivedObservation.class))
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Invalid input"
+ )
+ @Consumes(MediaType.APPLICATION_XML)
+ @Produces(MediaType.APPLICATION_XML)
+ @Transactional
+ public Response addObservation(DerivedObservation observation) {
+ return submitObservation(observation);
+ }
+
+ @PUT
+ @Path("{observationId}")
+ @Operation(summary = "Update an existing Observation", description = "Updates an existing observation with the supplied ID")
+ @Parameter(
+ name = "observationId",
+ description = "ID of the Observation to be updated",
+ required = true,
+ example = "123"
+ )
+ @RequestBody(
+ description = "XML representation of the Observation",
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML,
+ schema = @Schema(implementation = Observation.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "200",
+ description = "Observation updated successfully",
+ content = @Content(schema = @Schema(implementation = Observation.class))
+ )
+ @APIResponse(
+ responseCode = "404",
+ description = "An Observation with the supplied ID has not been found."
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Invalid input"
+ )
+ @Consumes(MediaType.APPLICATION_XML)
+ @Produces(MediaType.APPLICATION_XML)
+ @Transactional
+ public Response updateObservation(@PathParam("observationId") String id, SimpleObservation observation) {
+ try {
+ //Only update IF found
+ Observation existing = em.find(Observation.class, id);
+ if (existing != null && observation != null) {
+ observation.setId(id);
+ em.merge(observation);
+ return Response.ok(observation).build();
+ }
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+
+ return Response.status(Response.Status.NOT_FOUND)
+ .type(MediaType.TEXT_PLAIN)
+ .entity("Observation not found")
+ .build();
+ }
+
+ @GET
+ @Operation(summary = "Retrieve list(s) of observations", description = "Returns either all the Observations currently stored or a subset using pagination IF page AND size are supplied.")
+ @Parameters({
+ @Parameter(
+ name = "page",
+ description = "The page number to retrieve, zero-indexed. If not provided, ALL results are returned.",
+ in = ParameterIn.QUERY,
+ schema = @Schema(type = SchemaType.INTEGER, minimum = "0")
+ ),
+ @Parameter(
+ name = "size",
+ description = "The number of observations per page. If not provided, ALL results are returned.",
+ in = ParameterIn.QUERY,
+ schema = @Schema(type = SchemaType.INTEGER, minimum = "1")
+ )
+ })
+ @APIResponse(
+ responseCode = "200",
+ description = "List of observations retrieved successfully",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = ObservationListWrapper.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Internal error whilst retrieving Observations or parameter error (if supplied)."
+ )
+ @Produces(MediaType.APPLICATION_XML)
+ public Response getAllObservations(@QueryParam("page") Integer page, @QueryParam("size") Integer size) {
+ if ((page != null && size == null) || (page == null && size != null)) {
+ return errorResponse("Both 'page' and 'size' must be provided together or neither.");
+ }
+
+ try {
+ if (page != null && (page < 0 || size < 1)) {
+ return errorResponse("Page must be 0 or greater and size must be greater than 0.");
+ }
+
+ // Create query and apply pagination if required
+ TypedQuery query = em.createQuery("SELECT o FROM Observation o", Observation.class);
+ return performQuery(page, size, query);
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ }
+
+ @GET
+ @Path("/collection/{collectionId}")
+ @Operation(summary = "Retrieve observations from a collection", description = "Returns a list of observations that are members of the supplied collection")
+ @Parameters({
+ @Parameter(
+ name = "collectionId",
+ description = "The collection name to retrieve observations for",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "e-merlin"
+ ),
+ @Parameter(
+ name = "page",
+ description = "The page number to retrieve, zero-indexed. If not provided, ALL results are returned.",
+ in = ParameterIn.QUERY,
+ schema = @Schema(type = SchemaType.INTEGER, minimum = "0")
+ ),
+ @Parameter(
+ name = "size",
+ description = "The number of observations per page. If not provided, ALL results are returned.",
+ in = ParameterIn.QUERY,
+ schema = @Schema(type = SchemaType.INTEGER, minimum = "1")
+ )
+ })
+ @APIResponse(
+ responseCode = "200",
+ description = "List of observations retrieved successfully",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = ObservationListWrapper.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Internal error whilst retrieving Observations."
+ )
+ @Produces(MediaType.APPLICATION_XML)
+ public Response getObservations(@PathParam("collectionId") String collection, @QueryParam("page") Integer page, @QueryParam("size") Integer size) {
+ if ((page != null && size == null) || (page == null && size != null)) {
+ return errorResponse("Both 'page' and 'size' must be provided together or neither.");
+ }
+
+ try {
+ if (page != null && (page < 0 || size < 1)) {
+ return errorResponse("Page must be 0 or greater and size must be greater than 0.");
+ }
+
+ TypedQuery query = em.createQuery("SELECT o FROM Observation o WHERE o.collection = :collection", Observation.class);
+ query.setParameter("collection", collection);
+ return performQuery(page, size, query);
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ }
+
+ @GET
+ @Path("/{observationId}")
+ @Operation(summary = "Retrieve observations from a collection", description = "Returns a list of observations that are members of the supplied collection")
+ @Parameters({
+ @Parameter(
+ name = "observationId",
+ description = "The id of the observation",
+ required = true,
+ example = "123456"
+ )
+ })
+ @APIResponse(
+ responseCode = "200",
+ description = "Observation retrieved successfully",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_XML, schema = @Schema(implementation = Observation.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "404",
+ description = "Observation not found"
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Internal error whilst retrieving Observations."
+ )
+ @Produces(MediaType.APPLICATION_XML)
+ public Response getObservation(@PathParam("observationId") String observationId) {
+ try {
+ Observation observation = em.find(Observation.class, observationId);
+ if (observation != null) {
+ return Response.status(Response.Status.OK)
+ .entity(observation).build();
+ } else {
+ return Response.status(Response.Status.NOT_FOUND)
+ .type(MediaType.TEXT_PLAIN)
+ .entity("Observation with ID " + observationId + " not found").build();
+ }
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ }
+
+ @DELETE
+ @Path("/{observationId}")
+ @Operation(summary = "Delete an existing observation")
+ @Parameters({
+ @Parameter(
+ name = "observationId",
+ description = "The id of the observation to delete.",
+ required = true,
+ example = "123"
+ )
+ })
+ @APIResponse(
+ responseCode = "204",
+ description = "Observation deleted."
+ )
+ @APIResponse(
+ responseCode = "404",
+ description = "Observation not found"
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Internal error whilst deleting the observation."
+ )
@Transactional
- public Observation addObservation(Observation observation) {
- em.persist(observation);
- return observation;
+ public Response deleteObservation(@PathParam("observationId") String id) {
+ try {
+ Observation observation = em.find(Observation.class, id);
+ if (observation != null) {
+ em.remove(observation);
+ return Response.status(Response.Status.NO_CONTENT).build();
+ } else {
+ return Response.status(Response.Status.NOT_FOUND)
+ .type(MediaType.TEXT_PLAIN)
+ .entity("Observation with ID " + id + " not found")
+ .build();
+ }
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ }
+
+ @GET
+ @Path("/collections")
+ @Operation(summary = "Retrieve all collection IDs", description = "Returns a list of unique collectionIds as a TSV (Tab Separated List).")
+ @APIResponse(
+ responseCode = "200",
+ description = "CollectionIds retrieved successfully",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class)
+ )
+ )
+ @APIResponse(
+ responseCode = "400",
+ description = "Internal error whilst retrieving collectionIds."
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response getCollections(){
+ try {
+ TypedQuery query = em.createQuery("SELECT DISTINCT o.collection FROM Observation o", String.class);
+ List uniqueCollections = query.getResultList();
+
+ return Response.ok()
+ .type(MediaType.TEXT_PLAIN)
+ .entity(convertListToTsv(uniqueCollections))
+ .build();
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
}
+ /**
+ * Adds an observation to the database
+ * @param observation Either a SimpleObservation or a DerivedObservation
+ * @return Response containing status code and added observation (if successful)
+ */
+ private Response submitObservation(Observation observation) {
+ try {
+ em.persist(observation);
+ em.flush();
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ return Response.status(Response.Status.CREATED)
+ .entity(observation)
+ .build();
+ }
+
+ /**
+ * Generate an error response
+ * @param e Whatever exception has been thrown
+ * @return A 400 response containing the exception error.
+ */
+ private Response errorResponse (@NotNull Exception e){
+ String additional = "";
+ if (e instanceof PropertyValueException){
+ //Inform caller of exact property that's missing/invalid
+ additional = ((PropertyValueException)e).getPropertyName();
+ }
+ return Response.status(Response.Status.BAD_REQUEST)
+ .type(MediaType.TEXT_PLAIN)
+ .entity(e.getMessage() + " " + additional)
+ .build();
+ }
+
+ /**
+ * Generate an error message
+ * @param message Message to return to the caller.
+ * @return A 400 response containing the supplied message
+ */
+ private Response errorResponse (@NotNull String message){
+ return Response.status(Response.Status.BAD_REQUEST)
+ .type(MediaType.TEXT_PLAIN)
+ .entity(message)
+ .build();
+ }
+
+ /**
+ * Performs the supplied query (with or without the pagination parameters)
+ * @param page zero-indexed page index
+ * @param size number of entries per page
+ * @param query query to perform
+ * @return Response containing HTTP response code and expected body if successful.
+ */
+ private Response performQuery(Integer page, Integer size, TypedQuery query) {
+ try {
+ if (page != null && size != null) {
+ int firstResult = page * size;
+ query.setFirstResult(firstResult);
+ query.setMaxResults(size);
+ }
+
+ List observations = query.getResultList();
+ ObservationListWrapper wrapper = new ObservationListWrapper(observations);
+
+ return Response.ok(wrapper).build();
+ } catch (Exception e) {
+ return errorResponse(e);
+ }
+ }
+
+ /**
+ * Converts a List of strings to a TSV
+ * @param list The list of elements to convert to a TSV string.
+ * @return list of items "e-merlin test ALMA"
+ */
+ public String convertListToTsv(List list) {
+ StringJoiner joiner = new StringJoiner("\t");
+
+ for (String item : list) {
+ joiner.add(item);
+ }
+ return joiner.toString();
+ }
}
diff --git a/src/main/java/org/uksrc/archive/utils/CustomObjectMapper.java b/src/main/java/org/uksrc/archive/utils/CustomObjectMapper.java
new file mode 100644
index 0000000..7124e5c
--- /dev/null
+++ b/src/main/java/org/uksrc/archive/utils/CustomObjectMapper.java
@@ -0,0 +1,27 @@
+package org.uksrc.archive.utils;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.quarkus.jackson.ObjectMapperCustomizer;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Singleton;
+import org.ivoa.dm.caom2.Caom2Model;
+
+public class CustomObjectMapper {
+ private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory
+ .getLogger(CustomObjectMapper.class);
+
+ // Replaces the CDI producer for ObjectMapper built into Quarkus
+ @Singleton
+ @Produces
+ ObjectMapper objectMapper(Instance customizers) {
+ ObjectMapper mapper = Caom2Model.jsonMapper(); // Custom `ObjectMapper`
+ logger.info("custom jackson mapper used");
+ // Apply all ObjectMapperCustomizer beans (incl. Quarkus)
+ for (ObjectMapperCustomizer customizer : customizers) {
+ customizer.customize(mapper);
+ }
+
+ return mapper;
+ }
+}
diff --git a/src/main/java/org/uksrc/archive/utils/ObservationListWrapper.java b/src/main/java/org/uksrc/archive/utils/ObservationListWrapper.java
new file mode 100644
index 0000000..783e855
--- /dev/null
+++ b/src/main/java/org/uksrc/archive/utils/ObservationListWrapper.java
@@ -0,0 +1,46 @@
+package org.uksrc.archive.utils;
+
+import org.ivoa.dm.caom2.caom2.DerivedObservation;
+import org.ivoa.dm.caom2.caom2.Observation;
+
+import jakarta.xml.bind.annotation.*;
+import org.ivoa.dm.caom2.caom2.SimpleObservation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A wrapper class for a List<> of Observations.
+ * Allows JAXB to return a List of Observations using XML via the REST APIs and
+ * allows the custom naming of headers/tags/fields.
+ */
+@XmlRootElement(name = "Observations") // Root element for the list
+@XmlAccessorType(XmlAccessType.FIELD)
+public class ObservationListWrapper {
+
+ //@XmlElement(name = "Observation") //NOTE: Use this instead of the lower one to make them all "Observation" if required
+ @XmlElements({
+ @XmlElement(name = "SimpleObservation", type = SimpleObservation.class),
+ @XmlElement(name = "DerivedObservation", type = DerivedObservation.class)
+ })
+ private List observations = new ArrayList<>();
+
+ @SuppressWarnings("unused")
+ public ObservationListWrapper() {
+ }
+
+ public ObservationListWrapper(List observations) {
+ this.observations = observations;
+ }
+
+ @SuppressWarnings("unused")
+ public List getObservations() {
+ return observations;
+ }
+
+ @SuppressWarnings("unused")
+ public void setObservations(List observations) {
+ this.observations = observations;
+ }
+}
+
diff --git a/src/test/java/org/uksrc/archive/ObservationResourceTest.java b/src/test/java/org/uksrc/archive/ObservationResourceTest.java
new file mode 100644
index 0000000..b57915e
--- /dev/null
+++ b/src/test/java/org/uksrc/archive/ObservationResourceTest.java
@@ -0,0 +1,360 @@
+package org.uksrc.archive;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.common.mapper.TypeRef;
+import jakarta.inject.Inject;
+import jakarta.persistence.EntityManager;
+import jakarta.transaction.Transactional;
+import jakarta.ws.rs.core.Response;
+import org.ivoa.dm.caom2.caom2.Observation;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.uksrc.archive.utils.ObservationListWrapper;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.RestAssured.when;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Test class for the Observation class
+ * Requirements:
+ * Postgres DB with the CAOM (2.5) models added (as tables). Should be in place automatically via the Quarkus mechanisms.
+ */
+@QuarkusTest
+public class ObservationResourceTest {
+
+ //Caution with the id value if re-using.
+ private static final String XML_OBSERVATION = "" +
+ "%s" +
+ "%s" +
+ "science" +
+ "auri" +
+ "";
+
+ private static final String XML_DERIVED_OBSERVATION = "" +
+ "%s" +
+ "e-merlin" +
+ "science" +
+ "auri" +
+ "someone" +
+ "";
+
+ private static final String COLLECTION1 = "e-merlin";
+ private static final String COLLECTION2 = "testCollection";
+
+ @Inject
+ EntityManager em;
+
+ @BeforeEach
+ @Transactional
+ public void clearDatabase() {
+ // Clear the table
+ em.createQuery("DELETE FROM Observation").executeUpdate();
+ }
+
+ @Test
+ @DisplayName("Check that and empty database returns a robust response.")
+ public void testGettingObservations() {
+ // Wrapper required for de-serialisation of List
+ ObservationListWrapper wrapper = when()
+ .get("/observations/")
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .extract()
+ .as(new TypeRef<>() {
+ });
+
+ assert(wrapper.getObservations().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Add two observation and check two are returned.")
+ public void testGettingObservationsNonEmpty() {
+ try(Response res1 = addObservationToDatabase("1234", COLLECTION1);
+ Response res2 = addObservationToDatabase("6789", COLLECTION1)) {
+ assert (res1.getStatus() == Response.Status.CREATED.getStatusCode() &&
+ res2.getStatus() == Response.Status.CREATED.getStatusCode());
+
+ ObservationListWrapper wrapper = when()
+ .get("/observations/")
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .extract()
+ .as(new TypeRef<>() {
+ });
+
+ assert (wrapper.getObservations().size() == 2);
+ }
+ }
+
+ @ParameterizedTest
+ @DisplayName("Add an observation and check that part of the response body matches.")
+ @ValueSource(strings = {XML_OBSERVATION, XML_DERIVED_OBSERVATION})
+ public void testAddingObservation(String observation) {
+ String uniqueObservation = String.format(observation, "123", COLLECTION1);
+
+ //As the /add operation returns the added observation, check the body of the response for valid values
+ given()
+ .header("Content-Type", "application/xml")
+ .body(uniqueObservation)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.CREATED.getStatusCode())
+ .body("simpleObservation.id", is("123")) // XML expectation (remove 'simpleObservation.' for JSON)
+ .body("simpleObservation.intent", is("science"));
+ }
+
+ @Test
+ @DisplayName("Check that an error is raised if trying to add two observations with the same ID.")
+ public void testAddingDuplicateObservation() {
+ String duplicateObservation = String.format(XML_DERIVED_OBSERVATION, "256");
+
+ // Add 1st instance
+ given()
+ .header("Content-Type", "application/xml")
+ .body(duplicateObservation)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.CREATED.getStatusCode())
+ .body("simpleObservation.id", is("256"));
+
+ // An Observation with the same ID as an added resource should not be allowed.
+ given()
+ .header("Content-Type", "application/xml")
+ .body(duplicateObservation)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.BAD_REQUEST.getStatusCode())
+ .body(containsString("duplicate key value violates unique constraint"));
+ }
+
+ @Test
+ @DisplayName("Attempt to add some data that doesn't comply with model.")
+ public void testAddingJunkObservation() {
+ final String junkData = "doesn't conform with XML model for Observation";
+
+ given()
+ .header("Content-Type", "application/xml")
+ .body(junkData)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Attempt to add an Observation with a MUST property missing.")
+ public void testAddingIncompleteObservation() {
+ final String INCOMPLETE_XML_OBSERVATION = "" +
+ "444" +
+ "e-merlin" +
+ // "science" + //deliberately excluded
+ "auri" +
+ "";
+
+ given()
+ .header("Content-Type", "application/xml")
+ .body(INCOMPLETE_XML_OBSERVATION)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Add an observation, update one of its values and update, check it's been updated correctly.")
+ public void testUpdatingObservation() {
+ final String ID = "123";
+ String uniqueObservation = String.format(XML_OBSERVATION, ID, COLLECTION1);
+
+ // Add an observation
+ given()
+ .header("Content-Type", "application/xml")
+ .body(uniqueObservation)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.CREATED.getStatusCode())
+ .body("simpleObservation.id", is(ID))
+ .body("simpleObservation.intent", is("science"));
+
+ // Update it with a different value
+ String updatedObservation = uniqueObservation.replace("science", "calibration");
+ given()
+ .header("Content-Type", "application/xml")
+ .body(updatedObservation)
+ .when()
+ .put(("/observations/update/" + ID))
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .body("simpleObservation.id", is(ID))
+ .body("simpleObservation.intent", is("calibration"));
+
+ // For completeness, we need to check that the actual entry is updated
+ given()
+ .header("Content-Type", "application/xml")
+ .body(uniqueObservation)
+ .when()
+ .get("/observations/" + ID)
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .body("simpleObservation.id", is(ID)) // XML expectation (remove 'simpleObservation.' for JSON)
+ .body("simpleObservation.intent", is("calibration"));
+ }
+
+ @Test
+ @DisplayName("Attempt to update a non-existent observation and check the not found status.")
+ public void testUpdatingNonExistingObservation() {
+ final String ID = "1234";
+
+ String obs1 = String.format(XML_OBSERVATION, ID, COLLECTION1);
+ String updatedObservation = obs1.replace("science", "calibration");
+
+ given()
+ .header("Content-Type", "application/xml")
+ .body(updatedObservation)
+ .when()
+ .put(("/observations/update/" + ID))
+ .then()
+ .statusCode(Response.Status.NOT_FOUND.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Attempt to delete an observation.")
+ public void testDeletingObservation() {
+ final String ID = "256";
+ try(Response res = addObservationToDatabase(ID, COLLECTION1)) {
+ assert (res.getStatus() == Response.Status.CREATED.getStatusCode());
+
+ // Check it exists
+ given()
+ .header("Content-Type", "application/xml")
+ .when()
+ .get("/observations/" + ID)
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .body("simpleObservation.id", is(ID));
+
+ given()
+ .header("Content-Type", "application/xml")
+ .when()
+ .delete(("/observations/" + ID))
+ .then()
+ .statusCode(Response.Status.NO_CONTENT.getStatusCode());
+ }
+ }
+
+ @Test
+ @DisplayName("Test paging results, first page")
+ public void testPagingResults() {
+ for (int i = 0; i < 15; i++){
+ addObservationToDatabase(String.valueOf(i), COLLECTION1);
+ }
+
+ ObservationListWrapper wrapper = when()
+ .get("/observations?page=0&size=10")
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .extract()
+ .as(new TypeRef<>() {
+ });
+
+ assert(wrapper.getObservations().size() == 10);
+ }
+
+ @Test
+ @DisplayName("Test retrieving collection Ids")
+ public void testRetrievingCollectionIds() {
+ for (int i = 0; i < 5; i++){
+ addObservationToDatabase(String.valueOf(i), COLLECTION1);
+ }
+
+ for (int i = 5; i < 12; i++){
+ addObservationToDatabase(String.valueOf(i), COLLECTION2);
+ }
+
+ String collections = when()
+ .get("/observations/collections")
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .extract()
+ .asString();
+
+ //Split the response on the tab separator
+ String[] collectionIds = collections.split("\t");
+ List names = Arrays.asList(collectionIds);
+
+ assert(names.size() == 2);
+ assert(names.contains(COLLECTION1));
+ assert(names.contains(COLLECTION2));
+ }
+
+ @Test
+ @DisplayName("Test paging results, second page")
+ public void testPagingResults2() {
+ for (int i = 0; i < 15; i++){
+ addObservationToDatabase(String.valueOf(i), COLLECTION1);
+ }
+
+ ObservationListWrapper wrapper = when()
+ .get("/observations?page=1&size=10")
+ .then()
+ .statusCode(Response.Status.OK.getStatusCode())
+ .extract()
+ .as(new TypeRef<>() {
+ });
+
+ //As 15 were added, only five should be returned for the second page (0-indexed)
+ final int size = wrapper.getObservations().size();
+ assert(size == 5);
+
+ //Ensure that the returned 5 are actually the last five
+ Observation lastEntry = wrapper.getObservations().get(size - 1);
+ assert (lastEntry.getId().equals("14"));
+ }
+
+ @Test
+ @DisplayName("Attempt to delete an observation that doesn't exist.")
+ public void testDeletingNonExistingObservation() {
+ given()
+ .header("Content-Type", "application/xml")
+ .when()
+ .delete(("/observations/" + "9876"))
+ .then()
+ .statusCode(Response.Status.NOT_FOUND.getStatusCode());
+ }
+
+ /**
+ * Adds a SimpleObservation to the database with the supplied observationId
+ * @param observationId unique identifier for the observation
+ * @param collectionId identifier for the collection to add this observation to.
+ * @return Response of 400 for failure or 201 for created successfully.
+ */
+ private Response addObservationToDatabase(String observationId, String collectionId) {
+ String uniqueObservation = String.format(XML_OBSERVATION, observationId, collectionId);
+
+ try {
+ given()
+ .header("Content-Type", "application/xml")
+ .body(uniqueObservation)
+ .when()
+ .post("/observations/add")
+ .then()
+ .statusCode(Response.Status.CREATED.getStatusCode())
+ .body("simpleObservation.id", is(observationId));
+ } catch (Exception e) {
+ return Response.status(Response.Status.BAD_REQUEST.getStatusCode()).build();
+ }
+ return Response.status(Response.Status.CREATED.getStatusCode()).build();
+ }
+}