From 54df11bad70a5dc607704380aec0e6ffcc1b6e5d Mon Sep 17 00:00:00 2001 From: zack-rma Date: Mon, 20 Jul 2026 15:59:40 -0700 Subject: [PATCH] Initial implementation of bulk vertical datum info retrieval endpoint --- .../src/main/java/cwms/cda/ApiServlet.java | 3 +- .../main/java/cwms/cda/api/Controllers.java | 4 +- .../cwms/cda/api/VerticalDatumController.java | 98 +++++++++--- .../cwms/cda/data/dao/VerticalDatumDao.java | 45 +++++- .../api/VerticalDatumControllerTestIT.java | 148 ++++++++++++++++++ 5 files changed, 273 insertions(+), 25 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 57a679c47e..827a000990 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -91,9 +91,9 @@ import cwms.cda.api.TimeSeriesController; import cwms.cda.api.TimeSeriesFilteredController; import cwms.cda.api.TimeSeriesGroupController; -import cwms.cda.api.TimeSeriesVersionsController; import cwms.cda.api.TimeSeriesIdentifierDescriptorController; import cwms.cda.api.TimeSeriesRecentController; +import cwms.cda.api.TimeSeriesVersionsController; import cwms.cda.api.TimeZoneController; import cwms.cda.api.TurbineChangesDeleteController; import cwms.cda.api.TurbineChangesGetController; @@ -473,6 +473,7 @@ protected void configureRoutes() { VerticalDatumController vdiController = new VerticalDatumController(metrics); String vdiPath = format("/location/{%s}/vertical-datum", Controllers.LOCATION_ID); + get("/location/vertical-datum", vdiController::getAll); get(vdiPath, ctx -> vdiController.getOne(ctx, ctx.pathParam(Controllers.LOCATION_ID))); addCacheControl(vdiPath, 5, TimeUnit.MINUTES); post(vdiPath, vdiController::create, requiredRoles); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index ed05a37267..511b98615c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -40,9 +40,7 @@ import io.javalin.core.validation.Validator; import io.javalin.http.Context; import java.time.Instant; -import java.time.ZoneId; import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -75,6 +73,8 @@ public final class Controllers { public static final String LIKE = "like"; + public static final String OVERWRITE = "overwrite"; + public static final String UNIT_SYSTEM = "unit-system"; public static final String TIMESERIES_CATEGORY_LIKE = "timeseries-category-like"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/VerticalDatumController.java b/cwms-data-api/src/main/java/cwms/cda/api/VerticalDatumController.java index 2bdc82aa27..53f19018f8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/VerticalDatumController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/VerticalDatumController.java @@ -29,7 +29,9 @@ import static cwms.cda.api.Controllers.DELETE; import static cwms.cda.api.Controllers.GET_ONE; import static cwms.cda.api.Controllers.LOCATION_ID; +import static cwms.cda.api.Controllers.LOCATION_MASK; import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.OVERWRITE; import static cwms.cda.api.Controllers.RESULTS; import static cwms.cda.api.Controllers.SIZE; import static cwms.cda.api.Controllers.UNIT; @@ -42,6 +44,7 @@ import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; import com.google.common.flogger.FluentLogger; +import cwms.cda.api.errors.AlreadyExists; import cwms.cda.api.errors.CdaError; import cwms.cda.api.errors.ExceptionTraceSupport; import cwms.cda.data.dao.VerticalDatumDao; @@ -59,6 +62,7 @@ import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; import io.javalin.plugin.openapi.annotations.OpenApiResponse; import java.io.IOException; +import java.util.List; import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; @@ -68,6 +72,7 @@ public final class VerticalDatumController implements CrudHandler { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); // NOTE: manually expanded due to limits of OpenApi Annotations. private static final String VDI_PATH = "/location/{location-id}/vertical-datum"; + private static final String VDI_ALL_PATH = "/location/vertical-datum"; private final MetricRegistry metrics; private final Histogram requestResultSize; @@ -81,9 +86,49 @@ private Timer.Context markAndTime(String subject) { return Controllers.markAndTime(metrics, getClass().getName(), subject); } + @OpenApi( + queryParams = { + @OpenApiParam(name = LOCATION_MASK, description = "Filters on the location ID."), + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office."), + @OpenApiParam(name = UNIT, + description = "Specifies the unit of measure for elevation/offsets (e.g., m or ft). Default is m.") + }, + responses = { + @OpenApiResponse(status = Controllers.STATUS_200, + content = {@OpenApiContent(type = Formats.JSONV1, from = VerticalDatumInfo.class), + @OpenApiContent(type = Formats.JSON, from = VerticalDatumInfo.class), + @OpenApiContent(type = Formats.XMLV1, from = VerticalDatumInfo.class), + @OpenApiContent(type = Formats.XML, from = VerticalDatumInfo.class)}) + }, + description = "Returns Vertical Datum Info for all locations.", + path = VDI_ALL_PATH, + tags = {LOCATIONS_TAG} + ) @Override public void getAll(@NotNull Context ctx) { - ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented()); + String office = requiredParam(ctx, OFFICE); + String units = ctx.queryParamAsClass(UNIT, String.class).getOrDefault("m"); + String locationMask = ctx.queryParamAsClass(LOCATION_MASK, String.class).getOrDefault(null); + try (Timer.Context ignored = markAndTime(GET_ONE)) { + DSLContext dsl = getDslContext(ctx); + VerticalDatumDao dao = new VerticalDatumDao(dsl); + List info = dao.retrieveVerticalDatumInfoList(office, locationMask, units); + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, VerticalDatumInfo.class); + ctx.contentType(contentType.toString()); + String serialized = Formats.format(contentType, info, VerticalDatumInfo.class); + requestResultSize.update(serialized.length()); + ctx.status(HttpServletResponse.SC_OK); + + byte[] bytes = serialized.getBytes(); + ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); + ctx.res.getOutputStream().write(bytes); + } catch (IOException ex) { + CdaError error = ExceptionTraceSupport.buildError(ctx, + "Failed to process request to retrieve Vertical Datum Info", ex); + logger.atSevere().withCause(ex).log("Failed to process request to retrieve Vertical Datum Info"); + ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(error); + } } @OpenApi( @@ -117,7 +162,6 @@ public void getOne(@NotNull Context ctx, @NotNull String locationId) { ContentType contentType = Formats.parseHeader(formatHeader, VerticalDatumInfo.class); ctx.contentType(contentType.toString()); String serialized = Formats.format(contentType, info); - ctx.status(HttpServletResponse.SC_OK); requestResultSize.update(serialized.length()); ctx.status(HttpServletResponse.SC_OK); @@ -133,42 +177,54 @@ public void getOne(@NotNull Context ctx, @NotNull String locationId) { } @OpenApi( - requestBody = @OpenApiRequestBody( - content = { - @OpenApiContent(from = VerticalDatumInfo.class, type = Formats.JSONV1), - @OpenApiContent(from = VerticalDatumInfo.class, type = Formats.XMLV1) - }, - required = true), - queryParams = { - @OpenApiParam(name = LOCATION_ID, required = true, description = "Specifies the location id for this vertical-datum-info."), - @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office.") + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = VerticalDatumInfo.class, type = Formats.JSONV1), + @OpenApiContent(from = VerticalDatumInfo.class, type = Formats.XMLV1) }, - description = "Create Vertical Datum Info for a Location", - method = HttpMethod.POST, - path = VDI_PATH, - tags = {LOCATIONS_TAG}, - responses = { - @OpenApiResponse(status = Controllers.STATUS_201, description = "Vertical Datum Info successfully stored to CWMS.") - } + required = true), + queryParams = { + @OpenApiParam(name = LOCATION_ID, required = true, description = "Specifies the location id for this vertical-datum-info."), + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office."), + @OpenApiParam(name = OVERWRITE, type = Boolean.class, description = "If true, will overwrite any existing " + + "vertical-datum-info for the specified location. Default is false.") + }, + description = "Create Vertical Datum Info for a Location", + method = HttpMethod.POST, + path = VDI_PATH, + tags = {LOCATIONS_TAG}, + responses = { + @OpenApiResponse(status = Controllers.STATUS_201, + description = "Vertical Datum Info successfully stored to CWMS.") + } ) @Override public void create(@NotNull Context ctx) { try (Timer.Context ignored = markAndTime(CREATE)) { String formatHeader = ctx.req.getContentType(); + boolean overwrite = ctx.queryParamAsClass(OVERWRITE, Boolean.class).getOrDefault(false); ContentType contentType = Formats.parseHeader(formatHeader, VerticalDatumInfo.class); VerticalDatumInfo info = Formats.parseContent(contentType, ctx.body(), VerticalDatumInfo.class); //allow locationId and office to be specified in either the body or as query params, but require them to be present in one of those places String locationId = info.getLocation(); String office = info.getOffice(); - if(locationId == null || locationId.isBlank()) { + if (locationId == null || locationId.isBlank()) { locationId = requiredParam(ctx, LOCATION_ID); } - if(office == null || office.isBlank()) { + if (office == null || office.isBlank()) { office = requiredParam(ctx, OFFICE); } DSLContext dsl = getDslContext(ctx); VerticalDatumDao dao = new VerticalDatumDao(dsl); - dao.createVerticalDatumInfo(office, locationId, info); + try { + dao.createVerticalDatumInfo(office, locationId, info); + } catch (AlreadyExists ex) { + if (overwrite) { + dao.updateVerticalDatumInfo(office, locationId, info); + } else { + throw ex; + } + } StatusResponse re = new StatusResponse(office, "Vertical Datum Info successfully stored to CWMS.", locationId); ctx.status(HttpServletResponse.SC_CREATED).json(re); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatumDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatumDao.java index 1a072dbce1..6389f3c3b8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatumDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/VerticalDatumDao.java @@ -27,9 +27,14 @@ import cwms.cda.api.errors.NotFoundException; import cwms.cda.data.dto.VerticalDatumInfo; import cwms.cda.formatters.xml.XMLv1; +import java.util.ArrayList; +import java.util.List; +import org.jooq.Condition; import org.jooq.DSLContext; import org.jooq.Record1; +import org.jooq.Result; import usace.cwms.db.jooq.codegen.packages.CWMS_LOC_PACKAGE; +import usace.cwms.db.jooq.codegen.packages.CWMS_UTIL_PACKAGE; import usace.cwms.db.jooq.codegen.tables.AV_VERT_DATUM_OFFSET; /** @@ -51,6 +56,44 @@ public VerticalDatumInfo retrieveVerticalDatumInfo(String officeId, String locat }); } + public List retrieveVerticalDatumInfoList(String officeId, String locMask, String units) { + AV_VERT_DATUM_OFFSET view = AV_VERT_DATUM_OFFSET.AV_VERT_DATUM_OFFSET; + List resultList = new ArrayList<>(); + return connectionResult(dsl, conn -> { + DSLContext ctx = getDslContext(conn, officeId); + Condition whereCondition = view.OFFICE_ID.eq(officeId); + + if (locMask != null && !locMask.isEmpty()) { + whereCondition = whereCondition.and(view.LOCATION_ID.like(locMask)); + } + Result> result = + ctx.select(view).from(view) + .where(whereCondition) + .fetch(); + for (Record1 rec : result) { + usace.cwms.db.jooq.codegen.tables.records.AV_VERT_DATUM_OFFSET tab = rec.value1(); + String desc = tab.getDESCRIPTION(); + VerticalDatumInfo vdi = new VerticalDatumInfo.Builder() + .withOffice(tab.getOFFICE_ID()) + .withLocation(tab.getLOCATION_ID()) + .withNativeDatum(tab.getVERTICAL_DATUM_ID_1()) + .withLocalDatumName(tab.getVERTICAL_DATUM_ID_2()) + .withOffset(desc.contains("ESTIMATE"), tab.getVERTICAL_DATUM_ID_2(), tab.getOFFSET()) + .build(); + if (!units.equalsIgnoreCase("m")) { + vdi = new VerticalDatumInfo.Builder() + .from(vdi) + .withUnit(units) + .withElevation(CWMS_UTIL_PACKAGE.call_CONVERT_UNITS(ctx.configuration(), vdi.getElevation(), + "m", units)) + .build(); + } + resultList.add(vdi); + } + return resultList; + }); + } + public void createVerticalDatumInfo(String officeId, String locationId, VerticalDatumInfo vdi) { connection(dsl, conn -> { DSLContext ctx = getDslContext(conn, officeId); @@ -98,7 +141,7 @@ private void verifyVerticalDatumInfoExists(DSLContext ctx, String officeId, Stri .where(AV_VERT_DATUM_OFFSET.AV_VERT_DATUM_OFFSET.LOCATION_ID.eq(locationId)) .and(AV_VERT_DATUM_OFFSET.AV_VERT_DATUM_OFFSET.OFFICE_ID.eq(officeId)) .fetchOne(); - if(result == null) { + if (result == null) { throw new NotFoundException("No vertical datum info found for location " + locationId + " in office " + officeId); } } diff --git a/cwms-data-api/src/test/java/cwms/cda/api/VerticalDatumControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/VerticalDatumControllerTestIT.java index 3b211002a1..46f7786adc 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/VerticalDatumControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/VerticalDatumControllerTestIT.java @@ -35,6 +35,7 @@ import fixtures.TestAccounts; import fixtures.TestAccounts.KeyUser; import io.restassured.filter.log.LogDetail; +import java.util.List; import javax.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterAll; @@ -247,6 +248,97 @@ void test_vertical_datum_crud(ContentType contentType) { .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); } + @MethodSource("provideFormats") + @ParameterizedTest + void test_vertical_datum_getAll(ContentType contentType) { + // Build a VerticalDatumInfo payload + VerticalDatumInfo.Offset[] offsets = new VerticalDatumInfo.Offset[] { + new VerticalDatumInfo.Offset(true, "NAVD-88", -0.5) + }; + VerticalDatumInfo vdi = new VerticalDatumInfo.Builder() + .withOffice(OFFICE_ID) + .withLocation(TEST_LOCATION) + .withUnit("m") + .withNativeDatum("NGVD-29") + .withElevation(100.0) + .withOffsets(offsets) + .build(); + + String vdiPayload = Formats.format(contentType, vdi); + + KeyUser user = KeyUser.SPK_NORMAL; + + // CREATE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .contentType(contentType.toString()) + .body(vdiPayload) + .queryParam(OFFICE, OFFICE_ID) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/location/" + TEST_LOCATION + "/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // GET + String getBody = + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(Controllers.UNIT, "m") + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/location/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .extract() + .asString(); + + List got = Formats.parseContentList(contentType, getBody, VerticalDatumInfo.class); + assertEquals(1, got.size()); + assertEquals(100.0, got.get(0).getElevation(), 0.001); + assertEquals("NGVD-29", got.get(0).getNativeDatum()); + + // DELETE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .queryParam(OFFICE, OFFICE_ID) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete("/location/" + TEST_LOCATION + "/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + //VERIFY DELETE + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(Controllers.UNIT, "m") + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/location/" + TEST_LOCATION + "/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + @MethodSource("provideFormats") @ParameterizedTest void test_create_vertical_datum_already_exists_fails(ContentType contentType) { @@ -302,6 +394,62 @@ void test_create_vertical_datum_already_exists_fails(ContentType contentType) { .statusCode(is(HttpServletResponse.SC_CONFLICT)); } + @MethodSource("provideFormats") + @ParameterizedTest + void test_create_vertical_datum_already_exists_overwrite(ContentType contentType) { + // Build a VerticalDatumInfo payload + VerticalDatumInfo.Offset[] offsets = new VerticalDatumInfo.Offset[] { + new VerticalDatumInfo.Offset(true, "NAVD-88", -0.5) + }; + VerticalDatumInfo vdi = new VerticalDatumInfo.Builder() + .withOffice(OFFICE_ID) + .withLocation(TEST_LOCATION) + .withUnit("m") + .withNativeDatum("NGVD-29") + .withElevation(100.0) + .withOffsets(offsets) + .build(); + + String vdiPayload = Formats.format(contentType, vdi); + + KeyUser user = KeyUser.SPK_NORMAL; + + // First CREATE should succeed + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .contentType(contentType.toString()) + .body(vdiPayload) + .queryParam(OFFICE, OFFICE_ID) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/location/" + TEST_LOCATION + "/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Second CREATE with same payload should succeed + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(contentType.toString()) + .contentType(contentType.toString()) + .body(vdiPayload) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(Controllers.OVERWRITE, true) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/location/" + TEST_LOCATION + "/vertical-datum") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + } + @MethodSource("provideFormats") @ParameterizedTest void test_update_vertical_datum_not_found_returns_404(ContentType contentType) {