diff --git a/cda-gui/package-lock.json b/cda-gui/package-lock.json index cc71620c0..d33ee305e 100644 --- a/cda-gui/package-lock.json +++ b/cda-gui/package-lock.json @@ -1200,9 +1200,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1217,9 +1214,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1234,9 +1228,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1251,9 +1242,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1268,9 +1256,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1270,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1302,9 +1284,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1319,9 +1298,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1336,9 +1312,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1353,9 +1326,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1370,9 +1340,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1387,9 +1354,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1404,9 +1368,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ 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 bc4d6f2df..b8cd64f2d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -91,6 +91,7 @@ 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.TimeZoneController; @@ -500,6 +501,10 @@ protected void configureRoutes() { get(recentPath, new TimeSeriesRecentController(metrics)); addCacheControl(recentPath, 5, TimeUnit.MINUTES); + String versionsPath = "/timeseries/versions/"; + get(versionsPath, new TimeSeriesVersionsController(metrics)); + addCacheControl(versionsPath, 5, TimeUnit.MINUTES); + String filteredPath = "/timeseries/filtered"; get(filteredPath, new TimeSeriesFilteredController(metrics)); addCacheControl(filteredPath, 5, TimeUnit.MINUTES); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java new file mode 100644 index 000000000..07f6f001d --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java @@ -0,0 +1,120 @@ +/* + * MIT License + * + * Copyright (c) 2026 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; + +import static cwms.cda.api.Controllers.BEGIN; +import static cwms.cda.api.Controllers.END; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.PAGE; +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static cwms.cda.api.Controllers.RESULTS; +import static cwms.cda.api.Controllers.SIZE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.TIMEZONE; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.data.dao.TimeSeriesDao; +import cwms.cda.data.dao.TimeSeriesDaoImpl; +import cwms.cda.data.dto.TimeSeriesVersions; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import java.time.ZonedDateTime; +import javax.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public final class TimeSeriesVersionsController implements Handler { + private final MetricRegistry metrics; + private final Histogram requestResultSize; + private final String className = this.getClass().getName(); + + public TimeSeriesVersionsController(MetricRegistry metrics) { + this.metrics = metrics; + requestResultSize = this.metrics.histogram(MetricRegistry.name(className, RESULTS, SIZE)); + } + + protected TimeSeriesDao getTimeSeriesDao(DSLContext dsl) { + return new TimeSeriesDaoImpl(dsl, metrics); + } + + @OpenApi( + description = "Returns TimeSeries versions and their extents for a given TimeSeries identifier", + queryParams = { + @OpenApiParam(name = NAME, required = true, description = "The TimeSeries identifier"), + @OpenApiParam(name = OFFICE, description = "The office identifier"), + @OpenApiParam(name = BEGIN, description = "The start of the time window to filter versions"), + @OpenApiParam(name = END, description = "The end of the time window to filter versions"), + @OpenApiParam(name = TIMEZONE, description = "The timezone for the begin and end parameters (default is UTC)"), + @OpenApiParam(name = PAGE, description = "The cursor to the current page of data"), + @OpenApiParam(name = PAGE_SIZE, type = Integer.class, description = "The number of records fetched per-page") + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(type = Formats.JSONV1, from = TimeSeriesVersions.class) + }) + }, + tags = {TimeSeriesController.TAG} + ) + @Override + public void handle(@NotNull Context ctx) throws Exception { + try (Timer.Context ignored = Controllers.markAndTime(metrics, className, "getTimeSeriesVersions")) { + DSLContext dsl = getDslContext(ctx); + TimeSeriesDao dao = getTimeSeriesDao(dsl); + + String tsId = ctx.queryParam(NAME); + String office = ctx.queryParam(OFFICE); + String beginStr = ctx.queryParam(BEGIN); + String endStr = ctx.queryParam(END); + String timezone = ctx.queryParamAsClass(TIMEZONE, String.class).getOrDefault("UTC"); + String cursor = ctx.queryParam(PAGE); + int pageSize = ctx.queryParamAsClass(PAGE_SIZE, Integer.class).getOrDefault(100); + + ZonedDateTime begin = beginStr != null ? Controllers.queryParamAsZdt(ctx, BEGIN, timezone) : null; + ZonedDateTime end = endStr != null ? Controllers.queryParamAsZdt(ctx, END, timezone) : null; + + TimeSeriesVersions versions = dao.getTimeSeriesVersions(cursor, pageSize, tsId, office, begin, end); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesVersions.class); + ctx.contentType(contentType.toString()); + + String serialized = Formats.format(contentType, versions); + ctx.result(serialized); + ctx.status(HttpServletResponse.SC_OK); + requestResultSize.update(serialized.length()); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDao.java index bf4c7516f..a7281be23 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDao.java @@ -4,6 +4,7 @@ import cwms.cda.data.dto.Catalog; import cwms.cda.data.dto.RecentValue; import cwms.cda.data.dto.TimeSeries; +import cwms.cda.data.dto.TimeSeriesVersions; import cwms.cda.data.dto.filteredtimeseries.FilteredTimeSeries; import cwms.cda.formatters.csv.CsvConfiguration; @@ -53,6 +54,9 @@ TimeSeries getTimeseries(String cursor, int pageSize, String names, String offic TimeSeries getTimeseries(String cursor, int pageSize, TimeSeriesRequestParameters requestParameters); FilteredTimeSeries getTimeseries(String page, int pageSize, TimeSeriesRequestParameters requestParameters, FilteredTimeSeriesParameters filterParams); + TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, String names, String office, + ZonedDateTime begin, ZonedDateTime end); + String getTimeseries(String format, String names, String office, String unit, String datum, ZonedDateTime begin, ZonedDateTime end, ZoneId timezone); 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 349fbf72e..54a8a3469 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 @@ -6,6 +6,7 @@ import cwms.cda.data.dao.rsql.FieldResolver; import cwms.cda.data.dao.rsql.MapFieldResolver; import cwms.cda.data.dao.rsql.RSQLConditionBuilder; +import cwms.cda.data.dto.CwmsId; import cwms.cda.data.dto.filteredtimeseries.FilteredTimeSeries; import cwms.cda.data.dto.catalog.TimeSeriesAlias; import cwms.cda.formatters.csv.CsvConfiguration; @@ -27,7 +28,8 @@ import static org.jooq.impl.DSL.table; import static usace.cwms.db.jooq.codegen.tables.AV_CWMS_TS_ID2.AV_CWMS_TS_ID2; import static usace.cwms.db.jooq.codegen.tables.AV_TS_EXTENTS_UTC.AV_TS_EXTENTS_UTC; - +import cwms.cda.data.dto.TimeSeriesExtents; +import cwms.cda.data.dto.TimeSeriesVersions; import com.codahale.metrics.Gauge; import com.codahale.metrics.Histogram; import com.codahale.metrics.Meter; @@ -39,6 +41,10 @@ import com.google.common.flogger.FluentLogger; import cwms.cda.api.enums.UnitSystem; import cwms.cda.api.enums.VersionType; +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dao.rsql.FieldResolver; +import cwms.cda.data.dao.rsql.MapFieldResolver; +import cwms.cda.data.dao.rsql.RSQLConditionBuilder; import cwms.cda.data.dto.Catalog; import cwms.cda.data.dto.CwmsDTOPaginated; import cwms.cda.data.dto.RecentValue; @@ -220,6 +226,91 @@ public TimeSeriesDaoImpl(DSLContext dsl, @NotNull MetricRegistry metrics) { } + @Override + public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, String names, String office, + ZonedDateTime begin, ZonedDateTime end) { + Condition condition = AV_CWMS_TS_ID2.CWMS_TS_ID.eq(names); + if (office != null) { + condition = condition.and(AV_CWMS_TS_ID2.DB_OFFICE_ID.eq(office.toUpperCase())); + } + condition = condition.and(AV_CWMS_TS_ID2.ALIASED_ITEM.isNull()); + + Record tsRecord = dsl.select(AV_CWMS_TS_ID2.TS_CODE, AV_CWMS_TS_ID2.DB_OFFICE_ID, AV_CWMS_TS_ID2.CWMS_TS_ID) + .from(AV_CWMS_TS_ID2) + .where(condition) + .fetchOne(); + + if (tsRecord == null) { + throw new NotFoundException("Could not find time series for identifier: " + names); + } + + BigDecimal tsCode = tsRecord.get(AV_CWMS_TS_ID2.TS_CODE); + String officeId = tsRecord.get(AV_CWMS_TS_ID2.DB_OFFICE_ID); + String tsId = tsRecord.get(AV_CWMS_TS_ID2.CWMS_TS_ID); + + Condition extentsCondition = AV_TS_EXTENTS_UTC.TS_CODE.coerce(BigDecimal.class).eq(tsCode); + if (begin != null) { + extentsCondition = extentsCondition.and(AV_TS_EXTENTS_UTC.VERSION_TIME.ge(Timestamp.from(begin.toInstant()))); + } + if (end != null) { + extentsCondition = extentsCondition.and(AV_TS_EXTENTS_UTC.VERSION_TIME.le(Timestamp.from(end.toInstant()))); + } + + Condition pagingCondition = noCondition(); + if (cursor != null && !cursor.isEmpty()) { + String[] parts = CwmsDTOPaginated.decodeCursor(cursor); + if (parts.length > 0) { + Timestamp lastVersionTime = Timestamp.from(ZonedDateTime.parse(parts[0]).toInstant()); + pagingCondition = AV_TS_EXTENTS_UTC.VERSION_TIME.lessThan(lastVersionTime); + } + } + + Integer total = dsl.selectCount() + .from(AV_TS_EXTENTS_UTC) + .where(extentsCondition) + .fetchOne(0, Integer.class); + + Result results = dsl.select(AV_TS_EXTENTS_UTC.VERSION_TIME, + AV_TS_EXTENTS_UTC.EARLIEST_TIME, + AV_TS_EXTENTS_UTC.LATEST_TIME, + AV_TS_EXTENTS_UTC.LAST_UPDATE) + .from(AV_TS_EXTENTS_UTC) + .where(extentsCondition) + .and(pagingCondition) + .orderBy(AV_TS_EXTENTS_UTC.VERSION_TIME.desc().nullsFirst()) + .limit(pageSize) + .fetch(); + + TimeSeriesVersions.Builder builder = new TimeSeriesVersions.Builder() + .withTsId(new CwmsId.Builder() + .withName(tsId) + .withOfficeId(officeId) + .build()) + .withPage(cursor) + .withPageSize(pageSize) + .withTotal(total); + + for (Record row : results) { + builder.addVersion(new TimeSeriesExtents.Builder() + .withVersionTime(DateUtils.toZdt(row.get(AV_TS_EXTENTS_UTC.VERSION_TIME))) + .withEarliestTime(DateUtils.toZdt(row.get(AV_TS_EXTENTS_UTC.EARLIEST_TIME))) + .withLatestTime(DateUtils.toZdt(row.get(AV_TS_EXTENTS_UTC.LATEST_TIME))) + .withLastUpdate(DateUtils.toZdt(row.get(AV_TS_EXTENTS_UTC.LAST_UPDATE))) + .build()); + } + + if (results.size() == pageSize) { + Record lastRecord = results.get(results.size() - 1); + Timestamp lastVersionTime = lastRecord.get(AV_TS_EXTENTS_UTC.VERSION_TIME); + if (lastVersionTime != null) { + builder.withNextPage(CwmsDTOPaginated.encodeCursor(DateUtils.toZdt(lastVersionTime).format(DateTimeFormatter.ISO_INSTANT), pageSize, total)); + } + } + + return builder.build(); + } + + @Override public String getTimeseries(String format, String names, String office, String units, String datum, ZonedDateTime begin, ZonedDateTime end, ZoneId timezone) { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesVersions.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesVersions.java new file mode 100644 index 000000000..15c56a02b --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesVersions.java @@ -0,0 +1,113 @@ +/* + * MIT License + * + * Copyright (c) 2026 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.data.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonDeserialize(builder = TimeSeriesVersions.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@Schema(description = "Represents a list of TimeSeries versions and their extents") +public class TimeSeriesVersions extends CwmsDTOPaginated { + @Schema(description = "The TimeSeries identifier") + private final CwmsId tsId; + @Schema(description = "The list of versions and their extents") + private final List versions; + + private TimeSeriesVersions(Builder builder) { + super(builder.page, builder.pageSize, builder.total); + this.tsId = builder.tsId; + this.versions = Collections.unmodifiableList(builder.versions); + } + + public CwmsId getTsId() { + return tsId; + } + + public List getVersions() { + return versions; + } + + public static class Builder { + private CwmsId tsId; + private List versions = new ArrayList<>(); + private String page; + private int pageSize; + private Integer total; + private String nextPage; + + public Builder withTsId(CwmsId tsId) { + this.tsId = tsId; + return this; + } + + public Builder withVersions(List versions) { + this.versions = new ArrayList<>(versions); + return this; + } + + public Builder addVersion(TimeSeriesExtents version) { + this.versions.add(version); + return this; + } + + public Builder withPage(String page) { + this.page = page; + return this; + } + + public Builder withPageSize(int pageSize) { + this.pageSize = pageSize; + return this; + } + + public Builder withTotal(Integer total) { + this.total = total; + return this; + } + + public Builder withNextPage(String nextPage) { + this.nextPage = nextPage; + return this; + } + + public TimeSeriesVersions build() { + TimeSeriesVersions versions = new TimeSeriesVersions(this); + versions.nextPage = this.nextPage; + return versions; + } + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java new file mode 100644 index 000000000..dae410667 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java @@ -0,0 +1,198 @@ +/* + * MIT License + * + * Copyright (c) 2026 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; + +import static cwms.cda.api.Controllers.BEGIN; +import static cwms.cda.api.Controllers.END; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import cwms.cda.data.dao.TimeSeriesDao; +import cwms.cda.data.dao.TimeSeriesDaoImpl; +import cwms.cda.data.dto.TimeSeries; +import cwms.cda.formatters.Formats; +import io.restassured.filter.log.LogDetail; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +final class TimeSeriesVersionsControllerTestIT extends DataApiTestIT { + + private static final String OFFICE_ID = "SWT"; + private static final String TS_ID = "TestTS.Flow.Inst.1Hour.0.Test"; + + @BeforeAll + void setup() throws Exception { + createLocation(TS_ID.split("\\.")[0], true, OFFICE_ID); + + ZonedDateTime version1 = ZonedDateTime.parse("2021-01-01T00:00:00Z"); + TimeSeries ts1 = new TimeSeries(null, 0, 0, TS_ID, OFFICE_ID, + ZonedDateTime.parse("2021-01-01T00:00:00Z"), + ZonedDateTime.parse("2021-01-01T03:00:00Z"), + "cfs", java.time.Duration.ofHours(1)); + ts1.addValue(Timestamp.from(ZonedDateTime.parse("2021-01-01T00:00:00Z").toInstant()), 1.0, 0); + ts1.addValue(Timestamp.from(ZonedDateTime.parse("2021-01-01T01:00:00Z").toInstant()), 2.0, 0); + + connectionAsWebUser(conn -> { + TimeSeriesDao dao = new TimeSeriesDaoImpl(dslContext(conn, OFFICE_ID), null); + dao.store(ts1, Timestamp.from(version1.toInstant())); + }); + + ZonedDateTime version2 = ZonedDateTime.parse("2021-02-01T00:00:00Z"); + TimeSeries ts2 = new TimeSeries(null, 0, 0, TS_ID, OFFICE_ID, + ZonedDateTime.parse("2021-02-01T00:00:00Z"), + ZonedDateTime.parse("2021-02-01T03:00:00Z"), + "cfs", java.time.Duration.ofHours(1)); + ts2.addValue(Timestamp.from(ZonedDateTime.parse("2021-02-01T00:00:00Z").toInstant()), 10.0, 0); + ts2.addValue(Timestamp.from(ZonedDateTime.parse("2021-02-01T01:00:00Z").toInstant()), 20.0, 0); + + connectionAsWebUser(conn -> { + TimeSeriesDao dao = new TimeSeriesDaoImpl(dslContext(conn, OFFICE_ID), null); + dao.store(ts2, Timestamp.from(version2.toInstant())); + }); + } + + @AfterAll + void cleanup() throws Exception { + // Cleanup would normally happen via DataApiTestIT helpers or manual DAO calls if needed + } + + @Test + void test_get_versions() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("ts-id.name", equalTo(TS_ID)) + .body("ts-id.office-id", equalTo(OFFICE_ID)) + .body("versions", notNullValue()) + .body("versions.size()", is(2)); + } + + @Test + void test_get_versions_filtered() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(BEGIN, "2021-01-31T00:00:00Z") + .queryParam(END, "2021-02-02T00:00:00Z") + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(1)) + .body("versions[0].version-time", equalTo("2021-02-01T00:00:00Z")); + } + + @Test + void test_get_versions_not_found() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, "NonExistent.Flow.Inst.1Hour.0.Test") + .queryParam(OFFICE, OFFICE_ID) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + @Test + void test_get_versions_pagination() { + // Page 1 + String nextPage = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(PAGE_SIZE, 2) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(2)) + .body("next-page", notNullValue()) + .extract().path("next-page"); + + // Page 2 + nextPage = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .queryParam("page", nextPage) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(2)) + .body("next-page", notNullValue()) + .extract().path("next-page"); + + // Page 3 + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .queryParam("page", nextPage) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(1)) + .body("next-page", nullValue()); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesVersionsTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesVersionsTest.java new file mode 100644 index 000000000..296365ace --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesVersionsTest.java @@ -0,0 +1,64 @@ +package cwms.cda.data.dto; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import cwms.cda.formatters.json.JsonV2; +import cwms.cda.helpers.DTOMatch; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; + +class TimeSeriesVersionsTest { + + @Test + void test_roundtrip_serialization() throws JsonProcessingException { + TimeSeriesVersions versions = buildTimeSeriesVersions(); + + ObjectMapper om = JsonV2.buildObjectMapper(); + + String result = om.writeValueAsString(versions); + assertNotNull(result); + + TimeSeriesVersions deserialized = om.readValue(result, TimeSeriesVersions.class); + DTOMatch.assertMatch(versions, deserialized); + } + + private TimeSeriesVersions buildTimeSeriesVersions() { + ZonedDateTime version1 = ZonedDateTime.parse("2019-01-01T00:00:00Z"); + ZonedDateTime earliest1 = ZonedDateTime.parse("2020-01-01T00:00:00Z"); + ZonedDateTime latest1 = ZonedDateTime.parse("2021-01-01T00:00:00Z"); + ZonedDateTime updated1 = ZonedDateTime.parse("2022-01-01T00:00:00Z"); + + TimeSeriesExtents extents1 = new TimeSeriesExtents.Builder() + .withEarliestTime(earliest1) + .withLatestTime(latest1) + .withVersionTime(version1) + .withLastUpdate(updated1) + .build(); + + ZonedDateTime version2 = ZonedDateTime.parse("2019-02-01T00:00:00Z"); + ZonedDateTime earliest2 = ZonedDateTime.parse("2020-02-01T00:00:00Z"); + ZonedDateTime latest2 = ZonedDateTime.parse("2021-02-01T00:00:00Z"); + ZonedDateTime updated2 = ZonedDateTime.parse("2022-02-01T00:00:00Z"); + + TimeSeriesExtents extents2 = new TimeSeriesExtents.Builder() + .withEarliestTime(earliest2) + .withLatestTime(latest2) + .withVersionTime(version2) + .withLastUpdate(updated2) + .build(); + + return new TimeSeriesVersions.Builder() + .withTsId(new CwmsId.Builder() + .withName("TestTS") + .withOfficeId("SWT") + .build()) + .addVersion(extents1) + .addVersion(extents2) + .withPage("page") + .withPageSize(10) + .withTotal(2) + .build(); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java b/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java index 11ed2329f..415f7b7f1 100644 --- a/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java +++ b/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java @@ -29,6 +29,7 @@ import cwms.cda.data.dto.ParameterLegacy; import cwms.cda.data.dto.TimeExtents; import cwms.cda.data.dto.TimeSeriesExtents; +import cwms.cda.data.dto.TimeSeriesVersions; import cwms.cda.data.dto.TimeSeriesIdentifierDescriptor; import cwms.cda.data.dto.catalog.LocationAlias; import cwms.cda.data.dto.LocationToPublishedData; @@ -651,6 +652,13 @@ public static void assertMatch(TimeSeriesExtents first, TimeSeriesExtents second ); } + public static void assertMatch(TimeSeriesVersions first, TimeSeriesVersions second) { + assertAll( + () -> assertMatch(first.getTsId(), second.getTsId()), + () -> assertMatch(first.getVersions(), second.getVersions(), DTOMatch::assertMatch) + ); + } + public static void assertMatch(TimeExtents first, TimeExtents second) { assertAll( () -> assertEquals(first.getEarliestTime(), second.getEarliestTime(), "Start time does not match"),