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 d1a410f66..df542220a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -24,6 +24,7 @@ package cwms.cda; +import cwms.cda.api.AccessToWaterController; import static cwms.cda.api.Controllers.CONTRACT_NAME; import static cwms.cda.api.Controllers.LOCATION_ID; import static cwms.cda.api.Controllers.NAME; @@ -235,6 +236,7 @@ "/stream-locations/*", "/stream-reaches/*", "/measurements/*", + "/access-to-water/*", "/blobs/*", "/clobs/*", "/pools/*", @@ -604,6 +606,8 @@ protected void configureRoutes() { addCacheControl(measTimeExtents, 5, TimeUnit.MINUTES); cdaCrudCache(format(measurements + "{%s}", LOCATION_ID), new cwms.cda.api.MeasurementController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/access-to-water/{%s}", LOCATION_ID), + new AccessToWaterController(metrics), requiredRoles,5, TimeUnit.MINUTES); cdaCrudCache("/blobs/{blob-id}", new BlobController(metrics), requiredRoles,5, TimeUnit.MINUTES); cdaCrudCache("/clobs/{clob-id}", diff --git a/cwms-data-api/src/main/java/cwms/cda/api/AccessToWaterController.java b/cwms-data-api/src/main/java/cwms/cda/api/AccessToWaterController.java new file mode 100644 index 000000000..750bceeca --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/AccessToWaterController.java @@ -0,0 +1,164 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.api; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.MetricRegistry; +import static com.codahale.metrics.MetricRegistry.name; +import com.codahale.metrics.Timer; +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.CURSOR; +import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.LOCATION_MASK; +import static cwms.cda.api.Controllers.NOT_SUPPORTED_YET; +import static cwms.cda.api.Controllers.OFFICE_MASK; +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.UPDATE; +import static cwms.cda.api.Controllers.queryParamAsClass; +import cwms.cda.api.errors.CdaError; +import cwms.cda.data.dao.AccessToWaterRetrievalParameters; +import static cwms.cda.data.dao.JooqDao.getDslContext; +import cwms.cda.data.dao.AccessToWaterTimeSeriesDao; +import cwms.cda.data.dto.CwmsDTOPaginated; +import cwms.cda.data.dto.TimeSeriesIdentifiersByTypeList; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.apibuilder.CrudHandler; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.HttpCode; +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 javax.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public final class AccessToWaterController implements CrudHandler { + private static final String TAG = "AccessToWater"; + private final MetricRegistry metrics; + private final Histogram requestResultSize; + private static final int DEFAULT_PAGE_SIZE = 20; + + public AccessToWaterController(MetricRegistry metrics) { + this.metrics = metrics; + String className = this.getClass().getName(); + requestResultSize = this.metrics.histogram(name(className, RESULTS, SIZE)); + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi(ignore = true) + @Override + public void create(@NotNull Context context) { + try (final Timer.Context ignored = markAndTime(CREATE)) { + throw new UnsupportedOperationException(NOT_SUPPORTED_YET); + } + } + + @OpenApi(ignore = true) + @Override + public void delete(@NotNull Context context, @NotNull String s) { + try (final Timer.Context ignored = markAndTime(DELETE)) { + throw new UnsupportedOperationException(NOT_SUPPORTED_YET); + } + } + + @OpenApi( + queryParams = { + @OpenApiParam(name = OFFICE_MASK, description = "Office Id used to filter the results."), + @OpenApiParam(name = LOCATION_MASK, description = "Location Id used to filter the results."), + @OpenApiParam(name = PAGE, + description = "This end point can return a lot of data, this " + + "identifies where in the request you are. 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. Default " + + DEFAULT_PAGE_SIZE + "."), + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(isArray = true, type = Formats.JSONV1, from = TimeSeriesIdentifiersByTypeList.class) + }) + }, + description = "Returns matching time series identifiers for access to water.", + tags = {TAG} + ) + @Override + public void getAll(@NotNull Context ctx) { + String officeIdMask = ctx.queryParam(OFFICE_MASK); + String locationIdMask = ctx.queryParam(LOCATION_MASK); + String cursor = queryParamAsClass(ctx, new String[]{PAGE, CURSOR}, + String.class, "", metrics, name(AccessToWaterController.class.getName(), GET_ALL)); + if (!CwmsDTOPaginated.CURSOR_CHECK.invoke(cursor)) { + ctx.json(new CdaError("cursor or page passed in but failed validation")) + .status(HttpCode.BAD_REQUEST); + return; + } + int pageSize = queryParamAsClass(ctx, new String[]{PAGE_SIZE}, Integer.class, DEFAULT_PAGE_SIZE, metrics, + name(AccessToWaterController.class.getName(), GET_ALL)); + + try (Timer.Context ignored = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + AccessToWaterTimeSeriesDao dao = new AccessToWaterTimeSeriesDao(dsl); + AccessToWaterRetrievalParameters retrievalParams = new AccessToWaterRetrievalParameters(officeIdMask, locationIdMask); + TimeSeriesIdentifiersByTypeList result = dao.retrieveAccessToWaterTimeSeriesIds(retrievalParams, cursor, pageSize); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesIdentifiersByTypeList.class); + ctx.contentType(contentType.toString()); + String serialized = Formats.format(contentType, result); + ctx.result(serialized); + ctx.status(HttpServletResponse.SC_OK); + requestResultSize.update(serialized.length()); + } + } + + @OpenApi(ignore = true) + @Override + public void getOne(@NotNull Context context, @NotNull String s) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + throw new UnsupportedOperationException(NOT_SUPPORTED_YET); + } + } + + @OpenApi(ignore = true) + @Override + public void update(@NotNull Context context, @NotNull String s) { + try (final Timer.Context ignored = markAndTime(UPDATE)) { + throw new UnsupportedOperationException(NOT_SUPPORTED_YET); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterRetrievalParameters.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterRetrievalParameters.java new file mode 100644 index 000000000..286a476bc --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterRetrievalParameters.java @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.data.dao; +import java.util.Optional; + +public class AccessToWaterRetrievalParameters { + private final String locationIdsMask; + private final String officeIdsMask; + + public AccessToWaterRetrievalParameters(String locationIdsMask, String officeIdsMask) { + this.locationIdsMask = locationIdsMask; + this.officeIdsMask = officeIdsMask; + } + + public Optional getLocationId() { + return Optional.ofNullable(locationIdsMask); + } + + public Optional getOfficeId() { + return Optional.ofNullable(officeIdsMask); + } + +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDao.java new file mode 100644 index 000000000..69ce5b50f --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDao.java @@ -0,0 +1,198 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.data.dao; + +import cwms.cda.data.dto.CwmsDTOPaginated; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.TimeSeriesIdentifierDescriptor; +import cwms.cda.data.dto.TimeSeriesIdentifiersByType; +import cwms.cda.data.dto.TimeSeriesIdentifiersByTypeList; +import cwms.cda.data.dto.TimeSeriesMetaData; +import cwms.cda.data.dto.timeseriesprofile.TimeSeriesProfile; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import static java.util.stream.Collectors.toList; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Record1; +import org.jooq.Record5; +import org.jooq.SelectConditionStep; +import static org.jooq.impl.DSL.asterisk; +import static org.jooq.impl.DSL.count; +import static org.jooq.impl.DSL.noCondition; +import static org.jooq.impl.DSL.upper; +import static usace.cwms.db.jooq.codegen.tables.AV_A2W_TS_CODES_BY_LOC2.AV_A2W_TS_CODES_BY_LOC2; +import static usace.cwms.db.jooq.codegen.tables.AV_CWMS_TS_ID.AV_CWMS_TS_ID; + +public final class AccessToWaterTimeSeriesDao extends JooqDao { + + public AccessToWaterTimeSeriesDao(DSLContext dsl) { + super(dsl); + } + + public TimeSeriesIdentifiersByTypeList retrieveAccessToWaterTimeSeriesIds(AccessToWaterRetrievalParameters retrievalParameters, String cursor, int pageSize) + { + int total = 0; + String cursorOffice = null; + String cursorLocId = null; + + //TODO: Need to update this query to get notes, refresh date from a2w_loc2 view, + // bounding office, kind from location + // and active attribute from ts + Condition officeCondition = retrievalParameters.getOfficeId() + .map(AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID::eq) + .orElse(noCondition()); + + Condition locationCondition = retrievalParameters.getLocationId() + .map(AV_A2W_TS_CODES_BY_LOC2.LOCATION_ID::eq) + .orElse(noCondition()); + + Condition whereClause = locationCondition.and(officeCondition); + + if (cursor == null || cursor.isEmpty()) { + SelectConditionStep> count = dsl.select(count(asterisk())) + .from(AV_A2W_TS_CODES_BY_LOC2) + .leftOuterJoin(AV_CWMS_TS_ID) + .on(AV_A2W_TS_CODES_BY_LOC2.TS_CODE.equal(AV_CWMS_TS_ID.TS_CODE)) + .where(whereClause);// Ensures rows matching cwmsIds are returned + Record1 rec = count.fetchOne(); + if(rec != null) { + total = rec.value1(); + } + } else { + final String[] parts = CwmsDTOPaginated.decodeCursor(cursor, "||"); + + if (parts.length > 1) { + cursorOffice = TimeSeriesIdentifiersByTypeList.getOffice(cursor); + cursorLocId = TimeSeriesIdentifiersByTypeList.getId(cursor); + total = Integer.parseInt(parts[1]); + pageSize = Integer.parseInt(parts[2]); + } + } + int finalizedPageSize = pageSize; + + Condition moreInSameOffice = cursorLocId == null || cursorOffice == null ? noCondition() : + AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID.eq(cursorOffice.toUpperCase()) + .and(upper(AV_A2W_TS_CODES_BY_LOC2.LOCATION_ID).greaterThan(cursorLocId.toUpperCase())); + Condition nextOffices = cursorOffice == null ? noCondition(): + upper(AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID).greaterThan(cursorOffice.toUpperCase()); + Condition pagingCondition = moreInSameOffice.or(nextOffices); + + Map> locationToTsTypeMap = new LinkedHashMap<>(); + + connection(dsl, conn -> + dsl.select(AV_A2W_TS_CODES_BY_LOC2.CWMS_TS_ID, AV_A2W_TS_CODES_BY_LOC2.LOCATION_ID, + AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID, AV_CWMS_TS_ID.TIME_ZONE_ID, + AV_CWMS_TS_ID.INTERVAL_UTC_OFFSET) + .from(AV_A2W_TS_CODES_BY_LOC2) + .leftOuterJoin(AV_CWMS_TS_ID) + .on(AV_A2W_TS_CODES_BY_LOC2.TS_CODE.equal(AV_CWMS_TS_ID.TS_CODE)) + .where(whereClause) + .and(pagingCondition) + .orderBy(AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID, AV_A2W_TS_CODES_BY_LOC2.LOCATION_ID) + .limit(finalizedPageSize) + .fetchStream() + .forEach(row -> { + String locationId = row.get(AV_A2W_TS_CODES_BY_LOC2.LOCATION_ID); + String tsTypeFromRow = row.get(AV_A2W_TS_CODES_BY_LOC2.TS_TYPE); + String officeId = row.get(AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID); + + TimeSeriesMetaData tsId = buildTsId(row); + locationToTsTypeMap.computeIfAbsent(new InsensitiveCwmsId(new CwmsId.Builder() + .withOfficeId(officeId) + .withName(locationId) + .withBoundingOfficeId(null) //TODO: Need to update this to get bounding office from location + .withKind(null) //TODO: Need to update this to get kind from location + .build()), + k -> new LinkedHashMap<>()).put(tsTypeFromRow, tsId); + }) + ); + + List identifiersList = locationToTsTypeMap.entrySet().stream() + .map(entry -> new TimeSeriesIdentifiersByType.Builder() + .withLocationId(entry.getKey().getCwmsId()) + .withTimeSeriesIds(entry.getValue()) + .build()) + .collect(toList()); + + return new TimeSeriesIdentifiersByTypeList.Builder() + .withCursor(cursor) + .withTotal(total) + .withPageSize(pageSize) + .withTimeSeriesIdsForLocations(identifiersList) + .build(); + + } + + private TimeSeriesMetaData buildTsId(Record5 row) + { + String timeSeriesId = row.get(AV_A2W_TS_CODES_BY_LOC2.CWMS_TS_ID); + String officeId = row.get(AV_A2W_TS_CODES_BY_LOC2.DB_OFFICE_ID); + BigDecimal intervalUtcMinuteOffset = row.get(AV_CWMS_TS_ID.INTERVAL_UTC_OFFSET); + String timeZoneId = row.get(AV_CWMS_TS_ID.TIME_ZONE_ID); + return new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withOfficeId(officeId) + .withTimezoneName(timeZoneId) + .withTimeSeriesId(timeSeriesId) + .withIntervalOffsetMinutes(intervalUtcMinuteOffset.longValue()) + .withActive(true)//TODO: need to get this from db + .build()) + .build(); + } + + //building inner class that wraps CwmsId but implements equals hashcode called InsensitiveCwmsId + private static class InsensitiveCwmsId { + private final CwmsId cwmsId; + + public InsensitiveCwmsId(CwmsId cwmsId) { + this.cwmsId = cwmsId; + } + + private CwmsId getCwmsId() { + return cwmsId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + InsensitiveCwmsId that = (InsensitiveCwmsId) o; + return cwmsId.getName().equalsIgnoreCase(that.cwmsId.getName()) && + cwmsId.getOfficeId().equalsIgnoreCase(that.cwmsId.getOfficeId()); + } + + @Override + public int hashCode() { + return cwmsId.getName().toUpperCase().hashCode() + cwmsId.getOfficeId().toUpperCase().hashCode(); + } + } + +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/CwmsId.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/CwmsId.java index ef3493047..e1e5a49fb 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/CwmsId.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/CwmsId.java @@ -24,6 +24,8 @@ package cwms.cda.data.dto; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.PropertyNamingStrategies; @@ -32,6 +34,10 @@ import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; import cwms.cda.formatters.json.JsonV1; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.DEFAULT, Formats.JSON}) @JsonDeserialize(builder = CwmsId.Builder.class) @@ -41,10 +47,15 @@ public final class CwmsId extends CwmsDTO { private final String name; + private final Map properties; + @JsonIgnore + private final List requiredProperties; public CwmsId(Builder builder) { super(builder.officeId); this.name = builder.name; + this.properties = builder.properties; + this.requiredProperties = builder.requiredProperties; } public static CwmsId buildCwmsId(String officeId, String name) { @@ -59,16 +70,49 @@ protected void validateInternal(CwmsDTOValidator validator) { super.validateInternal(validator); validator.required(getOfficeId(), "office-id"); validator.required(getName(), "name"); + for(String requiredProperty : requiredProperties) { + validator.required(properties.get(requiredProperty), requiredProperty); + } } public String getName() { return name; } + public Map getProperties() { + return properties; + } + + @JsonIgnore + public String getProperty(String key) { + return properties.get(key); + } + public static class Builder { + private final Map properties = new LinkedHashMap<>(); + @JsonIgnore + private final List requiredProperties = new ArrayList<>(); private String officeId; private String name; + public Builder withProperties(Map properties) { + this.properties.putAll(properties); + return this; + } + + @JsonIgnore + final Builder withRequiredProperty(String key, String value) { + this.properties.put(key, value); + this.requiredProperties.add(key); + return this; + } + + @JsonAnySetter + public Builder withProperty(String key, String value) { + this.properties.put(key, value); + return this; + } + public Builder withOfficeId(String officeId) { this.officeId = officeId; return this; @@ -79,6 +123,18 @@ public Builder withName(String name) { return this; } + // ------ Known properties can have their builder methods added here to add to properties map------ // + public Builder withKind(String kind) { + this.properties.put("kind", kind); + return this; + } + + public Builder withBoundingOfficeId(String boundingOfficeId) { + this.properties.put("bounding-office-id", boundingOfficeId); + return this; + } + // ------ end of known property builders ------ // + public CwmsId build() { return new CwmsId(this); } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByType.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByType.java new file mode 100644 index 000000000..a9b43129d --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByType.java @@ -0,0 +1,86 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.data.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +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 java.util.HashMap; +import java.util.Map; + +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@JsonDeserialize(builder = TimeSeriesIdentifiersByType.Builder.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public final class TimeSeriesIdentifiersByType extends CwmsDTOBase { + @JsonProperty(required = true) + private final CwmsId locationId; + + private final Map timeSeriesIds; + + private TimeSeriesIdentifiersByType(Builder builder) { + this.locationId = builder.locationId; + this.timeSeriesIds = builder.timeSeriesIds; + } + + public CwmsId getLocationId() { + return locationId; + } + + public Map getTimeSeriesIds() { + return timeSeriesIds; + } + + public static class Builder { + private CwmsId locationId; + private Map timeSeriesIds = new HashMap<>(); + + public Builder withLocationId(CwmsId locationId) { + this.locationId = locationId; + return this; + } + + public Builder withTimeSeriesIds(Map timeSeriesIds) { + this.timeSeriesIds = timeSeriesIds; + return this; + } + + @JsonIgnore + public Builder withTimeSeriesId(String type, TimeSeriesMetaData tsId) { + this.timeSeriesIds.put(type, tsId); + return this; + } + + public TimeSeriesIdentifiersByType build() { + return new TimeSeriesIdentifiersByType(this); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeList.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeList.java new file mode 100644 index 000000000..e61bd86d4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeList.java @@ -0,0 +1,126 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.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 java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@JsonDeserialize(builder = TimeSeriesIdentifiersByTypeList.Builder.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.DEFAULT, Formats.JSON}) +public class TimeSeriesIdentifiersByTypeList extends CwmsDTOPaginated { + + private final List timeSeriesIdsForLocations; + + private TimeSeriesIdentifiersByTypeList(Builder builder) { + super(builder.cursor, builder.pageSize, builder.total); + this.timeSeriesIdsForLocations = new ArrayList<>(builder.timeSeriesIdsForLocations); + } + + public static String getOffice(String cursor) + { + String[] parts = CwmsDTOPaginated.decodeCursor(cursor); + if (parts.length > 1) { + String[] idAndOffice = CwmsDTOPaginated.decodeCursor(parts[0]); + if (idAndOffice.length > 0) { + return idAndOffice[0]; + } + } + return null; + } + + public static String getId(String cursor) { + String[] parts = CwmsDTOPaginated.decodeCursor(cursor); + if (parts.length > 1) { + String[] idAndOffice = CwmsDTOPaginated.decodeCursor(parts[0]); + if (idAndOffice.length > 1) { + return idAndOffice[1]; + } + } + return null; + } + + public List getTimeSeriesIdsForLocations() { + return Collections.unmodifiableList(timeSeriesIdsForLocations); + } + + public static class Builder { + private String cursor; + private int pageSize; + private Integer total; + private String nextPage; + + private List timeSeriesIdsForLocations = new ArrayList<>(); + + public Builder withCursor(String cursor) { + this.cursor = cursor; + 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 Builder withTimeSeriesIdsForLocations(Collection timeSeriesIdsForLocations) { + this.timeSeriesIdsForLocations = new ArrayList<>(timeSeriesIdsForLocations); + return this; + } + + public TimeSeriesIdentifiersByTypeList build() { + TimeSeriesIdentifiersByTypeList retval = new TimeSeriesIdentifiersByTypeList(this); + + if (timeSeriesIdsForLocations.size() == pageSize && !timeSeriesIdsForLocations.isEmpty()) { + TimeSeriesIdentifiersByType lastTimeSeriesIdentifiersByType = timeSeriesIdsForLocations.get(timeSeriesIdsForLocations.size() - 1); + String cursor = encodeCursor(CwmsDTOPaginated.delimiter, lastTimeSeriesIdentifiersByType.getLocationId().getOfficeId(), + lastTimeSeriesIdentifiersByType.getLocationId().getName()); + retval.nextPage = encodeCursor(cursor, pageSize, total); + } else { + retval.nextPage = null; + } + return retval; + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesMetaData.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesMetaData.java new file mode 100644 index 000000000..64221eaa3 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesMetaData.java @@ -0,0 +1,66 @@ +package cwms.cda.data.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +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 java.time.Instant; + +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@JsonDeserialize(builder = TimeSeriesMetaData.Builder.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public final class TimeSeriesMetaData { + @JsonProperty(required = true) + private final TimeSeriesIdentifierDescriptor tsId; + private final Instant dateRefreshed; + private final String notes; + + private TimeSeriesMetaData(Builder builder) { + this.tsId = builder.tsId; + this.dateRefreshed = builder.dateRefreshed; + this.notes = builder.notes; + } + + public TimeSeriesIdentifierDescriptor getTsId() { + return tsId; + } + + public Instant getDateRefreshed() { + return dateRefreshed; + } + + public String getNotes() { + return notes; + } + + public static class Builder { + private TimeSeriesIdentifierDescriptor tsId; + private Instant dateRefreshed; + private String notes; + + public Builder withTsId(TimeSeriesIdentifierDescriptor tsId) { + this.tsId = tsId; + return this; + } + + public Builder withDateRefreshed(Instant dateRefreshed) { + this.dateRefreshed = dateRefreshed; + return this; + } + + public Builder withNotes(String notes) { + this.notes = notes; + return this; + } + + public TimeSeriesMetaData build() { + return new TimeSeriesMetaData(this); + } + } + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/AccessToWaterControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/AccessToWaterControllerTestIT.java new file mode 100644 index 000000000..b190c96dc --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/AccessToWaterControllerTestIT.java @@ -0,0 +1,293 @@ +package cwms.cda.api; + +import static cwms.cda.api.Controllers.PAGE; +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static cwms.cda.data.dao.DaoTest.getDslContext; +import cwms.cda.data.dao.TimeSeriesDao; +import cwms.cda.data.dao.TimeSeriesDaoImpl; +import cwms.cda.data.dao.TimeSeriesDeleteOptions; +import cwms.cda.data.dto.TimeSeries; +import cwms.cda.formatters.Formats; +import static cwms.cda.security.KeyAccessManager.AUTH_HEADER; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.TestAccounts; +import hec.heclib.dss.DSSPathname; +import static io.restassured.RestAssured.given; +import io.restassured.filter.log.LogDetail; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.sql.CallableStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletResponse; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import org.jooq.DSLContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static usace.cwms.db.jooq.codegen.tables.AV_CWMS_TS_ID.AV_CWMS_TS_ID; + +@Tag("integration") +@Disabled +public final class AccessToWaterControllerTestIT extends DataApiTestIT { + + private static final TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + private static final String OFFICE_ID = "SPK"; + private static final ZonedDateTime start = ZonedDateTime.parse("2021-06-21T08:00:00-07:00[PST8PDT]"); + private static final ZonedDateTime end = ZonedDateTime.parse("2021-06-21T09:00:00-07:00[PST8PDT]"); + private static final Map> LOCATION_TO_TS_ID = new LinkedHashMap<>(); + private static final String STAGE = "STAGE"; + private static final String FLOW = "FLOW"; + private static final Map TS_CODE_MAP = new LinkedHashMap<>(); + + @BeforeAll + public static void beforeAll() throws Exception { + Map aarkParamToTsIdMap = new LinkedHashMap<>(); + aarkParamToTsIdMap.put(STAGE, "AARK.Stage.Inst.15Minutes.0.Ccp-Rev"); + aarkParamToTsIdMap.put(FLOW, "AARK.Flow.Inst.1Hour.0.Ccp-Rev"); + Map addiParamToTsIdMap = new LinkedHashMap<>(); + addiParamToTsIdMap.put(STAGE, "ADDI.Stage.Inst.15Minutes.0.Ccp-Rev"); + LOCATION_TO_TS_ID.put("AARK", aarkParamToTsIdMap); + LOCATION_TO_TS_ID.put("ADDI", addiParamToTsIdMap); + List tsToCreate = new ArrayList<>(); + for(String locationId : LOCATION_TO_TS_ID.keySet()) + { + try { + createLocation(locationId, true, OFFICE_ID, "SITE"); + createLocation(locationId, true, OFFICE_ID, "SITE"); + } catch (Exception e) { + throw new RuntimeException(e); + } + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + for(String tsId : tsIds.values()) + { + DSSPathname path = new DSSPathname(tsId); + int minutes = 15; + if("1Hour".equals(path.getDPart())) + { + minutes = 60; + } + int count = 60 / minutes; + TimeSeries ts = new TimeSeries(null, -1, 0, tsId, OFFICE_ID, start, end, "m", Duration.ofMinutes(minutes)); + + ZonedDateTime next = start; + for(int i = 0; i < count; i++) + { + Timestamp dateTime = Timestamp.valueOf(next.toLocalDateTime()); + ts.addValue(dateTime, (double) i, 0); + next = next.plusMinutes(minutes); + } + tsToCreate.add(ts); + } + } + + CwmsDatabaseContainer databaseLink = CwmsDataApiSetupCallback.getDatabaseLink(); + databaseLink.connection(c -> { + DSLContext context = getDslContext(c, databaseLink.getOfficeId()); + TimeSeriesDao timeSeriesDao = new TimeSeriesDaoImpl(context); + for(TimeSeries ts : tsToCreate) + { + timeSeriesDao.create(ts); + } + + // Retrieve ts_code for each TS ID + for (String locationId : LOCATION_TO_TS_ID.keySet()) { + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + for (String tsId : tsIds.values()) { + Integer tsCode = context.select(AV_CWMS_TS_ID.TS_CODE) + .from(AV_CWMS_TS_ID) + .where(AV_CWMS_TS_ID.CWMS_TS_ID.eq(tsId)) + .fetchOneInto(Integer.class); + + if (tsCode != null) { + TS_CODE_MAP.put(tsId, tsCode); + } else { + throw new RuntimeException("TS Code not found for " + tsId); + } + } + } + + // Call a2w store procedure + for (String locationId : LOCATION_TO_TS_ID.keySet()) { + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + context.connection(conn -> { + try (CallableStatement cs = conn.prepareCall("{ call cwms_cma_pkg.p_load_a2w_by_location(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) }")) { + cs.setString(1, OFFICE_ID); + cs.setString(2, locationId); + cs.setString(3, "Y"); // Example display flag + cs.setString(4, "Generated data"); // Example notes + cs.setInt(5, tsIds.values().size()); + cs.setObject(6, tsIds.get(STAGE) == null ? null : TS_CODE_MAP.get(tsIds.get(STAGE))); + cs.setObject(7, tsIds.get(FLOW) == null ? null : TS_CODE_MAP.get(tsIds.get(FLOW))); + cs.setObject(8, null); + cs.setObject(9, null); + cs.setObject(10, null); + cs.setObject(11, null); + cs.setObject(12, null); + cs.setObject(13, null); + cs.setObject(14, null); + cs.setObject(15, null); + cs.setObject(16, null); + cs.setObject(17, null); + cs.setObject(18, null); + cs.setObject(19, null); + cs.setObject(20, null); + cs.setObject(21, null); + cs.setObject(22, null); + cs.setObject(23, null); + cs.setObject(24, null); + cs.setObject(25, null); + cs.setObject(26, null); + cs.setObject(27, null); + cs.setObject(28, null); + cs.setObject(29, null); + cs.setObject(30, null); + cs.setObject(31, null); + cs.setObject(32, null); + cs.setObject(33, null); + cs.registerOutParameter(34, Types.VARCHAR); // p_error_msg as OUT parameter + cs.execute(); + } + }); + } + }, "CWMS_20"); + } + + @AfterAll + public static void afterAll() throws SQLException { + CwmsDatabaseContainer databaseLink = CwmsDataApiSetupCallback.getDatabaseLink(); + databaseLink.connection(c -> { + DSLContext context = getDslContext(c, databaseLink.getOfficeId()); + for (Object tsCode : TS_CODE_MAP.values()) { + context.connection(conn -> { + try (CallableStatement cs = conn.prepareCall("{ call cwms_cma_pkg.p_clear_a2w_ts_code(?) }")) { + cs.setObject(1, tsCode); + cs.execute(); + } + }); + } + TimeSeriesDeleteOptions options = new TimeSeriesDaoImpl.DeleteOptions.Builder() + .withStartTime(Date.from(start.toInstant())).withEndTime(Date.from(end.toInstant())) + .withEndTimeInclusive(true).withStartTimeInclusive(true).withMaxVersion(true) + .withVersionDate(Date.from(start.toInstant())).withOverrideProtection("T").build(); + TimeSeriesDao timeSeriesDao = new TimeSeriesDaoImpl(context); + LOCATION_TO_TS_ID.values().forEach(tsIds -> + tsIds.values().forEach(tsId -> + timeSeriesDao.delete(OFFICE_ID, tsId, options))); + }, CwmsDataApiSetupCallback.getWebUser()); + } + + @Test + void testGetAllNotPaginated() + { + // Create test parameters (mock the filters for officeId, locationId, and tsType) + String officeMask = "SPK"; // Example office ID + String locationMask = "AARK||ADDI"; // Example location IDs + String tsType = "Stage||Flow"; // Example time series types + + // Send a request to the endpoint to retrieve all time series profiles without pagination + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .contentType(Formats.JSONV1) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam("office-mask", officeMask) + .queryParam("location-mask", locationMask) + .queryParam("ts-type", tsType) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/access-to-water/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("result.size()", greaterThan(0)) // Ensure there is some data returned + .body("result[0].tsId", notNullValue()) // Check that each time series has a tsId + .body("result[0].locationId", equalTo("AARK")) // Example check for locationId + .body("result[0].tsType", equalTo("Stage")); // Example check for time series type + } + + @Test + void testGetAllPaginated() + { + // Create test parameters (mock the filters for officeId, locationId, and tsType) + String locationMask = "AARK||ADDI"; // Example location IDs + String tsType = "Stage||Flow"; // Example time series types + + // Send a request to the endpoint to retrieve all time series profiles without pagination + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .contentType(Formats.JSONV1) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam("office-mask", OFFICE_ID) + .queryParam("location-mask", locationMask) + .queryParam("ts-type", tsType) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/access-to-water/") // Endpoint for time series retrieval + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("result.size()", greaterThan(0)) // Ensure there is some data returned + .body("result[0].tsId", notNullValue()) // Check that each time series has a tsId + .body("result[0].locationId", equalTo("AARK")) // Example check for locationId + .body("result[0].tsType", equalTo("Stage")); // Example check for time series type + + // Retrieve with pagination, page 1 + ExtractableResponse response = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .contentType(Formats.JSONV1) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(PAGE_SIZE, 1) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/access-to-water/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("typed-time-series-identifiers.size()", is(1)) + .body("next-page", is(notNullValue())).extract(); + + String nextPageCursor = response.path("next-page"); + assert nextPageCursor != null; + + // Retrieve with pagination, page 2 + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSONV1) + .contentType(Formats.JSONV1) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(PAGE_SIZE, 1) + .queryParam(PAGE, nextPageCursor) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/access-to-water/") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("typed-time-series-identifiers.size()", is(1)); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDaoTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDaoTest.java new file mode 100644 index 000000000..0fac27a96 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dao/AccessToWaterTimeSeriesDaoTest.java @@ -0,0 +1,212 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package cwms.cda.data.dao; + +import cwms.cda.api.DataApiTestIT; +import static cwms.cda.data.dao.DaoTest.getDslContext; +import cwms.cda.data.dto.TimeSeries; +import fixtures.CwmsDataApiSetupCallback; +import hec.heclib.dss.DSSPathname; +import java.sql.CallableStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import org.jooq.DSLContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static usace.cwms.db.jooq.codegen.tables.AV_CWMS_TS_ID.AV_CWMS_TS_ID; + +@Tag("integration") +@Disabled +public final class AccessToWaterTimeSeriesDaoTest extends DataApiTestIT { + private static final String OFFICE_ID = "SPK"; + private static final ZonedDateTime start = ZonedDateTime.parse("2021-06-21T08:00:00-07:00[PST8PDT]"); + private static final ZonedDateTime end = ZonedDateTime.parse("2021-06-21T09:00:00-07:00[PST8PDT]"); + private static final Map> LOCATION_TO_TS_ID = new LinkedHashMap<>(); + private static final String STAGE = "STAGE"; + private static final String FLOW = "FLOW"; + private static final Map TS_CODE_MAP = new LinkedHashMap<>(); + + @BeforeAll + public static void beforeAll() throws Exception { + Map aarkParamToTsIdMap = new LinkedHashMap<>(); + aarkParamToTsIdMap.put(STAGE, "AARK.Stage.Inst.15Minutes.0.Ccp-Rev"); + aarkParamToTsIdMap.put(FLOW, "AARK.Flow.Inst.1Hour.0.Ccp-Rev"); + Map addiParamToTsIdMap = new LinkedHashMap<>(); + addiParamToTsIdMap.put(STAGE, "ADDI.Stage.Inst.15Minutes.0.Ccp-Rev"); + LOCATION_TO_TS_ID.put("AARK", aarkParamToTsIdMap); + LOCATION_TO_TS_ID.put("ADDI", addiParamToTsIdMap); + List tsToCreate = new ArrayList<>(); + for(String locationId : LOCATION_TO_TS_ID.keySet()) + { + try { + createLocation(locationId, true, OFFICE_ID, "SITE"); + createLocation(locationId, true, OFFICE_ID, "SITE"); + } catch (Exception e) { + throw new RuntimeException(e); + } + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + for(String tsId : tsIds.values()) + { + DSSPathname path = new DSSPathname(tsId); + int minutes = 15; + if("1Hour".equals(path.getDPart())) + { + minutes = 60; + } + int count = 60 / minutes; + TimeSeries ts = new TimeSeries(null, -1, 0, tsId, OFFICE_ID, start, end, "m", Duration.ofMinutes(minutes)); + + ZonedDateTime next = start; + for(int i = 0; i < count; i++) + { + Timestamp dateTime = Timestamp.valueOf(next.toLocalDateTime()); + ts.addValue(dateTime, (double) i, 0); + next = next.plusMinutes(minutes); + } + tsToCreate.add(ts); + } + } + + CwmsDatabaseContainer databaseLink = CwmsDataApiSetupCallback.getDatabaseLink(); + databaseLink.connection(c -> { + DSLContext context = getDslContext(c, databaseLink.getOfficeId()); + TimeSeriesDao timeSeriesDao = new TimeSeriesDaoImpl(context); + for(TimeSeries ts : tsToCreate) + { + timeSeriesDao.create(ts); + } + + // Retrieve ts_code for each TS ID + for (String locationId : LOCATION_TO_TS_ID.keySet()) { + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + for (String tsId : tsIds.values()) { + Integer tsCode = context.select(AV_CWMS_TS_ID.TS_CODE) + .from(AV_CWMS_TS_ID) + .where(AV_CWMS_TS_ID.CWMS_TS_ID.eq(tsId)) + .fetchOneInto(Integer.class); + + if (tsCode != null) { + TS_CODE_MAP.put(tsId, tsCode); + } else { + throw new RuntimeException("TS Code not found for " + tsId); + } + } + } + }, CwmsDataApiSetupCallback.getWebUser()); + + //TODO: need to get this store call working, also need to apply fix in controller IT + databaseLink.connection(c -> { + DSLContext context = getDslContext(c, databaseLink.getOfficeId()); + // Call a2w store procedure + for (String locationId : LOCATION_TO_TS_ID.keySet()) { + Map tsIds = LOCATION_TO_TS_ID.get(locationId); + context.connection(conn -> { + try (CallableStatement cs = conn.prepareCall("{ call cwms_cma_pkg.p_load_a2w_by_location(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) }")) { + cs.setString(1, OFFICE_ID); + cs.setString(2, locationId); + cs.setString(3, "Y"); // Example display flag + cs.setString(4, "Generated data"); // Example notes + cs.setInt(5, tsIds.values().size()); + cs.setObject(6, tsIds.get(STAGE) == null ? null : TS_CODE_MAP.get(tsIds.get(STAGE))); + cs.setObject(7, tsIds.get(FLOW) == null ? null : TS_CODE_MAP.get(tsIds.get(FLOW))); + cs.setObject(8, null); + cs.setObject(9, null); + cs.setObject(10, null); + cs.setObject(11, null); + cs.setObject(12, null); + cs.setObject(13, null); + cs.setObject(14, null); + cs.setObject(15, null); + cs.setObject(16, null); + cs.setObject(17, null); + cs.setObject(18, null); + cs.setObject(19, null); + cs.setObject(20, null); + cs.setObject(21, null); + cs.setObject(22, null); + cs.setObject(23, null); + cs.setObject(24, null); + cs.setObject(25, null); + cs.setObject(26, null); + cs.setObject(27, null); + cs.setObject(28, null); + cs.setObject(29, null); + cs.setObject(30, null); + cs.setObject(31, null); + cs.setObject(32, null); + cs.setObject(33, null); + cs.registerOutParameter(34, Types.VARCHAR); // p_error_msg as OUT parameter + + cs.execute(); + } + }); + } + }, "CWMS_20"); + } + + @AfterAll + public static void afterAll() throws SQLException { + CwmsDatabaseContainer databaseLink = CwmsDataApiSetupCallback.getDatabaseLink(); + databaseLink.connection(c -> { + DSLContext context = getDslContext(c, databaseLink.getOfficeId()); + for (Object tsCode : TS_CODE_MAP.values()) { + context.connection(conn -> { + try (CallableStatement cs = conn.prepareCall("{ call cwms_cma_pkg.p_clear_a2w_ts_code(?) }")) { + cs.setObject(1, tsCode); + cs.execute(); + } + }); + } + TimeSeriesDeleteOptions options = new TimeSeriesDaoImpl.DeleteOptions.Builder() + .withStartTime(Date.from(start.toInstant())).withEndTime(Date.from(end.toInstant())) + .withEndTimeInclusive(true).withStartTimeInclusive(true).withMaxVersion(true) + .withVersionDate(Date.from(start.toInstant())).withOverrideProtection("T").build(); + TimeSeriesDao timeSeriesDao = new TimeSeriesDaoImpl(context); + LOCATION_TO_TS_ID.values().forEach(tsIds -> + tsIds.values().forEach(tsId -> + timeSeriesDao.delete(OFFICE_ID, tsId, options))); + }, CwmsDataApiSetupCallback.getWebUser()); + } + + @Test + public void testRetrieval() throws Exception + { + System.out.println("TESTING A2W"); + //TODO: Need to write test for dao here + } + + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeTest.java new file mode 100644 index 000000000..547c71d3c --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesIdentifiersByTypeTest.java @@ -0,0 +1,299 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto; + +import java.time.ZoneId; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +import cwms.cda.api.errors.FieldException; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import cwms.cda.helpers.DTOMatch; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +final class TimeSeriesIdentifiersByTypeTest { + + @Test + void createAccessToWaterTimeSeriesData_allFieldsProvided_success() { + TimeSeriesMetaData ts1 = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Stage.Inst.15Minutes.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("This is a test") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) // Adding date refreshed + .build(); + + TimeSeriesMetaData ts2 = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Flow.Inst.15Minutes.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("This is another test") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) // Adding date refreshed + .build(); + + CwmsId locId = new CwmsId.Builder() + .withName("AARK") + .withOfficeId("SWT") + .withBoundingOfficeId("SWT") + .withKind("SITE") + .build(); + TimeSeriesIdentifiersByType items = new TimeSeriesIdentifiersByType.Builder() + .withLocationId(locId) + .withTimeSeriesId("STAGE", ts1) + .withTimeSeriesId("OUTFLOW", ts2) + .build(); + + assertAll( + () -> DTOMatch.assertMatch(locId, items.getLocationId()), + () -> DTOMatch.assertMatch(ts1, items.getTimeSeriesIds().get("STAGE")), + () -> DTOMatch.assertMatch(ts2, items.getTimeSeriesIds().get("OUTFLOW")) + ); + } + + @Test + void createAccessToWaterTimeSeriesData_missingField_throwsFieldException() { + TimeSeriesMetaData tsDescriptor = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Stage.Inst.15Minutes.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("This is a test") + .withDateRefreshed(Instant.now()) // Adding date refreshed + .build(); + + Map typeToTsIdMap = new HashMap<>(); + typeToTsIdMap.put("STAGE", tsDescriptor); + + assertAll( + () -> assertThrows(FieldException.class, () -> { + TimeSeriesIdentifiersByType item = new TimeSeriesIdentifiersByType.Builder() + .withTimeSeriesIds(typeToTsIdMap) + .build(); + item.validate(); + }, "The validate method should have thrown a FieldException because the location ID is missing") + ); + } + + @Test + void createAccessToWaterTimeSeriesData_serialize_roundtrip() { + TimeSeriesMetaData tsDescriptor = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Stage.Inst.15Minutes.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("This is a test") + .withDateRefreshed(Instant.now()) // Adding date refreshed + .build(); + TimeSeriesIdentifiersByType data = new TimeSeriesIdentifiersByType.Builder() + .withLocationId(new CwmsId.Builder() + .withName("AARK") + .withOfficeId("SWT") + .withBoundingOfficeId("SWT") + .withKind("SITE") + .build()) + .withTimeSeriesId("STAGE", tsDescriptor) + .build(); + + ContentType contentType = new ContentType(Formats.JSON); + String json = Formats.format(contentType, data); + TimeSeriesIdentifiersByType deserialized = Formats.parseContent(contentType, json, TimeSeriesIdentifiersByType.class); + DTOMatch.assertMatch(data, deserialized); + } + + @Test + void createAccessToWaterTimeSeriesData_deserialize_multipleEntries() throws Exception { + // Expected data + TimeSeriesMetaData ts1Stage = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Stage.Inst.15Minutes.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Stage data for AARK") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts2Outflow = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AARK.Flow.Inst.1Hour.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Flow data for AARK") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts3Precip = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AUGU.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Precipitation data for AUGU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts4Stage = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AUGU.Stage.Inst.1Hour.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Stage data for AUGU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts5Outflow = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("AUGU.Flow.Inst.1Hour.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Flow data for AUGU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts6OutflowAlt = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("ALTU.Flow-Res Out.Ave.1Hour.1Hour.Rev-Regi-Flowgroup") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Outflow data for ALTU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts7FloodStorage = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("ALTU.Stor-Flood Pool.Inst.1Hour.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Flood storage data for ALTU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + TimeSeriesMetaData ts8ConservationStorage = new TimeSeriesMetaData.Builder() + .withTsId(new TimeSeriesIdentifierDescriptor.Builder() + .withTimeSeriesId("ALTU.Stor-Conservation Pool.Inst.1Hour.0.Ccp-Rev") + .withOfficeId("SWT") + .withActive(true) + .withIntervalOffsetMinutes(0L) + .withZoneId(ZoneId.of("UTC")) + .build()) + .withNotes("Conservation storage data for ALTU") + .withDateRefreshed(Instant.parse("2025-03-03T12:00:00Z")) + .build(); + + // Expected data for locations + TimeSeriesIdentifiersByType expectedAARK = new TimeSeriesIdentifiersByType.Builder() + .withLocationId(new CwmsId.Builder() + .withName("AARK") + .withOfficeId("SWT") + .withBoundingOfficeId("SWT") + .withKind("SITE") + .build()) + .withTimeSeriesId("STAGE", ts1Stage) + .withTimeSeriesId("OUTFLOW", ts2Outflow) + .build(); + + TimeSeriesIdentifiersByType expectedAUGU = new TimeSeriesIdentifiersByType.Builder() + .withLocationId(new CwmsId.Builder() + .withName("AUGU") + .withOfficeId("SWT") + .withBoundingOfficeId("SWT") + .withKind("SITE") + .build()) + .withTimeSeriesId("PRECIP", ts3Precip) + .withTimeSeriesId("STAGE", ts4Stage) + .withTimeSeriesId("OUTFLOW", ts5Outflow) + .build(); + + TimeSeriesIdentifiersByType expectedALTU = new TimeSeriesIdentifiersByType.Builder() + .withLocationId(new CwmsId.Builder() + .withName("ALTU") + .withOfficeId("SWT") + .withBoundingOfficeId("SWT") + .withKind("SITE") + .build()) + .withTimeSeriesId("OUTFLOW", ts6OutflowAlt) + .withTimeSeriesId("FLOOD STORAGE", ts7FloodStorage) + .withTimeSeriesId("CONSERVATION STORAGE", ts8ConservationStorage) + .build(); + + TimeSeriesIdentifiersByTypeList list = new TimeSeriesIdentifiersByTypeList.Builder() + .withCursor("cursor") + .withPageSize(20) + .withTotal(100) + .withTimeSeriesIdsForLocations(Arrays.asList(expectedAARK, expectedAUGU, expectedALTU)) + .build(); + + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/data/dto/time_series_identifiers_by_type.json"); + assertNotNull(resource); + String json = IOUtils.toString(resource, StandardCharsets.UTF_8); + + ContentType contentType = new ContentType(Formats.JSON); + TimeSeriesIdentifiersByTypeList deserialized = Formats.parseContent(contentType, json, TimeSeriesIdentifiersByTypeList.class); + + // Verify the deserialized data matches the expected data + assertAll(() -> DTOMatch.assertMatch(list, deserialized)); + } + +} 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 49baac886..771f24ff2 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 @@ -27,6 +27,10 @@ import cwms.cda.data.dto.CwmsIdTimeExtentsEntry; import cwms.cda.data.dto.TimeExtents; import cwms.cda.data.dto.TimeSeriesExtents; +import cwms.cda.data.dto.TimeSeriesIdentifierDescriptor; +import cwms.cda.data.dto.TimeSeriesIdentifiersByType; +import cwms.cda.data.dto.TimeSeriesIdentifiersByTypeList; +import cwms.cda.data.dto.TimeSeriesMetaData; import cwms.cda.data.dto.location.kind.Lock; import cwms.cda.data.dto.CwmsDTOBase; import cwms.cda.data.dto.location.kind.GateChange; @@ -86,7 +90,14 @@ private DTOMatch() { public static void assertMatch(CwmsId first, CwmsId second, String variableName) { assertAll( () -> Assertions.assertEquals(first.getOfficeId(), second.getOfficeId(),variableName + " is not the same. Office ID differs"), - () -> Assertions.assertEquals(first.getName(), second.getName(),variableName + " is not the same. Name differs") + () -> Assertions.assertEquals(first.getName(), second.getName(),variableName + " is not the same. Name differs"), + () -> first.getProperties().forEach((key, value) -> { + if (second.getProperties().containsKey(key)) { + assertEquals(value, second.getProperties().get(key), variableName + " is not the same. Property " + key + " differs"); + } else { + fail(variableName + " is not the same. Property " + key + " is missing"); + } + }) ); } @@ -601,6 +612,72 @@ public static void assertMatch(CwmsIdTimeExtentsEntry first, CwmsIdTimeExtentsEn ); } + public static void assertMatch(TimeSeriesIdentifierDescriptor tsDescriptor, TimeSeriesIdentifierDescriptor timeSeriesIdDescriptor) { + assertAll( + () -> assertEquals(tsDescriptor.getIntervalOffsetMinutes(), timeSeriesIdDescriptor.getIntervalOffsetMinutes(), "Identifier does not match"), + () -> assertEquals(tsDescriptor.getTimeSeriesId(), timeSeriesIdDescriptor.getTimeSeriesId(), "Part does not match"), + () -> assertEquals(tsDescriptor.getOfficeId(), timeSeriesIdDescriptor.getOfficeId(), "Time series type does not match"), + () -> assertEquals(tsDescriptor.getTimezoneName(), timeSeriesIdDescriptor.getTimezoneName(), "Office ID does not match") + ); + } + + public static void assertMatch(TimeSeriesIdentifiersByType first, TimeSeriesIdentifiersByType second) + { + assertAll( + () -> assertMatch(first.getLocationId(), second.getLocationId()), + () -> assertEquals(first.getTimeSeriesIds().size(), second.getTimeSeriesIds().size(), "Type to TS ID map sizes do not match"), + () -> first.getTimeSeriesIds().forEach((type, tsId) -> { + if (!second.getTimeSeriesIds().containsKey(type)) { + fail("tsType " + type + " not found in both tsType to tsId maps"); + } + assertMatch(tsId, second.getTimeSeriesIds().get(type)); + }) + ); + } + + public static void assertMatch(TimeSeriesMetaData ts1, TimeSeriesMetaData ts2) { + assertAll( + () -> assertMatch(ts1.getTsId(), ts2.getTsId()), + () -> assertEquals(ts1.getDateRefreshed(), ts2.getDateRefreshed(), "Date-Refreshed does not match"), + () -> assertEquals(ts1.getNotes(), ts2.getNotes(), "Notes do not match") + ); + } + + public static void assertMatch(TimeSeriesIdentifiersByTypeList list, TimeSeriesIdentifiersByTypeList list2) { + assertAll( + () -> assertEquals(list.getPage(), list2.getPage(), "Page does not match"), + () -> assertEquals(list.getPageSize(), list2.getPageSize(), "Page size does not match"), + () -> assertEquals(list.getTotal(), list2.getTotal(), "Total does not match"), + () -> assertEquals(list.getNextPage(), list2.getNextPage(), "Next page does not match"), + () -> assertEquals(list.getTimeSeriesIdsForLocations().size(), list2.getTimeSeriesIdsForLocations().size(), "Time series identifiers sizes do not match"), + () -> list.getTimeSeriesIdsForLocations().forEach(tsIdsForLocation -> { + TimeSeriesIdentifiersByType found = list2.getTimeSeriesIdsForLocations().stream() + .filter(tsId1 -> isEqual(tsIdsForLocation.getLocationId(),tsId1.getLocationId())) + .findFirst().orElse(null); + assertNotNull(found, "Time series identifiers were expected but not found for locationId: " + tsIdsForLocation.getLocationId().getName()); + assertMatch(tsIdsForLocation, found); + }) + ); + } + + private static boolean isEqual(CwmsId loc1, CwmsId loc2) { + return loc1.getName().equals(loc2.getName()) + && loc1.getOfficeId().equals(loc2.getOfficeId()) + && sameProperties(loc1, loc2); + } + + private static boolean sameProperties(CwmsId loc1, CwmsId loc2) { + boolean retVal = true; + for (String key : loc1.getProperties().keySet()) { + if (!loc2.getProperties().containsKey(key) || !loc1.getProperties().get(key).equals(loc2.getProperties().get(key))) { + retVal = false; + break; + } + } + return retVal; + } + + @FunctionalInterface public interface AssertMatchMethod{ void assertMatch(T first, T second); diff --git a/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java b/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java index a995ebdab..32c986bc4 100644 --- a/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java +++ b/cwms-data-api/src/test/java/fixtures/CwmsDataApiSetupCallback.java @@ -97,7 +97,7 @@ private static int versionInt() { int ret = -999999; String tmp = schemaVersion(); - if (tmp.equalsIgnoreCase("latest-dev")) { + if (tmp.equalsIgnoreCase("latest-dev") || "Bypass".equalsIgnoreCase(tmp)) { ret = 999999; } else if(tmp.toLowerCase().endsWith("staging")) { diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_identifiers_by_type.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_identifiers_by_type.json new file mode 100644 index 000000000..c1709c4f0 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_identifiers_by_type.json @@ -0,0 +1,126 @@ +{ + "cursor": "cursor", + "page-size": 20, + "total": 100, + "next-page": "", + "time-series-ids-for-locations": [ + { + "location-id": { + "name": "AARK", + "office-id": "SWT", + "bounding-office-id": "SWT", + "kind": "SITE" + }, + "time-series-ids": { + "STAGE": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "AARK.Stage.Inst.15Minutes.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Stage data for AARK" + }, + "OUTFLOW": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "AARK.Flow.Inst.1Hour.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Flow data for AARK" + } + } + }, + { + "location-id": { + "name": "AUGU", + "office-id": "SWT", + "bounding-office-id": "SWT", + "kind": "SITE" + }, + "time-series-ids": { + "PRECIP": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "AUGU.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Precipitation data for AUGU" + }, + "STAGE": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "AUGU.Stage.Inst.1Hour.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Stage data for AUGU" + }, + "OUTFLOW": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "AUGU.Flow.Inst.1Hour.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Flow data for AUGU" + } + } + }, + { + "location-id": { + "name": "ALTU", + "office-id": "SWT", + "bounding-office-id": "SWT", + "kind": "SITE" + }, + "time-series-ids": { + "OUTFLOW": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "ALTU.Flow-Res Out.Ave.1Hour.1Hour.Rev-Regi-Flowgroup", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Outflow data for ALTU" + }, + "FLOOD STORAGE": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "ALTU.Stor-Flood Pool.Inst.1Hour.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Flood storage data for ALTU" + }, + "CONSERVATION STORAGE": { + "ts-id": { + "office-id": "SWT", + "time-series-id": "ALTU.Stor-Conservation Pool.Inst.1Hour.0.Ccp-Rev", + "timezone-name": "UTC", + "interval-offset-minutes": 0, + "active": true + }, + "date-refreshed": "2025-03-03T12:00:00Z", + "notes": "Conservation storage data for ALTU" + } + } + } + ] +}