diff --git a/cwms-data-api/src/main/java/cwms/cda/api/rating/RatingController.java b/cwms-data-api/src/main/java/cwms/cda/api/rating/RatingController.java index 691859fb9..24dd660c5 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/rating/RatingController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/rating/RatingController.java @@ -57,13 +57,17 @@ import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; import cwms.cda.api.Controllers; import cwms.cda.api.errors.CdaError; import cwms.cda.data.dao.JsonRatingUtils; import cwms.cda.data.dao.RatingDao; import cwms.cda.data.dao.RatingSetDao; +import cwms.cda.data.dao.RatingsVerticalDatumExtractor; +import cwms.cda.data.dao.VerticalDatum; import cwms.cda.data.dto.CwmsDTOBase; import cwms.cda.data.dto.StatusResponse; +import cwms.cda.data.dto.VerticalDatumInfo; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; @@ -88,7 +92,9 @@ import com.google.common.flogger.FluentLogger; import javax.servlet.http.HttpServletResponse; import javax.xml.transform.TransformerException; + import mil.army.usace.hec.cwms.rating.io.xml.RatingXmlFactory; +import mil.army.usace.hec.metadata.VerticalDatumException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jooq.DSLContext; @@ -136,7 +142,16 @@ protected RatingDao getRatingDao(DSLContext dsl) { required = true), queryParams = { @OpenApiParam(name = STORE_TEMPLATE, type = Boolean.class, - description = "Also store updates to the rating template. Default: true") + description = "Also store updates to the rating template. Default: true"), + @OpenApiParam(name = DATUM, type = VerticalDatum.class, description = "If the provided " + + "rating-set includes an explicit vertical-datum-info attribute " + + "then it is assumed that the data is in the datum specified by the vertical-datum-info. " + + "If the input rating-set does not include vertical-datum-info and " + + "this parameter is not provided it is assumed that the data is in the as-stored " + + "datum and no conversion is necessary. " + + "If the input rating-set does not include vertical-datum-info and " + + "this parameter is provided it is assumed that the data is in the Datum named by the argument " + + "and should be converted to the as-stored datum before being saved.") }, method = HttpMethod.POST, path = "/ratings", tags = {TAG}, responses = { @@ -149,7 +164,14 @@ public void create(@NotNull Context ctx) { RatingDao ratingDao = getRatingDao(dsl); boolean storeTemplate = ctx.queryParamAsClass(STORE_TEMPLATE, Boolean.class).getOrDefault(true); String ratingSet = deserializeRatingSet(ctx, storeTemplate); - ratingDao.create(ratingSet, false); + String datum = ctx.queryParam(DATUM); + VerticalDatum vd = null; + if(datum != null) { + vd = ctx.queryParamAsClass(DATUM, VerticalDatum.class) + .getOrDefault(null); + } + vd = RatingsVerticalDatumExtractor.getVerticalDatum(ratingSet).orElse(vd); + ratingDao.create(ratingSet, false, vd); StatusResponse re = new StatusResponse(RatingDao.extractOfficeFromXml(ratingSet), "Rating Set successfully stored to CWMS."); ctx.status(HttpServletResponse.SC_CREATED).json(re); } catch (IOException ex) { @@ -265,7 +287,9 @@ public void delete(@NotNull Context ctx, @NotNull String ratingSpecId) { + "\n* `NAVD88` The elevation values will in the " + "specified or default units above the NAVD-88 datum." + "\n* `NGVD29` The elevation values will be in the " - + "specified or default units above the NGVD-29 datum."), + + "specified or default units above the NGVD-29 datum." + + "\n* `NATIVE` The elevation values will be in the " + + "Location's native datum."), @OpenApiParam(name = AT, description = "Specifies the " + "start of the time window for data to be included in the response. " + "If this field is not specified, any required time window begins 24" @@ -362,6 +386,16 @@ public void getAll(@NotNull Context ctx) { @OpenApiParam(name = METHOD, description = "Specifies " + "the retrieval method used. If no method is provided EAGER will be used.", type = RatingSet.DatabaseLoadMethod.class), + @OpenApiParam(name = DATUM, description = "Specifies the " + + "elevation datum of the response. This field affects only elevation" + + " Ratings. Valid values for this field are:" + + "\n* `NAVD88` The elevation values will in the " + + "specified or default units above the NAVD-88 datum." + + "\n* `NGVD29` The elevation values will be in the " + + "specified or default units above the NGVD-29 datum." + + "\n* `NATIVE` The elevation values will be in the " + + "Location's native datum.", + type = VerticalDatum.class), }, responses = { @OpenApiResponse(status = STATUS_200, content = { @@ -377,6 +411,7 @@ public void getOne(@NotNull Context ctx, @NotNull String rating) { try (final Timer.Context ignored = markAndTime(GET_ONE)) { String officeId = ctx.queryParam(OFFICE); String timezone = ctx.queryParamAsClass(TIMEZONE, String.class).getOrDefault("UTC"); + VerticalDatum verticalDatum = VerticalDatum.getVerticalDatum(ctx.queryParam(DATUM)); Instant beginInstant = null; String begin = ctx.queryParam(BEGIN); @@ -394,7 +429,7 @@ public void getOne(@NotNull Context ctx, @NotNull String rating) { RatingSet.DatabaseLoadMethod.class) .getOrDefault(RatingSet.DatabaseLoadMethod.EAGER); - String body = getRatingSetString(ctx, method, officeId, rating, beginInstant, endInstant); + String body = getRatingSetString(ctx, method, officeId, rating, beginInstant, endInstant, verticalDatum); if (body != null) { ctx.result(body); ctx.status(HttpCode.OK); @@ -406,7 +441,7 @@ public void getOne(@NotNull Context ctx, @NotNull String rating) { @Nullable private String getRatingSetString(Context ctx, RatingSet.DatabaseLoadMethod method, String officeId, String rating, Instant begin, - Instant end) { + Instant end, VerticalDatum verticalDatum) { String retval = null; try (final Timer.Context ignored = markAndTime("getRatingSetString")) { @@ -421,9 +456,44 @@ private String getRatingSetString(Context ctx, RatingSet.DatabaseLoadMethod meth try { RatingSet ratingSet = getRatingSet(ctx, method, officeId, rating, begin, end); if (ratingSet != null) { + //Apply vertical datum conversion if needed + if (verticalDatum != null) { + try { + switch (verticalDatum) + { + case NAVD88: + ratingSet.toNAVD88(); + break; + case NGVD29: + ratingSet.toNGVD29(); + break; + case NATIVE: + ratingSet.toNativeVerticalDatum(); + break; + default: + logger.atSevere().log("Unknown vertical datum: %s", verticalDatum); + break; + } + VerticalDatumInfo vdi = RatingsVerticalDatumExtractor.deserializeVerticalDatumInfoXml(ratingSet.getVerticalDatumInfo()); + if(vdi != null && vdi.getOffsetForDatum(verticalDatum) != null) { + VerticalDatumInfo newVdi = vdi.convertedTo(vdi.getOffsetForDatum(verticalDatum)); + XmlMapper xmlMapper = new XmlMapper(); + String vdiXml = xmlMapper.writeValueAsString(newVdi); + ratingSet.setVerticalDatumInfo(vdiXml); + } + } catch (VerticalDatumException vde) { + logger.atWarning().withCause(vde).log("Failed to convert rating %s to requested vertical datum: %s", + rating, verticalDatum); + } + } if (isJson) { retval = JsonRatingUtils.toJson(ratingSet); } else { + //the toXml method in RatingXmlFactory converts to native-datum which breaks things coming back in the user-requested datum + //setting the current-datum to an unknown value prevents the call to convert to native-datum + if(ratingSet.getVerticalDatumContainer() != null && ratingSet.getVerticalDatumContainer().currentDatum != null) { + ratingSet.getVerticalDatumContainer().currentDatum = "ignoreConversionToNativeDatum"; + } retval = RatingXmlFactory.toXml(ratingSet, " "); } } else { @@ -483,7 +553,16 @@ private RatingSet getRatingSet(Context ctx, RatingSet.DatabaseLoadMethod method, @OpenApiParam(name = STORE_TEMPLATE, type = Boolean.class, description = "Also store updates to the rating template. Default: true"), @OpenApiParam(name = REPLACE_BASE_CURVE, type = Boolean.class, - description = "Replace the base curve of USGS stream flow rating. Default: false") + description = "Replace the base curve of USGS stream flow rating. Default: false"), + @OpenApiParam(name = DATUM, type = VerticalDatum.class, description = "If the provided " + + "rating-set includes an explicit vertical-datum-info attribute " + + "then it is assumed that the data is in the datum specified by the vertical-datum-info. " + + "If the input rating-set does not include vertical-datum-info and " + + "this parameter is not provided it is assumed that the data is in the as-stored " + + "datum and no conversion is necessary. " + + "If the input rating-set does not include vertical-datum-info and " + + "this parameter is provided it is assumed that the data is in the Datum named by the argument " + + "and should be converted to the as-stored datum before being saved.") }, method = HttpMethod.PATCH, path = "/ratings", tags = {TAG}) public void update(@NotNull Context ctx, @NotNull String ratingId) { @@ -498,7 +577,14 @@ public void update(@NotNull Context ctx, @NotNull String ratingId) { boolean replaceBaseCurve = ctx.queryParamAsClass(REPLACE_BASE_CURVE, Boolean.class) .getOrDefault(false); String ratingSet = deserializeRatingSet(ctx, storeTemplate); - ratingDao.store(ratingSet, replaceBaseCurve); + String datum = ctx.queryParam(DATUM); + VerticalDatum vd = null; + if(datum != null) { + vd = ctx.queryParamAsClass(DATUM, VerticalDatum.class) + .getOrDefault(null); + } + vd = RatingsVerticalDatumExtractor.getVerticalDatum(ratingSet).orElse(vd); + ratingDao.store(ratingSet, replaceBaseCurve, vd); StatusResponse re = new StatusResponse(RatingDao.extractOfficeFromXml(ratingSet), "Updated RatingSet"); ctx.status(HttpServletResponse.SC_OK).json(re); } catch (IOException ex) { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/JooqDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/JooqDao.java index 5577cc11c..5cb7ddf5e 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/JooqDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/JooqDao.java @@ -51,6 +51,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -72,6 +73,7 @@ import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; import usace.cwms.db.jooq.codegen.packages.CWMS_ENV_PACKAGE; +import usace.cwms.db.jooq.codegen.packages.CWMS_LOC_PACKAGE; import usace.cwms.db.jooq.codegen.packages.CWMS_UTIL_PACKAGE; @@ -215,6 +217,34 @@ protected static Double toDouble(BigDecimal bigDecimal) { return retVal; } + /** + * The idea here is that this will check the current default datum, + * possible switch to the specified datum and + * then run the code and + * if the datum was previously switched + * then switch back to the initial datum. + * @param targetDatum The desired ver + * @param dslContext + * @param cr + */ + protected void withDefaultDatum(@Nullable VerticalDatum targetDatum, DSLContext dslContext, ConnectionRunnable cr) { + String defaultVertDatum = CWMS_LOC_PACKAGE.call_GET_DEFAULT_VERTICAL_DATUM(dslContext.configuration()); + String targetName = (targetDatum != null) ? targetDatum.toString() : null; + boolean changeDefaultDatum = !Objects.equals(targetName, defaultVertDatum); + try { + if (changeDefaultDatum) { + CWMS_LOC_PACKAGE.call_SET_DEFAULT_VERTICAL_DATUM(dslContext.configuration(), targetName); + } + + connection(dslContext, cr); + } finally { + if (changeDefaultDatum) { + // If we changed it we should restore. + CWMS_LOC_PACKAGE.call_SET_DEFAULT_VERTICAL_DATUM(dslContext.configuration(), defaultVertDatum); + } + } + } + /** * Oracle supports case insensitive regexp search but the syntax for calling it is a * bit weird. This method lets Dao classes add a case-insensitive regexp search in diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingDao.java index e69a0944c..8e106c4ae 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingDao.java @@ -35,7 +35,7 @@ public interface RatingDao { Pattern officeMatcher = Pattern.compile(".*office-id=\"(.*?)\""); - void create(String ratingSet, boolean replaceBaseCurve) throws IOException, RatingException; + void create(String ratingSet, boolean replaceBaseCurve, VerticalDatum vd) throws IOException, RatingException; RatingSet retrieve(RatingSet.DatabaseLoadMethod method, String officeId, String specificationId, Instant start, Instant end) throws IOException, RatingException; @@ -46,10 +46,18 @@ String retrieveRatings(String format, String names, String unit, String datum, S String start, String end, String timezone); - void store(String ratingSet, boolean replaceBaseCurve) throws IOException, RatingException; + void store(String ratingSet, boolean replaceBaseCurve, VerticalDatum vd) throws IOException, RatingException; void delete(String officeId, String specificationId, Instant start, Instant end); + default void create(String ratingSet, boolean replaceBaseCurve) throws IOException, RatingException { + create(ratingSet, replaceBaseCurve, null); + } + + default void store(String ratingSet, boolean replaceBaseCurve) throws IOException, RatingException { + store(ratingSet, replaceBaseCurve, null); + } + static String extractOfficeFromXml(String xml) { Matcher officeMatch = officeMatcher.matcher(xml); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingSetDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingSetDao.java index 8a7b60a10..48309a622 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingSetDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingSetDao.java @@ -27,6 +27,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import cwms.cda.data.dto.VerticalDatumInfo; +import cwms.cda.data.dto.rating.RatingSpec; import hec.data.RatingException; import hec.data.cwmsRating.RatingSet; import java.io.IOException; @@ -34,10 +36,14 @@ import java.sql.Timestamp; import java.time.Instant; import java.util.List; + import mil.army.usace.hec.cwms.rating.io.jdbc.ConnectionProvider; import mil.army.usace.hec.cwms.rating.io.jdbc.RatingJdbcFactory; +import org.jetbrains.annotations.Nullable; +import org.jooq.ConnectionRunnable; import org.jooq.DSLContext; import org.jooq.exception.DataAccessException; +import usace.cwms.db.jooq.codegen.packages.CWMS_LOC_PACKAGE; import usace.cwms.db.jooq.codegen.packages.CWMS_RATING_PACKAGE; @@ -48,25 +54,8 @@ public RatingSetDao(DSLContext dsl) { } @Override - public void create(String ratingSetXml, boolean replaceBaseCurve) throws IOException, RatingException { - try { - connection(dsl, c -> { - // can't exist if we are creating, if it exists use store - String office = extractOfficeId(ratingSetXml); - DSLContext context = getDslContext(c, office); - String errs = CWMS_RATING_PACKAGE.call_STORE_RATINGS_XML__5(context.configuration(), - ratingSetXml, "T", replaceBaseCurve ? "T" : "F"); - if (errs != null && !errs.isEmpty()) { - throw new DataAccessException("Failed to create Rating", new RatingException(errs)); - } - }); - } catch (DataAccessException ex) { - Throwable cause = ex.getCause(); - if (cause instanceof RatingException) { - throw (RatingException) cause; - } - throw new IOException("Failed to create Rating", ex); - } + public void create(String ratingSetXml, boolean replaceBaseCurve, VerticalDatum vd) throws IOException, RatingException { + connection(dsl, connection -> storeWithDefaultDatum(ratingSetXml, replaceBaseCurve, true, vd, connection)); } private static String extractOfficeId(String ratingSet) throws JsonProcessingException { @@ -81,6 +70,18 @@ private static String extractOfficeId(String ratingSet) throws JsonProcessingExc return office; } + private static String extractLocationId(String ratingSet) throws JsonProcessingException { + XmlMapper xmlMapper = new XmlMapper(); + JsonNode node = xmlMapper.readTree(ratingSet); + List values = node.findValues("location-id"); + String location = ""; + if (!values.isEmpty()) { + //Getting the last instance since the order is template, spec, rating + location = values.get(values.size() - 1).textValue(); + } + return location; + } + @Override public String retrieveLatestXML(String officeId, String specificationId) { return connectionResult(dsl, c -> { @@ -116,9 +117,11 @@ public RatingSet retrieve(RatingSet.DatabaseLoadMethod method, String officeId, RatingSet.DatabaseLoadMethod finalMethod = method; - connection(dsl, c -> retval[0] = - RatingJdbcFactory.ratingSet(finalMethod, new RatingConnectionProvider(c), officeId, - specificationId, start, end, false)); + connection(dsl, c -> { + setOffice(c, officeId); + retval[0] = RatingJdbcFactory.ratingSet(finalMethod, new RatingConnectionProvider(c), officeId, + specificationId, start, end, false); + }); } catch (DataAccessException ex) { @@ -137,18 +140,20 @@ public RatingSet retrieve(RatingSet.DatabaseLoadMethod method, String officeId, // store/update @Override - public void store(String ratingSetXml, boolean replaceBaseCurve) throws IOException, RatingException { + public void store(String ratingSetXml, boolean replaceBaseCurve, VerticalDatum vd) throws IOException, RatingException { + connection(dsl, connection -> storeWithDefaultDatum(ratingSetXml, replaceBaseCurve, false, vd, connection)); + } + + private static void storeRatingSetXml(String ratingSetXml, boolean replaceBaseCurve, boolean failIfExists, Connection c) throws RatingException, IOException { try { - connection(dsl, c -> { - String office = extractOfficeId(ratingSetXml); - DSLContext context = getDslContext(c, office); - String errs = CWMS_RATING_PACKAGE.call_STORE_RATINGS_XML__5(context.configuration(), - ratingSetXml, "F", replaceBaseCurve ? "T" : "F"); - if (errs != null && !errs.isEmpty()) - { - throw new DataAccessException("Failed to store Rating", new RatingException(errs)); - } - }); + String office = extractOfficeId(ratingSetXml); + DSLContext context = getDslContext(c, office); + String errs = CWMS_RATING_PACKAGE.call_STORE_RATINGS_XML__5(context.configuration(), + ratingSetXml, formatBool(failIfExists), formatBool(replaceBaseCurve)); + if (errs != null && !errs.isEmpty()) + { + throw new DataAccessException("Failed to store Rating", new RatingException(errs)); + } } catch (DataAccessException ex) { Throwable cause = ex.getCause(); if (cause instanceof RatingException) { @@ -158,6 +163,52 @@ public void store(String ratingSetXml, boolean replaceBaseCurve) throws IOExcept } } + private void storeWithDefaultDatum(String ratingSetXml, boolean replaceBaseCurve, boolean failIfExists, + VerticalDatum vd, Connection connection) throws Throwable { + String office = extractOfficeId(ratingSetXml); + String locationId = extractLocationId(ratingSetXml); + DSLContext dslContext = getDslContext(connection, office); + if(vd != null) { + withLocalAndDefaultDatum(locationId, office, vd, dslContext, c -> storeRatingSetXml(ratingSetXml, replaceBaseCurve, failIfExists, c)); + } + else { + storeRatingSetXml(ratingSetXml, replaceBaseCurve, failIfExists, connection); + } + + } + + protected void withLocalAndDefaultDatum(String locationId, String officeId, @Nullable VerticalDatum targetDatum, DSLContext dslContext, ConnectionRunnable cr) { + boolean localDatumAdded = false; + try { + //if converting to NAVD88 or NGVD29, we need to set the local datum to the native datum temporarily or the conversion will fail in the db + if(targetDatum == VerticalDatum.NAVD88 || targetDatum == VerticalDatum.NGVD29) { + String vertDatum = CWMS_LOC_PACKAGE.call_GET_VERTICAL_DATUM_INFO_F__2(dslContext.configuration(), locationId, "m", officeId); + if(vertDatum != null) + { + XmlMapper xmlMapper = new XmlMapper(); + VerticalDatumInfo vdi = xmlMapper.readValue(vertDatum, VerticalDatumInfo.class); + String nativeDatum = vdi.getNativeDatum(); + // Only set local datum temporarily if native datum is NAVD88 or NGVD29 to allow conversion + // If native datum is unknown for some reason then just set to the target datum since there is no conversion needed anyways + if(nativeDatum == null || nativeDatum.isBlank() || "UNKNOWN".equalsIgnoreCase(nativeDatum)) { + CWMS_LOC_PACKAGE.call_SET_LOCAL_VERT_DATUM_NAME__2(dslContext.configuration(), locationId, targetDatum.toString(), "T", officeId); + localDatumAdded = true; + } else if(VerticalDatum.NAVD88 == VerticalDatum.getVerticalDatum(nativeDatum) || VerticalDatum.NGVD29 == VerticalDatum.getVerticalDatum(nativeDatum)) { + CWMS_LOC_PACKAGE.call_SET_LOCAL_VERT_DATUM_NAME__2(dslContext.configuration(), locationId, nativeDatum, "T", officeId); + localDatumAdded = true; + } + } + } + withDefaultDatum(targetDatum, dslContext, cr); + } catch (IOException e) { + throw new DataAccessException("Failed to parse vertical datum info for location " + locationId, e); + } finally { + if(localDatumAdded) { + CWMS_LOC_PACKAGE.call_DELETE_LOCAL_VERT_DATUM_NAME__2(dslContext.configuration(), locationId, officeId); + } + } + } + @Override public void delete(String officeId, String specificationId, Instant start, Instant end) { Timestamp startDate = new Timestamp(start.toEpochMilli()); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingsVerticalDatumExtractor.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingsVerticalDatumExtractor.java new file mode 100644 index 000000000..1b60bbdf1 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/RatingsVerticalDatumExtractor.java @@ -0,0 +1,60 @@ +package cwms.cda.data.dao; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import cwms.cda.data.dto.VerticalDatumInfo; + +import java.util.List; +import java.util.Optional; + +public class RatingsVerticalDatumExtractor { + + private RatingsVerticalDatumExtractor() { + throw new AssertionError("Utility class, don't instantiate"); + } + + public static Optional getVerticalDatum(String ratingSet) { + return Optional.ofNullable(ratingSet) + .flatMap(RatingsVerticalDatumExtractor::getVerticalDatumInfo) + .map(VerticalDatumInfo::getNativeDatum) + .filter(s -> !s.isEmpty()) + .map(s -> { + if (s.equalsIgnoreCase(VerticalDatum.OTHER.toString())) { + throw new IllegalArgumentException("Vertical Datum of OTHER is not currently supported."); + } + return VerticalDatum.getVerticalDatum(s); + }); + } + + public static Optional getVerticalDatumInfo(String ratingSet) { + try { + return extractVerticalDatumInfo(ratingSet).map(RatingsVerticalDatumExtractor::deserializeVerticalDatumInfoXml); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse Vertical Datum Info", e); + } + } + + public static VerticalDatumInfo deserializeVerticalDatumInfoXml(String vdiXml) { + XmlMapper xmlMapper = new XmlMapper(); + try { + return xmlMapper.readValue(vdiXml, VerticalDatumInfo.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse Vertical Datum Info", e); + } + } + + private static Optional extractVerticalDatumInfo(String ratingSet) throws JsonProcessingException { + XmlMapper xmlMapper = new XmlMapper(); + JsonNode node = xmlMapper.readTree(ratingSet); + List values = node.findValues("vertical-datum-info"); + Optional retVal = Optional.empty(); + if (!values.isEmpty()) { + JsonNode vdiNode = values.get(values.size() - 1); + retVal = Optional.ofNullable(xmlMapper.writer() + .withRootName("vertical-datum-info") + .writeValueAsString(vdiNode)); + } + return retVal; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java index 9230d22e7..bca30d2b1 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java @@ -1476,36 +1476,6 @@ public void create(TimeSeries input, }); } - // - - /** - * The idea here is that this will check the current default datum, - * possible switch to the specified datum and - * then run the code and - * if the datum was previously switched - * then switch back to the initial datum. - * @param targetDatum The desired ver - * @param dslContext - * @param cr - */ - private void withDefaultDatum(@Nullable VerticalDatum targetDatum, DSLContext dslContext, ConnectionRunnable cr) { - String defaultVertDatum = CWMS_LOC_PACKAGE.call_GET_DEFAULT_VERTICAL_DATUM(dslContext.configuration()); - String targetName = (targetDatum != null) ? targetDatum.toString() : null; - boolean changeDefaultDatum = !Objects.equals(targetDatum, defaultVertDatum); - try { - if (changeDefaultDatum) { - CWMS_LOC_PACKAGE.call_SET_DEFAULT_VERTICAL_DATUM(dslContext.configuration(), targetName); - } - - connection(dslContext, cr); - }finally{ - if (changeDefaultDatum) { - // If we changed it we should restore. - CWMS_LOC_PACKAGE.call_SET_DEFAULT_VERTICAL_DATUM(dslContext.configuration(), defaultVertDatum); - } - } - } - @Override public void store(TimeSeries timeSeries, Timestamp versionDate) { store(timeSeries, false, StoreRule.REPLACE_ALL, TimeSeriesDaoImpl.OVERRIDE_PROTECTION, null); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatum.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatum.java index 39aaef9b4..c23e573ed 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatum.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatum.java @@ -15,8 +15,8 @@ public enum VerticalDatum { public static VerticalDatum getVerticalDatum(String input) { VerticalDatum retval = null; - if (input != null) { - input = input.replace("-", ""); + if (input != null && !input.isBlank()) { + input = input.trim().replace("-", ""); retval = VerticalDatum.valueOf(input.toUpperCase()); } return retval; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/VerticalDatumInfo.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/VerticalDatumInfo.java index e4369a3cb..761d748f9 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/VerticalDatumInfo.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/VerticalDatumInfo.java @@ -7,10 +7,15 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import cwms.cda.data.dao.VerticalDatum; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @JsonRootName("vertical-datum-info") @JsonDeserialize(builder = VerticalDatumInfo.Builder.class) @@ -31,6 +36,8 @@ public class VerticalDatumInfo extends CwmsDTOBase { // Serialize empty arrays in the xml @JsonInclude(JsonInclude.Include.ALWAYS) + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "offset") VerticalDatumInfo.Offset[] offsets = new Offset[0]; private VerticalDatumInfo() { @@ -100,13 +107,24 @@ private VerticalDatumInfo.Offset[] buildConvertedOffsets(VerticalDatum convertTo //add the other offsets, adjusted VerticalDatumInfo.Offset[] offsets = getOffsets(); + //if contains a zero offset, we will mimic that for the converted datum by adding a zero offset (the datum we converted to) + boolean hasZeroOffset = Arrays.stream(offsets) + .anyMatch(offset -> offset.getValue() == 0.0); for (VerticalDatumInfo.Offset offset : offsets) { String toDatum = offset.getToDatum(); - if (!offset.isForDatum(convertTo.toString())) { - Double newOffsetValue = convertToOffsetToOriginal + offset.getValue(); - boolean isEstimate = offset.isEstimate() || convertToOffset.isEstimate(); - VerticalDatumInfo.Offset newOffset = new VerticalDatumInfo.Offset(isEstimate, toDatum, newOffsetValue); - newOffsets.add(newOffset); + Set existingDatums = newOffsets.stream().map(Offset::getToDatum) + .collect(Collectors.toSet()); + if(!existingDatums.contains(offset.getToDatum())) { + if (!offset.isForDatum(convertTo.toString())) { + Double newOffsetValue = convertToOffsetToOriginal + offset.getValue(); + boolean isEstimate = offset.isEstimate() || convertToOffset.isEstimate(); + VerticalDatumInfo.Offset newOffset = new VerticalDatumInfo.Offset(isEstimate, toDatum, newOffsetValue); + newOffsets.add(newOffset); + } else if(hasZeroOffset) { + //this is the one we converted to, its now zero offset + VerticalDatumInfo.Offset newOffset = new VerticalDatumInfo.Offset(false, toDatum, 0.0); + newOffsets.add(newOffset); + } } } return newOffsets.toArray(new VerticalDatumInfo.Offset[]{}); @@ -231,6 +249,8 @@ public VerticalDatumInfo.Builder withElevation(Double elevation) { return this; } + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "offset") public VerticalDatumInfo.Builder withOffsets(VerticalDatumInfo.Offset[] offsets) { this.offsets = offsets; return this; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/DataApiTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/DataApiTestIT.java index 4311d1394..2b42c0d0d 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/DataApiTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/DataApiTestIT.java @@ -33,6 +33,7 @@ import cwms.cda.data.dao.DeleteRule; import cwms.cda.data.dao.StreamDao; import cwms.cda.data.dao.basin.BasinDao; +import cwms.cda.data.dao.VerticalDatum; import cwms.cda.data.dto.Location; import cwms.cda.data.dto.LocationCategory; import cwms.cda.data.dto.LocationGroup; @@ -81,6 +82,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.extension.ExtendWith; import usace.cwms.db.jooq.codegen.packages.CWMS_ENV_PACKAGE; +import usace.cwms.db.jooq.codegen.packages.CWMS_LOC_PACKAGE; import usace.cwms.db.jooq.codegen.packages.CWMS_UTIL_PACKAGE; /** @@ -330,6 +332,66 @@ protected static void createLocation(String location, boolean active, String off }, "cwms_20"); } + protected static void createLocationWithVerticalDatum(String location, boolean active, String office, VerticalDatum verticalDatum) throws SQLException + { + createLocation(location, active, office); + updateLocation(location, active, office, verticalDatum); + } + + private static void updateLocation(String location, boolean active, String officeId, VerticalDatum verticalDatum) throws SQLException { + + String P_LOCATION_ID = location; + String P_LOCATION_TYPE = "SITE"; + Number P_ELEVATION = 11; + String P_ELEV_UNIT_ID = "m"; + + // Pretty sure this isn't supposed to have a dash. The create doesn't check. The default create just passes null. + // If it has a dash then the offsets don't work. + // select VERTICAL_DATUM, count(*) as COUNT + // from AT_PHYSICAL_LOCATION + // group by VERTICAL_DATUM + // order by COUNT desc + // has no entries with a dash in the name (unless we've run this test with a dash). + String P_VERTICAL_DATUM = verticalDatum.toString(); + Number P_LATITUDE = 38.5757; // pretty sure that if these are 0,0 then its not inside the navd88 bounds and the offsets come back [] + Number P_LONGITUDE = -121.4789; + String P_HORIZONTAL_DATUM = "WGS84"; + String P_PUBLIC_NAME = "Integration Test Sac Dam"; + String P_LONG_NAME= null; + String P_DESCRIPTION = "for testing"; + String P_TIME_ZONE_ID = "UTC"; + String P_COUNTY_NAME = "Sacramento"; + String P_STATE_INITIAL = "CA"; + String P_ACTIVE = active ? "T" : "F"; + String P_DB_OFFICE_ID = officeId; + + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + DSLContext dslContext = getDslContext(c, officeId); + + // CWMS_LOC_PACKAGE.call_DELETE_LOCATION(dslContext.configuration(), P_LOCATION_ID, String.valueOf(DeleteRule.DELETE_LOC_CASCADE), P_DB_OFFICE_ID); + // CWMS_LOC_PACKAGE.call_CREATE_LOCATION(dslContext.configuration(), + // P_LOCATION_ID, P_LOCATION_TYPE, P_ELEVATION, P_ELEV_UNIT_ID, P_VERTICAL_DATUM, P_LATITUDE, P_LONGITUDE, + // P_HORIZONTAL_DATUM, P_PUBLIC_NAME, P_LONG_NAME, P_DESCRIPTION, P_TIME_ZONE_ID, P_COUNTY_NAME, P_STATE_INITIAL, + // P_ACTIVE, P_DB_OFFICE_ID); + + String P_IGNORENULLS = "F"; + CWMS_LOC_PACKAGE.call_UPDATE_LOCATION(dslContext.configuration(), + P_LOCATION_ID, P_LOCATION_TYPE, P_ELEVATION, P_ELEV_UNIT_ID, P_VERTICAL_DATUM, P_LATITUDE, P_LONGITUDE, + P_HORIZONTAL_DATUM, P_PUBLIC_NAME, P_LONG_NAME, P_DESCRIPTION, P_TIME_ZONE_ID, P_COUNTY_NAME, P_STATE_INITIAL, + P_ACTIVE, P_IGNORENULLS, P_DB_OFFICE_ID ); + + }); + + } + + private static DSLContext getDslContext(Connection database, String officeId) + { + DSLContext dsl = DSL.using(database, SQLDialect.ORACLE18C); + CWMS_ENV_PACKAGE.call_SET_SESSION_OFFICE_ID(dsl.configuration(), officeId); + return dsl; + } + /** * Creates a location saving the data for later deletion. With the following defaults: * diff --git a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java index 3a956f82e..099989698 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java @@ -373,7 +373,7 @@ enum GetAllTest void test_1206_rating_create_json() throws IOException { // example from 1206 but office changed to SPK String body = readResourceFile("cwms/cda/api/spk/ratings_ind.json"); - + body = body.replaceAll("Barren", EXISTING_LOC); TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; // Create the set @@ -397,7 +397,7 @@ void test_1206_rating_create_json() throws IOException { void test_1206_rating_create_xml() throws IOException { // example from 1206 but office changed to SPK and converted to xml String body = readResourceFile("cwms/cda/api/spk/ratings_ind.xml"); - + body = body.replaceAll("Barren", EXISTING_LOC); TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; // Create the set diff --git a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestVerticalDatumIT.java b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestVerticalDatumIT.java new file mode 100644 index 000000000..1acbb4f58 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestVerticalDatumIT.java @@ -0,0 +1,603 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.rating; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import cwms.cda.api.DataApiTestIT; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dao.VerticalDatum; +import cwms.cda.data.dto.VerticalDatumInfo; +import cwms.cda.formatters.Formats; +import fixtures.TestAccounts; +import hec.data.cwmsRating.AbstractRating; +import hec.data.cwmsRating.RatingSet; +import hec.data.cwmsRating.io.RatingSetContainer; +import hec.data.cwmsRating.io.RatingSpecContainer; +import io.restassured.filter.log.LogDetail; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.io.IOException; +import java.util.stream.Stream; +import javax.servlet.http.HttpServletResponse; +import mil.army.usace.hec.cwms.rating.io.xml.RatingSetContainerXmlFactory; +import mil.army.usace.hec.cwms.rating.io.xml.RatingSpecXmlFactory; +import mil.army.usace.hec.cwms.rating.io.xml.RatingXmlFactory; +import mil.army.usace.hec.metadata.VerticalDatumContainer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import static cwms.cda.api.Controllers.*; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("integration") +class RatingsControllerTestVerticalDatumIT extends DataApiTestIT { + static final String BASE_LOCATION = "RatingDatumTest"; + static final String LOC_WITH_NAVD88 = BASE_LOCATION + "-NAVD88"; + static final String LOC_WITH_NGVD29 = BASE_LOCATION + "-NGVD29"; + static final String TEMPLATE = "Elev;Area.Standard"; + static final String SPK = "SPK"; + + @BeforeAll + static void beforeAll() throws Exception { + //Make sure we always have something. + createLocation(BASE_LOCATION, true, SPK); + createLocationWithVerticalDatum(LOC_WITH_NAVD88, true, SPK, VerticalDatum.NAVD88); + createLocationWithVerticalDatum(LOC_WITH_NGVD29, true, SPK, VerticalDatum.NGVD29); + + String xml = readVerticalDatumRatingXml(BASE_LOCATION); + RatingSetContainer container = RatingSetContainerXmlFactory.ratingSetContainerFromXml(xml); + RatingSpecContainer specContainer = container.ratingSpecContainer; + String templateXml = RatingSpecXmlFactory.toXml(specContainer, "", 0); + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String specXml = RatingSpecXmlFactory.toXml(specContainer, "", 0, true); + + createTemplate(templateXml, user); + + createSpec(specXml, user); + } + + @Test + void test_create_with_datum_param_differs_from_location_native_datum() throws Exception { + // Verify RatingsController create (POST /ratings) accepts the datum query parameter + // when the input rating XML does not include vertical-datum-info. + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Build rating XML for BASE_LOCATION and strip datum info + String xmlWithDatum = readVerticalDatumRatingXml(LOC_WITH_NGVD29); + String xml = stripVerticalDatumInfo(xmlWithDatum); + + RatingSet ratingSet = RatingXmlFactory.ratingSet(xmlWithDatum); + String ratingId = ratingSet.getRatingSpec().getRatingSpecId(); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode root = xmlMapper.readTree(xmlWithDatum); + JsonNode firstIndNode = root + .path("simple-rating") + .path("rating-points") + .path("point") + .get(0) + .path("ind"); + double firstElev = firstIndNode.asDouble(); + JsonNode vdiNode = root + .path("simple-rating") + .path("vertical-datum-info"); + VerticalDatumInfo vdi = xmlMapper.treeToValue(vdiNode, VerticalDatumInfo.class); + VerticalDatumInfo.Offset offset = vdi.getOffsetForDatum(VerticalDatum.NAVD88); + + // First create with a datum that doesn't match native datum for location + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xml) //using xml with no datum info so param is used + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .queryParam(DATUM, VerticalDatum.NAVD88) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // 2) verify elevation is as expected + Response response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId); + response.then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ratings.simple-rating.rating-points.point[0].ind.toDouble()", closeTo(firstElev + offset.getValue(), 0.001)); + + deleteRatingEffectiveDates(user, ratingId); + } + + @Test + void test_update_datum_not_in_body_uses_param() throws Exception { + // Verify RatingsController update/store (PATCH /ratings/{id}) accepts the datum query parameter + // when the input rating XML does not include vertical-datum-info. + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Build rating XML for BASE_LOCATION and strip datum info + String xmlWithDatum = readVerticalDatumRatingXml(LOC_WITH_NGVD29); + String xml = stripVerticalDatumInfo(xmlWithDatum); + + RatingSet ratingSet = RatingXmlFactory.ratingSet(xmlWithDatum); + String ratingId = ratingSet.getRatingSpec().getRatingSpecId(); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode root = xmlMapper.readTree(xmlWithDatum); + JsonNode firstIndNode = root + .path("simple-rating") + .path("rating-points") + .path("point") + .get(0) + .path("ind"); + double firstElev = firstIndNode.asDouble(); + JsonNode vdiNode = root + .path("simple-rating") + .path("vertical-datum-info"); + VerticalDatumInfo vdi = xmlMapper.treeToValue(vdiNode, VerticalDatumInfo.class); + VerticalDatumInfo.Offset offset = vdi.getOffsetForDatum(VerticalDatum.NAVD88); + + // First POST to ensure the rating exists + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xmlWithDatum) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // 2) verify elevation is as expected + Response response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId); + response.then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ratings.simple-rating.rating-points.point[0].ind.toDouble()", closeTo(firstElev, 0.001)); + + + // 3) PATCH with datum = NAVD88 and no datum info in body means we apply offset, so value changes + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xml) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .queryParam(DATUM, VerticalDatum.NAVD88) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/ratings/" + ratingId) + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)); + + // 4) retrieve rating and verify value is changed as expected + response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId); + response.then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ratings.simple-rating.rating-points.point[0].ind.toDouble()", closeTo(firstElev + offset.getValue(), 0.001)); + + deleteRatingEffectiveDates(user, ratingId); + } + + @Test + void test_update_with_datum_in_body_ignores_param() throws Exception { + // Verify RatingsController update/store (PATCH /ratings/{id}) ignores the datum query parameter + // when the input rating XML does include vertical-datum-info. + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Build rating XML for BASE_LOCATION and strip datum info + String xmlWithDatum = readVerticalDatumRatingXml(LOC_WITH_NGVD29); + + RatingSet ratingSet = RatingXmlFactory.ratingSet(xmlWithDatum); + String ratingId = ratingSet.getRatingSpec().getRatingSpecId(); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode root = xmlMapper.readTree(xmlWithDatum); + JsonNode firstIndNode = root + .path("simple-rating") + .path("rating-points") + .path("point") + .get(0) + .path("ind"); + double firstElev = firstIndNode.asDouble(); + + // First POST to ensure the rating exists + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xmlWithDatum) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // 2) verify elevation is as expected + Response response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId); + response.then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ratings.simple-rating.rating-points.point[0].ind.toDouble()", closeTo(firstElev, 0.001)); + + + // 3) PATCH with datum = NAVD88 but it should be ignored since body contains datum info + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xmlWithDatum) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .queryParam(DATUM, VerticalDatum.NAVD88) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/ratings/" + ratingId) + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)); + + // 4) retrieve rating and verify elevation is still as expected + response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId); + response.then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ratings.simple-rating.rating-points.point[0].ind.toDouble()", closeTo(firstElev, 0.001)); + + deleteRatingEffectiveDates(user, ratingId); + } + + private static void createSpec(String specXml, TestAccounts.KeyUser user) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(specXml) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .post("/ratings/spec") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + } + + private static void createTemplate(String templateXml, TestAccounts.KeyUser user) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(templateXml) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .post("/ratings/template") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + } + + @AfterAll + static void cleanUp() { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Delete Template + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .queryParam(METHOD, JooqDao.DeleteMethod.DELETE_ALL) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .delete("/ratings/template/" + TEMPLATE) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + } + + @MethodSource(value = "provideDatumCombinations") + @ParameterizedTest + void test_vertical_datum_get_all(TestLocationIds locId, TestLocationVerticalDatumData testData) throws Exception { + String xml = readVerticalDatumRatingXml(locId._locationId); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode root = xmlMapper.readTree(xml); + JsonNode vdiNode = root + .path("simple-rating") + .path("vertical-datum-info"); + VerticalDatumInfo vdi = xmlMapper.treeToValue(vdiNode, VerticalDatumInfo.class); + vdi = vdi.convertedTo(vdi.getOffsetForDatum(locId._nativeDatum)); + String newVdiXml = xmlMapper.writeValueAsString(vdi); + xml = xml.replaceAll("", newVdiXml); + RatingSet originalRatingSet = RatingXmlFactory.ratingSet(xml); + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = originalRatingSet.getRatingSpec().getRatingSpecId(); + AbstractRating originalRating = originalRatingSet.getRatings()[0]; + originalRating.setVerticalDatumContainer(null); + + storeRatingFromXml(xml, user); + + //Request the one rating id we stored, using the getAll endpoint with a query param filter + String requestedVerticalDatum = testData._requestedVerticalDatum == null ? "" : testData._requestedVerticalDatum.toString(); + ExtractableResponse response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .queryParam(DATUM, requestedVerticalDatum) + .queryParam(NAME, ratingId) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .get("/ratings") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.XMLV2)) + .extract(); + + deleteRatingEffectiveDates(user, ratingId); + } + + @MethodSource(value = "provideDatumCombinations") + @ParameterizedTest + void test_vertical_datum_get_one(TestLocationIds locId, TestLocationVerticalDatumData testData) throws Exception { + //This tests getting a rating with various combinations of native location datum and requested datum + //Storing a rating without any vertical datum info, then requesting it back with various datum requests + String xml = readVerticalDatumRatingXml(locId._locationId); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode root = xmlMapper.readTree(xml); + JsonNode vdiNode = root + .path("simple-rating") + .path("vertical-datum-info"); + VerticalDatumInfo vdi = xmlMapper.treeToValue(vdiNode, VerticalDatumInfo.class); + vdi = vdi.convertedTo(vdi.getOffsetForDatum(locId._nativeDatum)); + String newVdiXml = xmlMapper.writeValueAsString(vdi); + xml = xml.replaceAll("", newVdiXml); + RatingSet originalRatingSet = RatingXmlFactory.ratingSet(xml); + double firstElev = originalRatingSet.getRatings()[0].getValues(0)[0].getIndValue(); + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = originalRatingSet.getRatingSpec().getRatingSpecId(); + AbstractRating originalRating = originalRatingSet.getRatings()[0]; + originalRating.setVerticalDatumContainer(null); + + double expectedFirstElev = firstElev; + if (testData._requestedVerticalDatum == VerticalDatum.NATIVE || testData._requestedVerticalDatum == null || locId._nativeDatum == null) { + expectedFirstElev = firstElev; + } + else if(testData._requestedVerticalDatum != locId._nativeDatum) { + //Need to apply offset + + VerticalDatumInfo.Offset offset = vdi.getOffsetForDatum(testData._requestedVerticalDatum); + //storedValue = NAVD88 + offset + //-> NAVD88 = storedValue - offset + expectedFirstElev = firstElev - offset.getValue(); + } + + storeRatingFromXml(xml, user); + + //Use getOne endpoint to get the rating we just stored + String requestedVerticalDatum = testData._requestedVerticalDatum == null ? "" : testData._requestedVerticalDatum.toString(); + ExtractableResponse response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .queryParam(OFFICE, SPK) + .queryParam(DATUM, requestedVerticalDatum) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .get("/ratings/" + ratingId) + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.XMLV2)) + .extract(); + + deleteRatingEffectiveDates(user, ratingId); + + RatingSet receivedRatingSet = RatingXmlFactory.ratingSet(response.body().asString()); + VerticalDatumContainer receivedDatumContainer = receivedRatingSet.getVerticalDatumContainer(); + assertEquals(locId._nativeDatum == null, receivedDatumContainer == null, "Received VerticalDatumContainer presence mismatch. Expected " + (locId._nativeDatum == null ? "null" : "not null")); + + double receivedFirstElev = receivedRatingSet.getRatings()[0].getValues(0)[0].getIndValue(); + + assertEquals(expectedFirstElev, receivedFirstElev, "Unexpected elev value received"); + } + + private static void storeRatingFromXml(String xml, TestAccounts.KeyUser user) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .body(xml) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .post("/ratings") + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_CREATED)); + } + + private static void deleteRatingEffectiveDates(TestAccounts.KeyUser user, String ratingId) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .contentType(Formats.XMLV2) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .queryParam(BEGIN, "2000-01-01T00:00:00Z") + .queryParam(END, "2100-01-01T00:00:00Z") + .when() + .redirects() + .follow(true) + .redirects() + .max(3) + .delete("/ratings/" + ratingId) + .then() + .assertThat() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + } + + private static Stream provideDatumCombinations() { + //This provides information for 3 locations: + // - LOC_WITH_NAVD88: native datum NAVD88 + // - LOC_WITH_NGVD29: native datum NGVD29 + //And for each location, we test requesting: + // - null + // - NATIVE + // - NAVD88 + // - NGVD29 + // + //This creates a 2 x 4 matrix of test cases to cover all combinations of these parameters + return Stream.of(TestLocationIds.values()) + .filter(locId -> !BASE_LOCATION.equals(locId._locationId)) + .flatMap(locId -> Stream.of(TestLocationVerticalDatumData.values()) + .map(datum -> Arguments.of(locId, datum))); + } + + + static @NotNull String readVerticalDatumRatingXml(String location) throws IOException { + return readResourceFile("cwms/cda/api/vertical_datum_example_rating.xml").replace("{office-id}", SPK) + .replace("{location}", location); + } + + // Remove the vertical-datum-info element from the rating XML so that the controller must + // rely on the datum query parameter or the location's native datum. + private static String stripVerticalDatumInfo(String xml) { + return xml.replaceAll("(?s)", ""); + } + + private enum TestLocationIds { + BASE(BASE_LOCATION, null), + NAVD88(LOC_WITH_NAVD88, VerticalDatum.NAVD88), + NGVD29(LOC_WITH_NGVD29, VerticalDatum.NGVD29), + ; + + final String _locationId; + final VerticalDatum _nativeDatum; + + TestLocationIds(String locationId, VerticalDatum nativeDatum) { + _locationId = locationId; + _nativeDatum = nativeDatum; + } + } + + private enum TestLocationVerticalDatumData { + NULL(null), + NATIVE(VerticalDatum.NATIVE), + NAVD88(VerticalDatum.NAVD88), + NGVD29(VerticalDatum.NGVD29), + ; + + final VerticalDatum _requestedVerticalDatum; + + TestLocationVerticalDatumData(VerticalDatum requestedVerticalDatum) { + _requestedVerticalDatum = requestedVerticalDatum; + } + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsVerticalDatumExtractorTest.java b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsVerticalDatumExtractorTest.java new file mode 100644 index 000000000..f19230fb4 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsVerticalDatumExtractorTest.java @@ -0,0 +1,20 @@ +package cwms.cda.api.rating; + +import cwms.cda.data.dao.RatingsVerticalDatumExtractor; +import cwms.cda.data.dao.VerticalDatum; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RatingsVerticalDatumExtractorTest { + @Test + void testGetVerticalDatum() throws Exception { + String xml = RatingsControllerTestVerticalDatumIT.readVerticalDatumRatingXml(RatingsControllerTestVerticalDatumIT.LOC_WITH_NGVD29); + Optional datum = RatingsVerticalDatumExtractor.getVerticalDatum(xml); + assertTrue(datum.isPresent()); + datum.ifPresent(vd -> assertSame(VerticalDatum.NGVD29, vd)); + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/vertical_datum_example_rating.xml b/cwms-data-api/src/test/resources/cwms/cda/api/vertical_datum_example_rating.xml new file mode 100644 index 000000000..4b9cf1cab --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/vertical_datum_example_rating.xml @@ -0,0 +1,83 @@ + + + + Elev;Area + Standard + + + Elev + LINEAR + NEAREST + NEAREST + + + Area + + + + {location}.Elev;Area.Standard.Production + Elev;Area.Standard + {location} + Production + + LINEAR + NEAREST + NEAREST + true + true + true + true + + 2222233332 + + 2222233332 + + + + {location}.Elev;Area.Standard.Production + + NGVD-29 + 36.089 + + NGVD-29 + 0.0 + + + NAVD-88 + -2.532 + + + ft;acre + 2016-09-06T20:08:00Z + + 2016-09-06T20:08:00Z + true + + + + 620.0 + 0.0 + + + 621.0 + 1.0 + + + 622.0 + 2.0 + + + 623.0 + 3.0 + + + 624.0 + 4.0 + + + 625.0 + 5.0 + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e49a183e..13efc0bff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ hec-nucleus = "2.0.1" flogger = "0.7.4" google-findbugs = "3.0.2" error_prone_annotations = "2.15.0" -cwms-ratings = "2.0.2" +cwms-ratings = "4.2.2" javalin = "4.6.8" tomcat = "9.0.112" swagger-core = "2.2.23"