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..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 +} +