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 8409df4a8c..929cc7dbd0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -341,7 +341,7 @@ private void getOpenApiOptions(JavalinConfig config) { schemeProcessor.apply(ctx, api); api.getPaths().forEach((key,path) -> { setSecurityRequirements(key,path, schemeProcessor.getSecurityRequirements()); - // yeah, we really need to figure out how to update everything, + // yeah, we really need to figure out how to update everything, // this is supported as an annotation in newer versions. if (key.startsWith("/rss")) { path.getGet().getResponses().forEach((p, r) -> { @@ -356,7 +356,7 @@ private void getOpenApiOptions(JavalinConfig config) { try (ScanResult scanResult = new ClassGraph() .acceptPackages("cwms.cda.data.dto") .scan()) { - List> csvDtoClasses = + List> csvDtoClasses = scanResult.getClassesImplementing(CwmsCsvDTO.class.getName()) .loadClasses(CwmsCsvDTO.class); for (Class clazz : csvDtoClasses) { @@ -397,11 +397,11 @@ private void getOpenApiOptions(JavalinConfig config) { doc.header(IS_NEW_LRTS, Boolean.class, p -> p.description( - "If True, will use use the new 'Local Regular Time Series" + "If True, will use use the new 'Local Regular Time Series" + " naming scheme. For example 1DayLocal. Instead of the original" + " PsuedoRegular based scheme, for example ~1DayLocal." + " NOTE: this parameter only applies to the input and output of" - + " Time Series names. It is added to all endpoints and will be ignored" + + " Time Series names. It is added to all endpoints and will be ignored" + " when not required. Default values is false if not set.") ); }) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java index 4472099152..11dba183f2 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -60,7 +60,7 @@ import cwms.cda.api.TimeSeriesCategoryController; import cwms.cda.api.TimeSeriesController; import cwms.cda.api.TimeSeriesFilteredController; -import cwms.cda.api.TimeSeriesGroupController; +import cwms.cda.api.timeseriesgroup.TimeSeriesGroupControllerV1; import cwms.cda.api.TimeSeriesIdentifierDescriptorController; import cwms.cda.api.TimeSeriesRecentController; import cwms.cda.api.TimeSeriesVersionsController; @@ -131,6 +131,7 @@ import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserController; import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserCreateController; import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserDeleteController; +import cwms.cda.api.timeseriesgroup.TimeSeriesGroupControllerV2; import cwms.cda.api.watersupply.AccountingCatalogController; import cwms.cda.api.watersupply.AccountingCreateController; import cwms.cda.api.watersupply.WaterContractCatalogController; @@ -281,8 +282,13 @@ public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredR new TimeSeriesCategoryController(metrics), requiredRoles,5, TimeUnit.MINUTES); cdaCrudCache(String.format("/timeseries/identifier-descriptor/{%s}", Controllers.TIMESERIES_ID), new TimeSeriesIdentifierDescriptorController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/timeseries/group/{group-id}", - new TimeSeriesGroupController(metrics), requiredRoles,5, TimeUnit.MINUTES); + //------- Time Series Group --------// + String timeSeriesGroupPath = "/timeseries/group/{group-id}"; + cdaCrudCache(timeSeriesGroupPath, + new TimeSeriesGroupControllerV1(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(formatV2(timeSeriesGroupPath, Controllers.GROUP_ID), + new TimeSeriesGroupControllerV2(metrics), requiredRoles, 5, TimeUnit.MINUTES); + //----------------------------------// cdaCrudCache("/timeseries/{timeseries}", new TimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); addRatingHandlers(requiredRoles, metrics, cdaAccessManager); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java new file mode 100644 index 0000000000..3986288d8b --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java @@ -0,0 +1,279 @@ +/* + * 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.timeseriesgroup; + +import static com.codahale.metrics.MetricRegistry.name; +import static cwms.cda.api.Controllers.CASCADE_DELETE; +import static cwms.cda.api.Controllers.CATEGORY_ID; +import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.CWMS_OFFICE; +import static cwms.cda.api.Controllers.FAIL_IF_EXISTS; +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.IGNORE_MISSING; +import static cwms.cda.api.Controllers.IGNORE_NULLS; +import static cwms.cda.api.Controllers.INCLUDE_ASSIGNED; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.TIMESERIES_CATEGORY_LIKE; +import static cwms.cda.api.Controllers.TIMESERIES_GROUP_LIKE; +import static cwms.cda.api.Controllers.UPDATE; +import static cwms.cda.api.Controllers.queryParamAsClass; +import static cwms.cda.api.Controllers.requiredParam; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.BaseCrudHandler; +import cwms.cda.api.errors.CdaError; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dao.TimeSeriesGroupDao; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +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.HttpCode; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public abstract class TimeSeriesGroupController extends BaseCrudHandler { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + protected static final String TAG = "Timeseries Groups"; + + protected TimeSeriesGroupController(MetricRegistry metrics) { + super(metrics); + } + + protected DSLContext getDslContext(Context ctx) { + return JooqDao.getDslContext(ctx); + } + + protected void getAll(@NotNull Context ctx, String groupOffice) { + try (final Timer.Context ignored = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + String tsOffice = ctx.queryParam(OFFICE); + String categoryOffice = ctx.queryParam(CATEGORY_OFFICE_ID); + + boolean includeAssigned = queryParamAsClass(ctx, new String[]{INCLUDE_ASSIGNED}, + Boolean.class, true, getMetrics(), name(getClass().getName(), GET_ALL)); + String tsCategoryLike = queryParamAsClass(ctx, new String[]{TIMESERIES_CATEGORY_LIKE}, + String.class, null, getMetrics(), name(getClass().getName(), GET_ALL)); + String tsGroupLike = queryParamAsClass(ctx, new String[]{TIMESERIES_GROUP_LIKE}, + String.class, null, getMetrics(), name(getClass().getName(), GET_ALL)); + + List grps = dao.getTimeSeriesGroups(tsOffice, groupOffice, categoryOffice, + includeAssigned, tsCategoryLike, tsGroupLike); + if (grps.isEmpty()) { + CdaError re = new CdaError("No data found for The provided office"); + logger.atInfo().log("%s for request %s", re, ctx.fullUrl()); + ctx.status(HttpCode.NOT_FOUND).json(re); + } else { + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); + + String result = Formats.format(contentType, grps, TimeSeriesGroup.class); + + updateResultSize(result); + + ctx.status(HttpServletResponse.SC_OK); + ctx.contentType(contentType.toString()); + + byte[] bytes = result.getBytes(); + ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); + ctx.res.getOutputStream().write(bytes); + } + } catch (IOException ex) { + CdaError re = new CdaError("Failure to process request to retrieve time series groups"); + logger.atSevere().withCause(ex).log("Failed to process request to retrieve time series groups"); + ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); + } + } + + protected void getOne(@NotNull Context ctx, @NotNull String groupId, String groupOffice) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + DSLContext dsl = getDslContext(ctx); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + String tsOffice = ctx.queryParam(OFFICE); + String categoryId = ctx.queryParam(CATEGORY_ID); + String categoryOffice = ctx.queryParam(CATEGORY_OFFICE_ID); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); + + TimeSeriesGroup group = dao.getTimeSeriesGroup(tsOffice, groupOffice, categoryOffice, categoryId, groupId); + + if (group != null) { + String result = Formats.format(contentType, group); + + ctx.contentType(contentType.toString()); + updateResultSize(result); + + ctx.status(HttpServletResponse.SC_OK); + + byte[] bytes = result.getBytes(); + ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); + ctx.res.getOutputStream().write(bytes); + } else { + CdaError re = new CdaError("Unable to find group based on parameters given"); + logger.atInfo().log("%s%sfor request %s", re, System.lineSeparator(), ctx.fullUrl()); + ctx.status(HttpServletResponse.SC_NOT_FOUND).json(re); + } + } catch (IOException ex) { + CdaError re = new CdaError("Failure to process request to retrieve time series group"); + logger.atSevere().withCause(ex).log("Failed to process request to retrieve time series group"); + ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); + } + } + + protected TimeSeriesGroup deserializeGroup(Context ctx) { + ContentType contentType = Formats.parseHeader(ctx.req.getContentType(), TimeSeriesGroup.class); + return Formats.parseContent(contentType, ctx.body(), TimeSeriesGroup.class); + } + + @Override + public void create(@NotNull Context ctx) { + try (Timer.Context ignored = markAndTime(CREATE)) { + DSLContext dsl = getDslContext(ctx); + + TimeSeriesGroup deserialize = deserializeGroup(ctx); + + if (!deserialize.getTimeSeriesCategory().getOfficeId().equalsIgnoreCase(CWMS_OFFICE) + && (!deserialize.getOfficeId().equalsIgnoreCase(deserialize.getTimeSeriesCategory().getOfficeId()) + || deserialize.getOfficeId().equalsIgnoreCase(CWMS_OFFICE))) { + throw new IllegalArgumentException("TimeSeries Group office ID cannot be CWMS and must match the " + + "TimeSeries Category office ID"); + } + + boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); + boolean failIfExists = ctx.queryParamAsClass(FAIL_IF_EXISTS, Boolean.class).getOrDefault(true); + boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + List missingTimeSeries = dao.create(deserialize, failIfExists, ignoreNulls, ignoreMissing); + if (missingTimeSeries.isEmpty()) { + ctx.status(HttpServletResponse.SC_CREATED); + } else { + Map detailsMap = new HashMap<>(); + StringBuilder sb = new StringBuilder(); + for (CwmsId cwmsId : missingTimeSeries) { + sb.append(cwmsId.getName()); + sb.append(", "); + } + sb.delete(sb.length() - 2, sb.length()); + detailsMap.put("missing-time-series", sb.toString()); + if (ignoreMissing) { + ctx.status(HttpCode.MULTI_STATUS); + + } else { + ctx.status(HttpServletResponse.SC_BAD_REQUEST); + detailsMap.put("message", + "One or more time series were not found and could not be assigned to the group"); + } + ctx.json(detailsMap); + } + + } + } + + protected void delete(@NotNull Context ctx, @NotNull String groupId, @NotNull String office) { + try (Timer.Context ignored = markAndTime(UPDATE)) { + DSLContext dsl = getDslContext(ctx); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + + boolean cascadeDelete = ctx.queryParamAsClass(CASCADE_DELETE, Boolean.class).getOrDefault(false); + String categoryId = requiredParam(ctx, CATEGORY_ID); + dao.delete(categoryId, groupId, office, cascadeDelete); + ctx.status(HttpServletResponse.SC_NO_CONTENT); + } + } + + /** + * Persists a new description for a group, if the description has changed. Preserves the + * group's existing assigned time series. + * + * @param dao The dao to use to persist the change. + * @param existingGroup The group as currently stored. + * @param newDescription The description provided in the request. If null, no update occurs. + * @return The group reflecting the persisted description (or the unmodified existing group if + * no update was necessary). + */ + protected TimeSeriesGroup updateDescriptionIfChanged(TimeSeriesGroupDao dao, TimeSeriesGroup existingGroup, + String newDescription) { + if (newDescription != null && !newDescription.equalsIgnoreCase(existingGroup.getDescription())) { + TimeSeriesGroup updated = new TimeSeriesGroup(new TimeSeriesGroup(existingGroup.getTimeSeriesCategory(), + existingGroup.getOfficeId(), existingGroup.getId(), newDescription, + existingGroup.getSharedAliasId(), existingGroup.getSharedRefTsId()), + existingGroup.getAssignedTimeSeries()); + dao.create(updated, false, false); + return updated; + } + return existingGroup; + } + + /** + * Sets the response status/body for an update that attempted to assign one or more time + * series that do not exist. If there were no missing time series, this just sets a 200 OK + * status. Shared between v1's full-body update and v2's membership-based update, both of + * which can attempt to assign time series as part of a PATCH. + * + * @param ctx The request context to set the response on. + * @param missingTimeSeries Time series that were requested to be assigned but do not exist. + * @param ignoreMissing Whether missing time series should be tolerated (207) or treated as a + * failure (400). + */ + protected void respondToMissingTimeSeries(Context ctx, List missingTimeSeries, boolean ignoreMissing) { + if (missingTimeSeries.isEmpty()) { + ctx.status(HttpServletResponse.SC_OK); + return; + } + + Map detailsMap = new HashMap<>(); + StringBuilder sb = new StringBuilder(); + for (CwmsId cwmsId : missingTimeSeries) { + sb.append(cwmsId.getName()); + sb.append(", "); + } + sb.delete(sb.length() - 2, sb.length()); + detailsMap.put("missing-timeseries", sb.toString()); + if (ignoreMissing) { + ctx.status(HttpCode.MULTI_STATUS); + } else { + ctx.status(HttpServletResponse.SC_BAD_REQUEST); + detailsMap.put("message", + "One or more time series were not found and could not be assigned to the group"); + } + ctx.json(detailsMap); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java similarity index 54% rename from cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java rename to cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java index 93df958cc6..dbf5e36216 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java @@ -22,17 +22,14 @@ * SOFTWARE. */ -package cwms.cda.api; +package cwms.cda.api.timeseriesgroup; -import static com.codahale.metrics.MetricRegistry.name; import static cwms.cda.api.Controllers.CASCADE_DELETE; import static cwms.cda.api.Controllers.CATEGORY_ID; import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.CWMS_OFFICE; import static cwms.cda.api.Controllers.FAIL_IF_EXISTS; -import static cwms.cda.api.Controllers.GET_ALL; -import static cwms.cda.api.Controllers.GET_ONE; import static cwms.cda.api.Controllers.GROUP_ID; import static cwms.cda.api.Controllers.GROUP_OFFICE_ID; import static cwms.cda.api.Controllers.IGNORE_MISSING; @@ -40,63 +37,41 @@ import static cwms.cda.api.Controllers.INCLUDE_ASSIGNED; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.REPLACE_ASSIGNED_TS; -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.STATUS_404; import static cwms.cda.api.Controllers.STATUS_501; import static cwms.cda.api.Controllers.TIMESERIES_CATEGORY_LIKE; import static cwms.cda.api.Controllers.TIMESERIES_GROUP_LIKE; -import static cwms.cda.api.Controllers.UPDATE; -import static cwms.cda.api.Controllers.queryParamAsClass; import static cwms.cda.api.Controllers.requiredParam; -import static cwms.cda.data.dao.JooqDao.getDslContext; -import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; -import com.google.common.flogger.FluentLogger; -import cwms.cda.api.errors.CdaError; import cwms.cda.data.dao.TimeSeriesGroupDao; import cwms.cda.data.dto.CwmsId; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; 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.HttpMethod; 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.OpenApiRequestBody; import io.javalin.plugin.openapi.annotations.OpenApiResponse; -import java.io.IOException; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; -public class TimeSeriesGroupController implements CrudHandler { - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - public static final String TAG = "Timeseries Groups"; - - private final MetricRegistry metrics; - - private final Histogram requestResultSize; - - public TimeSeriesGroupController(MetricRegistry metrics) { - this.metrics = metrics; - String className = this.getClass().getName(); - - requestResultSize = this.metrics.histogram((name(className, RESULTS, SIZE))); - } +/** + * Version 1 of the Timeseries Group controller. Create, delete, and retrieve behavior is shared + * with {@link TimeSeriesGroupControllerV2} via {@link TimeSeriesGroupController}. + * This version's PATCH/update accepts a full {@link TimeSeriesGroup} body; the assigned time + * series in that body either replace or are added to the group's existing assignments. + */ +public class TimeSeriesGroupControllerV1 extends TimeSeriesGroupController { - private Timer.Context markAndTime(String subject) { - return Controllers.markAndTime(metrics, getClass().getName(), subject); + public TimeSeriesGroupControllerV1(MetricRegistry metrics) { + super(metrics); } @OpenApi( @@ -127,49 +102,8 @@ private Timer.Context markAndTime(String subject) { tags = {TAG}) @Override public void getAll(@NotNull Context ctx) { - try (final Timer.Context ignored = markAndTime(GET_ALL)) { - DSLContext dsl = getDslContext(ctx); - - TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); - String tsOffice = ctx.queryParam(OFFICE); - String groupOffice = ctx.queryParam(GROUP_OFFICE_ID); - String categoryOffice = ctx.queryParam(CATEGORY_OFFICE_ID); - - boolean includeAssigned = queryParamAsClass(ctx, new String[]{INCLUDE_ASSIGNED}, - Boolean.class, true, metrics, name(TimeSeriesGroupController.class.getName(), - GET_ALL)); - String tsCategoryLike = queryParamAsClass(ctx, new String[]{TIMESERIES_CATEGORY_LIKE}, - String.class, null, metrics, name(TimeSeriesGroupController.class.getName(), GET_ALL)); - String tsGroupLike = queryParamAsClass(ctx, new String[]{TIMESERIES_GROUP_LIKE}, - String.class, null, metrics, name(TimeSeriesGroupController.class.getName(), GET_ALL)); - - List grps = dao.getTimeSeriesGroups(tsOffice, groupOffice, categoryOffice, - includeAssigned, tsCategoryLike, tsGroupLike); - if (grps.isEmpty()) { - CdaError re = new CdaError("No data found for The provided office"); - logger.atInfo().log("%s for request %s", re, ctx.fullUrl()); - ctx.status(HttpCode.NOT_FOUND).json(re); - } else { - String formatHeader = ctx.header(Header.ACCEPT); - ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); - - String result = Formats.format(contentType, grps, TimeSeriesGroup.class); - - requestResultSize.update(result.length()); - - ctx.status(HttpServletResponse.SC_OK); - ctx.contentType(contentType.toString()); - - byte[] bytes = result.getBytes(); - ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); - ctx.res.getOutputStream().write(bytes); - } - } catch (IOException ex) { - CdaError re = new CdaError("Failure to process request to retrieve time series groups"); - logger.atSevere().withCause(ex).log("Failed to process request to retrieve time series groups"); - ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); - } - + String groupOffice = ctx.queryParam(GROUP_OFFICE_ID); + super.getAll(ctx, groupOffice); } @OpenApi( @@ -198,43 +132,8 @@ Boolean.class, true, metrics, name(TimeSeriesGroupController.class.getName(), description = "Retrieves requested timeseries group", tags = {"Timeseries Groups"}) @Override public void getOne(@NotNull Context ctx, @NotNull String groupId) { - try (final Timer.Context ignored = markAndTime(GET_ONE)) { - DSLContext dsl = getDslContext(ctx); - - TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); - String tsOffice = ctx.queryParam(OFFICE); - String categoryId = ctx.queryParam(CATEGORY_ID); - - // Not marked as required to maintain backwards compatibility with existing clients - String groupOffice = ctx.queryParam(GROUP_OFFICE_ID); - String categoryOffice = ctx.queryParam(CATEGORY_OFFICE_ID); - - String formatHeader = ctx.header(Header.ACCEPT); - ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); - - TimeSeriesGroup group = dao.getTimeSeriesGroup(tsOffice, groupOffice, categoryOffice, categoryId, groupId); - - if (group != null) { - String result = Formats.format(contentType, group); - - ctx.contentType(contentType.toString()); - requestResultSize.update(result.length()); - - ctx.status(HttpServletResponse.SC_OK); - - byte[] bytes = result.getBytes(); - ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); - ctx.res.getOutputStream().write(bytes); - } else { - CdaError re = new CdaError("Unable to find group based on parameters given"); - logger.atInfo().log("%s%sfor request %s", re, System.lineSeparator(), ctx.fullUrl()); - ctx.status(HttpServletResponse.SC_NOT_FOUND).json(re); - } - } catch (IOException ex) { - CdaError re = new CdaError("Failure to process request to retrieve time series group"); - logger.atSevere().withCause(ex).log("Failed to process request to retrieve time series group"); - ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); - } + String groupOffice = ctx.queryParam(GROUP_OFFICE_ID); + super.getOne(ctx, groupId, groupOffice); } @OpenApi( @@ -265,49 +164,7 @@ public void getOne(@NotNull Context ctx, @NotNull String groupId) { ) @Override public void create(@NotNull Context ctx) { - try (Timer.Context ignored = markAndTime(CREATE)) { - DSLContext dsl = getDslContext(ctx); - - String formatHeader = ctx.req.getContentType(); - String body = ctx.body(); - ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); - TimeSeriesGroup deserialize = Formats.parseContent(contentType, body, TimeSeriesGroup.class); - - if (!deserialize.getTimeSeriesCategory().getOfficeId().equalsIgnoreCase(CWMS_OFFICE) - && (!deserialize.getOfficeId().equalsIgnoreCase(deserialize.getTimeSeriesCategory().getOfficeId()) - || deserialize.getOfficeId().equalsIgnoreCase(CWMS_OFFICE))) { - throw new IllegalArgumentException("TimeSeries Group office ID cannot be CWMS and must match the " - + "TimeSeries Category office ID"); - } - - boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); - boolean failIfExists = ctx.queryParamAsClass(FAIL_IF_EXISTS, Boolean.class).getOrDefault(true); - boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); - TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); - List missingTimeSeries = dao.create(deserialize, failIfExists, ignoreNulls, ignoreMissing); - if (missingTimeSeries.isEmpty()) { - ctx.status(HttpServletResponse.SC_CREATED); - } else { - Map detailsMap = new HashMap<>(); - StringBuilder sb = new StringBuilder(); - for (CwmsId cwmsId : missingTimeSeries) { - sb.append(cwmsId.getName()); - sb.append(", "); - } - sb.delete(sb.length() - 2, sb.length()); - detailsMap.put("missing-time-series", sb.toString()); - if (ignoreMissing) { - ctx.status(HttpCode.MULTI_STATUS); - - } else { - ctx.status(HttpServletResponse.SC_BAD_REQUEST); - detailsMap.put("message", - "One or more time series were not found and could not be assigned to the group"); - } - ctx.json(detailsMap); - } - - } + super.create(ctx); } @OpenApi( @@ -350,10 +207,7 @@ public void update(@NotNull Context ctx, @NotNull String oldGroupId) { TimeSeriesGroupDao timeSeriesGroupDao = new TimeSeriesGroupDao(dsl); TimeSeriesGroup existingGroup = timeSeriesGroupDao.getTimeSeriesGroup(office, null, null, group.getTimeSeriesCategory().getId(), oldGroupId); - if (!existingGroup.getDescription().equalsIgnoreCase(group.getDescription())) { - existingGroup = updateClearedFields(group, existingGroup); - timeSeriesGroupDao.create(existingGroup, false, false); - } + updateDescriptionIfChanged(timeSeriesGroupDao, existingGroup, group.getDescription()); if (!office.equalsIgnoreCase(CWMS_OFFICE) && !oldGroupId.equals(group.getId())) { timeSeriesGroupDao.renameTimeSeriesGroup(oldGroupId, group); } @@ -363,37 +217,10 @@ public void update(@NotNull Context ctx, @NotNull String oldGroupId) { } boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); List missingTimeSeries = timeSeriesGroupDao.assignTs(group, office, ignoreMissing); - if (missingTimeSeries.isEmpty()) { - ctx.status(HttpServletResponse.SC_OK); - } else { - Map detailsMap = new HashMap<>(); - StringBuilder sb = new StringBuilder(); - for (CwmsId cwmsId : missingTimeSeries) { - sb.append(cwmsId.getName()); - sb.append(", "); - } - sb.delete(sb.length() - 2, sb.length()); - detailsMap.put("missing-timeseries", sb.toString()); - if (ignoreMissing) { - ctx.status(HttpCode.MULTI_STATUS); - } else { - ctx.status(HttpServletResponse.SC_BAD_REQUEST); - detailsMap.put("message", - "One or more time series were not found and could not be assigned to the group"); - } - ctx.json(detailsMap); - } + respondToMissingTimeSeries(ctx, missingTimeSeries, ignoreMissing); } } - private TimeSeriesGroup updateClearedFields(TimeSeriesGroup groupBody, - TimeSeriesGroup existingTimeSeriesGroup) { - return new TimeSeriesGroup(new TimeSeriesGroup(existingTimeSeriesGroup.getTimeSeriesCategory(), - existingTimeSeriesGroup.getOfficeId(), existingTimeSeriesGroup.getId(), groupBody.getDescription(), - existingTimeSeriesGroup.getSharedAliasId(), existingTimeSeriesGroup.getSharedRefTsId()), - existingTimeSeriesGroup.getAssignedTimeSeries()); - } - @OpenApi( description = "Deletes requested time series group", pathParams = { @@ -413,16 +240,7 @@ private TimeSeriesGroup updateClearedFields(TimeSeriesGroup groupBody, ) @Override public void delete(@NotNull Context ctx, @NotNull String groupId) { - try (Timer.Context ignored = markAndTime(UPDATE)) { - DSLContext dsl = getDslContext(ctx); - - TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); - - boolean cascadeDelete = ctx.queryParamAsClass(CASCADE_DELETE, Boolean.class).getOrDefault(false); - String office = requiredParam(ctx, OFFICE); - String categoryId = requiredParam(ctx, CATEGORY_ID); - dao.delete(categoryId, groupId, office, cascadeDelete); - ctx.status(HttpServletResponse.SC_NO_CONTENT); - } + String office = requiredParam(ctx, OFFICE); + super.delete(ctx, groupId, office); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java new file mode 100644 index 0000000000..c239b2ca9c --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java @@ -0,0 +1,300 @@ +/* + * 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.timeseriesgroup; + +import static cwms.cda.api.Controllers.CASCADE_DELETE; +import static cwms.cda.api.Controllers.CATEGORY_ID; +import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; +import static cwms.cda.api.Controllers.CWMS_OFFICE; +import static cwms.cda.api.Controllers.FAIL_IF_EXISTS; +import static cwms.cda.api.Controllers.GROUP_ID; +import static cwms.cda.api.Controllers.IGNORE_MISSING; +import static cwms.cda.api.Controllers.IGNORE_NULLS; +import static cwms.cda.api.Controllers.INCLUDE_ASSIGNED; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.STATUS_404; +import static cwms.cda.api.Controllers.STATUS_501; +import static cwms.cda.api.Controllers.TIMESERIES_CATEGORY_LIKE; +import static cwms.cda.api.Controllers.TIMESERIES_GROUP_LIKE; +import static cwms.cda.api.Controllers.UPDATE; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.data.dao.TimeSeriesGroupDao; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.plugin.openapi.annotations.HttpMethod; +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.OpenApiRequestBody; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +/** + * Version 2 of the Timeseries Group controller. Create, delete, and retrieve are identical to v1 + * (see {@link TimeSeriesGroupController}). PATCH/update differs: instead of a full + * {@link TimeSeriesGroup} body carrying the complete list of assigned time series, this version + * accepts a {@link TimeSeriesGroupPatch} body whose {@link Membership} describes only the time + * series ids to assign and/or unassign. This lets callers add or remove a handful of time series + * from a (potentially very large) group without submitting the group's entire list of assigned + * time series. + */ +public final class TimeSeriesGroupControllerV2 extends TimeSeriesGroupController { + + public TimeSeriesGroupControllerV2(MetricRegistry metrics) { + super(metrics); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning " + + "office of the timeseries group(s) to be included in the response."), + }, + queryParams = { + @OpenApiParam(name = OFFICE, description = "Specifies the owning office of the " + + "timeseries assigned to the group(s) whose data is to be included in the response. If this " + + "field is not specified, group information for all assigned TS offices shall be returned. " + + "Not to be confused with the path parameter of the same name, which specifies the " + + "owning office of the group(s) themselves."), + @OpenApiParam(name = INCLUDE_ASSIGNED, type = Boolean.class, description = "Include" + + " the assigned timeseries in the returned timeseries groups. (default: true)"), + @OpenApiParam(name = TIMESERIES_CATEGORY_LIKE, description = "Posix regular expression " + + "matching against the timeseries category id"), + @OpenApiParam(name = CATEGORY_OFFICE_ID, description = "Specifies the owning office of the " + + "timeseries group category"), + @OpenApiParam(name = TIMESERIES_GROUP_LIKE, description = "Posix regular expression " + + "matching against the timeseries group id") + }, + responses = { + @OpenApiResponse(status = STATUS_200, + content = {@OpenApiContent(isArray = true, from = + TimeSeriesGroup.class, type = Formats.JSON) + }), + @OpenApiResponse(status = STATUS_404, description = "Based on the combination of " + + "inputs provided the timeseries group(s) were not found."), + @OpenApiResponse(status = STATUS_501, description = "request format is not " + + "implemented")}, description = "Returns CWMS Timeseries Groups Data", + tags = {TAG}) + @Override + public void getAll(@NotNull Context ctx) { + String groupOffice = ctx.pathParam(OFFICE); + super.getAll(ctx, groupOffice); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning " + + "office of the timeseries group whose data is to be included in the response."), + @OpenApiParam(name = GROUP_ID, required = true, description = "Specifies " + + "the timeseries group whose data is to be included in the response") + }, + queryParams = { + @OpenApiParam(name = OFFICE, description = "Specifies the " + + "owning office of the timeseries assigned to the group whose data is to be included" + + " in the response. This will limit the assigned timeseries returned to only those" + + " assigned to the specified office. Not to be confused with the path parameter of the " + + "same name, which specifies the owning office of the group itself."), + @OpenApiParam(name = CATEGORY_OFFICE_ID, description = "Specifies the owning office of the " + + "timeseries group category"), + @OpenApiParam(name = CATEGORY_ID, description = "Specifies" + + " the category containing the timeseries group whose data is to be " + + "included in the response."), + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(from = TimeSeriesGroup.class, type = Formats.JSON), + }) + }, + description = "Retrieves requested timeseries group", tags = {"Timeseries Groups"}) + @Override + public void getOne(@NotNull Context ctx, @NotNull String groupId) { + String groupOffice = ctx.pathParam(OFFICE); + super.getOne(ctx, groupId, groupOffice); + } + + @OpenApi( + description = "Create new TimeSeriesGroup", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning " + + "office of the timeseries group to be created. Must match the office id in the " + + "request body.") + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TimeSeriesGroup.class, type = Formats.JSON) + }, + required = true), + queryParams = { + @OpenApiParam(name = FAIL_IF_EXISTS, type = Boolean.class, + description = "Create will fail if provided ID already exists. Default: true"), + @OpenApiParam(name = IGNORE_MISSING, type = Boolean.class, description = "If true, do not fail when " + + "attempting to assign a time series that does not exist to the group"), + @OpenApiParam(name = IGNORE_NULLS, type = Boolean.class, + description = "Ignore null values in the request body. Caution, if " + FAIL_IF_EXISTS + + " is false and " + IGNORE_NULLS + " is false, then the create will proceed whether " + + "there was an existing group or not. If there was an existing group with a " + + "description and the provided body does not specify a description (its null) the " + + "combination of flags will cause the database to replace the description with null. " + + "If " + IGNORE_NULLS + " is false and the provided body does not specify the " + + "list of assigned time series this will result in the database replacing the list " + + "with an empty list." + + "Default: true") + }, + method = HttpMethod.POST, + tags = {TAG} + ) + @Override + public void create(@NotNull Context ctx) { + String officeFromPath = ctx.pathParam(OFFICE); + validateOffice(officeFromPath, deserializeGroup(ctx).getOfficeId()); + super.create(ctx); + } + + @OpenApi( + description = "Update an existing TimeSeriesGroup using time series membership changes. Allows " + + "renaming the group, updating its description, and assigning and/or unassigning specific " + + "time series without having to submit the group's full list of assigned time series.", + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TimeSeriesGroupPatch.class, type = Formats.JSON) + }, + required = true), + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "office of the timeseries group, category, and time series being patched. Must " + + "match the office id in the request body."), + @OpenApiParam(name = GROUP_ID, required = true, description = "Specifies " + + "the original timeseries group to rename.") + }, + queryParams = { + @OpenApiParam(name = IGNORE_MISSING, type = Boolean.class, description = "If true, do not fail when " + + "a time series to assign does not exist. Default is false"), + }, + method = HttpMethod.PATCH, + tags = {TAG} + ) + @Override + public void update(@NotNull Context ctx, @NotNull String oldGroupId) { + try (Timer.Context ignored = markAndTime(UPDATE)) { + DSLContext dsl = getDslContext(ctx); + String office = ctx.pathParam(OFFICE); + ContentType contentType = Formats.parseHeader(ctx.req.getContentType(), TimeSeriesGroupPatch.class); + TimeSeriesGroupPatch patch = Formats.parseContent(contentType, ctx.body(), TimeSeriesGroupPatch.class); + validateOffice(office, patch.getOfficeId()); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + String categoryId = patch.getTimeSeriesCategory().getId(); + TimeSeriesGroup existingGroup = dao.getTimeSeriesGroup(office, null, null, categoryId, oldGroupId); + existingGroup = updateDescriptionIfChanged(dao, existingGroup, patch.getDescription()); + + String currentGroupId = oldGroupId; + if (!office.equalsIgnoreCase(CWMS_OFFICE) && patch.getId() != null + && !oldGroupId.equals(patch.getId())) { + TimeSeriesGroup renameTarget = new TimeSeriesGroup(existingGroup.getTimeSeriesCategory(), + existingGroup.getOfficeId(), patch.getId(), existingGroup.getDescription(), + existingGroup.getSharedAliasId(), existingGroup.getSharedRefTsId()); + dao.renameTimeSeriesGroup(oldGroupId, renameTarget); + currentGroupId = patch.getId(); + } + + boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); + Membership membership = patch.getMembership(); + List missingTimeSeries = Collections.emptyList(); + if (membership != null) { + List unassign = membership.getUnassign(); + if (unassign != null && !unassign.isEmpty()) { + dao.unassignTsIds(categoryId, currentGroupId, office, unassign); + } + + List assign = membership.getAssign(); + if (assign != null && !assign.isEmpty()) { + List assignedTimeSeries = new ArrayList<>(); + for (AssignedTimeSeries ts : assign) { + String tsOffice = ts.getOfficeId() != null ? ts.getOfficeId() : office; + assignedTimeSeries.add(new AssignedTimeSeries(tsOffice, ts.getTimeseriesId(), + ts.getAliasId(), ts.getRefTsId(), ts.getAttribute())); + } + TimeSeriesGroup groupForAssign = new TimeSeriesGroup(new TimeSeriesGroup( + existingGroup.getTimeSeriesCategory(), office, currentGroupId, + existingGroup.getDescription(), existingGroup.getSharedAliasId(), + existingGroup.getSharedRefTsId()), assignedTimeSeries); + missingTimeSeries = dao.assignTs(groupForAssign, office, ignoreMissing); + } + } + + respondToMissingTimeSeries(ctx, missingTimeSeries, ignoreMissing); + } + } + + @OpenApi( + description = "Deletes requested time series group", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the time series group to be deleted"), + @OpenApiParam(name = GROUP_ID, description = "The time series group to be deleted"), + }, + queryParams = { + @OpenApiParam(name = CATEGORY_ID, required = true, description = "Specifies the " + + "time series category of the time series group to be deleted"), + @OpenApiParam(name = CASCADE_DELETE, type = Boolean.class, + description = "Specifies whether to unassign time series in this group before deleting. " + + "Default: false"), + }, + method = HttpMethod.DELETE, + tags = {TAG} + ) + @Override + public void delete(@NotNull Context ctx, @NotNull String groupId) { + String office = ctx.pathParam(OFFICE); + super.delete(ctx, groupId, office); + } + + /** + * v2 primary-resource standard: the office in the path must match the office embedded in + * the request body (the group's own owning office, per {@link TimeSeriesGroup#getOfficeId()} + * / {@link TimeSeriesGroupPatch#getOfficeId()}). + */ + private void validateOffice(String officeFromPath, String officeFromBody) { + if (officeFromPath == null) { + throw new IllegalArgumentException("Office ID is required in the path parameter."); + } + if (!officeFromPath.equalsIgnoreCase(officeFromBody)) { + throw new IllegalArgumentException("Office ID in path parameter does not match office ID in " + + "request body."); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java index 6398c2152c..c7449fb210 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java @@ -25,7 +25,7 @@ package cwms.cda.data.dao; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import java.util.List; import java.util.Objects; import java.util.Optional; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java index 41a23fab9e..32a0bb3c21 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java @@ -34,7 +34,7 @@ import cwms.cda.data.dto.AssignedTimeSeries; import cwms.cda.data.dto.CwmsId; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -377,6 +377,21 @@ private void deleteViaUnassign(DSLContext dslContext, String categoryId, String } + public void unassignTsIds(String categoryId, String groupId, String office, List tsIds) { + if (tsIds == null || tsIds.isEmpty()) { + throw new IllegalArgumentException("At least one time series id must be provided to unassign."); + } + + connection(dsl, conn -> { + DSLContext dslContext = getDslContext(conn, office); + dslContext.transaction((Configuration config) -> { + for (String tsId : tsIds) { + CWMS_TS_PACKAGE.call_UNASSIGN_TS_GROUP(config, categoryId, groupId, tsId, "F", office); + } + }); + }); + } + public void unassignAll(String categoryId, String groupId, String office) { dsl.transaction((Configuration config) -> unassignAll(config, categoryId, groupId, office) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java new file mode 100644 index 0000000000..db6ea4ea43 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java @@ -0,0 +1,91 @@ +/* + * 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.timeseriesgroup; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsDTOBase; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "Describes time series to assign to, and/or unassign from, a timeseries group") +@JsonDeserialize(builder = Membership.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class Membership extends CwmsDTOBase { + private final List assign; + private final List unassign; + + private Membership(Builder builder) { + this.assign = builder.assign != null ? builder.assign : new ArrayList<>(); + this.unassign = builder.unassign != null ? builder.unassign : new ArrayList<>(); + } + + @Schema(description = "Time series to assign to the group") + public List getAssign() { + return assign; + } + + @Schema(description = "Time series ids to unassign from the group") + public List getUnassign() { + return unassign; + } + + public static class Builder { + private List assign; + private List unassign; + + public Builder() { + } + + public Builder withAssign(List assign) { + this.assign = assign != null ? new ArrayList<>(assign) : null; + return this; + } + + public Builder withUnassign(List unassign) { + this.unassign = unassign != null ? new ArrayList<>(unassign) : null; + return this; + } + + @JsonIgnore + public Builder from(Membership membership) { + if (membership != null) { + this.assign = membership.getAssign(); + this.unassign = membership.getUnassign(); + } + return this; + } + + public Membership build() { + return new Membership(this); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java similarity index 97% rename from cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java rename to cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java index d237ade474..dcaf8b0a8b 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java @@ -22,12 +22,15 @@ * SOFTWARE. */ -package cwms.cda.data.dto; +package cwms.cda.data.dto.timeseriesgroup; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonRootName; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsDTO; +import cwms.cda.data.dto.TimeSeriesCategory; import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; import cwms.cda.formatters.json.JsonV1; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java new file mode 100644 index 0000000000..185ed0e08a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java @@ -0,0 +1,160 @@ +/* + * 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.timeseriesgroup; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTO; +import cwms.cda.data.dto.TimeSeriesCategory; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV2; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "A PATCH of a timeseries group, describing time series " + + "including membership describing assignment and unassignment of time series to the group.") +@JsonRootName("timeseries-group") +@JsonDeserialize(builder = TimeSeriesGroupPatch.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV2.class) +public final class TimeSeriesGroupPatch extends CwmsDTO { + @JsonProperty(required = true) + private final String id; + + @JsonProperty(required = true) + private final TimeSeriesCategory timeSeriesCategory; + + private final String description; + private final String sharedAliasId; + private final String sharedRefTsId; + private final Membership membership; + + private TimeSeriesGroupPatch(Builder builder) { + super(builder.officeId); + this.id = builder.id; + this.timeSeriesCategory = builder.timeSeriesCategory != null + ? new TimeSeriesCategory(builder.timeSeriesCategory) : null; + this.description = builder.description; + this.sharedAliasId = builder.sharedAliasId; + this.sharedRefTsId = builder.sharedRefTsId; + this.membership = builder.membership; + } + + public String getId() { + return id; + } + + public TimeSeriesCategory getTimeSeriesCategory() { + return timeSeriesCategory; + } + + public String getDescription() { + return description; + } + + public String getSharedAliasId() { + return sharedAliasId; + } + + public String getSharedRefTsId() { + return sharedRefTsId; + } + + public Membership getMembership() { + return membership; + } + + public static class Builder { + private String officeId; + private String id; + private TimeSeriesCategory timeSeriesCategory; + private String description; + private String sharedAliasId; + private String sharedRefTsId; + private Membership membership; + + public Builder() { + } + + public Builder withOfficeId(String officeId) { + this.officeId = officeId; + return this; + } + + public Builder withId(String id) { + this.id = id; + return this; + } + + public Builder withTimeSeriesCategory(TimeSeriesCategory timeSeriesCategory) { + this.timeSeriesCategory = timeSeriesCategory; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withSharedAliasId(String sharedAliasId) { + this.sharedAliasId = sharedAliasId; + return this; + } + + public Builder withSharedRefTsId(String sharedRefTsId) { + this.sharedRefTsId = sharedRefTsId; + return this; + } + + public Builder withMembership(Membership membership) { + this.membership = membership; + return this; + } + + @JsonIgnore + public Builder from(TimeSeriesGroupPatch patch) { + if (patch != null) { + this.officeId = patch.getOfficeId(); + this.id = patch.getId(); + this.timeSeriesCategory = patch.getTimeSeriesCategory(); + this.description = patch.getDescription(); + this.sharedAliasId = patch.getSharedAliasId(); + this.sharedRefTsId = patch.getSharedRefTsId(); + this.membership = patch.getMembership(); + } + return this; + } + + public TimeSeriesGroupPatch build() { + return new TimeSeriesGroupPatch(this); + } + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java index e6f9a9149c..45d9351eff 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesCategoryControllerTestIT.java @@ -38,7 +38,7 @@ import cwms.cda.data.dao.TimeSeriesCategoryDao; import cwms.cda.data.dao.TimeSeriesGroupDao; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import fixtures.CwmsDataApiSetupCallback; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV1TestIT.java similarity index 99% rename from cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerTestIT.java rename to cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV1TestIT.java index 95486fe53c..d1f597c705 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV1TestIT.java @@ -63,7 +63,7 @@ import cwms.cda.data.dto.LocationCategory; import cwms.cda.data.dto.TimeSeries; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import cwms.cda.helpers.DatabaseHelpers.SCHEMA_VERSION; @@ -98,7 +98,7 @@ import org.junit.jupiter.params.provider.ValueSource; @Tag("integration") -final class TimeSeriesGroupControllerTestIT extends DataApiTestIT { +final class TimeSeriesGroupControllerV1TestIT extends DataApiTestIT { private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); private final List categoriesToCleanup = new ArrayList<>(); diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java new file mode 100644 index 0000000000..f4075835e8 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java @@ -0,0 +1,584 @@ +/* + * 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.CASCADE_DELETE; +import static cwms.cda.api.Controllers.CATEGORY_ID; +import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; +import static cwms.cda.api.Controllers.FAIL_IF_EXISTS; +import static cwms.cda.api.Controllers.OFFICE; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dao.TimeSeriesCategoryDao; +import cwms.cda.data.dao.TimeSeriesGroupDao; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.TimeSeriesCategory; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.TestAccounts; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import org.jooq.Configuration; +import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the v2 Timeseries Group controller ({@code /v2/timeseries/group}). + * Create, delete, and retrieve are shared with v1 (via {@code AbstractTimeSeriesGroupController}), + * so {@link #test_v2_create_read_delete()} exercises those the same way + * {@link TimeSeriesGroupControllerV1TestIT} does for v1, just against the v2 routes. The remaining + * tests focus on what's unique to v2: PATCH driven by a {@code membership} of time series ids to + * assign/unassign, instead of a full list of assigned time series. + */ +@Tag("integration") +final class TimeSeriesGroupControllerV2TestIT extends DataApiTestIT { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String V2_GROUP_PATH = "/v2/timeseries/group"; + + private static final String LOCATION = "TsGroupV2Test"; + private static final String TS1 = LOCATION + ".Precip-Cumulative.Inst.15Minutes.0.raw-cda"; + private static final String TS2 = LOCATION + ".Precip-INC.Total.15Minutes.15Minutes.calc-cda"; + private static final String TS3 = LOCATION + ".Stage.Inst.15Minutes.0.raw-cda"; + + private final List categoriesToCleanup = new ArrayList<>(); + private final List groupsToCleanup = new ArrayList<>(); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + @BeforeAll + static void load_data() throws Exception { + createLocation(LOCATION, true, "SPK"); + createTimeseries("SPK", TS1); + createTimeseries("SPK", TS2); + createTimeseries("SPK", TS3); + } + + @AfterEach + void clear_data() throws Exception { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + Configuration configuration = DSL.using(c).configuration(); + TimeSeriesGroupDao groupDao = new TimeSeriesGroupDao(configuration.dsl()); + TimeSeriesCategoryDao categoryDao = new TimeSeriesCategoryDao(configuration.dsl()); + + for (TimeSeriesGroup group : groupsToCleanup) { + String assignOffice = group.getOfficeId(); + try { + groupDao.unassignForOffice(group.getTimeSeriesCategory().getId(), group.getId(), + group.getOfficeId(), assignOffice); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Group not found"); + } catch (DataAccessException e) { + LOGGER.atInfo().withCause(e).log("Failed to unassign time series in office %s", assignOffice); + } + + try { + groupDao.delete(group.getTimeSeriesCategory().getId(), group.getId(), group.getOfficeId(), true); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Group not found"); + } catch (DataAccessException e) { + LOGGER.atInfo().withCause(e).log("Failed to delete group in office %s", group.getOfficeId()); + } + } + for (TimeSeriesCategory category : categoriesToCleanup) { + try { + categoryDao.delete(category.getId(), true, category.getOfficeId()); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Category not found"); + } + } + groupsToCleanup.clear(); + categoriesToCleanup.clear(); + }, CwmsDataApiSetupCallback.getWebUser()); + } + + private TimeSeriesCategory createCategory(String officeId, String catId) throws Exception { + TimeSeriesCategory cat = new TimeSeriesCategory(officeId, catId, "IntegrationTesting"); + categoriesToCleanup.add(cat); + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesCategory.class); + String categoryJson = Formats.format(contentType, cat); + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(categoryJson) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .queryParam(FAIL_IF_EXISTS, false) + .when() + .post("/timeseries/category") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + return cat; + } + + private void createGroup(TimeSeriesGroup group) { + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroup.class); + String groupJson = Formats.format(contentType, group); + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(groupJson) + .header("Authorization", user.toHeaderValue()) + .queryParam(FAIL_IF_EXISTS, false) + .when() + .post(V2_GROUP_PATH + "/" + group.getOfficeId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + } + + private String patchBody(TimeSeriesGroupPatch patch) { + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + return Formats.format(contentType, patch); + } + + @Test + void test_v2_create_read_delete() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_create_read_delete"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_create_read_delete", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + + createGroup(group); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("office-id", equalTo(group.getOfficeId())) + .body("id", equalTo(group.getId())) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .header("Authorization", user.toHeaderValue()) + .queryParam(CATEGORY_ID, cat.getId()) + .queryParam(CASCADE_DELETE, "true") + .when() + .delete(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + @Test + void test_v2_patch_assign_time_series() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_assign"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_assign", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Arrays.asList( + new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2), + new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3))) + .withUnassign(Collections.emptyList()) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series.size()", is(3)) + .body("assigned-time-series.timeseries-id", hasItem(TS1)) + .body("assigned-time-series.timeseries-id", hasItem(TS2)) + .body("assigned-time-series.timeseries-id", hasItem(TS3)) + .body("assigned-time-series.alias-id", hasItem("AliasId2")) + .body("assigned-time-series.alias-id", hasItem("AliasId3")); + } + + @Test + void test_v2_patch_unassign_time_series() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_unassign"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_unassign", + "IntegrationTesting", "sharedTsAliasId", TS1); + List assigned = group.getAssignedTimeSeries(); + assigned.add(new AssignedTimeSeries(officeId, TS1, "AliasId1", TS1, 1)); + assigned.add(new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2)); + assigned.add(new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3)); + groupsToCleanup.add(group); + createGroup(group); + + // Only specify the ids to unassign - not the full set of assigned time series. + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Arrays.asList(TS1, TS2)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("id", equalTo(group.getId())) + .body("assigned-time-series.size()", is(1)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS3)); + } + + @Test + void test_v2_patch_assign_and_unassign_together() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_combo"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_combo", + "IntegrationTesting", "sharedTsAliasId", TS1); + List assigned = group.getAssignedTimeSeries(); + assigned.add(new AssignedTimeSeries(officeId, TS1, "AliasId1", TS1, 1)); + assigned.add(new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2)); + groupsToCleanup.add(group); + createGroup(group); + + // Unassign TS1 while assigning TS3, in a single request. + Membership membership = new Membership.Builder() + .withAssign(Collections.singletonList(new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3))) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series.size()", is(2)) + .body("assigned-time-series.timeseries-id", hasItem(TS2)) + .body("assigned-time-series.timeseries-id", hasItem(TS3)); + } + + @Test + void test_v2_patch_rename_and_description() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_rename"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_rename_orig", + "Original description", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + createGroup(group); + + String newGroupId = "test_v2_patch_rename_new"; + TimeSeriesGroup renamedGroupForCleanup = new TimeSeriesGroup(cat, officeId, newGroupId, + "Updated description", "sharedTsAliasId", TS1); + groupsToCleanup.add(renamedGroupForCleanup); + + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(newGroupId) + .withTimeSeriesCategory(cat) + .withDescription("Updated description") + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + newGroupId) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("id", equalTo(newGroupId)) + .body("description", equalTo("Updated description")) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + @Test + void test_v2_patch_requires_matching_office() throws Exception { + // v2 primary-resource standard: office is a required path segment. This test verifies + // that the office in the path must match the office embedded in the request body. + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_requires_office"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_requires_office", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + // Path office ("WRONG_OFFICE") does not match the body's office (officeId). + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/WRONG_OFFICE/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_BAD_REQUEST)) + .body("message", equalTo("Bad Request")); + + // Confirm the time series is still assigned, since the mismatched request should not + // have been processed. + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + } + + @Test + void test_v2_patch_only_supports_plain_json() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_json_only"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_json_only", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + // The v2 patch DTO only supports plain JSON - the versioned JSONV1 content type should + // not be resolvable for it. + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSONV1) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_ACCEPTABLE)); + + // Confirm the time series is still assigned, since the malformed request should not + // have been processed. + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .queryParam(OFFICE, officeId) + .queryParam(CATEGORY_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + officeId + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java index 302a2dd16d..499410c73b 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java @@ -46,7 +46,7 @@ import cwms.cda.data.dto.LocationCategory; import cwms.cda.data.dto.LocationGroup; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.data.dto.TimeSeriesIdentifierDescriptor; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java index be7fd14635..16e1ced447 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java @@ -46,7 +46,7 @@ import cwms.cda.data.dto.AssignedTimeSeries; import cwms.cda.data.dto.TimeSeries; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import cwms.cda.formatters.json.JsonV1; diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java index 60cbfde512..8f0ca44343 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import static cwms.cda.data.dao.DaoTest.getConnection; import static cwms.cda.data.dao.DaoTest.getDslContext; diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java new file mode 100644 index 0000000000..93773c6dd8 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java @@ -0,0 +1,127 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import cwms.cda.helpers.DTOMatch; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +final class TimeSeriesGroupPatchTest { + + private static final String OFFICE_ID = "SPK"; + private static final String GROUP_ID = "group-id"; + private static final String CATEGORY_ID = "category-id"; + private static final String CATEGORY_DESCRIPTION = "category description"; + private static final String GROUP_DESCRIPTION = "patch description"; + private static final String SHARED_ALIAS_ID = "shared-alias"; + private static final String SHARED_REF_TS_ID = "Shared.Flow.Inst.1Hour.0.Raw"; + private static final String ASSIGN_TS_ID = "Loc.Flow.Inst.1Hour.0.Raw"; + private static final String ASSIGN_ALIAS_ID = "AliasId1"; + private static final String ASSIGN_REF_TS_ID = "Loc2.Flow.Inst.1Hour.0.Raw"; + private static final int ASSIGN_ATTRIBUTE = 5; + private static final String UNASSIGN_TS_ID = "Loc3.Flow.Inst.1Hour.0.Raw"; + + @Test + void test_serialize_json() { + TimeSeriesGroupPatch patch = buildTimeSeriesGroupPatch(); + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + String result = Formats.format(contentType, patch); + assertNotNull(result); + + assertTrue(result.contains("\"office-id\":\"" + OFFICE_ID + "\"")); + assertTrue(result.contains("\"id\":\"" + GROUP_ID + "\"")); + assertTrue(result.contains("\"time-series-category\"")); + assertTrue(result.contains("\"description\":\"" + GROUP_DESCRIPTION + "\"")); + assertTrue(result.contains("\"shared-alias-id\":\"" + SHARED_ALIAS_ID + "\"")); + assertTrue(result.contains("\"shared-ref-ts-id\":\"" + SHARED_REF_TS_ID + "\"")); + assertTrue(result.contains("\"membership\"")); + assertTrue(result.contains("\"assign\"")); + assertTrue(result.contains("\"unassign\"")); + assertTrue(result.contains(ASSIGN_TS_ID)); + assertTrue(result.contains(ASSIGN_ALIAS_ID)); + assertTrue(result.contains(UNASSIGN_TS_ID)); + } + + @Test + void test_serialize_deserialize_roundtrip() { + TimeSeriesGroupPatch patch = buildTimeSeriesGroupPatch(); + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + String json = Formats.format(contentType, patch); + TimeSeriesGroupPatch deserialized = Formats.parseContent(contentType, json, TimeSeriesGroupPatch.class); + + DTOMatch.assertMatch(patch, deserialized); + } + + @Test + void test_deserialize_from_file() throws IOException { + String json; + try (InputStream stream = getClass().getResourceAsStream("time_series_group_patch.json")) { + assertNotNull(stream); + json = IOUtils.toString(stream, StandardCharsets.UTF_8); + } + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + TimeSeriesGroupPatch deserialized = Formats.parseContent(contentType, json, TimeSeriesGroupPatch.class); + + DTOMatch.assertMatch(buildTimeSeriesGroupPatch(), deserialized); + } + + private TimeSeriesGroupPatch buildTimeSeriesGroupPatch() { + TimeSeriesCategory category = new TimeSeriesCategory(OFFICE_ID, CATEGORY_ID, CATEGORY_DESCRIPTION); + AssignedTimeSeries assign = new AssignedTimeSeries(OFFICE_ID, ASSIGN_TS_ID, ASSIGN_ALIAS_ID, + ASSIGN_REF_TS_ID, ASSIGN_ATTRIBUTE); + Membership membership = new Membership.Builder() + .withAssign(Collections.singletonList(assign)) + .withUnassign(Collections.singletonList(UNASSIGN_TS_ID)) + .build(); + return new TimeSeriesGroupPatch.Builder() + .withTimeSeriesCategory(category) + .withOfficeId(OFFICE_ID) + .withId(GROUP_ID) + .withDescription(GROUP_DESCRIPTION) + .withSharedAliasId(SHARED_ALIAS_ID) + .withSharedRefTsId(SHARED_REF_TS_ID) + .withMembership(membership) + .build(); + } + + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java index d099dcf4c7..a0b2b2d6aa 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.List; + +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import org.junit.jupiter.api.Test; import cwms.cda.formatters.ContentType; 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 928f134bcf..60f2a202b7 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 @@ -24,6 +24,7 @@ package cwms.cda.helpers; +import cwms.cda.data.dto.AssignedTimeSeries; import cwms.cda.data.dto.CwmsIdTimeExtentsEntry; import cwms.cda.data.dto.Entity; import cwms.cda.data.dto.ParameterLegacy; @@ -68,6 +69,8 @@ import cwms.cda.data.dto.stream.StreamLocation; import cwms.cda.data.dto.stream.StreamNode; import cwms.cda.data.dto.stream.StreamReach; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; import cwms.cda.data.dto.watersupply.PumpLocation; import cwms.cda.data.dto.watersupply.PumpTransfer; import cwms.cda.data.dto.watersupply.WaterSupplyAccounting; @@ -760,6 +763,42 @@ public static void assertMatch(LocationToPublishedDataList list, LocationToPubli ); } + public static void assertMatch(TimeSeriesGroupPatch first, TimeSeriesGroupPatch second) { + assertAll(() -> assertEquals(first.getOfficeId(), second.getOfficeId(), "Office IDs do not match"), + () -> assertEquals(first.getId(), second.getId(), "Time series group IDs do not match"), + () -> assertEquals(first.getDescription(), second.getDescription(), "Descriptions do not match"), + () -> assertEquals(first.getSharedAliasId(), second.getSharedAliasId(), "Shared alias IDs do not match"), + () -> assertEquals(first.getSharedRefTsId(), second.getSharedRefTsId(), "Shared reference time series IDs do not match"), + () -> assertEquals(first.getTimeSeriesCategory(), second.getTimeSeriesCategory(), "Time series categories do not match"), + () -> assertMatch(first.getMembership(), second.getMembership()) + ); + } + + public static void assertMatch(Membership first, Membership second) { + assertEquals(first.getUnassign().size(), second.getUnassign().size(), "Unassign list sizes do not match"); + assertEquals(first.getAssign().size(), second.getAssign().size(), "Assign list sizes do not match"); + + List firstAssigned = first.getAssign(); + List secondAssigned = second.getAssign(); + for (int i = 0; i < firstAssigned.size(); i++) { + AssignedTimeSeries expectedTs = firstAssigned.get(i); + AssignedTimeSeries actualTs = secondAssigned.get(i); + assertEquals(expectedTs.getOfficeId(), actualTs.getOfficeId(), "Office IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getTimeseriesId(), actualTs.getTimeseriesId(), "Time series IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getAliasId(), actualTs.getAliasId(), "Alias IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getRefTsId(), actualTs.getRefTsId(), "Reference time series IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getAttribute(), actualTs.getAttribute(), "Attributes do not match for assigned time series at index " + i); + } + + List firstUnassigned = first.getUnassign(); + List secondUnassigned = second.getUnassign(); + for (int i = 0; i < firstUnassigned.size(); i++) { + String expectedTsId = firstUnassigned.get(i); + String actualTsId = secondUnassigned.get(i); + assertEquals(expectedTsId, actualTsId, "Time series IDs do not match for unassigned time series at index " + i); + } + } + public static void assertMatch(ForecastLocation first, ForecastLocation second) { if (first == null || second == null) { assertEquals(first, second, "ForecastLocation null mismatch"); diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json new file mode 100644 index 0000000000..cfc5d9a1c5 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json @@ -0,0 +1,26 @@ +{ + "office-id": "SPK", + "id": "group-id", + "time-series-category": { + "office-id": "SPK", + "id": "category-id", + "description": "category description" + }, + "description": "patch description", + "shared-alias-id": "shared-alias", + "shared-ref-ts-id": "Shared.Flow.Inst.1Hour.0.Raw", + "membership": { + "assign": [ + { + "office-id": "SPK", + "timeseries-id": "Loc.Flow.Inst.1Hour.0.Raw", + "alias-id": "AliasId1", + "ref-ts-id": "Loc2.Flow.Inst.1Hour.0.Raw", + "attribute": 5 + } + ], + "unassign": [ + "Loc3.Flow.Inst.1Hour.0.Raw" + ] + } +}