From 166e72f3a6afe10fcc533f3dd66e5c4bb3b5131b Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Mon, 22 Jun 2026 12:44:26 -0700 Subject: [PATCH 1/4] CDA-99 - Implements timeseries/versions catalog endpoint --- cda-gui/package-lock.json | 39 --- .../src/main/java/cwms/cda/ApiServlet.java | 5 + .../cda/api/TimeSeriesVersionsController.java | 154 ++++++++++++ .../java/cwms/cda/data/dao/TimeSeriesDao.java | 4 + .../cwms/cda/data/dao/TimeSeriesDaoImpl.java | 91 ++++++- .../cwms/cda/data/dto/TimeSeriesVersions.java | 113 +++++++++ .../TimeSeriesVersionsControllerTestIT.java | 228 ++++++++++++++++++ .../cda/data/dto/TimeSeriesVersionsTest.java | 87 +++++++ .../test/java/cwms/cda/helpers/DTOMatch.java | 8 + .../cda/data/dto/time_series_versions.json | 24 ++ 10 files changed, 712 insertions(+), 41 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesVersions.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesVersionsTest.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_versions.json 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 fb533449e..57a679c47 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; @@ -503,6 +504,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..69771054a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java @@ -0,0 +1,154 @@ +/* + * 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.GET_ALL; +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.api.Controllers.TIME_FORMAT_DESC; +import static cwms.cda.api.Controllers.requiredParam; +import static cwms.cda.api.TimeSeriesController.DEFAULT_PAGE_SIZE; +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)); + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + private 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 = "Specifies the " + + "name of the time series whose data is to be included in the " + + "response. A case insensitive comparison is used to match names."), + @OpenApiParam(name = OFFICE, description = "Specifies the" + + " owning office of the time series(s) whose data is to be included " + + "in the response."), + @OpenApiParam(name = BEGIN, 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" + + " hours prior to the specified or default end time. " + + TIME_FORMAT_DESC), + @OpenApiParam(name = END, description = "Specifies the " + + "end of the time window for data to be included in the response. If" + + " this field is not specified, any required time window ends at the" + + " current time. " + + TIME_FORMAT_DESC), + @OpenApiParam(name = TIMEZONE, description = "Specifies " + + "the time zone of the values of the begin and end fields (unless " + + "otherwise specified)." + + "If this field is not specified, the default time zone " + + "of UTC shall be used.\r\nIgnored if begin was specified with " + + "offset and timezone."), + @OpenApiParam(name = PAGE, description = "This end point can return large amounts " + + "of data as a series of pages. This parameter is used to describes the " + + "current location in the response stream. This is an opaque " + + "value, and can be obtained from the 'next-page' value in the response."), + @OpenApiParam(name = PAGE_SIZE, type = Integer.class, description = "How many entries per page returned. " + + "For JSON/XML paging, this controls page size. " + + "For CSV, this controls the internal fetch batch size used while streaming a single response. " + + "CSV clients do not request subsequent pages. " + + "Default " + DEFAULT_PAGE_SIZE +". Use 0 to return an empty values array, " + + "or -1 to return the entire window in one response without a next-page cursor. " + + "Values less than -1 are invalid."), + }, + 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 = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + TimeSeriesDao dao = getTimeSeriesDao(dsl); + + String tsId = requiredParam(ctx, 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(DEFAULT_PAGE_SIZE); + + 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..be817884e 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,11 +41,11 @@ 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.dto.Catalog; import cwms.cda.data.dto.CwmsDTOPaginated; import cwms.cda.data.dto.RecentValue; import cwms.cda.data.dto.TimeSeries; -import cwms.cda.data.dto.TimeSeriesExtents; import cwms.cda.data.dto.Tsv; import cwms.cda.data.dto.TsvDqu; import cwms.cda.data.dto.TsvId; @@ -220,6 +222,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..52f9a1f78 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java @@ -0,0 +1,228 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import cwms.cda.formatters.Formats; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +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 = "SPK"; + private static final String TS_ID = "TestTS.Temp-Water.Inst.1Day.0.cda-test"; + + @BeforeAll + void setup() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + InputStream resource = this.getClass().getResourceAsStream( + "/cwms/cda/api/lrl/1day_offset_version_date.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + tsData = tsData.replace("Buckhorn", TS_ID.split("\\.")[0]); + + JsonNode ts = mapper.readTree(tsData); + String location = ts.get(NAME).asText().split("\\.")[0]; + String officeId = ts.get("office-id").asText(); + + createLocation(location, true, officeId); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // inserting the time series + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .contentType(Formats.JSONV2) + .body(tsData) + .header("Authorization",user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/timeseries/") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + String secondVersionDate = "1604786000000"; + tsData = tsData.replace("1594786000000", secondVersionDate).replace("35,", "47.5,"); + // inserting the second time series + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .contentType(Formats.JSONV2) + .body(tsData) + .header("Authorization",user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/timeseries/") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + } + + @AfterAll + void cleanup() throws Exception { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV2) + .contentType(Formats.JSONV2) + .header("Authorization",user.toHeaderValue()) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(BEGIN, "2019-07-15T00:00:00Z") + .queryParam(END,"2024-07-15T00:00:00Z") + .when() + .redirects().follow(true) + .redirects().max(3) + .delete("/timeseries/" + TS_ID) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + deleteLocation(TS_ID.split("\\.")[0], OFFICE_ID); + } + + @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, "2020-07-15T00:00:00Z") + .queryParam(END, "2020-07-16T00: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("2020-07-15T04:06:40Z")); + } + + @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, 1) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(1)) + .body("next-page", notNullValue()) + .extract().path("next-page"); + + // Page 2 + 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..9758b3e39 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesVersionsTest.java @@ -0,0 +1,87 @@ +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.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +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); + } + + @Test + void test_roundtrip_from_example_json_fixture() throws IOException { + ObjectMapper om = JsonV2.buildObjectMapper(); + + String inputJson; + try (InputStream is = getClass().getResourceAsStream("/cwms/cda/data/dto/time_series_versions.json")) { + assertNotNull(is); + inputJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + + TimeSeriesVersions fromFixture = om.readValue(inputJson, TimeSeriesVersions.class); + assertNotNull(fromFixture); + + String serialized = om.writeValueAsString(fromFixture); + assertNotNull(serialized); + + TimeSeriesVersions roundTripped = om.readValue(serialized, TimeSeriesVersions.class); + DTOMatch.assertMatch(fromFixture, roundTripped); + } + + 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"), diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_versions.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_versions.json new file mode 100644 index 000000000..55639650d --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_versions.json @@ -0,0 +1,24 @@ +{ + "ts-id": { + "name": "Buckhorn.Temp-Water.Inst.1Day.0.cda-test", + "office-id": "SPK" + }, + "versions": [ + { + "earliest-time": "2020-01-01T00:00:00Z", + "latest-time": "2021-01-01T00:00:00Z", + "version-time": "2019-01-01T00:00:00Z", + "last-update": "2022-01-01T00:00:00Z" + }, + { + "earliest-time": "2020-02-01T00:00:00Z", + "latest-time": "2021-02-01T00:00:00Z", + "version-time": "2019-02-01T00:00:00Z", + "last-update": "2022-02-01T00:00:00Z" + } + ], + "page": "page", + "page-size": 10, + "total": 2 +} + From e09f6712cbd2fd1d91d7fb6649ece6f4d1a69ac5 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Tue, 30 Jun 2026 11:44:58 -0700 Subject: [PATCH 2/4] CDA-99 - Reverts package-lock.json --- cda-gui/package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cda-gui/package-lock.json b/cda-gui/package-lock.json index d33ee305e..cc71620c0 100644 --- a/cda-gui/package-lock.json +++ b/cda-gui/package-lock.json @@ -1200,6 +1200,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1214,6 +1217,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1228,6 +1234,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1242,6 +1251,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1256,6 +1268,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1270,6 +1285,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1284,6 +1302,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1298,6 +1319,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1312,6 +1336,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1326,6 +1353,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1340,6 +1370,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1354,6 +1387,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1368,6 +1404,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ From b140f18fd7ccf25c7b2578b84956d013e577a275 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Wed, 1 Jul 2026 11:15:51 -0700 Subject: [PATCH 3/4] CDA-99 - Updated openApi descriptions. Added more test coverage. Updated to use Instants. Added readTheDocs for timeseries/versions --- .../cda/api/TimeSeriesVersionsController.java | 43 ++++------- .../java/cwms/cda/data/dao/TimeSeriesDao.java | 3 +- .../cwms/cda/data/dao/TimeSeriesDaoImpl.java | 32 ++++---- .../TimeSeriesVersionsControllerTestIT.java | 30 +++++++- .../endpoints/timeSeries_endpoints/index.rst | 1 + .../timeSeries-versions.rst | 73 +++++++++++++++++++ 6 files changed, 139 insertions(+), 43 deletions(-) create mode 100644 docs/source/endpoints/timeSeries_endpoints/timeSeries-versions.rst 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 index 69771054a..207f9ad9c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java @@ -24,6 +24,7 @@ package cwms.cda.api; +import static com.codahale.metrics.MetricRegistry.name; import static cwms.cda.api.Controllers.BEGIN; import static cwms.cda.api.Controllers.END; import static cwms.cda.api.Controllers.GET_ALL; @@ -34,9 +35,11 @@ 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.api.Controllers.TIME_FORMAT_DESC; +import static cwms.cda.api.Controllers.queryParamAsClass; +import static cwms.cda.api.Controllers.queryParamAsInstant; import static cwms.cda.api.Controllers.requiredParam; +import static cwms.cda.api.Controllers.validateTimeSeriesPageSize; import static cwms.cda.api.TimeSeriesController.DEFAULT_PAGE_SIZE; import static cwms.cda.data.dao.JooqDao.getDslContext; @@ -55,7 +58,8 @@ 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 java.time.Instant; import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; @@ -79,38 +83,26 @@ private TimeSeriesDao getTimeSeriesDao(DSLContext dsl) { } @OpenApi( - description = "Returns TimeSeries versions and their extents for a given TimeSeries identifier", + description = "Returns TimeSeries versions and their extents for a given TimeSeries identifier. Aliases are supported for the TimeSeries identifier.", queryParams = { @OpenApiParam(name = NAME, required = true, description = "Specifies the " + "name of the time series whose data is to be included in the " + "response. A case insensitive comparison is used to match names."), - @OpenApiParam(name = OFFICE, description = "Specifies the" + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the" + " owning office of the time series(s) whose data is to be included " + "in the response."), @OpenApiParam(name = BEGIN, 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" - + " hours prior to the specified or default end time. " + TIME_FORMAT_DESC), @OpenApiParam(name = END, description = "Specifies the " - + "end of the time window for data to be included in the response. If" - + " this field is not specified, any required time window ends at the" - + " current time. " + + "end of the time window for data to be included in the response. " + TIME_FORMAT_DESC), - @OpenApiParam(name = TIMEZONE, description = "Specifies " - + "the time zone of the values of the begin and end fields (unless " - + "otherwise specified)." - + "If this field is not specified, the default time zone " - + "of UTC shall be used.\r\nIgnored if begin was specified with " - + "offset and timezone."), @OpenApiParam(name = PAGE, description = "This end point can return large amounts " + "of data as a series of pages. This parameter is used to describes the " + "current location in the response stream. This is an opaque " + "value, and can be obtained from the 'next-page' value in the response."), @OpenApiParam(name = PAGE_SIZE, type = Integer.class, description = "How many entries per page returned. " - + "For JSON/XML paging, this controls page size. " - + "For CSV, this controls the internal fetch batch size used while streaming a single response. " - + "CSV clients do not request subsequent pages. " + + "For paging, this controls page size. " + "Default " + DEFAULT_PAGE_SIZE +". Use 0 to return an empty values array, " + "or -1 to return the entire window in one response without a next-page cursor. " + "Values less than -1 are invalid."), @@ -129,16 +121,13 @@ public void handle(@NotNull Context ctx) throws Exception { TimeSeriesDao dao = getTimeSeriesDao(dsl); String tsId = requiredParam(ctx, 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 office = requiredParam(ctx, OFFICE); + Instant begin = queryParamAsInstant(ctx, BEGIN); + Instant end = queryParamAsInstant(ctx, END); String cursor = ctx.queryParam(PAGE); - int pageSize = ctx.queryParamAsClass(PAGE_SIZE, Integer.class).getOrDefault(DEFAULT_PAGE_SIZE); - - ZonedDateTime begin = beginStr != null ? Controllers.queryParamAsZdt(ctx, BEGIN, timezone) : null; - ZonedDateTime end = endStr != null ? Controllers.queryParamAsZdt(ctx, END, timezone) : null; - + int pageSize = validateTimeSeriesPageSize(queryParamAsClass(ctx, + new String[]{PAGE_SIZE}, Integer.class, DEFAULT_PAGE_SIZE, metrics, + name(TimeSeriesVersionsController.class.getName(), GET_ALL))); TimeSeriesVersions versions = dao.getTimeSeriesVersions(cursor, pageSize, tsId, office, begin, end); String formatHeader = ctx.header(Header.ACCEPT); 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 a7281be23..ef120121d 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 @@ -9,6 +9,7 @@ import cwms.cda.formatters.csv.CsvConfiguration; import java.sql.Timestamp; +import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; @@ -55,7 +56,7 @@ TimeSeries getTimeseries(String cursor, int pageSize, String names, String offic FilteredTimeSeries getTimeseries(String page, int pageSize, TimeSeriesRequestParameters requestParameters, FilteredTimeSeriesParameters filterParams); TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, String names, String office, - ZonedDateTime begin, ZonedDateTime end); + Instant begin, Instant 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 be817884e..0dc9fb702 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 @@ -223,11 +223,11 @@ 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); + public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, String name, String office, + Instant begin, Instant end) { + Condition condition = AV_CWMS_TS_ID2.CWMS_TS_ID.equalIgnoreCase(name); if (office != null) { - condition = condition.and(AV_CWMS_TS_ID2.DB_OFFICE_ID.eq(office.toUpperCase())); + condition = condition.and(AV_CWMS_TS_ID2.DB_OFFICE_ID.equalIgnoreCase(office.toUpperCase())); } condition = condition.and(AV_CWMS_TS_ID2.ALIASED_ITEM.isNull()); @@ -237,7 +237,7 @@ public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, Str .fetchOne(); if (tsRecord == null) { - throw new NotFoundException("Could not find time series for identifier: " + names); + throw new NotFoundException("Could not find time series for identifier: " + name); } BigDecimal tsCode = tsRecord.get(AV_CWMS_TS_ID2.TS_CODE); @@ -246,10 +246,10 @@ public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, Str 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()))); + extentsCondition = extentsCondition.and(AV_TS_EXTENTS_UTC.VERSION_TIME.ge(Timestamp.from(begin))); } if (end != null) { - extentsCondition = extentsCondition.and(AV_TS_EXTENTS_UTC.VERSION_TIME.le(Timestamp.from(end.toInstant()))); + extentsCondition = extentsCondition.and(AV_TS_EXTENTS_UTC.VERSION_TIME.le(Timestamp.from(end))); } Condition pagingCondition = noCondition(); @@ -265,17 +265,23 @@ public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, Str .from(AV_TS_EXTENTS_UTC) .where(extentsCondition) .fetchOne(0, Integer.class); - - Result results = dsl.select(AV_TS_EXTENTS_UTC.VERSION_TIME, + + var query = 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(); + .orderBy(AV_TS_EXTENTS_UTC.VERSION_TIME.desc().nullsFirst()); + + Result results; + if (pageSize > -1) { + results = query.limit(pageSize).fetch(); + } else { + results = query.fetch(); + } TimeSeriesVersions.Builder builder = new TimeSeriesVersions.Builder() .withTsId(new CwmsId.Builder() @@ -299,7 +305,7 @@ public TimeSeriesVersions getTimeSeriesVersions(String cursor, int pageSize, Str 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)); + builder.withNextPage(CwmsDTOPaginated.encodeCursor(lastVersionTime.toInstant(), pageSize, total)); } } 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 index 52f9a1f78..9959906cc 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesVersionsControllerTestIT.java @@ -153,7 +153,10 @@ void test_get_versions() { .body("ts-id.name", equalTo(TS_ID)) .body("ts-id.office-id", equalTo(OFFICE_ID)) .body("versions", notNullValue()) - .body("versions.size()", is(2)); + .body("versions.size()", is(2)) + .body("total", is(2)) + .body("versions[0].version-time", equalTo("2020-11-07T21:53:20Z")) + .body("versions[1].version-time", equalTo("2020-07-15T04:06:40Z")); } @Test @@ -207,6 +210,8 @@ void test_get_versions_pagination() { .statusCode(is(HttpServletResponse.SC_OK)) .body("versions.size()", is(1)) .body("next-page", notNullValue()) + .body("total", is(2)) + .body("versions[0].version-time", equalTo("2020-11-07T21:53:20Z")) .extract().path("next-page"); // Page 2 @@ -223,6 +228,27 @@ void test_get_versions_pagination() { .assertThat() .statusCode(is(HttpServletResponse.SC_OK)) .body("versions.size()", is(1)) - .body("next-page", nullValue()); + .body("next-page", nullValue()) + .body("total", is(2)) + .body("versions[0].version-time", equalTo("2020-07-15T04:06:40Z")); + + //test page-size = -1 + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .queryParam(NAME, TS_ID) + .queryParam(OFFICE, OFFICE_ID) + .queryParam(PAGE_SIZE, -1) + .when() + .get("/timeseries/versions/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("versions.size()", is(2)) + .body("next-page", nullValue()) + .body("total", is(2)) + .body("versions[0].version-time", equalTo("2020-11-07T21:53:20Z")) + .body("versions[1].version-time", equalTo("2020-07-15T04:06:40Z")); } } diff --git a/docs/source/endpoints/timeSeries_endpoints/index.rst b/docs/source/endpoints/timeSeries_endpoints/index.rst index ebec55275..a28320b7b 100644 --- a/docs/source/endpoints/timeSeries_endpoints/index.rst +++ b/docs/source/endpoints/timeSeries_endpoints/index.rst @@ -27,3 +27,4 @@ Browse Time Series GET Endpoints: /timeseries/profile-parser/{location-id}/{parameter-id} /timeseries/profile-instance /timeseries/profile-instance/{location-id}/{parameter-id}/{version} + /timeseries/versions diff --git a/docs/source/endpoints/timeSeries_endpoints/timeSeries-versions.rst b/docs/source/endpoints/timeSeries_endpoints/timeSeries-versions.rst new file mode 100644 index 000000000..cab89e450 --- /dev/null +++ b/docs/source/endpoints/timeSeries_endpoints/timeSeries-versions.rst @@ -0,0 +1,73 @@ +TimeSeries — GET /timeseries/versions +===================================== + +What it does +------------ + +Returns the available versions of a TimeSeries and the data extent +associated with each version. + +For each version, the response includes: + +- The version date/time. +- The earliest time for which data exists. +- The latest time for which data exists. +- The date/time the version was last updated. + +Aliases are supported. + +When to use +----------- + +- To determine which versions exist for a TimeSeries. +- To retrieve and inspect the available versions. +- To determine whether versions exist within a particular time window. + + +.. csv-table:: GET /timeseries/versions - Endpoint Parameters + :header: "Parameter", "Description", "Required", "When to Use" + :widths: 30, 40, 20, 60 + + name,":ref:`def-name`","Yes",":ref:`when_name`" + office,":ref:`def-office`","Yes",":ref:`when_office`" + begin,":ref:`def-start`","",":ref:`when_start`" + end,":ref:`def-end`","",":ref:`when_end`" + page,":ref:`def-page`","",":ref:`when_page`" + page-size,":ref:`def-page_size`","",":ref:`when_page_size`" + + +Examples +-------- + +1. The user wants to retrieve all versions available for a TimeSeries. + + .. code-block:: urlencoded + + GET /timeseries/versions?name=MISSOURI.Flow.Inst.1Hour.0.Raw&office=SPK + +2. | The user wants to retrieve versions available within a specific time window: + | (**begin**) :code:`2025-01-01T00:00:00Z` + | (**end**) :code:`2025-12-31T23:59:59Z` + + .. code-block:: urlencoded + + GET /timeseries/versions?name=MISSOURI.Flow.Inst.1Hour.0.Raw&office=SPK&begin=2025-01-01T00:00:00Z&end=2025-12-31T23:59:59Z + +3. | The user wants all results returned in a single response: + | (**page-size**) :code:`-1` + + .. code-block:: urlencoded + + GET /timeseries/versions?name=MISSOURI.Flow.Inst.1Hour.0.Raw&office=SPK&page-size=-1 + +4. | The user wants to retrieve the next page of results using a paging cursor: + | (**page**) :code:`` + + .. code-block:: urlencoded + + GET /timeseries/versions?name=MISSOURI.Flow.Inst.1Hour.0.Raw&office=SPK&page= + + +See the consolidated API documentation: :doc:`/api-references`. + +.. include:: /_includes/feedback_button.rst \ No newline at end of file From 9c9130c55de6d0313fba6bbbfb98cc71c2731725 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Thu, 2 Jul 2026 14:15:15 -0700 Subject: [PATCH 4/4] CDA-99 - Adds back timezone query param --- .../java/cwms/cda/api/TimeSeriesVersionsController.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 207f9ad9c..08feb8ac4 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesVersionsController.java @@ -35,6 +35,7 @@ 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.api.Controllers.TIME_FORMAT_DESC; import static cwms.cda.api.Controllers.queryParamAsClass; import static cwms.cda.api.Controllers.queryParamAsInstant; @@ -97,6 +98,12 @@ private TimeSeriesDao getTimeSeriesDao(DSLContext dsl) { @OpenApiParam(name = END, description = "Specifies the " + "end of the time window for data to be included in the response. " + TIME_FORMAT_DESC), + @OpenApiParam(name = TIMEZONE, description = "Specifies " + + "the time zone of the values of the begin and end fields (unless " + + "otherwise specified)." + + "If this field is not specified, the default time zone " + + "of UTC shall be used.\r\nIgnored if begin was specified with " + + "offset and timezone."), @OpenApiParam(name = PAGE, description = "This end point can return large amounts " + "of data as a series of pages. This parameter is used to describes the " + "current location in the response stream. This is an opaque "