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 aaf5771eda..c339584082 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -343,7 +343,7 @@ private void getOpenApiOptions(JavalinConfig config) { api.getPaths().forEach((key,path) -> { setSecurityRequirements(key,path, schemeProcessor.getSecurityRequirements()); setUserListTags(key, path); - // 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) -> { 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..f5c6fe19f0 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java @@ -0,0 +1,264 @@ +/* + * 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); + } + } + + protected 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()); + } + + /** + * 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 55% 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 ee8e77bde5..7a2add53ee 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,40 +37,27 @@ 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; @@ -81,23 +65,16 @@ 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( @@ -128,49 +105,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( @@ -199,43 +135,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( @@ -266,49 +167,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( @@ -364,37 +223,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 = { @@ -414,16 +246,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..9721647970 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java @@ -0,0 +1,356 @@ +/* + * 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.api.errors.NotFoundException; +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.TimeSeriesGroupMembership; +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.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +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 TimeSeriesGroupMembership} 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. This is NOT the office of the " + + "category."), + }, + 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. 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"), + @OpenApiParam(name = IGNORE_NULLS, type = Boolean.class, description = "Ignore null values in the request body. " + + IGNORE_NULLS + " is not used to unassign time series. Unassignment must be explicitly specified in the request body. " + + "Default: true") + }, + 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); + Boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); + ContentType contentType = Formats.parseHeader(ctx.req.getContentType(), TimeSeriesGroupPatch.class); + TimeSeriesGroupPatch patch = Formats.parseContent(contentType, ctx.body(), TimeSeriesGroupPatch.class); + validateOffice(office, patch.getOfficeId()); + + TimeSeriesGroupMembership membership = patch.getMembership(); + validateNoAssignUnassignOverlap(membership); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + String categoryId = patch.getTimeSeriesCategory().getId(); + TimeSeriesGroup existingGroup = dao.getTimeSeriesGroup(office, null, null, categoryId, oldGroupId); + if(existingGroup == null) { + throw new NotFoundException("Time series group " + oldGroupId + " does not exist in category " + + categoryId + " for group office " + office); + } + + boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); + List newAndExistingAssignedTimeSeries = mergeAssigned(existingGroup, membership); + // Store metadata/assignments against the group's CURRENT id - renaming (if requested) is + // a separate step below. Targeting patch.getId() here would create a second row under + // the new id before the rename call runs, and the rename would then collide with it. + TimeSeriesGroup groupWithAssignment = new TimeSeriesGroup(new TimeSeriesGroup(patch.getTimeSeriesCategory(), + patch.getOfficeId(), + oldGroupId, + patch.getDescription(), + patch.getSharedAliasId(), + patch.getSharedRefTsId()), newAndExistingAssignedTimeSeries); + List missingTimeSeries = dao.create(groupWithAssignment, false, ignoreNulls, ignoreMissing); + + //Handle rename + 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(); + } + + //Handle unassignment + if (membership != null) { + List unassign = membership.getUnassign(); + if (unassign != null && !unassign.isEmpty()) { + dao.unassignTsIds(categoryId, currentGroupId, office, unassign); + } + } + + respondToMissingTimeSeries(ctx, missingTimeSeries, ignoreMissing); + } + } + + private static List mergeAssigned(TimeSeriesGroup existingGroup, TimeSeriesGroupMembership membership) { + Map byKey = new LinkedHashMap<>(); + for (AssignedTimeSeries ts : existingGroup.getAssignedTimeSeries()) { + byKey.put(key(ts.getOfficeId(), ts.getTimeseriesId()), ts); + } + if (membership != null) { + for (AssignedTimeSeries ts : membership.getAssign()) { + byKey.put(key(ts.getOfficeId(), ts.getTimeseriesId()), ts); + } + } + return new ArrayList<>(byKey.values()); + } + + /** + * A time series can't be assigned and unassigned by the same patch - reject the request + * up front rather than letting the outcome depend on call order. + */ + private void validateNoAssignUnassignOverlap(TimeSeriesGroupMembership membership) { + if (membership == null) { + return; + } + List unassign = membership.getUnassign(); + List assign = membership.getAssign(); + if (unassign == null || unassign.isEmpty() || assign == null || assign.isEmpty()) { + return; + } + + Set unassignKeys = new HashSet<>(); + for (CwmsId id : unassign) { + unassignKeys.add(key(id.getOfficeId(), id.getName())); + } + for (AssignedTimeSeries ts : assign) { + String key = key(ts.getOfficeId(), ts.getTimeseriesId()); + if (unassignKeys.contains(key)) { + String tsOffice = ts.getOfficeId(); + throw new IllegalArgumentException("Time series " + ts.getTimeseriesId() + " (office " + tsOffice + + ") cannot be included in both the assign and unassign lists."); + } + } + } + + private static String key(String office, String tsId) { + return office.toUpperCase() + "/" + tsId.toUpperCase(); + } + + @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..c130a0b09b 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 (CwmsId tsId : tsIds) { + CWMS_TS_PACKAGE.call_UNASSIGN_TS_GROUP(config, categoryId, groupId, tsId.getName(), "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.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/TimeSeriesGroupMembership.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupMembership.java new file mode 100644 index 0000000000..c821d3865e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupMembership.java @@ -0,0 +1,95 @@ +/* + * 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.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.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.data.dto.CwmsId; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.List; + +//name in json is just "membership" +@Schema(description = "Describes time series to assign to, and/or unassign from, a timeseries group") +@JsonDeserialize(builder = TimeSeriesGroupMembership.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonRootName("membership") +public final class TimeSeriesGroupMembership extends CwmsDTOBase { + private final List assign; + private final List unassign; + + private TimeSeriesGroupMembership(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 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(TimeSeriesGroupMembership membership) { + if (membership != null) { + this.assign = membership.getAssign(); + this.unassign = membership.getUnassign(); + } + return this; + } + + public TimeSeriesGroupMembership build() { + return new TimeSeriesGroupMembership(this); + } + } +} 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..86fee507bf --- /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 TimeSeriesGroupMembership 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 TimeSeriesGroupMembership 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 TimeSeriesGroupMembership 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(TimeSeriesGroupMembership 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..229e466586 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java @@ -0,0 +1,640 @@ +/* + * 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.CwmsId; +import cwms.cda.data.dto.TimeSeriesCategory; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupMembership; +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); + + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.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. + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Arrays.asList(CwmsId.buildCwmsId(officeId, TS1), CwmsId.buildCwmsId(officeId, 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. + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.singletonList(new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3))) + .withUnassign(Collections.singletonList(CwmsId.buildCwmsId(officeId, 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_rejects_ts_in_both_assign_and_unassign() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_overlap"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_overlap", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + // TS1 appears in both the assign and unassign lists - this should be rejected outright. + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.singletonList(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1))) + .withUnassign(Collections.singletonList(CwmsId.buildCwmsId(officeId, 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_BAD_REQUEST)); + + // Confirm nothing changed, since the invalid 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.size()", is(1)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + } + + @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); + + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(CwmsId.buildCwmsId(officeId, 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); + + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(CwmsId.buildCwmsId(officeId, 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..2365eb7847 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java @@ -0,0 +1,126 @@ +/* + * 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.TimeSeriesGroupMembership; +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 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); + TimeSeriesGroupMembership membership = new TimeSeriesGroupMembership.Builder() + .withAssign(Collections.singletonList(assign)) + .withUnassign(Collections.singletonList(CwmsId.buildCwmsId(OFFICE_ID, 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..1de5c1b4ff 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.TimeSeriesGroupMembership; +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,43 @@ 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(TimeSeriesGroupMembership first, TimeSeriesGroupMembership 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++) { + CwmsId expectedTsId = firstUnassigned.get(i); + CwmsId actualTsId = secondUnassigned.get(i); + assertEquals(expectedTsId.getOfficeId(), actualTsId.getOfficeId(), "Office IDs do not match for unassigned time series at index " + i); + assertEquals(expectedTsId.getName(), actualTsId.getName(), "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..edcc3990b4 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json @@ -0,0 +1,29 @@ +{ + "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": [ + { + "office-id": "SPK", + "name": "Loc3.Flow.Inst.1Hour.0.Raw" + } + ] + } +}