From f185fb44a2f7ddf75ebfee934445e6f2e95f9c56 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Wed, 19 Aug 2026 15:14:51 -0700 Subject: [PATCH] CDA-135 - Supports unassignment of tsids from ts group in /v2/ of timeseries group PATCH. --- .../src/main/java/cwms/cda/ApiServlet.java | 660 +---------------- .../cda/ApiServletRouteConfiguration.java | 695 ++++++++++++++++++ .../TimeSeriesGroupController.java | 286 +++++++ .../TimeSeriesGroupControllerV1.java} | 219 +----- .../TimeSeriesGroupControllerV2.java | 272 +++++++ .../cda/data/dao/TimeSeriesCategoryDao.java | 2 +- .../cwms/cda/data/dao/TimeSeriesGroupDao.java | 17 +- .../data/dto/timeseriesgroup/Membership.java | 91 +++ .../TimeSeriesGroup.java | 5 +- .../timeseriesgroup/TimeSeriesGroupPatch.java | 160 ++++ .../java/cwms/cda/api/OpenApiDocTest.java | 50 +- .../TimeSeriesCategoryControllerTestIT.java | 2 +- ...=> TimeSeriesGroupControllerV1TestIT.java} | 4 +- .../TimeSeriesGroupControllerV2TestIT.java | 581 +++++++++++++++ ...sIdentifierDescriptorControllerTestIT.java | 2 +- .../cda/api/TimeSeriesRecentControllerIT.java | 2 +- .../cda/data/dao/TimeSeriesGroupDaoTest.java | 2 +- .../data/dto/TimeSeriesGroupPatchTest.java | 127 ++++ .../cda/data/dto/TimeSeriesGroupTest.java | 2 + .../test/java/cwms/cda/helpers/DTOMatch.java | 39 + .../cda/data/dto/time_series_group_patch.json | 26 + 21 files changed, 2379 insertions(+), 865 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java rename cwms-data-api/src/main/java/cwms/cda/api/{TimeSeriesGroupController.java => timeseriesgroup/TimeSeriesGroupControllerV1.java} (54%) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java rename cwms-data-api/src/main/java/cwms/cda/data/dto/{ => timeseriesgroup}/TimeSeriesGroup.java (97%) create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java rename cwms-data-api/src/test/java/cwms/cda/api/{TimeSeriesGroupControllerTestIT.java => TimeSeriesGroupControllerV1TestIT.java} (99%) create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json 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 0c0e3ccc9a..2e0058e3b5 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -24,23 +24,13 @@ package cwms.cda; -import static cwms.cda.api.Controllers.CONTRACT_NAME; -import static cwms.cda.api.Controllers.LOCATION_ID; -import static cwms.cda.api.Controllers.NAME; -import static cwms.cda.api.Controllers.OFFICE; -import static cwms.cda.api.Controllers.PROJECT_ID; -import static cwms.cda.api.Controllers.RATING_ID; -import static cwms.cda.api.Controllers.WATER_USER; import static cwms.cda.openapi.ExampleUtils.addEndpointExamples; import static io.javalin.apibuilder.ApiBuilder.crud; import static io.javalin.apibuilder.ApiBuilder.delete; import static io.javalin.apibuilder.ApiBuilder.get; import static io.javalin.apibuilder.ApiBuilder.patch; import static io.javalin.apibuilder.ApiBuilder.post; -import static io.javalin.apibuilder.ApiBuilder.prefixPath; -import static io.javalin.apibuilder.ApiBuilder.staticInstance; import static java.lang.String.format; -import static java.util.stream.Collectors.toList; import com.codahale.metrics.Meter; import com.codahale.metrics.MetricRegistry; @@ -49,165 +39,32 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.common.flogger.FluentLogger; -import cwms.cda.api.BasinController; -import cwms.cda.api.BinaryTimeSeriesController; -import cwms.cda.api.BinaryTimeSeriesValueController; -import cwms.cda.api.BlobController; -import cwms.cda.api.CatalogController; -import cwms.cda.api.CdaVersionHandler; -import cwms.cda.api.ClobController; import cwms.cda.api.Controllers; -import cwms.cda.api.CountyController; -import cwms.cda.api.DownstreamLocationsGetController; -import cwms.cda.api.EmbankmentController; -import cwms.cda.api.EntityController; -import cwms.cda.api.ForecastFileController; -import cwms.cda.api.ForecastInstanceController; -import cwms.cda.api.ForecastSpecController; -import cwms.cda.api.LevelRefsController; -import cwms.cda.api.LevelsAsTimeSeriesController; -import cwms.cda.api.LevelsController; -import cwms.cda.api.LocationCategoryController; -import cwms.cda.api.LocationController; -import cwms.cda.api.LocationGroupController; -import cwms.cda.api.LocationKindController; -import cwms.cda.api.LookupTypeController; -import cwms.cda.api.MeasurementTimeExtentsGetController; -import cwms.cda.api.OfficeController; -import cwms.cda.api.ParametersController; -import cwms.cda.api.PoolController; -import cwms.cda.api.ProjectController; -import cwms.cda.api.PropertyController; -import cwms.cda.api.PublishedController; -import cwms.cda.api.SpecifiedLevelController; -import cwms.cda.api.StandardTextController; -import cwms.cda.api.StateController; -import cwms.cda.api.StreamController; -import cwms.cda.api.StreamLocationController; -import cwms.cda.api.StreamReachController; -import cwms.cda.api.TextTimeSeriesController; -import cwms.cda.api.TextTimeSeriesValueController; -import cwms.cda.api.TimeSeriesCategoryController; -import cwms.cda.api.TimeSeriesController; -import cwms.cda.api.TimeSeriesFilteredController; -import cwms.cda.api.TimeSeriesGroupController; -import cwms.cda.api.TimeSeriesVersionsController; -import cwms.cda.api.TimeSeriesIdentifierDescriptorController; -import cwms.cda.api.TimeSeriesRecentController; -import cwms.cda.api.TimeZoneController; -import cwms.cda.api.TurbineChangesDeleteController; -import cwms.cda.api.TurbineChangesGetController; -import cwms.cda.api.TurbineChangesPostController; -import cwms.cda.api.TurbineController; -import cwms.cda.api.UnitsController; -import cwms.cda.api.UpstreamLocationsGetController; -import cwms.cda.api.VerticalDatumController; -import cwms.cda.api.auth.ApiKeyController; -import cwms.cda.api.auth.userlists.AddUserListMemberController; -import cwms.cda.api.auth.userlists.CreateUserListController; -import cwms.cda.api.auth.userlists.DeleteUserListController; -import cwms.cda.api.auth.userlists.UpdateUserListController; -import cwms.cda.api.auth.userlists.UserListCandidatesController; -import cwms.cda.api.auth.userlists.UserListController; -import cwms.cda.api.auth.userlists.UserListMemberController; -import cwms.cda.api.auth.userlists.UserListMembersController; -import cwms.cda.api.auth.userlists.UserListsController; -import cwms.cda.api.auth.users.UserProfileController; -import cwms.cda.api.auth.users.UsersController; -import cwms.cda.api.auth.users.roles.AddRoleController; -import cwms.cda.api.auth.users.roles.DeleteRolesController; -import cwms.cda.api.auth.users.roles.GetRolesController; import cwms.cda.api.enums.UnitSystem; import cwms.cda.api.errors.ApplicationException; import cwms.cda.api.errors.CdaError; import cwms.cda.api.errors.ExceptionTraceSupport; -import cwms.cda.api.location.kind.GateChangeCreateController; -import cwms.cda.api.location.kind.GateChangeDeleteController; -import cwms.cda.api.location.kind.GateChangeGetAllController; -import cwms.cda.api.location.kind.LockController; -import cwms.cda.api.location.kind.OutletController; -import cwms.cda.api.location.kind.VirtualOutletController; -import cwms.cda.api.location.kind.VirtualOutletCreateController; -import cwms.cda.api.project.LockRevokerRightsCatalog; -import cwms.cda.api.project.ProjectChildLocationHandler; -import cwms.cda.api.project.ProjectLockCatalog; -import cwms.cda.api.project.ProjectLockGetOne; -import cwms.cda.api.project.ProjectLockRelease; -import cwms.cda.api.project.ProjectLockRequest; -import cwms.cda.api.project.ProjectLockRevoke; -import cwms.cda.api.project.ProjectLockRevokeDeny; -import cwms.cda.api.project.ProjectPublishStatusUpdate; -import cwms.cda.api.project.RemoveAllLockRevokerRights; -import cwms.cda.api.project.UpdateLockRevokerRights; -import cwms.cda.api.rating.RateTimeSeriesController; -import cwms.cda.api.rating.RateValuesController; -import cwms.cda.api.rating.RatingController; -import cwms.cda.api.rating.RatingEffectiveDatesController; -import cwms.cda.api.rating.RatingLatestController; -import cwms.cda.api.rating.RatingMetadataController; -import cwms.cda.api.rating.RatingSpecController; -import cwms.cda.api.rating.RatingTemplateController; -import cwms.cda.api.rating.ReverseRateTimeSeriesController; -import cwms.cda.api.rating.ReverseRateValuesController; -import cwms.cda.api.rss.RssHandler; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCatalogController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCreateController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileDeleteController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceCatalogController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceCreateController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceDeleteController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserCatalogController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserCreateController; -import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserDeleteController; -import cwms.cda.api.watersupply.AccountingCatalogController; -import cwms.cda.api.watersupply.AccountingCreateController; -import cwms.cda.api.watersupply.WaterContractCatalogController; -import cwms.cda.api.watersupply.WaterContractController; -import cwms.cda.api.watersupply.WaterContractCreateController; -import cwms.cda.api.watersupply.WaterContractDeleteController; -import cwms.cda.api.watersupply.WaterContractTypeCatalogController; -import cwms.cda.api.watersupply.WaterContractTypeCreateController; -import cwms.cda.api.watersupply.WaterContractTypeDeleteController; -import cwms.cda.api.watersupply.WaterContractUpdateController; -import cwms.cda.api.watersupply.WaterPumpDisassociateController; -import cwms.cda.api.watersupply.WaterUserCatalogController; -import cwms.cda.api.watersupply.WaterUserController; -import cwms.cda.api.watersupply.WaterUserCreateController; -import cwms.cda.api.watersupply.WaterUserDeleteController; -import cwms.cda.api.watersupply.WaterUserUpdateController; import cwms.cda.data.dao.JooqDao; import cwms.cda.data.dao.rss.QueueManager; import cwms.cda.data.dto.csv.CwmsCsvDTO; -import cwms.cda.features.CdaFeatures; import cwms.cda.formatters.Formats; import cwms.cda.formatters.csv.CsvExampleGenerator; import cwms.cda.openapi.OpenApiSchemeProcessor; import cwms.cda.security.Authenticator; import cwms.cda.security.CdaAccessManager; -import cwms.cda.security.DataApiPrincipal; -import cwms.cda.security.MissingRolesException; import cwms.cda.security.Role; import io.github.classgraph.ClassGraph; import io.github.classgraph.ScanResult; import io.javalin.Javalin; -import io.javalin.apibuilder.CrudFunction; -import io.javalin.apibuilder.CrudHandler; -import io.javalin.apibuilder.CrudHandlerKt; import io.javalin.core.JavalinConfig; import io.javalin.core.security.RouteRole; -import io.javalin.http.Context; import io.javalin.core.util.Header; import io.javalin.core.validation.JavalinValidation; import io.javalin.http.BadRequestResponse; -import io.javalin.http.Handler; import io.javalin.http.JavalinServlet; import io.javalin.plugin.openapi.OpenApiOptions; import io.javalin.plugin.openapi.OpenApiPlugin; import io.opentelemetry.api.trace.Span; -import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; @@ -224,12 +81,9 @@ import java.nio.file.Paths; import java.time.DateTimeException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.jar.Manifest; import javax.annotation.Resource; import javax.servlet.ServletConfig; @@ -240,8 +94,6 @@ import javax.servlet.http.HttpServletResponse; import javax.sql.DataSource; import org.apache.http.entity.ContentType; -import org.jetbrains.annotations.NotNull; -import org.togglz.core.context.FeatureContext; import org.jooq.exception.DataAccessException; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; @@ -290,7 +142,8 @@ "/users/*", "/roles/*", "/version/*", - "/rss/*" + "/rss/*", + "/v2/*" }) public class ApiServlet extends HttpServlet { @@ -456,6 +309,11 @@ public void init() { logger.atInfo().log("Javalin initialized."); } + private void configureRoutes() { + RouteRole[] requiredRoles = {new Role(CWMS_USERS_ROLE)}; + ApiServletRouteConfiguration.configureRoutes(metrics, requiredRoles, cdaAccessManager); + } + private String obtainFullVersion(ServletConfig servletConfig) throws ServletException { String relativeWARPath = "/META-INF/MANIFEST.MF"; String absoluteDiskPath = servletConfig.getServletContext().getRealPath(relativeWARPath); @@ -469,510 +327,6 @@ private String obtainFullVersion(ServletConfig servletConfig) throws ServletExce } } - protected void configureRoutes() { - - RouteRole[] requiredRoles = {new Role(CWMS_USERS_ROLE)}; - - get("/", ctx -> ctx.result("Welcome to the CWMS REST API") - .contentType(Formats.PLAIN)); - // Even view on this one requires authorization - crud("/auth/keys/{key-name}",new ApiKeyController(metrics), new RouteRole[]{new Role(CAC_USER), - new Role(CWMS_USERS_ROLE)}); - cdaCrudCache("/location/category/{category-id}", - new LocationCategoryController(metrics), requiredRoles, 5, TimeUnit.MINUTES); - cdaCrudCache("/location/group/{group-id}", - new LocationGroupController(metrics), requiredRoles, 5, TimeUnit.MINUTES); - get("/locations/with-kinds/", new LocationKindController(metrics)); - cdaCrudCache("/locations/{location-id}", - new LocationController(metrics), requiredRoles, 5, TimeUnit.MINUTES); - - VerticalDatumController vdiController = new VerticalDatumController(metrics); - String vdiPath = format("/location/{%s}/vertical-datum", Controllers.LOCATION_ID); - get(vdiPath, ctx -> vdiController.getOne(ctx, ctx.pathParam(Controllers.LOCATION_ID))); - addCacheControl(vdiPath, 5, TimeUnit.MINUTES); - post(vdiPath, vdiController::create, requiredRoles); - patch(vdiPath, ctx -> vdiController.update(ctx, ctx.pathParam(Controllers.LOCATION_ID)), requiredRoles); - delete(vdiPath, ctx -> vdiController.delete(ctx, ctx.pathParam(Controllers.LOCATION_ID)), requiredRoles); - cdaCrudCache("/entity/{entity-id}", - new EntityController(metrics), requiredRoles, 5, TimeUnit.MINUTES); - cdaCrudCache("/states/{state}", - new StateController(metrics), requiredRoles, 60, TimeUnit.MINUTES); - cdaCrudCache("/counties/{county}", - new CountyController(metrics), requiredRoles, 60, TimeUnit.MINUTES); - cdaCrudCache("/offices/{office}", - new OfficeController(metrics), requiredRoles, 60, TimeUnit.MINUTES); - cdaCrudCache("/units/{unit-id}", - new UnitsController(metrics), requiredRoles, 60, TimeUnit.MINUTES); - cdaCrudCache("/parameters/{param-id}", - new ParametersController(metrics), requiredRoles, 60, TimeUnit.MINUTES); - cdaCrudCache("/timezones/{zone}", - new TimeZoneController(metrics), requiredRoles,60, TimeUnit.MINUTES); - cdaCrudCache(format("/levels/{%s}", Controllers.LEVEL_ID), - new LevelsController(metrics), requiredRoles,5, TimeUnit.MINUTES); - String levelTsPath = format("/levels/{%s}/timeseries", Controllers.LEVEL_ID); - get(levelTsPath, new LevelsAsTimeSeriesController(metrics)); - addCacheControl(levelTsPath, 5, TimeUnit.MINUTES); - String levelRefsPath = "/level-refs/"; - get(levelRefsPath, new LevelRefsController(metrics)); - addCacheControl(levelRefsPath, 5, TimeUnit.MINUTES); - String recentPath = "/timeseries/recent/"; - get(recentPath, new TimeSeriesRecentController(metrics)); - addCacheControl(recentPath, 5, TimeUnit.MINUTES); - - String versionsPath = "/timeseries/versions/"; - get(versionsPath, new TimeSeriesVersionsController(metrics)); - addCacheControl(versionsPath, 5, TimeUnit.MINUTES); - - String filteredPath = "/timeseries/filtered"; - get(filteredPath, new TimeSeriesFilteredController(metrics)); - addCacheControl(filteredPath, 5, TimeUnit.MINUTES); - - cdaCrudCache(format("/standard-text-id/{%s}", Controllers.STANDARD_TEXT_ID), - new StandardTextController(metrics), requiredRoles,1, TimeUnit.DAYS); - - String textTsPath = format("/timeseries/text/{%s}", NAME); - cdaCrudCache(textTsPath, new TextTimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); - String textValuePath = textTsPath + "/value"; - get(textValuePath, new TextTimeSeriesValueController(metrics)); - addCacheControl(textValuePath, 1, TimeUnit.DAYS); - - String binTsPath = format("/timeseries/binary/{%s}", NAME); - cdaCrudCache(binTsPath, new BinaryTimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); - String textBinaryValuePath = binTsPath + "/value"; - get(textBinaryValuePath, new BinaryTimeSeriesValueController(metrics)); - addCacheControl(textBinaryValuePath, 1, TimeUnit.DAYS); - - String timeSeriesProfilePath = "/timeseries/profile/"; - get(format("%s{%s}/{%s}", timeSeriesProfilePath, Controllers.LOCATION_ID, Controllers.PARAMETER_ID), - new TimeSeriesProfileController(metrics)); - delete(format("%s/{%s}/{%s}", timeSeriesProfilePath, Controllers.LOCATION_ID, - Controllers.PARAMETER_ID), new TimeSeriesProfileDeleteController(metrics), - requiredRoles); - get(format(timeSeriesProfilePath, Controllers.LOCATION_ID, Controllers.PARAMETER_ID), - new TimeSeriesProfileCatalogController(metrics)); - post(timeSeriesProfilePath, new TimeSeriesProfileCreateController(metrics), requiredRoles); - - String timeSeriesProfileParserPath = "/timeseries/profile-parser/"; - get(format("%s{%s}/{%s}/", timeSeriesProfileParserPath, Controllers.LOCATION_ID, - Controllers.PARAMETER_ID), new TimeSeriesProfileParserController(metrics)); - post(timeSeriesProfileParserPath, new TimeSeriesProfileParserCreateController(metrics), requiredRoles); - delete(format("%s{%s}/{%s}/", timeSeriesProfileParserPath, Controllers.LOCATION_ID, - Controllers.PARAMETER_ID), new TimeSeriesProfileParserDeleteController(metrics), - requiredRoles); - get(timeSeriesProfileParserPath, new TimeSeriesProfileParserCatalogController(metrics)); - - String timeSeriesProfileInstancePath = "/timeseries/profile-instance/"; - get(format("%s{%s}/{%s}/{%s}/", timeSeriesProfileInstancePath, Controllers.LOCATION_ID, - Controllers.PARAMETER_ID, Controllers.VERSION), - new TimeSeriesProfileInstanceController(metrics)); - post(timeSeriesProfileInstancePath, new TimeSeriesProfileInstanceCreateController(metrics), requiredRoles); - delete(format("%s{%s}/{%s}/{%s}/", timeSeriesProfileInstancePath, Controllers.LOCATION_ID, - Controllers.PARAMETER_ID, Controllers.VERSION), - new TimeSeriesProfileInstanceDeleteController(metrics), requiredRoles); - get(timeSeriesProfileInstancePath, new TimeSeriesProfileInstanceCatalogController(metrics)); - - cdaCrudCache("/timeseries/category/{category-id}", - 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); - cdaCrudCache("/timeseries/{timeseries}", - new TimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); - addRatingHandlers(requiredRoles); - cdaCrudCache("/catalog/{dataset}", - new CatalogController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/basins/{name}", - new BasinController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/streams/{%s}", NAME), - new StreamController(metrics), requiredRoles,5, TimeUnit.MINUTES); - - String downstreamLocations = format("/stream-locations/{%s}/{%s}/downstream-locations", - Controllers.OFFICE, Controllers.NAME); - get(downstreamLocations,new DownstreamLocationsGetController(metrics)); - addCacheControl(downstreamLocations, 5, TimeUnit.MINUTES); - String upstreamLocations = format("/stream-locations/{%s}/{%s}/upstream-locations", - Controllers.OFFICE, Controllers.NAME); - - get(upstreamLocations,new UpstreamLocationsGetController(metrics)); - addCacheControl(upstreamLocations, 5, TimeUnit.MINUTES); - cdaCrudCache(format("/stream-locations/{%s}", NAME), - new StreamLocationController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/stream-reaches/{%s}", NAME), - new StreamReachController(metrics), requiredRoles,1, TimeUnit.DAYS); - String measurements = "/measurements/"; - String measTimeExtents = measurements + "time-extents"; - get(measTimeExtents,new MeasurementTimeExtentsGetController(metrics)); - addCacheControl(measTimeExtents, 5, TimeUnit.MINUTES); - cdaCrudCache(format("%s{%s}", measurements, LOCATION_ID), - new cwms.cda.api.MeasurementController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/published/{%s}", LOCATION_ID), - new PublishedController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/blobs/{blob-id}", - new BlobController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/clobs/{clob-id}", - new ClobController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/pools/{pool-id}", - new PoolController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/specified-levels/{specified-level-id}", - new SpecifiedLevelController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/forecast-instance/{%s}", Controllers.NAME), - new ForecastInstanceController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/forecast-spec/{%s}", Controllers.NAME), - new ForecastSpecController(metrics), requiredRoles,5, TimeUnit.MINUTES); - String forecastFilePath = format("/forecast-instance/{%s}/file-data", NAME); - get(forecastFilePath, new ForecastFileController(metrics)); - addCacheControl(forecastFilePath, 1, TimeUnit.DAYS); - - - post(format("/projects/status-update/{%s}", NAME), new ProjectPublishStatusUpdate(metrics), requiredRoles); - - addWaterUserHandlers(format("/projects/{%s}/{%s}/water-user", OFFICE, PROJECT_ID), requiredRoles); - addWaterContractHandlers(format("/projects/{%s}/{%s}/water-user/{%s}/contracts", OFFICE, PROJECT_ID, - WATER_USER), requiredRoles); - addAccountingHandlers(format("/projects/{%s}/{%s}/water-user/{%s}" - + "/contracts/{%s}/accounting", OFFICE, PROJECT_ID, WATER_USER, CONTRACT_NAME), requiredRoles); - delete(format("/projects/{%s}/{%s}/water-user/{%s}/contracts/{%s}/pumps/{%s}", OFFICE, PROJECT_ID, - WATER_USER, CONTRACT_NAME, NAME), new WaterPumpDisassociateController(metrics), requiredRoles); - addWaterContractTypeHandlers(format("/projects/{%s}/contract-types", OFFICE), requiredRoles); - - cdaCrudCache(format("/projects/embankments/{%s}", Controllers.NAME), - new EmbankmentController(metrics), requiredRoles,1, TimeUnit.DAYS); - cdaCrudCache(format("/projects/turbines/{%s}", Controllers.NAME), - new TurbineController(metrics), requiredRoles,1, TimeUnit.DAYS); - cdaCrudCache(format("/projects/locks/{%s}", Controllers.NAME), - new LockController(metrics), requiredRoles,1, TimeUnit.DAYS); - String turbineChanges = format("/projects/{%s}/{%s}/turbine-changes", Controllers.OFFICE, Controllers.NAME); - get(turbineChanges,new TurbineChangesGetController(metrics)); - addCacheControl(turbineChanges, 5, TimeUnit.MINUTES); - post(turbineChanges, new TurbineChangesPostController(metrics), requiredRoles); - delete(turbineChanges, new TurbineChangesDeleteController(metrics), requiredRoles); - - String outletPath = format("/projects/outlets/{%s}", NAME); - String gateChangePath = format("/projects/{%s}/{%s}/gate-changes", OFFICE, - Controllers.PROJECT_ID); - String gateChangeCreatePath = "/projects/gate-changes"; - String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, - Controllers.PROJECT_ID, NAME); - String virtualOutletCreatePath = "/projects/virtual-outlets"; - cdaCrudCache(outletPath, new OutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); - post(gateChangeCreatePath, new GateChangeCreateController(metrics), requiredRoles); - get(gateChangePath, new GateChangeGetAllController(metrics)); - delete(gateChangePath, new GateChangeDeleteController(metrics), requiredRoles); - cdaCrudCache(virtualOutletPath, new VirtualOutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); - post(virtualOutletCreatePath, new VirtualOutletCreateController(metrics), requiredRoles); - - get("/projects/locations/", new ProjectChildLocationHandler(metrics)); - cdaCrudCache(format("/projects/{%s}", Controllers.NAME), - new ProjectController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache(format("/properties/{%s}", Controllers.NAME), - new PropertyController(metrics), true, requiredRoles,1, TimeUnit.DAYS); - cdaCrudCache(format("/lookup-types/{%s}", Controllers.NAME), - new LookupTypeController(metrics), requiredRoles,1, TimeUnit.DAYS); - - addProjectLocksHandlers("/project-locks/{name}", requiredRoles); - addProjectLockRightsHandlers("/project-lock-rights/{project-id}", requiredRoles); - - addUserManagementHandlers(); - - get("/version/", new CdaVersionHandler(metrics), requiredRoles); - get(format("/rss/{%s}/{%s}", Controllers.OFFICE, Controllers.NAME), new RssHandler(metrics)); - } - - private void addUserManagementHandlers() { - RouteRole[] adminRoles = new RouteRole[] { new Role("CWMS User Admins")}; - RouteRole[] userRoles = new RouteRole[] {new Role(CWMS_USERS_ROLE), new Role(CAC_USER)}; - crud("/users/{user-name}", new UsersController(metrics), adminRoles); - get("/roles", new GetRolesController(metrics), adminRoles); - String userProfilePath = "/user/profile"; - get(userProfilePath, new UserProfileController(metrics), userRoles); - cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServlet::hasAnyRole); - addUserListHandlers(userRoles); - post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); - delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); - - } - - private void addUserListHandlers(RouteRole[] userRoles) { - String userListCandidatesPath = "/user/list-member-candidates"; - String userListsPath = "/user/list"; - String userListPath = "/user/list/{user-list-id}"; - String userListMembersPath = "/user/list/{user-list-id}/members"; - String userListMemberPath = "/user/list/{user-list-id}/members/{user-id}"; - if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { - get(userListCandidatesPath, new UserListCandidatesController(metrics), userRoles); - get(userListsPath, new UserListsController(metrics), userRoles); - post(userListsPath, new CreateUserListController(metrics), userRoles); - get(userListPath, new UserListController(metrics), userRoles); - patch(userListPath, new UpdateUserListController(metrics), userRoles); - delete(userListPath, new DeleteUserListController(metrics), userRoles); - get(userListMembersPath, new UserListMembersController(metrics), userRoles); - post(userListMembersPath, new AddUserListMemberController(metrics), userRoles); - delete(userListMemberPath, new UserListMemberController(metrics), userRoles); - } else { - get(userListCandidatesPath, this::userListsUnsupported, userRoles); - get(userListsPath, this::userListsUnsupported, userRoles); - post(userListsPath, this::userListsUnsupported, userRoles); - get(userListPath, this::userListsUnsupported, userRoles); - patch(userListPath, this::userListsUnsupported, userRoles); - delete(userListPath, this::userListsUnsupported, userRoles); - get(userListMembersPath, this::userListsUnsupported, userRoles); - post(userListMembersPath, this::userListsUnsupported, userRoles); - delete(userListMemberPath, this::userListsUnsupported, userRoles); - } - cdaAccessManager.addCustomAuthorizer(userListCandidatesPath, ApiServlet::hasAnyRole); - cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServlet::hasAnyRole); - cdaAccessManager.addCustomAuthorizer(userListPath, ApiServlet::hasAnyRole); - cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServlet::hasAnyRole); - cdaAccessManager.addCustomAuthorizer(userListMemberPath, ApiServlet::hasAnyRole); - } - - private void userListsUnsupported(Context ctx) { - ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED) - .json(new CdaError("User lists are not enabled for this CDA deployment.")); - } - - private static Boolean hasAnyRole(DataApiPrincipal p, Set roles) throws MissingRolesException { - boolean retVal = roles.stream().anyMatch(p.getRoles()::contains); - if(!retVal) { - List requiredRoleNames = roles.stream() - .map(Object::toString) - .collect(toList()); - throw new MissingRolesException(requiredRoleNames, "Missing one of the following roles {" + String.join(",", requiredRoleNames) + "}"); - } - return true; - } - - private void addRatingHandlers(RouteRole[] requiredRoles) { - /** - * The POST handlers for /ratings/rate-* intentionally do not have - * require roles. Instead they are rate limited if not authenticated. - * POST is used as sending a body with GET is not standard and we cannot - * be sure clients, or future servers, would correctly support that. - */ - String rateValues = format("/ratings/rate-values/{%s}/{%s}", OFFICE, RATING_ID); - post(rateValues, new RateValuesController(metrics)); - String rateTs = format("/ratings/rate-ts/{%s}/{%s}", OFFICE, RATING_ID); - post(rateTs, new RateTimeSeriesController(metrics)); - String reverseRateValues = format("/ratings/reverse-rate-values/{%s}/{%s}", OFFICE, RATING_ID); - post(reverseRateValues, new ReverseRateValuesController(metrics)); - String reverseRateTs = format("/ratings/reverse-rate-ts/{%s}/{%s}", OFFICE, RATING_ID); - post(reverseRateTs, new ReverseRateTimeSeriesController(metrics)); - cdaCrudCache("/ratings/template/{template-id}", - new RatingTemplateController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/ratings/spec/{rating-id}", - new RatingSpecController(metrics), requiredRoles,5, TimeUnit.MINUTES); - cdaCrudCache("/ratings/metadata/{rating-id}", - new RatingMetadataController(metrics), requiredRoles,5, TimeUnit.MINUTES); - get("/ratings/{rating-id}/latest", new RatingLatestController(metrics)); - get("/ratings/effective-dates", new RatingEffectiveDatesController(metrics)); - cdaCrudCache("/ratings/{rating-id}", - new RatingController(metrics), requiredRoles,5, TimeUnit.MINUTES); - addRateLimit(rateTs, requiredRoles); - addRateLimit(reverseRateTs, requiredRoles); - addRateLimit(reverseRateValues, requiredRoles); - addRateLimit(rateValues, requiredRoles); - } - - /** - * Add a rate limiter to a specified endpoint path, allowing authorized users to bypass the limit. - * @param path the path to add the rate limiter to. - * @param requiredRoles the user roles required to access the path. - */ - private void addRateLimit(String path, RouteRole[] requiredRoles) { - cdaAccessManager.addRateLimitedEndpoint(path, requiredRoles); - } - - private void addAccountingHandlers(String path, RouteRole[] requiredRoles) { - get(path, new AccountingCatalogController(metrics)); - post(path, new AccountingCreateController(metrics), requiredRoles); - } - - private void addProjectLocksHandlers(String path, RouteRole[] requiredRoles) { - String pathWithoutResource = path.replace(getResourceId(path), ""); - - get(path, new ProjectLockGetOne(metrics), requiredRoles); - get(pathWithoutResource, new ProjectLockCatalog(metrics), requiredRoles); - post(pathWithoutResource + "deny", new ProjectLockRevokeDeny(metrics), requiredRoles); - post(pathWithoutResource, new ProjectLockRequest(metrics), requiredRoles); - post(pathWithoutResource + "release", new ProjectLockRelease(metrics), requiredRoles); - delete(path, new ProjectLockRevoke(metrics), requiredRoles); - } - - private void addProjectLockRightsHandlers(String path, RouteRole[] requiredRoles) { - String pathWithoutResource = path.replace(getResourceId(path), ""); - get(pathWithoutResource, new LockRevokerRightsCatalog(metrics), requiredRoles); - post(pathWithoutResource + "remove-all", new RemoveAllLockRevokerRights(metrics), requiredRoles); - post(pathWithoutResource + "update", new UpdateLockRevokerRights(metrics), requiredRoles); - - } - - - private void addWaterUserHandlers(String path, RouteRole[] requiredRoles) { - get(path + format("/{%s}", WATER_USER), new WaterUserController(metrics), requiredRoles); - get(path, new WaterUserCatalogController(metrics), requiredRoles); - post(path, new WaterUserCreateController(metrics), requiredRoles); - patch(path + format("/{%s}", WATER_USER), new WaterUserUpdateController(metrics), requiredRoles); - delete(path + format("/{%s}", WATER_USER), new WaterUserDeleteController(metrics), requiredRoles); - } - - private void addWaterContractHandlers(String path, RouteRole[] requiredRoles) { - get(path + format("/{%s}", CONTRACT_NAME), new WaterContractController(metrics), requiredRoles); - get(path, new WaterContractCatalogController(metrics), requiredRoles); - post(path, new WaterContractCreateController(metrics), requiredRoles); - patch(path + format("/{%s}", CONTRACT_NAME), new WaterContractUpdateController(metrics), requiredRoles); - delete(path + format("/{%s}", CONTRACT_NAME), new WaterContractDeleteController(metrics), requiredRoles); - } - - private void addWaterContractTypeHandlers(String path, RouteRole[] requiredRoles) { - post(path, new WaterContractTypeCreateController(metrics), requiredRoles); - get(path, new WaterContractTypeCatalogController(metrics), requiredRoles); - delete(path + "/{display-value}", new WaterContractTypeDeleteController(metrics), requiredRoles); - } - - /** - * This method delegates to the cdaCrud method but also adds an after filter for the specified - * path. If the request was a GET request and the response does not already include - * Cache-Control then the filter will add the Cache-Control max-age header with the specified - * number of seconds. - * Controllers can include their own Cache-Control headers via: - * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" - * This method lets the ApiServlet configure a default max-age for controllers that don't or - * forget to set their own. - * @param path where to register the routes. - * @param crudHandler the handler requests should be forwarded to. - * @param roles the required these roles are present to access post, patch - * @param duration the number of TimeUnit to cache GET responses. - * @param timeUnit the TimeUnit to use for duration. - */ - public static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, - @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { - cdaCrudCache(path, crudHandler, false, roles, duration, timeUnit); - } - - /** - * This method delegates to the cdaCrud method but also adds an after filter for the specified - * path. If the request was a GET request and the response does not already include - * Cache-Control then the filter will add the Cache-Control max-age header with the specified - * number of seconds. - * Controllers can include their own Cache-Control headers via: - * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" - * This method lets the ApiServlet configure a default max-age for controllers that don't or - * forget to set their own. - * @param path where to register the routes. - * @param crudHandler the handler requests should be forwarded to. - * @param getRequiresAuth if the get handlers should have an authorization check - * @param roles the required these roles are present to access post, patch - * @param duration the number of TimeUnit to cache GET responses. - * @param timeUnit the TimeUnit to use for duration. - */ - public static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, - @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { - cdaCrud(path, crudHandler, getRequiresAuth, roles); - - // path like /offices/{office} will match /offices/SWT getOne style url - addCacheControl(path, duration, timeUnit); - - String pathWithoutResource = path.replace(getResourceId(path), ""); - // path like "/offices/" matches /offices getAll style url - addCacheControl(pathWithoutResource, duration, timeUnit); - } - - private static void addCacheControl(@NotNull String path, long duration, TimeUnit timeUnit) { - if (timeUnit != null && duration > 0) { - staticInstance().after(path, ctx -> { - String method = ctx.req.getMethod(); // "GET" - if (ctx.status() == HttpServletResponse.SC_OK - && "GET".equals(method) - && (!ctx.res.containsHeader(Header.CACHE_CONTROL))) { - // only set the cache control header if it is not already set. - ctx.header(Header.CACHE_CONTROL, "max-age=" + timeUnit.toSeconds(duration)); - } - }); - } - } - - /** - * This method is very similar to the ApiBuilder.crud method but the specified roles - * are only required for the post, patch and delete methods. getOne and getAll are always - * allowed. - * @param path where to register the routes. - * @param crudHandler the handler requests should be forwarded to. - * @param roles the accessmanager will require these roles are present to access post, patch - * and delete methods - */ - public static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, - @NotNull RouteRole... roles) { - cdaCrud(path, crudHandler, false, roles); - } - - /** - * This method is very similar to the ApiBuilder.crud method but the specified roles - * are only required for the post, patch and delete methods. getOne and getAll are always - * allowed. - * @param path where to register the routes. - * @param crudHandler the handler requests should be forwarded to. - * @param getRequiresAuth If all operations on this handler should have an authorization check - * @param roles the accessmanager will require these roles are present to access post, patch - * and delete methods - */ - public static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, - @NotNull RouteRole... roles) { - String fullPath = prefixPath(path); - String resourceId = getResourceId(fullPath); - - //noinspection KotlinInternalInJava - Map crudFunctions = CrudHandlerKt.getCrudFunctions(crudHandler, resourceId); - - Javalin instance = staticInstance(); - // getOne and getAll are assumed not to need authorization - String pathWithoutResource = fullPath.replace(resourceId, ""); - if (getRequiresAuth) { - instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE), roles); - instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL), roles); - } else { - instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE)); - instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL)); - } - - // create, update and delete need authorization. - instance.post(pathWithoutResource, crudFunctions.get(CrudFunction.CREATE), roles); - instance.patch(fullPath, crudFunctions.get(CrudFunction.UPDATE), roles); - instance.delete(fullPath, crudFunctions.get(CrudFunction.DELETE), roles); - } - - /** - * Given a path like "/location/category/{category-id}" this method returns "{category-id}". - * @param fullPath the full path to extract the resource id from. - * @return the resource id portion of the path. - * @throws IllegalArgumentException if the path does not contain a resource id. - */ - @NotNull - public static String getResourceId(String fullPath) { - String[] subPaths = Arrays.stream(fullPath.split("/")) - .filter(it -> !it.isEmpty()).toArray(String[]::new); - if (subPaths.length < 2) { - throw new IllegalArgumentException("CrudHandler requires a path like " - + "'/resource/{resource-id}' given: " + fullPath); - } - String resourceId = subPaths[subPaths.length - 1]; - if (!( - (resourceId.startsWith("{") && resourceId.endsWith("}")) - || - (resourceId.startsWith("<") && resourceId.endsWith(">")) - )) { - throw new IllegalArgumentException("CrudHandler requires a path-parameter at the " - + "end of the provided path, e.g. '/users/{user-id}' or '/users/' given: " + fullPath); - } - String resourceBase = subPaths[subPaths.length - 2]; - if (resourceBase.startsWith("{") || resourceBase.startsWith("<") - || resourceBase.endsWith("}") || resourceBase.endsWith(">")) { - throw new IllegalArgumentException("CrudHandler requires a resource base at the " - + "beginning of the provided path, e.g. '/users/{user-id}' given: " + fullPath); - } - return resourceId; - } - private void getOpenApiOptions(JavalinConfig config) { Info applicationInfo = new Info().title(APPLICATION_TITLE).version(ApiServlet.getApiVersion()) .description("CWMS REST API for Data Retrieval"); diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java new file mode 100644 index 0000000000..193da2092e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -0,0 +1,695 @@ +package cwms.cda; + +import static cwms.cda.ApiServlet.CAC_USER; +import static cwms.cda.ApiServlet.CWMS_USERS_ROLE; +import static cwms.cda.api.Controllers.CONTRACT_NAME; +import static cwms.cda.api.Controllers.LOCATION_ID; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.PROJECT_ID; +import static cwms.cda.api.Controllers.RATING_ID; +import static cwms.cda.api.Controllers.WATER_USER; +import static io.javalin.apibuilder.ApiBuilder.crud; +import static io.javalin.apibuilder.ApiBuilder.delete; +import static io.javalin.apibuilder.ApiBuilder.get; +import static io.javalin.apibuilder.ApiBuilder.patch; +import static io.javalin.apibuilder.ApiBuilder.post; +import static io.javalin.apibuilder.ApiBuilder.prefixPath; +import static io.javalin.apibuilder.ApiBuilder.staticInstance; +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; + +import com.codahale.metrics.MetricRegistry; +import cwms.cda.api.BasinController; +import cwms.cda.api.BinaryTimeSeriesController; +import cwms.cda.api.BinaryTimeSeriesValueController; +import cwms.cda.api.BlobController; +import cwms.cda.api.CatalogController; +import cwms.cda.api.CdaVersionHandler; +import cwms.cda.api.ClobController; +import cwms.cda.api.Controllers; +import cwms.cda.api.CountyController; +import cwms.cda.api.DownstreamLocationsGetController; +import cwms.cda.api.EmbankmentController; +import cwms.cda.api.EntityController; +import cwms.cda.api.ForecastFileController; +import cwms.cda.api.ForecastInstanceController; +import cwms.cda.api.ForecastSpecController; +import cwms.cda.api.LevelRefsController; +import cwms.cda.api.LevelsAsTimeSeriesController; +import cwms.cda.api.LevelsController; +import cwms.cda.api.LocationCategoryController; +import cwms.cda.api.LocationController; +import cwms.cda.api.LocationGroupController; +import cwms.cda.api.LocationKindController; +import cwms.cda.api.LookupTypeController; +import cwms.cda.api.MeasurementTimeExtentsGetController; +import cwms.cda.api.OfficeController; +import cwms.cda.api.ParametersController; +import cwms.cda.api.PoolController; +import cwms.cda.api.ProjectController; +import cwms.cda.api.PropertyController; +import cwms.cda.api.PublishedController; +import cwms.cda.api.SpecifiedLevelController; +import cwms.cda.api.StandardTextController; +import cwms.cda.api.StateController; +import cwms.cda.api.StreamController; +import cwms.cda.api.StreamLocationController; +import cwms.cda.api.StreamReachController; +import cwms.cda.api.TextTimeSeriesController; +import cwms.cda.api.TextTimeSeriesValueController; +import cwms.cda.api.TimeSeriesCategoryController; +import cwms.cda.api.TimeSeriesController; +import cwms.cda.api.TimeSeriesFilteredController; +import cwms.cda.api.timeseriesgroup.TimeSeriesGroupControllerV1; +import cwms.cda.api.TimeSeriesIdentifierDescriptorController; +import cwms.cda.api.TimeSeriesRecentController; +import cwms.cda.api.TimeSeriesVersionsController; +import cwms.cda.api.TimeZoneController; +import cwms.cda.api.TurbineChangesDeleteController; +import cwms.cda.api.TurbineChangesGetController; +import cwms.cda.api.TurbineChangesPostController; +import cwms.cda.api.TurbineController; +import cwms.cda.api.UnitsController; +import cwms.cda.api.UpstreamLocationsGetController; +import cwms.cda.api.VerticalDatumController; +import cwms.cda.api.auth.ApiKeyController; +import cwms.cda.api.auth.userlists.AddUserListMemberController; +import cwms.cda.api.auth.userlists.CreateUserListController; +import cwms.cda.api.auth.userlists.DeleteUserListController; +import cwms.cda.api.auth.userlists.UpdateUserListController; +import cwms.cda.api.auth.userlists.UserListCandidatesController; +import cwms.cda.api.auth.userlists.UserListController; +import cwms.cda.api.auth.userlists.UserListMemberController; +import cwms.cda.api.auth.userlists.UserListMembersController; +import cwms.cda.api.auth.userlists.UserListsController; +import cwms.cda.api.auth.users.UserProfileController; +import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.users.roles.AddRoleController; +import cwms.cda.api.auth.users.roles.DeleteRolesController; +import cwms.cda.api.auth.users.roles.GetRolesController; +import cwms.cda.api.errors.CdaError; +import cwms.cda.api.location.kind.GateChangeCreateController; +import cwms.cda.api.location.kind.GateChangeDeleteController; +import cwms.cda.api.location.kind.GateChangeGetAllController; +import cwms.cda.api.location.kind.LockController; +import cwms.cda.api.location.kind.OutletController; +import cwms.cda.api.location.kind.VirtualOutletController; +import cwms.cda.api.location.kind.VirtualOutletCreateController; +import cwms.cda.api.project.LockRevokerRightsCatalog; +import cwms.cda.api.project.ProjectChildLocationHandler; +import cwms.cda.api.project.ProjectLockCatalog; +import cwms.cda.api.project.ProjectLockGetOne; +import cwms.cda.api.project.ProjectLockRelease; +import cwms.cda.api.project.ProjectLockRequest; +import cwms.cda.api.project.ProjectLockRevoke; +import cwms.cda.api.project.ProjectLockRevokeDeny; +import cwms.cda.api.project.ProjectPublishStatusUpdate; +import cwms.cda.api.project.RemoveAllLockRevokerRights; +import cwms.cda.api.project.UpdateLockRevokerRights; +import cwms.cda.api.rating.RateTimeSeriesController; +import cwms.cda.api.rating.RateValuesController; +import cwms.cda.api.rating.RatingController; +import cwms.cda.api.rating.RatingEffectiveDatesController; +import cwms.cda.api.rating.RatingLatestController; +import cwms.cda.api.rating.RatingMetadataController; +import cwms.cda.api.rating.RatingSpecController; +import cwms.cda.api.rating.RatingTemplateController; +import cwms.cda.api.rating.ReverseRateTimeSeriesController; +import cwms.cda.api.rating.ReverseRateValuesController; +import cwms.cda.api.rss.RssHandler; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCatalogController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCreateController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileDeleteController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceCatalogController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceCreateController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileInstanceDeleteController; +import cwms.cda.api.timeseriesprofile.TimeSeriesProfileParserCatalogController; +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; +import cwms.cda.api.watersupply.WaterContractController; +import cwms.cda.api.watersupply.WaterContractCreateController; +import cwms.cda.api.watersupply.WaterContractDeleteController; +import cwms.cda.api.watersupply.WaterContractTypeCatalogController; +import cwms.cda.api.watersupply.WaterContractTypeCreateController; +import cwms.cda.api.watersupply.WaterContractTypeDeleteController; +import cwms.cda.api.watersupply.WaterContractUpdateController; +import cwms.cda.api.watersupply.WaterPumpDisassociateController; +import cwms.cda.api.watersupply.WaterUserCatalogController; +import cwms.cda.api.watersupply.WaterUserController; +import cwms.cda.api.watersupply.WaterUserCreateController; +import cwms.cda.api.watersupply.WaterUserDeleteController; +import cwms.cda.api.watersupply.WaterUserUpdateController; +import cwms.cda.features.CdaFeatures; +import cwms.cda.formatters.Formats; +import cwms.cda.security.CdaAccessManager; +import cwms.cda.security.DataApiPrincipal; +import cwms.cda.security.MissingRolesException; +import cwms.cda.security.Role; +import io.javalin.Javalin; +import io.javalin.apibuilder.CrudFunction; +import io.javalin.apibuilder.CrudHandler; +import io.javalin.apibuilder.CrudHandlerKt; +import io.javalin.core.security.RouteRole; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import org.jetbrains.annotations.NotNull; +import org.togglz.core.context.FeatureContext; + +import javax.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public final class ApiServletRouteConfiguration { + + private ApiServletRouteConfiguration() { + throw new AssertionError("Utility class - do not instantiate"); + } + + public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredRoles, CdaAccessManager cdaAccessManager) { + + get("/", ctx -> ctx.result("Welcome to the CWMS REST API") + .contentType(Formats.PLAIN)); + // Even view on this one requires authorization + crud("/auth/keys/{key-name}",new ApiKeyController(metrics), new RouteRole[]{new Role(CAC_USER), + new Role(CWMS_USERS_ROLE)}); + cdaCrudCache("/location/category/{category-id}", + new LocationCategoryController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + cdaCrudCache("/location/group/{group-id}", + new LocationGroupController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + get("/locations/with-kinds/", new LocationKindController(metrics)); + cdaCrudCache("/locations/{location-id}", + new LocationController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + + VerticalDatumController vdiController = new VerticalDatumController(metrics); + String vdiPath = format("/location/{%s}/vertical-datum", Controllers.LOCATION_ID); + get(vdiPath, ctx -> vdiController.getOne(ctx, ctx.pathParam(Controllers.LOCATION_ID))); + addCacheControl(vdiPath, 5, TimeUnit.MINUTES); + post(vdiPath, vdiController::create, requiredRoles); + patch(vdiPath, ctx -> vdiController.update(ctx, ctx.pathParam(Controllers.LOCATION_ID)), requiredRoles); + delete(vdiPath, ctx -> vdiController.delete(ctx, ctx.pathParam(Controllers.LOCATION_ID)), requiredRoles); + cdaCrudCache("/entity/{entity-id}", + new EntityController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + cdaCrudCache("/states/{state}", + new StateController(metrics), requiredRoles, 60, TimeUnit.MINUTES); + cdaCrudCache("/counties/{county}", + new CountyController(metrics), requiredRoles, 60, TimeUnit.MINUTES); + cdaCrudCache("/offices/{office}", + new OfficeController(metrics), requiredRoles, 60, TimeUnit.MINUTES); + cdaCrudCache("/units/{unit-id}", + new UnitsController(metrics), requiredRoles, 60, TimeUnit.MINUTES); + cdaCrudCache("/parameters/{param-id}", + new ParametersController(metrics), requiredRoles, 60, TimeUnit.MINUTES); + cdaCrudCache("/timezones/{zone}", + new TimeZoneController(metrics), requiredRoles,60, TimeUnit.MINUTES); + cdaCrudCache(format("/levels/{%s}", Controllers.LEVEL_ID), + new LevelsController(metrics), requiredRoles,5, TimeUnit.MINUTES); + String levelTsPath = format("/levels/{%s}/timeseries", Controllers.LEVEL_ID); + get(levelTsPath, new LevelsAsTimeSeriesController(metrics)); + addCacheControl(levelTsPath, 5, TimeUnit.MINUTES); + String levelRefsPath = "/level-refs/"; + get(levelRefsPath, new LevelRefsController(metrics)); + addCacheControl(levelRefsPath, 5, TimeUnit.MINUTES); + String recentPath = "/timeseries/recent/"; + get(recentPath, new TimeSeriesRecentController(metrics)); + addCacheControl(recentPath, 5, TimeUnit.MINUTES); + + String versionsPath = "/timeseries/versions/"; + get(versionsPath, new TimeSeriesVersionsController(metrics)); + addCacheControl(versionsPath, 5, TimeUnit.MINUTES); + + String filteredPath = "/timeseries/filtered"; + get(filteredPath, new TimeSeriesFilteredController(metrics)); + addCacheControl(filteredPath, 5, TimeUnit.MINUTES); + + cdaCrudCache(format("/standard-text-id/{%s}", Controllers.STANDARD_TEXT_ID), + new StandardTextController(metrics), requiredRoles,1, TimeUnit.DAYS); + + String textTsPath = format("/timeseries/text/{%s}", NAME); + cdaCrudCache(textTsPath, new TextTimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); + String textValuePath = textTsPath + "/value"; + get(textValuePath, new TextTimeSeriesValueController(metrics)); + addCacheControl(textValuePath, 1, TimeUnit.DAYS); + + String binTsPath = format("/timeseries/binary/{%s}", NAME); + cdaCrudCache(binTsPath, new BinaryTimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); + String textBinaryValuePath = binTsPath + "/value"; + get(textBinaryValuePath, new BinaryTimeSeriesValueController(metrics)); + addCacheControl(textBinaryValuePath, 1, TimeUnit.DAYS); + + String timeSeriesProfilePath = "/timeseries/profile/"; + get(format("%s{%s}/{%s}", timeSeriesProfilePath, Controllers.LOCATION_ID, Controllers.PARAMETER_ID), + new TimeSeriesProfileController(metrics)); + delete(format("%s/{%s}/{%s}", timeSeriesProfilePath, Controllers.LOCATION_ID, + Controllers.PARAMETER_ID), new TimeSeriesProfileDeleteController(metrics), + requiredRoles); + get(format(timeSeriesProfilePath, Controllers.LOCATION_ID, Controllers.PARAMETER_ID), + new TimeSeriesProfileCatalogController(metrics)); + post(timeSeriesProfilePath, new TimeSeriesProfileCreateController(metrics), requiredRoles); + + String timeSeriesProfileParserPath = "/timeseries/profile-parser/"; + get(format("%s{%s}/{%s}/", timeSeriesProfileParserPath, Controllers.LOCATION_ID, + Controllers.PARAMETER_ID), new TimeSeriesProfileParserController(metrics)); + post(timeSeriesProfileParserPath, new TimeSeriesProfileParserCreateController(metrics), requiredRoles); + delete(format("%s{%s}/{%s}/", timeSeriesProfileParserPath, Controllers.LOCATION_ID, + Controllers.PARAMETER_ID), new TimeSeriesProfileParserDeleteController(metrics), + requiredRoles); + get(timeSeriesProfileParserPath, new TimeSeriesProfileParserCatalogController(metrics)); + + String timeSeriesProfileInstancePath = "/timeseries/profile-instance/"; + get(format("%s{%s}/{%s}/{%s}/", timeSeriesProfileInstancePath, Controllers.LOCATION_ID, + Controllers.PARAMETER_ID, Controllers.VERSION), + new TimeSeriesProfileInstanceController(metrics)); + post(timeSeriesProfileInstancePath, new TimeSeriesProfileInstanceCreateController(metrics), requiredRoles); + delete(format("%s{%s}/{%s}/{%s}/", timeSeriesProfileInstancePath, Controllers.LOCATION_ID, + Controllers.PARAMETER_ID, Controllers.VERSION), + new TimeSeriesProfileInstanceDeleteController(metrics), requiredRoles); + get(timeSeriesProfileInstancePath, new TimeSeriesProfileInstanceCatalogController(metrics)); + + cdaCrudCache("/timeseries/category/{category-id}", + new TimeSeriesCategoryController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(String.format("/timeseries/identifier-descriptor/{%s}", Controllers.TIMESERIES_ID), + new TimeSeriesIdentifierDescriptorController(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); + cdaCrudCache("/catalog/{dataset}", + new CatalogController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/basins/{name}", + new BasinController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/streams/{%s}", NAME), + new StreamController(metrics), requiredRoles,5, TimeUnit.MINUTES); + + String downstreamLocations = format("/stream-locations/{%s}/{%s}/downstream-locations", + Controllers.OFFICE, Controllers.NAME); + get(downstreamLocations,new DownstreamLocationsGetController(metrics)); + addCacheControl(downstreamLocations, 5, TimeUnit.MINUTES); + String upstreamLocations = format("/stream-locations/{%s}/{%s}/upstream-locations", + Controllers.OFFICE, Controllers.NAME); + + get(upstreamLocations,new UpstreamLocationsGetController(metrics)); + addCacheControl(upstreamLocations, 5, TimeUnit.MINUTES); + cdaCrudCache(format("/stream-locations/{%s}", NAME), + new StreamLocationController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/stream-reaches/{%s}", NAME), + new StreamReachController(metrics), requiredRoles,1, TimeUnit.DAYS); + String measurements = "/measurements/"; + String measTimeExtents = measurements + "time-extents"; + get(measTimeExtents,new MeasurementTimeExtentsGetController(metrics)); + addCacheControl(measTimeExtents, 5, TimeUnit.MINUTES); + cdaCrudCache(format("%s{%s}", measurements, LOCATION_ID), + new cwms.cda.api.MeasurementController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/published/{%s}", LOCATION_ID), + new PublishedController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/blobs/{blob-id}", + new BlobController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/clobs/{clob-id}", + new ClobController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/pools/{pool-id}", + new PoolController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/specified-levels/{specified-level-id}", + new SpecifiedLevelController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/forecast-instance/{%s}", Controllers.NAME), + new ForecastInstanceController(metrics), requiredRoles,5, TimeUnit.MINUTES); + //-------Forecast Spec--------// + String forecastSpecPath = "/forecast-spec/{%s}"; + cdaCrudCache(format(forecastSpecPath, Controllers.NAME), + new ForecastSpecController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + //----------------------------// + String forecastFilePath = format("/forecast-instance/{%s}/file-data", NAME); + get(forecastFilePath, new ForecastFileController(metrics)); + addCacheControl(forecastFilePath, 1, TimeUnit.DAYS); + + + post(format("/projects/status-update/{%s}", NAME), new ProjectPublishStatusUpdate(metrics), requiredRoles); + + addWaterUserHandlers(format("/projects/{%s}/{%s}/water-user", OFFICE, PROJECT_ID), requiredRoles, metrics); + addWaterContractHandlers(format("/projects/{%s}/{%s}/water-user/{%s}/contracts", OFFICE, PROJECT_ID, + WATER_USER), requiredRoles, metrics); + addAccountingHandlers(format("/projects/{%s}/{%s}/water-user/{%s}" + + "/contracts/{%s}/accounting", OFFICE, PROJECT_ID, WATER_USER, CONTRACT_NAME), requiredRoles, metrics); + delete(format("/projects/{%s}/{%s}/water-user/{%s}/contracts/{%s}/pumps/{%s}", OFFICE, PROJECT_ID, + WATER_USER, CONTRACT_NAME, NAME), new WaterPumpDisassociateController(metrics), requiredRoles); + addWaterContractTypeHandlers(format("/projects/{%s}/contract-types", OFFICE), requiredRoles, metrics); + + cdaCrudCache(format("/projects/embankments/{%s}", Controllers.NAME), + new EmbankmentController(metrics), requiredRoles,1, TimeUnit.DAYS); + cdaCrudCache(format("/projects/turbines/{%s}", Controllers.NAME), + new TurbineController(metrics), requiredRoles,1, TimeUnit.DAYS); + cdaCrudCache(format("/projects/locks/{%s}", Controllers.NAME), + new LockController(metrics), requiredRoles,1, TimeUnit.DAYS); + String turbineChanges = format("/projects/{%s}/{%s}/turbine-changes", Controllers.OFFICE, Controllers.NAME); + get(turbineChanges,new TurbineChangesGetController(metrics)); + addCacheControl(turbineChanges, 5, TimeUnit.MINUTES); + post(turbineChanges, new TurbineChangesPostController(metrics), requiredRoles); + delete(turbineChanges, new TurbineChangesDeleteController(metrics), requiredRoles); + + String outletPath = format("/projects/outlets/{%s}", NAME); + String gateChangePath = format("/projects/{%s}/{%s}/gate-changes", OFFICE, + Controllers.PROJECT_ID); + String gateChangeCreatePath = "/projects/gate-changes"; + String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, + Controllers.PROJECT_ID, NAME); + String virtualOutletCreatePath = "/projects/virtual-outlets"; + cdaCrudCache(outletPath, new OutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); + post(gateChangeCreatePath, new GateChangeCreateController(metrics), requiredRoles); + get(gateChangePath, new GateChangeGetAllController(metrics)); + delete(gateChangePath, new GateChangeDeleteController(metrics), requiredRoles); + cdaCrudCache(virtualOutletPath, new VirtualOutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); + post(virtualOutletCreatePath, new VirtualOutletCreateController(metrics), requiredRoles); + + get("/projects/locations/", new ProjectChildLocationHandler(metrics)); + cdaCrudCache(format("/projects/{%s}", Controllers.NAME), + new ProjectController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/properties/{%s}", Controllers.NAME), + new PropertyController(metrics), true, requiredRoles,1, TimeUnit.DAYS); + cdaCrudCache(format("/lookup-types/{%s}", Controllers.NAME), + new LookupTypeController(metrics), requiredRoles,1, TimeUnit.DAYS); + + addProjectLocksHandlers("/project-locks/{name}", requiredRoles, metrics); + addProjectLockRightsHandlers("/project-lock-rights/{project-id}", requiredRoles, metrics); + + addUserManagementHandlers(metrics, cdaAccessManager); + + get("/version/", new CdaVersionHandler(metrics), requiredRoles); + get(format("/rss/{%s}/{%s}", Controllers.OFFICE, Controllers.NAME), new RssHandler(metrics)); + } + + private static void addUserManagementHandlers(MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + RouteRole[] adminRoles = new RouteRole[] { new Role("CWMS User Admins")}; + RouteRole[] userRoles = new RouteRole[] {new Role(CWMS_USERS_ROLE), new Role(CAC_USER)}; + crud("/users/{user-name}", new UsersController(metrics), adminRoles); + get("/roles", new GetRolesController(metrics), adminRoles); + String userProfilePath = "/user/profile"; + get(userProfilePath, new UserProfileController(metrics), userRoles); + cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServletRouteConfiguration::hasAnyRole); + addUserListHandlers(userRoles, metrics, cdaAccessManager); + post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); + delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); + + } + + private static void addUserListHandlers(RouteRole[] userRoles, MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + String userListCandidatesPath = "/user/list-member-candidates"; + String userListsPath = "/user/list"; + String userListPath = "/user/list/{user-list-id}"; + String userListMembersPath = "/user/list/{user-list-id}/members"; + String userListMemberPath = "/user/list/{user-list-id}/members/{user-id}"; + if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { + get(userListCandidatesPath, new UserListCandidatesController(metrics), userRoles); + get(userListsPath, new UserListsController(metrics), userRoles); + post(userListsPath, new CreateUserListController(metrics), userRoles); + get(userListPath, new UserListController(metrics), userRoles); + patch(userListPath, new UpdateUserListController(metrics), userRoles); + delete(userListPath, new DeleteUserListController(metrics), userRoles); + get(userListMembersPath, new UserListMembersController(metrics), userRoles); + post(userListMembersPath, new AddUserListMemberController(metrics), userRoles); + delete(userListMemberPath, new UserListMemberController(metrics), userRoles); + } else { + get(userListCandidatesPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListsPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + post(userListsPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + patch(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + delete(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListMembersPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + post(userListMembersPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + delete(userListMemberPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + } + cdaAccessManager.addCustomAuthorizer(userListCandidatesPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListMemberPath, ApiServletRouteConfiguration::hasAnyRole); + } + + private static void userListsUnsupported(Context ctx) { + ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED) + .json(new CdaError("User lists are not enabled for this CDA deployment.")); + } + + private static Boolean hasAnyRole(DataApiPrincipal p, Set roles) throws MissingRolesException { + boolean retVal = roles.stream().anyMatch(p.getRoles()::contains); + if(!retVal) { + List requiredRoleNames = roles.stream() + .map(Object::toString) + .collect(toList()); + throw new MissingRolesException(requiredRoleNames, "Missing one of the following roles {" + String.join(",", requiredRoleNames) + "}"); + } + return true; + } + + private static void addRatingHandlers(RouteRole[] requiredRoles, MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + /** + * The POST handlers for /ratings/rate-* intentionally do not have + * require roles. Instead they are rate limited if not authenticated. + * POST is used as sending a body with GET is not standard and we cannot + * be sure clients, or future servers, would correctly support that. + */ + String rateValues = format("/ratings/rate-values/{%s}/{%s}", OFFICE, RATING_ID); + post(rateValues, new RateValuesController(metrics)); + String rateTs = format("/ratings/rate-ts/{%s}/{%s}", OFFICE, RATING_ID); + post(rateTs, new RateTimeSeriesController(metrics)); + String reverseRateValues = format("/ratings/reverse-rate-values/{%s}/{%s}", OFFICE, RATING_ID); + post(reverseRateValues, new ReverseRateValuesController(metrics)); + String reverseRateTs = format("/ratings/reverse-rate-ts/{%s}/{%s}", OFFICE, RATING_ID); + post(reverseRateTs, new ReverseRateTimeSeriesController(metrics)); + cdaCrudCache("/ratings/template/{template-id}", + new RatingTemplateController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/ratings/spec/{rating-id}", + new RatingSpecController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/ratings/metadata/{rating-id}", + new RatingMetadataController(metrics), requiredRoles,5, TimeUnit.MINUTES); + get("/ratings/{rating-id}/latest", new RatingLatestController(metrics)); + get("/ratings/effective-dates", new RatingEffectiveDatesController(metrics)); + cdaCrudCache("/ratings/{rating-id}", + new RatingController(metrics), requiredRoles,5, TimeUnit.MINUTES); + addRateLimit(rateTs, requiredRoles, cdaAccessManager); + addRateLimit(reverseRateTs, requiredRoles, cdaAccessManager); + addRateLimit(reverseRateValues, requiredRoles, cdaAccessManager); + addRateLimit(rateValues, requiredRoles, cdaAccessManager); + } + + /** + * Add a rate limiter to a specified endpoint path, allowing authorized users to bypass the limit. + * + * @param path the path to add the rate limiter to. + * @param requiredRoles the user roles required to access the path. + * @param cdaAccessManager + */ + private static void addRateLimit(String path, RouteRole[] requiredRoles, CdaAccessManager cdaAccessManager) { + cdaAccessManager.addRateLimitedEndpoint(path, requiredRoles); + } + + private static void addAccountingHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path, new AccountingCatalogController(metrics)); + post(path, new AccountingCreateController(metrics), requiredRoles); + } + + private static void addProjectLocksHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + String pathWithoutResource = path.replace(getResourceId(path), ""); + + get(path, new ProjectLockGetOne(metrics), requiredRoles); + get(pathWithoutResource, new ProjectLockCatalog(metrics), requiredRoles); + post(pathWithoutResource + "deny", new ProjectLockRevokeDeny(metrics), requiredRoles); + post(pathWithoutResource, new ProjectLockRequest(metrics), requiredRoles); + post(pathWithoutResource + "release", new ProjectLockRelease(metrics), requiredRoles); + delete(path, new ProjectLockRevoke(metrics), requiredRoles); + } + + private static void addProjectLockRightsHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + String pathWithoutResource = path.replace(getResourceId(path), ""); + get(pathWithoutResource, new LockRevokerRightsCatalog(metrics), requiredRoles); + post(pathWithoutResource + "remove-all", new RemoveAllLockRevokerRights(metrics), requiredRoles); + post(pathWithoutResource + "update", new UpdateLockRevokerRights(metrics), requiredRoles); + + } + + + private static void addWaterUserHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path + format("/{%s}", WATER_USER), new WaterUserController(metrics), requiredRoles); + get(path, new WaterUserCatalogController(metrics), requiredRoles); + post(path, new WaterUserCreateController(metrics), requiredRoles); + patch(path + format("/{%s}", WATER_USER), new WaterUserUpdateController(metrics), requiredRoles); + delete(path + format("/{%s}", WATER_USER), new WaterUserDeleteController(metrics), requiredRoles); + } + + private static void addWaterContractHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path + format("/{%s}", CONTRACT_NAME), new WaterContractController(metrics), requiredRoles); + get(path, new WaterContractCatalogController(metrics), requiredRoles); + post(path, new WaterContractCreateController(metrics), requiredRoles); + patch(path + format("/{%s}", CONTRACT_NAME), new WaterContractUpdateController(metrics), requiredRoles); + delete(path + format("/{%s}", CONTRACT_NAME), new WaterContractDeleteController(metrics), requiredRoles); + } + + private static void addWaterContractTypeHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + post(path, new WaterContractTypeCreateController(metrics), requiredRoles); + get(path, new WaterContractTypeCatalogController(metrics), requiredRoles); + delete(path + "/{display-value}", new WaterContractTypeDeleteController(metrics), requiredRoles); + } + + /** + * Given a path like "/location/category/{category-id}" this method returns "{category-id}". + * @param fullPath the full path to extract the resource id from. + * @return the resource id portion of the path. + * @throws IllegalArgumentException if the path does not contain a resource id. + */ + @NotNull + public static String getResourceId(String fullPath) { + String[] subPaths = Arrays.stream(fullPath.split("/")) + .filter(it -> !it.isEmpty()).toArray(String[]::new); + if (subPaths.length < 2) { + throw new IllegalArgumentException("CrudHandler requires a path like " + + "'/resource/{resource-id}' given: " + fullPath); + } + String resourceId = subPaths[subPaths.length - 1]; + if (!( + (resourceId.startsWith("{") && resourceId.endsWith("}")) + || + (resourceId.startsWith("<") && resourceId.endsWith(">")) + )) { + throw new IllegalArgumentException("CrudHandler requires a path-parameter at the " + + "end of the provided path, e.g. '/users/{user-id}' or '/users/' given: " + fullPath); + } + String resourceBase = subPaths[subPaths.length - 2]; + if (resourceBase.startsWith("{") || resourceBase.startsWith("<") + || resourceBase.endsWith("}") || resourceBase.endsWith(">")) { + throw new IllegalArgumentException("CrudHandler requires a resource base at the " + + "beginning of the provided path, e.g. '/users/{user-id}' given: " + fullPath); + } + return resourceId; + } + + /** + * This method delegates to the cdaCrud method but also adds an after filter for the specified + * path. If the request was a GET request and the response does not already include + * Cache-Control then the filter will add the Cache-Control max-age header with the specified + * number of seconds. + * Controllers can include their own Cache-Control headers via: + * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" + * This method lets the ApiServlet configure a default max-age for controllers that don't or + * forget to set their own. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param roles the required these roles are present to access post, patch + * @param duration the number of TimeUnit to cache GET responses. + * @param timeUnit the TimeUnit to use for duration. + */ + private static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, + @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { + cdaCrudCache(path, crudHandler, false, roles, duration, timeUnit); + } + + /** + * This method delegates to the cdaCrud method but also adds an after filter for the specified + * path. If the request was a GET request and the response does not already include + * Cache-Control then the filter will add the Cache-Control max-age header with the specified + * number of seconds. + * Controllers can include their own Cache-Control headers via: + * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" + * This method lets the ApiServlet configure a default max-age for controllers that don't or + * forget to set their own. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param getRequiresAuth if the get handlers should have an authorization check + * @param roles the required these roles are present to access post, patch + * @param duration the number of TimeUnit to cache GET responses. + * @param timeUnit the TimeUnit to use for duration. + */ + private static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, + @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { + cdaCrud(path, crudHandler, getRequiresAuth, roles); + + // path like /offices/{office} will match /offices/SWT getOne style url + addCacheControl(path, duration, timeUnit); + + String pathWithoutResource = path.replace(getResourceId(path), ""); + // path like "/offices/" matches /offices getAll style url + addCacheControl(pathWithoutResource, duration, timeUnit); + } + + private static void addCacheControl(@NotNull String path, long duration, TimeUnit timeUnit) { + if (timeUnit != null && duration > 0) { + staticInstance().after(path, ctx -> { + String method = ctx.req.getMethod(); // "GET" + if (ctx.status() == HttpServletResponse.SC_OK + && "GET".equals(method) + && (!ctx.res.containsHeader(Header.CACHE_CONTROL))) { + // only set the cache control header if it is not already set. + ctx.header(Header.CACHE_CONTROL, "max-age=" + timeUnit.toSeconds(duration)); + } + }); + } + } + + /** + * This method is very similar to the ApiBuilder.crud method but the specified roles + * are only required for the post, patch and delete methods. getOne and getAll are always + * allowed. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param roles the accessmanager will require these roles are present to access post, patch + * and delete methods + */ + private static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, + @NotNull RouteRole... roles) { + cdaCrud(path, crudHandler, false, roles); + } + + /** + * This method is very similar to the ApiBuilder.crud method but the specified roles + * are only required for the post, patch and delete methods. getOne and getAll are always + * allowed. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param getRequiresAuth If all operations on this handler should have an authorization check + * @param roles the accessmanager will require these roles are present to access post, patch + * and delete methods + */ + private static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, + @NotNull RouteRole... roles) { + String fullPath = prefixPath(path); + String resourceId = getResourceId(fullPath); + + //noinspection KotlinInternalInJava + Map crudFunctions = CrudHandlerKt.getCrudFunctions(crudHandler, resourceId); + + Javalin instance = staticInstance(); + // getOne and getAll are assumed not to need authorization + String pathWithoutResource = fullPath.replace(resourceId, ""); + if (getRequiresAuth) { + instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE), roles); + instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL), roles); + } else { + instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE)); + instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL)); + } + + // create, update and delete need authorization. + instance.post(pathWithoutResource, crudFunctions.get(CrudFunction.CREATE), roles); + instance.patch(fullPath, crudFunctions.get(CrudFunction.UPDATE), roles); + instance.delete(fullPath, crudFunctions.get(CrudFunction.DELETE), roles); + } + + private static String formatV2(String path, Object... args) { + return format("/v2/" + path, args); + } +} 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..125580822e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupController.java @@ -0,0 +1,286 @@ +/* + * 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.GROUP_OFFICE_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.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); + } + + @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, 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); + } + } + + @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()); + 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); + } + } + + @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); + } + + } + } + + @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); + } + } + + /** + * Persists a new description for a group, if the description has changed. Preserves the + * group's existing assigned time series. + * + * @param dao The dao to use to persist the change. + * @param existingGroup The group as currently stored. + * @param newDescription The description provided in the request. If null, no update occurs. + * @return The group reflecting the persisted description (or the unmodified existing group if + * no update was necessary). + */ + protected TimeSeriesGroup updateDescriptionIfChanged(TimeSeriesGroupDao dao, TimeSeriesGroup existingGroup, + String newDescription) { + if (newDescription != null && !newDescription.equalsIgnoreCase(existingGroup.getDescription())) { + TimeSeriesGroup updated = new TimeSeriesGroup(new TimeSeriesGroup(existingGroup.getTimeSeriesCategory(), + existingGroup.getOfficeId(), existingGroup.getId(), newDescription, + existingGroup.getSharedAliasId(), existingGroup.getSharedRefTsId()), + existingGroup.getAssignedTimeSeries()); + dao.create(updated, false, false); + return updated; + } + return existingGroup; + } + + /** + * Sets the response status/body for an update that attempted to assign one or more time + * series that do not exist. If there were no missing time series, this just sets a 200 OK + * status. Shared between v1's full-body update and v2's membership-based update, both of + * which can attempt to assign time series as part of a PATCH. + * + * @param ctx The request context to set the response on. + * @param missingTimeSeries Time series that were requested to be assigned but do not exist. + * @param ignoreMissing Whether missing time series should be tolerated (207) or treated as a + * failure (400). + */ + protected void respondToMissingTimeSeries(Context ctx, List missingTimeSeries, boolean ignoreMissing) { + if (missingTimeSeries.isEmpty()) { + ctx.status(HttpServletResponse.SC_OK); + return; + } + + Map detailsMap = new HashMap<>(); + StringBuilder sb = new StringBuilder(); + for (CwmsId cwmsId : missingTimeSeries) { + sb.append(cwmsId.getName()); + sb.append(", "); + } + sb.delete(sb.length() - 2, sb.length()); + detailsMap.put("missing-timeseries", sb.toString()); + if (ignoreMissing) { + ctx.status(HttpCode.MULTI_STATUS); + } else { + ctx.status(HttpServletResponse.SC_BAD_REQUEST); + detailsMap.put("message", + "One or more time series were not found and could not be assigned to the group"); + } + ctx.json(detailsMap); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java similarity index 54% rename from cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java rename to cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java index 93df958cc6..b3cf410e84 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesGroupController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV1.java @@ -22,17 +22,14 @@ * SOFTWARE. */ -package cwms.cda.api; +package cwms.cda.api.timeseriesgroup; -import static com.codahale.metrics.MetricRegistry.name; import static cwms.cda.api.Controllers.CASCADE_DELETE; import static cwms.cda.api.Controllers.CATEGORY_ID; import static cwms.cda.api.Controllers.CATEGORY_OFFICE_ID; import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.CWMS_OFFICE; import static cwms.cda.api.Controllers.FAIL_IF_EXISTS; -import static cwms.cda.api.Controllers.GET_ALL; -import static cwms.cda.api.Controllers.GET_ONE; import static cwms.cda.api.Controllers.GROUP_ID; import static cwms.cda.api.Controllers.GROUP_OFFICE_ID; import static cwms.cda.api.Controllers.IGNORE_MISSING; @@ -40,63 +37,41 @@ import static cwms.cda.api.Controllers.INCLUDE_ASSIGNED; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.REPLACE_ASSIGNED_TS; -import static cwms.cda.api.Controllers.RESULTS; -import static cwms.cda.api.Controllers.SIZE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.STATUS_404; import static cwms.cda.api.Controllers.STATUS_501; import static cwms.cda.api.Controllers.TIMESERIES_CATEGORY_LIKE; import static cwms.cda.api.Controllers.TIMESERIES_GROUP_LIKE; -import static cwms.cda.api.Controllers.UPDATE; -import static cwms.cda.api.Controllers.queryParamAsClass; import static cwms.cda.api.Controllers.requiredParam; -import static cwms.cda.data.dao.JooqDao.getDslContext; -import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; -import com.google.common.flogger.FluentLogger; -import cwms.cda.api.errors.CdaError; import cwms.cda.data.dao.TimeSeriesGroupDao; import cwms.cda.data.dto.CwmsId; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; -import io.javalin.apibuilder.CrudHandler; -import io.javalin.core.util.Header; import io.javalin.http.Context; -import io.javalin.http.HttpCode; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; import io.javalin.plugin.openapi.annotations.OpenApiContent; import io.javalin.plugin.openapi.annotations.OpenApiParam; import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; import io.javalin.plugin.openapi.annotations.OpenApiResponse; -import java.io.IOException; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; -public class TimeSeriesGroupController implements CrudHandler { - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - public static final String TAG = "Timeseries Groups"; - - private final MetricRegistry metrics; - - private final Histogram requestResultSize; - - public TimeSeriesGroupController(MetricRegistry metrics) { - this.metrics = metrics; - String className = this.getClass().getName(); - - requestResultSize = this.metrics.histogram((name(className, RESULTS, SIZE))); - } +/** + * Version 1 of the Timeseries Group controller. Create, delete, and retrieve behavior is shared + * with {@link TimeSeriesGroupControllerV2} via {@link TimeSeriesGroupController}. + * This version's PATCH/update accepts a full {@link TimeSeriesGroup} body; the assigned time + * series in that body either replace or are added to the group's existing assignments. + */ +public class TimeSeriesGroupControllerV1 extends TimeSeriesGroupController { - private Timer.Context markAndTime(String subject) { - return Controllers.markAndTime(metrics, getClass().getName(), subject); + public TimeSeriesGroupControllerV1(MetricRegistry metrics) { + super(metrics); } @OpenApi( @@ -127,49 +102,7 @@ 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); - } - + super.getAll(ctx); } @OpenApi( @@ -198,43 +131,7 @@ 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); - } + super.getOne(ctx, groupId); } @OpenApi( @@ -265,49 +162,7 @@ public void getOne(@NotNull Context ctx, @NotNull String groupId) { ) @Override public void create(@NotNull Context ctx) { - try (Timer.Context ignored = markAndTime(CREATE)) { - DSLContext dsl = getDslContext(ctx); - - String formatHeader = ctx.req.getContentType(); - String body = ctx.body(); - ContentType contentType = Formats.parseHeader(formatHeader, TimeSeriesGroup.class); - TimeSeriesGroup deserialize = Formats.parseContent(contentType, body, TimeSeriesGroup.class); - - if (!deserialize.getTimeSeriesCategory().getOfficeId().equalsIgnoreCase(CWMS_OFFICE) - && (!deserialize.getOfficeId().equalsIgnoreCase(deserialize.getTimeSeriesCategory().getOfficeId()) - || deserialize.getOfficeId().equalsIgnoreCase(CWMS_OFFICE))) { - throw new IllegalArgumentException("TimeSeries Group office ID cannot be CWMS and must match the " - + "TimeSeries Category office ID"); - } - - boolean ignoreNulls = ctx.queryParamAsClass(IGNORE_NULLS, Boolean.class).getOrDefault(true); - boolean failIfExists = ctx.queryParamAsClass(FAIL_IF_EXISTS, Boolean.class).getOrDefault(true); - boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); - TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); - List missingTimeSeries = dao.create(deserialize, failIfExists, ignoreNulls, ignoreMissing); - if (missingTimeSeries.isEmpty()) { - ctx.status(HttpServletResponse.SC_CREATED); - } else { - Map detailsMap = new HashMap<>(); - StringBuilder sb = new StringBuilder(); - for (CwmsId cwmsId : missingTimeSeries) { - sb.append(cwmsId.getName()); - sb.append(", "); - } - sb.delete(sb.length() - 2, sb.length()); - detailsMap.put("missing-time-series", sb.toString()); - if (ignoreMissing) { - ctx.status(HttpCode.MULTI_STATUS); - - } else { - ctx.status(HttpServletResponse.SC_BAD_REQUEST); - detailsMap.put("message", - "One or more time series were not found and could not be assigned to the group"); - } - ctx.json(detailsMap); - } - - } + super.create(ctx); } @OpenApi( @@ -350,10 +205,7 @@ public void update(@NotNull Context ctx, @NotNull String oldGroupId) { TimeSeriesGroupDao timeSeriesGroupDao = new TimeSeriesGroupDao(dsl); TimeSeriesGroup existingGroup = timeSeriesGroupDao.getTimeSeriesGroup(office, null, null, group.getTimeSeriesCategory().getId(), oldGroupId); - if (!existingGroup.getDescription().equalsIgnoreCase(group.getDescription())) { - existingGroup = updateClearedFields(group, existingGroup); - timeSeriesGroupDao.create(existingGroup, false, false); - } + updateDescriptionIfChanged(timeSeriesGroupDao, existingGroup, group.getDescription()); if (!office.equalsIgnoreCase(CWMS_OFFICE) && !oldGroupId.equals(group.getId())) { timeSeriesGroupDao.renameTimeSeriesGroup(oldGroupId, group); } @@ -363,37 +215,10 @@ public void update(@NotNull Context ctx, @NotNull String oldGroupId) { } boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); List missingTimeSeries = timeSeriesGroupDao.assignTs(group, office, ignoreMissing); - if (missingTimeSeries.isEmpty()) { - ctx.status(HttpServletResponse.SC_OK); - } else { - Map detailsMap = new HashMap<>(); - StringBuilder sb = new StringBuilder(); - for (CwmsId cwmsId : missingTimeSeries) { - sb.append(cwmsId.getName()); - sb.append(", "); - } - sb.delete(sb.length() - 2, sb.length()); - detailsMap.put("missing-timeseries", sb.toString()); - if (ignoreMissing) { - ctx.status(HttpCode.MULTI_STATUS); - } else { - ctx.status(HttpServletResponse.SC_BAD_REQUEST); - detailsMap.put("message", - "One or more time series were not found and could not be assigned to the group"); - } - ctx.json(detailsMap); - } + respondToMissingTimeSeries(ctx, missingTimeSeries, ignoreMissing); } } - private TimeSeriesGroup updateClearedFields(TimeSeriesGroup groupBody, - TimeSeriesGroup existingTimeSeriesGroup) { - return new TimeSeriesGroup(new TimeSeriesGroup(existingTimeSeriesGroup.getTimeSeriesCategory(), - existingTimeSeriesGroup.getOfficeId(), existingTimeSeriesGroup.getId(), groupBody.getDescription(), - existingTimeSeriesGroup.getSharedAliasId(), existingTimeSeriesGroup.getSharedRefTsId()), - existingTimeSeriesGroup.getAssignedTimeSeries()); - } - @OpenApi( description = "Deletes requested time series group", pathParams = { @@ -413,16 +238,6 @@ 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); - } + super.delete(ctx, groupId); } } 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..5a2cc049d7 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/timeseriesgroup/TimeSeriesGroupControllerV2.java @@ -0,0 +1,272 @@ +/* + * 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.GROUP_OFFICE_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 static cwms.cda.api.Controllers.requiredParam; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.data.dao.TimeSeriesGroupDao; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +/** + * Version 2 of the Timeseries Group controller. Create, delete, and retrieve are identical to v1 + * (see {@link TimeSeriesGroupController}). PATCH/update differs: instead of a full + * {@link TimeSeriesGroup} body carrying the complete list of assigned time series, this version + * accepts a {@link TimeSeriesGroupPatch} body whose {@link Membership} describes only the time + * series ids to assign and/or unassign. This lets callers add or remove a handful of time series + * from a (potentially very large) group without submitting the group's entire list of assigned + * time series. + */ +public final class TimeSeriesGroupControllerV2 extends TimeSeriesGroupController { + + public TimeSeriesGroupControllerV2(MetricRegistry metrics) { + super(metrics); + } + + @OpenApi( + 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."), + @OpenApiParam(name = GROUP_OFFICE_ID, description = "Specifies the owning office of the " + + "timeseries group"), + @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) { + super.getAll(ctx); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = GROUP_ID, required = true, description = "Specifies " + + "the timeseries group whose data is to be included in the response") + }, + queryParams = { + @OpenApiParam(name = OFFICE, description = "Specifies the " + + "owning office of the timeseries assigned to the group whose data is to be included" + + " in the response. This will limit the assigned timeseries returned to only those" + + " assigned to the specified office."), + @OpenApiParam(name = CATEGORY_OFFICE_ID, description = "Specifies the owning office of the " + + "timeseries group category"), + @OpenApiParam(name = GROUP_OFFICE_ID, description = "Specifies the owning office of the " + + "timeseries group"), + @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) { + super.getOne(ctx, groupId); + } + + @OpenApi( + description = "Create new TimeSeriesGroup", + 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) { + 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 = 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 = OFFICE, required = true, description = "Specifies the " + + "office of the user making the request. This is the office that the timeseries, group, and category " + + "belong to. If the group and/or category belong to the CWMS office, " + + "this only identifies the timeseries."), + }, + 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 = requiredParam(ctx, OFFICE); + ContentType contentType = Formats.parseHeader(ctx.req.getContentType(), TimeSeriesGroupPatch.class); + TimeSeriesGroupPatch patch = Formats.parseContent(contentType, ctx.body(), TimeSeriesGroupPatch.class); + + TimeSeriesGroupDao dao = new TimeSeriesGroupDao(dsl); + String categoryId = patch.getTimeSeriesCategory().getId(); + TimeSeriesGroup existingGroup = dao.getTimeSeriesGroup(office, null, null, categoryId, oldGroupId); + existingGroup = updateDescriptionIfChanged(dao, existingGroup, patch.getDescription()); + + String currentGroupId = oldGroupId; + if (!office.equalsIgnoreCase(CWMS_OFFICE) && patch.getId() != null + && !oldGroupId.equals(patch.getId())) { + TimeSeriesGroup renameTarget = new TimeSeriesGroup(existingGroup.getTimeSeriesCategory(), + existingGroup.getOfficeId(), patch.getId(), existingGroup.getDescription(), + existingGroup.getSharedAliasId(), existingGroup.getSharedRefTsId()); + dao.renameTimeSeriesGroup(oldGroupId, renameTarget); + currentGroupId = patch.getId(); + } + + boolean ignoreMissing = ctx.queryParamAsClass(IGNORE_MISSING, Boolean.class).getOrDefault(false); + Membership membership = patch.getMembership(); + List missingTimeSeries = Collections.emptyList(); + if (membership != null) { + List unassign = membership.getUnassign(); + if (unassign != null && !unassign.isEmpty()) { + dao.unassignTsIds(categoryId, currentGroupId, office, unassign); + } + + List assign = membership.getAssign(); + if (assign != null && !assign.isEmpty()) { + List assignedTimeSeries = new ArrayList<>(); + for (AssignedTimeSeries ts : assign) { + String tsOffice = ts.getOfficeId() != null ? ts.getOfficeId() : office; + assignedTimeSeries.add(new AssignedTimeSeries(tsOffice, ts.getTimeseriesId(), + ts.getAliasId(), ts.getRefTsId(), ts.getAttribute())); + } + TimeSeriesGroup groupForAssign = new TimeSeriesGroup(new TimeSeriesGroup( + existingGroup.getTimeSeriesCategory(), office, currentGroupId, + existingGroup.getDescription(), existingGroup.getSharedAliasId(), + existingGroup.getSharedRefTsId()), assignedTimeSeries); + missingTimeSeries = dao.assignTs(groupForAssign, office, ignoreMissing); + } + } + + respondToMissingTimeSeries(ctx, missingTimeSeries, ignoreMissing); + } + } + + @OpenApi( + description = "Deletes requested time series group", + pathParams = { + @OpenApiParam(name = 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 = OFFICE, required = true, description = "Specifies the " + + "owning office 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) { + super.delete(ctx, groupId); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java index 6398c2152c..c7449fb210 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesCategoryDao.java @@ -25,7 +25,7 @@ package cwms.cda.data.dao; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import java.util.List; import java.util.Objects; import java.util.Optional; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java index 41a23fab9e..32a0bb3c21 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesGroupDao.java @@ -34,7 +34,7 @@ import cwms.cda.data.dto.AssignedTimeSeries; import cwms.cda.data.dto.CwmsId; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -377,6 +377,21 @@ private void deleteViaUnassign(DSLContext dslContext, String categoryId, String } + public void unassignTsIds(String categoryId, String groupId, String office, List tsIds) { + if (tsIds == null || tsIds.isEmpty()) { + throw new IllegalArgumentException("At least one time series id must be provided to unassign."); + } + + connection(dsl, conn -> { + DSLContext dslContext = getDslContext(conn, office); + dslContext.transaction((Configuration config) -> { + for (String tsId : tsIds) { + CWMS_TS_PACKAGE.call_UNASSIGN_TS_GROUP(config, categoryId, groupId, tsId, "F", office); + } + }); + }); + } + public void unassignAll(String categoryId, String groupId, String office) { dsl.transaction((Configuration config) -> unassignAll(config, categoryId, groupId, office) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java new file mode 100644 index 0000000000..db6ea4ea43 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/Membership.java @@ -0,0 +1,91 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.timeseriesgroup; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsDTOBase; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "Describes time series to assign to, and/or unassign from, a timeseries group") +@JsonDeserialize(builder = Membership.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class Membership extends CwmsDTOBase { + private final List assign; + private final List unassign; + + private Membership(Builder builder) { + this.assign = builder.assign != null ? builder.assign : new ArrayList<>(); + this.unassign = builder.unassign != null ? builder.unassign : new ArrayList<>(); + } + + @Schema(description = "Time series to assign to the group") + public List getAssign() { + return assign; + } + + @Schema(description = "Time series ids to unassign from the group") + public List getUnassign() { + return unassign; + } + + public static class Builder { + private List assign; + private List unassign; + + public Builder() { + } + + public Builder withAssign(List assign) { + this.assign = assign != null ? new ArrayList<>(assign) : null; + return this; + } + + public Builder withUnassign(List unassign) { + this.unassign = unassign != null ? new ArrayList<>(unassign) : null; + return this; + } + + @JsonIgnore + public Builder from(Membership membership) { + if (membership != null) { + this.assign = membership.getAssign(); + this.unassign = membership.getUnassign(); + } + return this; + } + + public Membership build() { + return new Membership(this); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java similarity index 97% rename from cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java rename to cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java index d237ade474..dcaf8b0a8b 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/TimeSeriesGroup.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroup.java @@ -22,12 +22,15 @@ * SOFTWARE. */ -package cwms.cda.data.dto; +package cwms.cda.data.dto.timeseriesgroup; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonRootName; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.CwmsDTO; +import cwms.cda.data.dto.TimeSeriesCategory; import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; import cwms.cda.formatters.json.JsonV1; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java new file mode 100644 index 0000000000..185ed0e08a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/timeseriesgroup/TimeSeriesGroupPatch.java @@ -0,0 +1,160 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.timeseriesgroup; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTO; +import cwms.cda.data.dto.TimeSeriesCategory; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV2; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "A PATCH of a timeseries group, describing time series " + + "including membership describing assignment and unassignment of time series to the group.") +@JsonRootName("timeseries-group") +@JsonDeserialize(builder = TimeSeriesGroupPatch.Builder.class) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV2.class) +public final class TimeSeriesGroupPatch extends CwmsDTO { + @JsonProperty(required = true) + private final String id; + + @JsonProperty(required = true) + private final TimeSeriesCategory timeSeriesCategory; + + private final String description; + private final String sharedAliasId; + private final String sharedRefTsId; + private final Membership membership; + + private TimeSeriesGroupPatch(Builder builder) { + super(builder.officeId); + this.id = builder.id; + this.timeSeriesCategory = builder.timeSeriesCategory != null + ? new TimeSeriesCategory(builder.timeSeriesCategory) : null; + this.description = builder.description; + this.sharedAliasId = builder.sharedAliasId; + this.sharedRefTsId = builder.sharedRefTsId; + this.membership = builder.membership; + } + + public String getId() { + return id; + } + + public TimeSeriesCategory getTimeSeriesCategory() { + return timeSeriesCategory; + } + + public String getDescription() { + return description; + } + + public String getSharedAliasId() { + return sharedAliasId; + } + + public String getSharedRefTsId() { + return sharedRefTsId; + } + + public Membership getMembership() { + return membership; + } + + public static class Builder { + private String officeId; + private String id; + private TimeSeriesCategory timeSeriesCategory; + private String description; + private String sharedAliasId; + private String sharedRefTsId; + private Membership membership; + + public Builder() { + } + + public Builder withOfficeId(String officeId) { + this.officeId = officeId; + return this; + } + + public Builder withId(String id) { + this.id = id; + return this; + } + + public Builder withTimeSeriesCategory(TimeSeriesCategory timeSeriesCategory) { + this.timeSeriesCategory = timeSeriesCategory; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withSharedAliasId(String sharedAliasId) { + this.sharedAliasId = sharedAliasId; + return this; + } + + public Builder withSharedRefTsId(String sharedRefTsId) { + this.sharedRefTsId = sharedRefTsId; + return this; + } + + public Builder withMembership(Membership membership) { + this.membership = membership; + return this; + } + + @JsonIgnore + public Builder from(TimeSeriesGroupPatch patch) { + if (patch != null) { + this.officeId = patch.getOfficeId(); + this.id = patch.getId(); + this.timeSeriesCategory = patch.getTimeSeriesCategory(); + this.description = patch.getDescription(); + this.sharedAliasId = patch.getSharedAliasId(); + this.sharedRefTsId = patch.getSharedRefTsId(); + this.membership = patch.getMembership(); + } + return this; + } + + public TimeSeriesGroupPatch build() { + return new TimeSeriesGroupPatch(this); + } + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java b/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java index 660b554955..a5269a6f02 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java @@ -32,6 +32,7 @@ import com.github.javaparser.ast.expr.MethodCallExpr; import com.github.javaparser.ast.expr.NameExpr; import com.github.javaparser.resolution.Resolvable; +import com.github.javaparser.resolution.declarations.ResolvedMethodDeclaration; import com.github.javaparser.resolution.declarations.ResolvedValueDeclaration; import com.github.javaparser.resolution.types.ResolvedType; import com.google.common.flogger.FluentLogger; @@ -47,6 +48,7 @@ import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -281,7 +283,7 @@ private OpenApiParamUsage parseParamInfo(CompilationUnit unit, Class clazz, M MethodDeclaration methodDeclaration = getMethodDeclaration(unit, method); String context = methodDeclaration.getParameter(0).getNameAsString(); - List methodCalls = methodDeclaration.findAll(MethodCallExpr.class); + List methodCalls = collectMethodCallExprs(methodDeclaration, new HashSet<>()); Set optionalTypedQueryParams = readParamUsagesSetFromCall(methodCalls, call -> readQueryParamAsClassFromCall(unit, context, clazz, call), "queryParamAsClass"); Set optionalDoubleQueryParams = readParamUsagesFromCall(methodCalls, call -> readUsageFromCall(unit, clazz, call, false), "queryParamAsDouble"); Set filteredTsParam = readParamUsagesFromCall(methodCalls, this::findTsParamsFromUsage, "from"); @@ -342,6 +344,52 @@ private OpenApiParamUsage parseParamInfo(CompilationUnit unit, Class clazz, M return new OpenApiParamUsage(pathParams, queryParams, resourceId); } + private List collectMethodCallExprs(MethodDeclaration methodDeclaration, Set visited) { + List calls = new ArrayList<>(methodDeclaration.findAll(MethodCallExpr.class)); + + for (MethodCallExpr call : methodDeclaration.findAll(MethodCallExpr.class)) { + boolean isSuperDelegation = call.getScope().filter(Expression::isSuperExpr).isPresent() + && call.getNameAsString().equals(methodDeclaration.getNameAsString()); + if (!isSuperDelegation) { + continue; + } + + MethodDeclaration delegate = resolveSuperDelegate(call, visited); + if (delegate != null) { + calls.addAll(collectMethodCallExprs(delegate, visited)); + } + } + return calls; + } + + private MethodDeclaration resolveSuperDelegate(MethodCallExpr superCall, Set visited) { + try { + ResolvedMethodDeclaration resolved = superCall.resolve(); + String declaringClassName = resolved.declaringType().getQualifiedName(); + String visitKey = declaringClassName + "#" + resolved.getName() + "/" + resolved.getNumberOfParams(); + if (!visited.add(visitKey)) { + // Already followed this exact delegation once on this call chain; avoid looping forever + // if two classes ever end up delegating to each other. + return null; + } + + Class declaringClass = Class.forName(declaringClassName); + CompilationUnit ancestorUnit = OpenApiTestHelper.readCompilationUnit(declaringClass); + return ancestorUnit.findAll(MethodDeclaration.class) + .stream() + .filter(m -> m.getNameAsString().equals(resolved.getName())) + .filter(m -> m.getParameters().size() == resolved.getNumberOfParams()) + .findFirst() + .orElse(null); + } catch (Exception ex) { + LOGGER.atWarning().withCause(ex).log( + "Unable to resolve super delegation call '%s' while checking parameter usage; " + + "parameters only read by the delegated-to method will not be detected.", + superCall); + return null; + } + } + private OpenApiParamUsageInfo findTsParamsFromUsage(MethodCallExpr call) { boolean isRightFunc = call.getScope() .filter(Expression::isFieldAccessExpr) 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..15a6b9bf35 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesGroupControllerV2TestIT.java @@ -0,0 +1,581 @@ +/* + * 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.GROUP_OFFICE_ID; +import static cwms.cda.api.Controllers.OFFICE; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dao.TimeSeriesCategoryDao; +import cwms.cda.data.dao.TimeSeriesGroupDao; +import cwms.cda.data.dto.AssignedTimeSeries; +import cwms.cda.data.dto.TimeSeriesCategory; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.TestAccounts; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import org.jooq.Configuration; +import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the v2 Timeseries Group controller ({@code /v2/timeseries/group}). + * Create, delete, and retrieve are shared with v1 (via {@code AbstractTimeSeriesGroupController}), + * so {@link #test_v2_create_read_delete()} exercises those the same way + * {@link TimeSeriesGroupControllerV1TestIT} does for v1, just against the v2 routes. The remaining + * tests focus on what's unique to v2: PATCH driven by a {@code membership} of time series ids to + * assign/unassign, instead of a full list of assigned time series. + */ +@Tag("integration") +final class TimeSeriesGroupControllerV2TestIT extends DataApiTestIT { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String V2_GROUP_PATH = "/v2/timeseries/group"; + + private static final String LOCATION = "TsGroupV2Test"; + private static final String TS1 = LOCATION + ".Precip-Cumulative.Inst.15Minutes.0.raw-cda"; + private static final String TS2 = LOCATION + ".Precip-INC.Total.15Minutes.15Minutes.calc-cda"; + private static final String TS3 = LOCATION + ".Stage.Inst.15Minutes.0.raw-cda"; + + private final List categoriesToCleanup = new ArrayList<>(); + private final List groupsToCleanup = new ArrayList<>(); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + @BeforeAll + static void load_data() throws Exception { + createLocation(LOCATION, true, "SPK"); + createTimeseries("SPK", TS1); + createTimeseries("SPK", TS2); + createTimeseries("SPK", TS3); + } + + @AfterEach + void clear_data() throws Exception { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + Configuration configuration = DSL.using(c).configuration(); + TimeSeriesGroupDao groupDao = new TimeSeriesGroupDao(configuration.dsl()); + TimeSeriesCategoryDao categoryDao = new TimeSeriesCategoryDao(configuration.dsl()); + + for (TimeSeriesGroup group : groupsToCleanup) { + String assignOffice = group.getOfficeId(); + try { + groupDao.unassignForOffice(group.getTimeSeriesCategory().getId(), group.getId(), + group.getOfficeId(), assignOffice); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Group not found"); + } catch (DataAccessException e) { + LOGGER.atInfo().withCause(e).log("Failed to unassign time series in office %s", assignOffice); + } + + try { + groupDao.delete(group.getTimeSeriesCategory().getId(), group.getId(), group.getOfficeId(), true); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Group not found"); + } catch (DataAccessException e) { + LOGGER.atInfo().withCause(e).log("Failed to delete group in office %s", group.getOfficeId()); + } + } + for (TimeSeriesCategory category : categoriesToCleanup) { + try { + categoryDao.delete(category.getId(), true, category.getOfficeId()); + } catch (NotFoundException e) { + LOGGER.atConfig().withCause(e).log("Category not found"); + } + } + groupsToCleanup.clear(); + categoriesToCleanup.clear(); + }, CwmsDataApiSetupCallback.getWebUser()); + } + + private TimeSeriesCategory createCategory(String officeId, String catId) throws Exception { + TimeSeriesCategory cat = new TimeSeriesCategory(officeId, catId, "IntegrationTesting"); + categoriesToCleanup.add(cat); + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesCategory.class); + String categoryJson = Formats.format(contentType, cat); + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(categoryJson) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .queryParam(FAIL_IF_EXISTS, false) + .when() + .post("/timeseries/category") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + return cat; + } + + private void createGroup(TimeSeriesGroup group) { + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroup.class); + String groupJson = Formats.format(contentType, group); + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(groupJson) + .header("Authorization", user.toHeaderValue()) + .queryParam(FAIL_IF_EXISTS, false) + .when() + .post(V2_GROUP_PATH) + .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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + 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(OFFICE, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .queryParam(CASCADE_DELETE, "true") + .when() + .delete(V2_GROUP_PATH + "/" + 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) + .queryParam(GROUP_OFFICE_ID, officeId) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + @Test + void test_v2_patch_assign_time_series() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_assign"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_assign", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Arrays.asList( + new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2), + new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3))) + .withUnassign(Collections.emptyList()) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .patch(V2_GROUP_PATH + "/" + 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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series.size()", is(3)) + .body("assigned-time-series.timeseries-id", hasItem(TS1)) + .body("assigned-time-series.timeseries-id", hasItem(TS2)) + .body("assigned-time-series.timeseries-id", hasItem(TS3)) + .body("assigned-time-series.alias-id", hasItem("AliasId2")) + .body("assigned-time-series.alias-id", hasItem("AliasId3")); + } + + @Test + void test_v2_patch_unassign_time_series() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_unassign"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_unassign", + "IntegrationTesting", "sharedTsAliasId", TS1); + List assigned = group.getAssignedTimeSeries(); + assigned.add(new AssignedTimeSeries(officeId, TS1, "AliasId1", TS1, 1)); + assigned.add(new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2)); + assigned.add(new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3)); + groupsToCleanup.add(group); + createGroup(group); + + // Only specify the ids to unassign - not the full set of assigned time series. + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Arrays.asList(TS1, TS2)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .patch(V2_GROUP_PATH + "/" + 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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("id", equalTo(group.getId())) + .body("assigned-time-series.size()", is(1)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS3)); + } + + @Test + void test_v2_patch_assign_and_unassign_together() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_combo"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_combo", + "IntegrationTesting", "sharedTsAliasId", TS1); + List assigned = group.getAssignedTimeSeries(); + assigned.add(new AssignedTimeSeries(officeId, TS1, "AliasId1", TS1, 1)); + assigned.add(new AssignedTimeSeries(officeId, TS2, "AliasId2", TS2, 2)); + groupsToCleanup.add(group); + createGroup(group); + + // Unassign TS1 while assigning TS3, in a single request. + Membership membership = new Membership.Builder() + .withAssign(Collections.singletonList(new AssignedTimeSeries(officeId, TS3, "AliasId3", TS3, 3))) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .patch(V2_GROUP_PATH + "/" + 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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series.size()", is(2)) + .body("assigned-time-series.timeseries-id", hasItem(TS2)) + .body("assigned-time-series.timeseries-id", hasItem(TS3)); + } + + @Test + void test_v2_patch_rename_and_description() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_rename"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_rename_orig", + "Original description", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + createGroup(group); + + String newGroupId = "test_v2_patch_rename_new"; + TimeSeriesGroup renamedGroupForCleanup = new TimeSeriesGroup(cat, officeId, newGroupId, + "Updated description", "sharedTsAliasId", TS1); + groupsToCleanup.add(renamedGroupForCleanup); + + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(newGroupId) + .withTimeSeriesCategory(cat) + .withDescription("Updated description") + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .patch(V2_GROUP_PATH + "/" + 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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + 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) + .queryParam(GROUP_OFFICE_ID, officeId) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)); + } + + @Test + void test_v2_patch_requires_office() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_requires_office"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_requires_office", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .when() + .patch(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_BAD_REQUEST)) + .body("message", equalTo("Bad Request")) + .body("details.'missing query parameters'", equalTo(OFFICE)); + } + + @Test + void test_v2_patch_only_supports_plain_json() throws Exception { + String officeId = user.getOperatingOffice(); + TimeSeriesCategory cat = createCategory(officeId, "test_v2_patch_json_only"); + TimeSeriesGroup group = new TimeSeriesGroup(cat, officeId, "test_v2_patch_json_only", + "IntegrationTesting", "sharedTsAliasId", TS1); + group.getAssignedTimeSeries().add(new AssignedTimeSeries(officeId, TS1, "AliasId", TS1, 1)); + groupsToCleanup.add(group); + createGroup(group); + + Membership membership = new Membership.Builder() + .withAssign(Collections.emptyList()) + .withUnassign(Collections.singletonList(TS1)) + .build(); + TimeSeriesGroupPatch patch = new TimeSeriesGroupPatch.Builder() + .withOfficeId(officeId) + .withId(group.getId()) + .withTimeSeriesCategory(cat) + .withDescription(group.getDescription()) + .withSharedAliasId(group.getSharedAliasId()) + .withSharedRefTsId(group.getSharedRefTsId()) + .withMembership(membership) + .build(); + + // The v2 patch DTO only supports plain JSON - the versioned JSONV1 content type should + // not be resolvable for it. + given() + .log().ifValidationFails() + .accept(Formats.JSON) + .contentType(Formats.JSONV1) + .body(patchBody(patch)) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, officeId) + .when() + .patch(V2_GROUP_PATH + "/" + 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(GROUP_OFFICE_ID, officeId) + .queryParam(CATEGORY_ID, cat.getId()) + .when() + .get(V2_GROUP_PATH + "/" + group.getId()) + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("assigned-time-series[0].timeseries-id", equalTo(TS1)); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java index 302a2dd16d..499410c73b 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesIdentifierDescriptorControllerTestIT.java @@ -46,7 +46,7 @@ import cwms.cda.data.dto.LocationCategory; import cwms.cda.data.dto.LocationGroup; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.data.dto.TimeSeriesIdentifierDescriptor; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java index be7fd14635..16e1ced447 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TimeSeriesRecentControllerIT.java @@ -46,7 +46,7 @@ import cwms.cda.data.dto.AssignedTimeSeries; import cwms.cda.data.dto.TimeSeries; import cwms.cda.data.dto.TimeSeriesCategory; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; import cwms.cda.formatters.json.JsonV1; diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java index 60cbfde512..8f0ca44343 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dao/TimeSeriesGroupDaoTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import cwms.cda.data.dto.TimeSeriesGroup; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import static cwms.cda.data.dao.DaoTest.getConnection; import static cwms.cda.data.dao.DaoTest.getDslContext; diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java new file mode 100644 index 0000000000..93773c6dd8 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupPatchTest.java @@ -0,0 +1,127 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import cwms.cda.helpers.DTOMatch; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +final class TimeSeriesGroupPatchTest { + + private static final String OFFICE_ID = "SPK"; + private static final String GROUP_ID = "group-id"; + private static final String CATEGORY_ID = "category-id"; + private static final String CATEGORY_DESCRIPTION = "category description"; + private static final String GROUP_DESCRIPTION = "patch description"; + private static final String SHARED_ALIAS_ID = "shared-alias"; + private static final String SHARED_REF_TS_ID = "Shared.Flow.Inst.1Hour.0.Raw"; + private static final String ASSIGN_TS_ID = "Loc.Flow.Inst.1Hour.0.Raw"; + private static final String ASSIGN_ALIAS_ID = "AliasId1"; + private static final String ASSIGN_REF_TS_ID = "Loc2.Flow.Inst.1Hour.0.Raw"; + private static final int ASSIGN_ATTRIBUTE = 5; + private static final String UNASSIGN_TS_ID = "Loc3.Flow.Inst.1Hour.0.Raw"; + + @Test + void test_serialize_json() { + TimeSeriesGroupPatch patch = buildTimeSeriesGroupPatch(); + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + String result = Formats.format(contentType, patch); + assertNotNull(result); + + assertTrue(result.contains("\"office-id\":\"" + OFFICE_ID + "\"")); + assertTrue(result.contains("\"id\":\"" + GROUP_ID + "\"")); + assertTrue(result.contains("\"time-series-category\"")); + assertTrue(result.contains("\"description\":\"" + GROUP_DESCRIPTION + "\"")); + assertTrue(result.contains("\"shared-alias-id\":\"" + SHARED_ALIAS_ID + "\"")); + assertTrue(result.contains("\"shared-ref-ts-id\":\"" + SHARED_REF_TS_ID + "\"")); + assertTrue(result.contains("\"membership\"")); + assertTrue(result.contains("\"assign\"")); + assertTrue(result.contains("\"unassign\"")); + assertTrue(result.contains(ASSIGN_TS_ID)); + assertTrue(result.contains(ASSIGN_ALIAS_ID)); + assertTrue(result.contains(UNASSIGN_TS_ID)); + } + + @Test + void test_serialize_deserialize_roundtrip() { + TimeSeriesGroupPatch patch = buildTimeSeriesGroupPatch(); + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + String json = Formats.format(contentType, patch); + TimeSeriesGroupPatch deserialized = Formats.parseContent(contentType, json, TimeSeriesGroupPatch.class); + + DTOMatch.assertMatch(patch, deserialized); + } + + @Test + void test_deserialize_from_file() throws IOException { + String json; + try (InputStream stream = getClass().getResourceAsStream("time_series_group_patch.json")) { + assertNotNull(stream); + json = IOUtils.toString(stream, StandardCharsets.UTF_8); + } + + ContentType contentType = Formats.parseHeader(Formats.JSON, TimeSeriesGroupPatch.class); + TimeSeriesGroupPatch deserialized = Formats.parseContent(contentType, json, TimeSeriesGroupPatch.class); + + DTOMatch.assertMatch(buildTimeSeriesGroupPatch(), deserialized); + } + + private TimeSeriesGroupPatch buildTimeSeriesGroupPatch() { + TimeSeriesCategory category = new TimeSeriesCategory(OFFICE_ID, CATEGORY_ID, CATEGORY_DESCRIPTION); + AssignedTimeSeries assign = new AssignedTimeSeries(OFFICE_ID, ASSIGN_TS_ID, ASSIGN_ALIAS_ID, + ASSIGN_REF_TS_ID, ASSIGN_ATTRIBUTE); + Membership membership = new Membership.Builder() + .withAssign(Collections.singletonList(assign)) + .withUnassign(Collections.singletonList(UNASSIGN_TS_ID)) + .build(); + return new TimeSeriesGroupPatch.Builder() + .withTimeSeriesCategory(category) + .withOfficeId(OFFICE_ID) + .withId(GROUP_ID) + .withDescription(GROUP_DESCRIPTION) + .withSharedAliasId(SHARED_ALIAS_ID) + .withSharedRefTsId(SHARED_REF_TS_ID) + .withMembership(membership) + .build(); + } + + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java index d099dcf4c7..a0b2b2d6aa 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/TimeSeriesGroupTest.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.List; + +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroup; import org.junit.jupiter.api.Test; import cwms.cda.formatters.ContentType; diff --git a/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java b/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java index edaa8561a1..6402689390 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; @@ -66,6 +67,8 @@ import cwms.cda.data.dto.stream.StreamLocation; import cwms.cda.data.dto.stream.StreamNode; import cwms.cda.data.dto.stream.StreamReach; +import cwms.cda.data.dto.timeseriesgroup.Membership; +import cwms.cda.data.dto.timeseriesgroup.TimeSeriesGroupPatch; import cwms.cda.data.dto.watersupply.PumpLocation; import cwms.cda.data.dto.watersupply.PumpTransfer; import cwms.cda.data.dto.watersupply.WaterSupplyAccounting; @@ -754,6 +757,42 @@ public static void assertMatch(LocationToPublishedDataList list, LocationToPubli ); } + public static void assertMatch(TimeSeriesGroupPatch first, TimeSeriesGroupPatch second) { + assertAll(() -> assertEquals(first.getOfficeId(), second.getOfficeId(), "Office IDs do not match"), + () -> assertEquals(first.getId(), second.getId(), "Time series group IDs do not match"), + () -> assertEquals(first.getDescription(), second.getDescription(), "Descriptions do not match"), + () -> assertEquals(first.getSharedAliasId(), second.getSharedAliasId(), "Shared alias IDs do not match"), + () -> assertEquals(first.getSharedRefTsId(), second.getSharedRefTsId(), "Shared reference time series IDs do not match"), + () -> assertEquals(first.getTimeSeriesCategory(), second.getTimeSeriesCategory(), "Time series categories do not match"), + () -> assertMatch(first.getMembership(), second.getMembership()) + ); + } + + public static void assertMatch(Membership first, Membership second) { + assertEquals(first.getUnassign().size(), second.getUnassign().size(), "Unassign list sizes do not match"); + assertEquals(first.getAssign().size(), second.getAssign().size(), "Assign list sizes do not match"); + + List firstAssigned = first.getAssign(); + List secondAssigned = second.getAssign(); + for (int i = 0; i < firstAssigned.size(); i++) { + AssignedTimeSeries expectedTs = firstAssigned.get(i); + AssignedTimeSeries actualTs = secondAssigned.get(i); + assertEquals(expectedTs.getOfficeId(), actualTs.getOfficeId(), "Office IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getTimeseriesId(), actualTs.getTimeseriesId(), "Time series IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getAliasId(), actualTs.getAliasId(), "Alias IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getRefTsId(), actualTs.getRefTsId(), "Reference time series IDs do not match for assigned time series at index " + i); + assertEquals(expectedTs.getAttribute(), actualTs.getAttribute(), "Attributes do not match for assigned time series at index " + i); + } + + List firstUnassigned = first.getUnassign(); + List secondUnassigned = second.getUnassign(); + for (int i = 0; i < firstUnassigned.size(); i++) { + String expectedTsId = firstUnassigned.get(i); + String actualTsId = secondUnassigned.get(i); + assertEquals(expectedTsId, actualTsId, "Time series IDs do not match for unassigned time series at index " + i); + } + } + private static boolean isEqual(CwmsId loc1, CwmsId loc2) { return loc1.getName().equals(loc2.getName()) && loc1.getOfficeId().equals(loc2.getOfficeId()); diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json new file mode 100644 index 0000000000..cfc5d9a1c5 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/time_series_group_patch.json @@ -0,0 +1,26 @@ +{ + "office-id": "SPK", + "id": "group-id", + "time-series-category": { + "office-id": "SPK", + "id": "category-id", + "description": "category description" + }, + "description": "patch description", + "shared-alias-id": "shared-alias", + "shared-ref-ts-id": "Shared.Flow.Inst.1Hour.0.Raw", + "membership": { + "assign": [ + { + "office-id": "SPK", + "timeseries-id": "Loc.Flow.Inst.1Hour.0.Raw", + "alias-id": "AliasId1", + "ref-ts-id": "Loc2.Flow.Inst.1Hour.0.Raw", + "attribute": 5 + } + ], + "unassign": [ + "Loc3.Flow.Inst.1Hour.0.Raw" + ] + } +}