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(); + } +}