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 ad4b50f517..8409df4a8c 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,7 @@ 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 +33,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.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.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.core.util.Header; import io.javalin.core.validation.JavalinValidation; import io.javalin.http.BadRequestResponse; -import io.javalin.http.Context; -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 +75,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,11 +88,9 @@ import javax.servlet.http.HttpServletResponse; import javax.sql.DataSource; import org.apache.http.entity.ContentType; -import org.jetbrains.annotations.NotNull; import org.jooq.exception.DataAccessException; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; -import org.togglz.core.context.FeatureContext; /** @@ -290,7 +136,8 @@ "/users/*", "/roles/*", "/version/*", - "/rss/*" + "/rss/*", + "/v2/*" }) public class ApiServlet extends HttpServlet { @@ -456,6 +303,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,514 +321,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"; - - 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); - String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, - Controllers.PROJECT_ID, NAME); - cdaCrudCache(virtualOutletPath, new VirtualOutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); - String virtualOutletCreatePath = "/projects/virtual-outlets"; - 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; - } - - /** - * 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. - * @param requiredRoles roles required for actions requiring authorization. - */ - private void addRatingHandlers(RouteRole[] requiredRoles) { - - 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..4472099152 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -0,0 +1,709 @@ +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.forecast.ForecastFileController; +import cwms.cda.api.forecast.ForecastInstanceController; +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.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.forecast.ForecastSpecControllerV1; +import cwms.cda.api.forecast.ForecastSpecControllerV2; +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.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); + cdaCrudCache("/timeseries/group/{group-id}", + new TimeSeriesGroupController(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 ForecastSpecControllerV1(metrics), requiredRoles, 5, TimeUnit.MINUTES); + cdaCrudCache(formatV2(forecastSpecPath, Controllers.NAME), + new ForecastSpecControllerV2(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"; + + 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); + String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, + Controllers.PROJECT_ID, NAME); + cdaCrudCache(virtualOutletPath, new VirtualOutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); + String virtualOutletCreatePath = "/projects/virtual-outlets"; + 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; + } + + /** + * 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. + * @param requiredRoles roles required for actions requiring authorization. + */ + private static void addRatingHandlers(RouteRole[] requiredRoles, MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + + 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 + private 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); + } + // The segment immediately before the id is allowed to be a param itself (e.g. v2's + // "{office}" segment on a primary resource) as long as there's a literal resource + // base somewhere earlier in the path -- that's what actually anchors the route. + boolean hasLiteralResourceBase = Arrays.stream(subPaths, 0, subPaths.length - 1) + .anyMatch(segment -> !(segment.startsWith("{") || segment.startsWith("<") + || segment.endsWith("}") || segment.endsWith(">"))); + if (!hasLiteralResourceBase) { + 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); + } + + /** + * Formats a v2 route path, per the standard that v2 primary-resource routes carry + * {office} as a path segment immediately before the resource's own id segment, e.g. + * {@code "/forecast-spec/{%s}"} becomes {@code "/v2/forecast-spec/{office}/{name}"}. + * Sub-resources (nested under some other primary resource) do not get an office + * segment of their own -- this only applies when formatting a primary resource's path. + */ + private static String formatV2(String path, Object... args) { + int lastSlash = path.lastIndexOf('/'); + String pathWithOffice = path.substring(0, lastSlash) + format("/{%s}", OFFICE) + path.substring(lastSlash); + return format("/v2/" + pathWithOffice, args); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/ForecastFileController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastFileController.java similarity index 96% rename from cwms-data-api/src/main/java/cwms/cda/api/ForecastFileController.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastFileController.java index 607d59492f..938a64d4ce 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/ForecastFileController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastFileController.java @@ -22,13 +22,16 @@ * SOFTWARE. */ -package cwms.cda.api; +package cwms.cda.api.forecast; import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import cwms.cda.api.BinaryTimeSeriesValueController; +import cwms.cda.api.Controllers; +import cwms.cda.api.RangeRequestUtil; import cwms.cda.api.errors.CdaError; -import cwms.cda.data.dao.ForecastInstanceDao; +import cwms.cda.data.dao.forecast.ForecastInstanceDao; import cwms.cda.data.dao.StreamConsumer; import cwms.cda.helpers.DateUtils; import io.javalin.core.util.Header; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/ForecastInstanceController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastInstanceController.java similarity index 99% rename from cwms-data-api/src/main/java/cwms/cda/api/ForecastInstanceController.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastInstanceController.java index 87f62f5e72..e95a2907b5 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/ForecastInstanceController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastInstanceController.java @@ -1,4 +1,4 @@ -package cwms.cda.api; +package cwms.cda.api.forecast; import static cwms.cda.api.Controllers.CREATE; import static cwms.cda.api.Controllers.DELETE; @@ -19,9 +19,10 @@ 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.api.errors.ExceptionTraceSupport; -import cwms.cda.data.dao.ForecastInstanceDao; +import cwms.cda.data.dao.forecast.ForecastInstanceDao; import cwms.cda.data.dao.JooqDao; import cwms.cda.data.dto.forecast.ForecastInstance; import cwms.cda.formatters.ContentType; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java new file mode 100644 index 0000000000..92943b34c3 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java @@ -0,0 +1,173 @@ +package cwms.cda.api.forecast; + +import static cwms.cda.api.Controllers.CREATE; +import static cwms.cda.api.Controllers.DELETE; +import static cwms.cda.api.Controllers.DESIGNATOR; +import static cwms.cda.api.Controllers.DESIGNATOR_MASK; +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.ID_MASK; +import static cwms.cda.api.Controllers.METHOD; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.SOURCE_ENTITY; +import static cwms.cda.api.Controllers.SOURCE_ENTITY_LIKE; +import static cwms.cda.api.Controllers.UPDATE; + +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.api.errors.ExceptionTraceSupport; +import cwms.cda.data.dao.forecast.ForecastSpecDao; +import cwms.cda.data.dao.DeleteRule; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import java.io.IOException; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public abstract class ForecastSpecController extends BaseCrudHandler { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + + static final String TAG = "Forecast"; + + protected ForecastSpecController(MetricRegistry metrics) { + super(metrics); + } + + protected DSLContext getDslContext(Context ctx) { + return JooqDao.getDslContext(ctx); + } + + /** Builds the version-specific DAO for this request. */ + protected abstract ForecastSpecDao newDao(DSLContext dsl); + + /** The DTO type this controller reads and writes */ + protected abstract Class getDtoClass(); + + @Override + public void create(@NotNull Context ctx) { + try (final Timer.Context ignored = markAndTime(CREATE)) { + DSLContext dsl = getDslContext(ctx); + ForecastSpecDao dao = newDao(dsl); + T forecastSpec = deserializeForecastSpec(ctx); + + dao.create(forecastSpec); + + ctx.status(HttpServletResponse.SC_CREATED); + } + } + + protected void delete(Context ctx, String name, String office) { + String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); + + JooqDao.DeleteMethod deleteMethod = ctx.queryParamAsClass(METHOD, JooqDao.DeleteMethod.class) + .getOrDefault(JooqDao.DeleteMethod.DELETE_KEY); + DeleteRule deleteRule; + switch (deleteMethod) { + case DELETE_ALL: + deleteRule = DeleteRule.DELETE_ALL; + break; + case DELETE_DATA: + deleteRule = DeleteRule.DELETE_DATA; + break; + case DELETE_KEY: + deleteRule = DeleteRule.DELETE_KEY; + break; + default: + throw new IllegalArgumentException("Delete Method provided does not match accepted rule constants: " + + deleteMethod); + } + try (final Timer.Context ignored = markAndTime(DELETE)) { + DSLContext dsl = getDslContext(ctx); + ForecastSpecDao dao = newDao(dsl); + + dao.delete(office, name, designator, deleteRule); + ctx.status(HttpServletResponse.SC_NO_CONTENT); + } + } + + protected void getAll(Context ctx, String office) { + try (final Timer.Context ignored = markAndTime(GET_ALL)) { + String names = ctx.queryParamAsClass(ID_MASK, String.class).getOrDefault("*"); + String designator = ctx.queryParamAsClass(DESIGNATOR_MASK, String.class).allowNullable().get(); + String sourceEntity = ctx.queryParamAsClass(SOURCE_ENTITY, String.class).getOrDefault("*"); + String entityLike = ctx.queryParamAsClass(SOURCE_ENTITY_LIKE, String.class).allowNullable().get(); + + DSLContext dsl = getDslContext(ctx); + ForecastSpecDao dao = newDao(dsl); + + List specs = dao.getForecastSpecs(office, names, designator, sourceEntity, entityLike); + + writeResponse(ctx, specs); + } catch (IOException ex) { + handleWriteFailure(ctx, ex, "Failed to process request to retrieve forecast specs"); + } + } + + protected void getOne(Context ctx, String name, String office) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); + + DSLContext dsl = getDslContext(ctx); + ForecastSpecDao dao = newDao(dsl); + + T spec = dao.getForecastSpec(office, name, designator); + + writeResponse(ctx, spec); + } catch (IOException ex) { + handleWriteFailure(ctx, ex, "Failed to process request to retrieve forecast spec"); + } + } + + @Override + public void update(@NotNull Context ctx, @NotNull String name) { + logUnusedPathParameter(ctx, NAME, "Body contains information"); + try (final Timer.Context ignored = markAndTime(UPDATE)) { + T forecastSpec = deserializeForecastSpec(ctx); + DSLContext dsl = getDslContext(ctx); + ForecastSpecDao dao = newDao(dsl); + dao.update(forecastSpec); + ctx.status(HttpServletResponse.SC_OK); + } + } + + private void writeResponse(Context ctx, List specs) throws IOException { + ContentType contentType = Formats.parseHeader(ctx.header(Header.ACCEPT), getDtoClass()); + writeBytes(ctx, contentType, Formats.format(contentType, specs, getDtoClass())); + } + + private void writeResponse(Context ctx, T spec) throws IOException { + ContentType contentType = Formats.parseHeader(ctx.header(Header.ACCEPT), getDtoClass()); + writeBytes(ctx, contentType, Formats.format(contentType, spec)); + } + + private void writeBytes(Context ctx, ContentType contentType, String result) throws IOException { + updateResultSize(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); + } + + private void handleWriteFailure(Context ctx, IOException ex, String message) { + CdaError error = ExceptionTraceSupport.buildError(ctx, message, ex); + LOGGER.atSevere().withCause(ex).log("%s (handler: %s)", message, getClass().getSimpleName()); + ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(error); + } + + protected T deserializeForecastSpec(Context ctx) { + ContentType contentType = Formats.parseHeader(ctx.req.getContentType(), getDtoClass()); + return Formats.parseContent(contentType, ctx.body(), getDtoClass()); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/ForecastSpecController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java similarity index 55% rename from cwms-data-api/src/main/java/cwms/cda/api/ForecastSpecController.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java index cf60e209ee..cc6682ead3 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/ForecastSpecController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java @@ -1,11 +1,7 @@ -package cwms.cda.api; +package cwms.cda.api.forecast; -import static cwms.cda.api.Controllers.CREATE; -import static cwms.cda.api.Controllers.DELETE; import static cwms.cda.api.Controllers.DESIGNATOR; import static cwms.cda.api.Controllers.DESIGNATOR_MASK; -import static cwms.cda.api.Controllers.GET_ALL; -import static cwms.cda.api.Controllers.GET_ONE; import static cwms.cda.api.Controllers.ID_MASK; import static cwms.cda.api.Controllers.METHOD; import static cwms.cda.api.Controllers.NAME; @@ -16,21 +12,14 @@ import static cwms.cda.api.Controllers.STATUS_400; import static cwms.cda.api.Controllers.STATUS_404; import static cwms.cda.api.Controllers.STATUS_501; -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 com.google.common.flogger.FluentLogger; -import cwms.cda.api.errors.CdaError; -import cwms.cda.api.errors.ExceptionTraceSupport; -import cwms.cda.data.dao.DeleteRule; -import cwms.cda.data.dao.ForecastSpecDao; +import cwms.cda.data.dao.forecast.ForecastSpecDao; +import cwms.cda.data.dao.forecast.ForecastSpecDaoV1; import cwms.cda.data.dao.JooqDao; import cwms.cda.data.dto.forecast.ForecastSpec; -import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; -import io.javalin.core.util.Header; import io.javalin.http.Context; import io.javalin.plugin.openapi.annotations.HttpMethod; import io.javalin.plugin.openapi.annotations.OpenApi; @@ -38,23 +27,24 @@ 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.List; -import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; -public final class ForecastSpecController extends BaseCrudHandler { - private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); - public static final String TAG = "Forecast"; +public final class ForecastSpecControllerV1 extends ForecastSpecController { - public ForecastSpecController(MetricRegistry metrics) { + public ForecastSpecControllerV1(MetricRegistry metrics) { super(metrics); } - protected DSLContext getDslContext(Context ctx) { - return JooqDao.getDslContext(ctx); + @Override + protected ForecastSpecDao newDao(DSLContext dsl) { + return new ForecastSpecDaoV1(dsl); + } + + @Override + protected Class getDtoClass() { + return ForecastSpec.class; } @OpenApi( @@ -70,15 +60,7 @@ protected DSLContext getDslContext(Context ctx) { ) @Override public void create(@NotNull Context ctx) { - try (final Timer.Context ignored = markAndTime(CREATE)) { - DSLContext dsl = getDslContext(ctx); - ForecastSpecDao dao = new ForecastSpecDao(dsl); - ForecastSpec forecastSpec = deserializeForecastSpec(ctx); - - dao.create(forecastSpec); - - ctx.status(HttpServletResponse.SC_CREATED); - } + super.create(ctx); } @OpenApi( @@ -106,32 +88,7 @@ public void create(@NotNull Context ctx) { @Override public void delete(@NotNull Context ctx, @NotNull String name) { String office = requiredParam(ctx, OFFICE); - String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); - - JooqDao.DeleteMethod deleteMethod = ctx.queryParamAsClass(METHOD, JooqDao.DeleteMethod.class) - .getOrDefault(JooqDao.DeleteMethod.DELETE_KEY); - DeleteRule deleteRule; - switch (deleteMethod) { - case DELETE_ALL: - deleteRule = DeleteRule.DELETE_ALL; - break; - case DELETE_DATA: - deleteRule = DeleteRule.DELETE_DATA; - break; - case DELETE_KEY: - deleteRule = DeleteRule.DELETE_KEY; - break; - default: - throw new IllegalArgumentException("Delete Method provided does not match accepted rule constants: " - + deleteMethod); - } - try (final Timer.Context ignored = markAndTime(DELETE)) { - DSLContext dsl = getDslContext(ctx); - ForecastSpecDao dao = new ForecastSpecDao(dsl); - - dao.delete(office, name, designator, deleteRule); - ctx.status(HttpServletResponse.SC_NO_CONTENT); - } + super.delete(ctx, name, office); } @OpenApi( @@ -166,38 +123,8 @@ public void delete(@NotNull Context ctx, @NotNull String name) { ) @Override public void getAll(@NotNull Context ctx) { - try (final Timer.Context ignored = markAndTime(GET_ALL)) { - String office = ctx.queryParam(OFFICE); - String names = ctx.queryParamAsClass(ID_MASK, String.class).getOrDefault("*"); - String designator = ctx.queryParamAsClass(DESIGNATOR_MASK, String.class).allowNullable().get(); - String sourceEntity = ctx.queryParamAsClass(SOURCE_ENTITY, String.class).getOrDefault("*"); - String entityLike = ctx.queryParamAsClass(SOURCE_ENTITY_LIKE, String.class).allowNullable().get(); - - DSLContext dsl = getDslContext(ctx); - ForecastSpecDao dao = new ForecastSpecDao(dsl); - - List specs = dao.getForecastSpecs(office, names, designator, - sourceEntity, entityLike); - - String formatHeader = ctx.header(Header.ACCEPT); - ContentType contentType = Formats.parseHeader(formatHeader, ForecastSpec.class); - String result = Formats.format(contentType, specs, ForecastSpec.class); - - updateResultSize(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 error = ExceptionTraceSupport.buildError(ctx, - "Failed to process request to retrieve forecast specs", ex); - LOGGER.atSevere().withCause(ex).log("Failed to process request to retrieve forecast specs"); - ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(error); - } + String office = ctx.queryParam(OFFICE); + super.getAll(ctx, office); } @OpenApi( @@ -229,33 +156,8 @@ public void getAll(@NotNull Context ctx) { ) @Override public void getOne(@NotNull Context ctx, @NotNull String name) { - try (final Timer.Context ignored = markAndTime(GET_ONE)) { - String office = requiredParam(ctx, OFFICE); - String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); - - DSLContext dsl = getDslContext(ctx); - ForecastSpecDao dao = new ForecastSpecDao(dsl); - - ForecastSpec spec = dao.getForecastSpec(office, name, designator); - - String formatHeader = ctx.header(Header.ACCEPT); - ContentType contentType = Formats.parseHeader(formatHeader, ForecastSpec.class); - String result = Formats.format(contentType, spec); - - updateResultSize(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 error = ExceptionTraceSupport.buildError(ctx, - "Failed to process request to retrieve forecast spec", ex); - LOGGER.atSevere().withCause(ex).log("Failed to process request to retrieve forecast spec"); - ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(error); - } + String office = requiredParam(ctx, OFFICE); + super.getOne(ctx, name, office); } @OpenApi( @@ -277,20 +179,6 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { ) @Override public void update(@NotNull Context ctx, @NotNull String name) { - logUnusedPathParameter(ctx, NAME, "Body contains information"); - try (final Timer.Context ignored = markAndTime(UPDATE)) { - ForecastSpec forecastSpec = deserializeForecastSpec(ctx); - DSLContext dsl = getDslContext(ctx); - ForecastSpecDao dao = new ForecastSpecDao(dsl); - dao.update(forecastSpec); - ctx.status(HttpServletResponse.SC_OK); - } + super.update(ctx, name); } - - private ForecastSpec deserializeForecastSpec(Context ctx) { - String formatHeader = ctx.req.getContentType(); - ContentType contentType = Formats.parseHeader(formatHeader, ForecastSpec.class); - return Formats.parseContent(contentType, ctx.body(), ForecastSpec.class); - } - } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java new file mode 100644 index 0000000000..04e44f7cbe --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java @@ -0,0 +1,206 @@ +package cwms.cda.api.forecast; + +import static cwms.cda.api.Controllers.DESIGNATOR; +import static cwms.cda.api.Controllers.DESIGNATOR_MASK; +import static cwms.cda.api.Controllers.ID_MASK; +import static cwms.cda.api.Controllers.METHOD; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.SOURCE_ENTITY; +import static cwms.cda.api.Controllers.SOURCE_ENTITY_LIKE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.STATUS_400; +import static cwms.cda.api.Controllers.STATUS_404; +import static cwms.cda.api.Controllers.STATUS_501; + +import com.codahale.metrics.MetricRegistry; +import cwms.cda.data.dao.forecast.ForecastSpecDao; +import cwms.cda.data.dao.forecast.ForecastSpecDaoV2; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dto.forecast.ForecastSpecV2; +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 org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + + +public final class ForecastSpecControllerV2 extends ForecastSpecController { + + public ForecastSpecControllerV2(MetricRegistry metrics) { + super(metrics); + } + + @Override + protected ForecastSpecDao newDao(DSLContext dsl) { + return new ForecastSpecDaoV2(dsl); + } + + @Override + protected Class getDtoClass() { + return ForecastSpecV2.class; + } + + @OpenApi( + description = "Used to create and save forecast spec data", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec to be created."), + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON) + }, + required = true + ), + method = HttpMethod.POST, + tags = TAG + ) + @Override + public void create(@NotNull Context ctx) { + String officeFromPath = ctx.pathParam(OFFICE); + validateOffice(ctx, officeFromPath); + super.create(ctx); + } + + @OpenApi( + description = "Used to delete forecast spec data based on unique fields", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec whose data is to be deleted."), + @OpenApiParam(name = NAME, required = true, description = "Specifies the " + + "spec id of the forecast spec whose data is to be deleted."), + }, + queryParams = { + @OpenApiParam(name = DESIGNATOR, description = "Specifies the " + + "designator of the forecast spec whose data is to be deleted."), + @OpenApiParam(name = METHOD, description = "Specifies the delete method used. " + + "Defaults to \"DELETE_KEY\"", + type = JooqDao.DeleteMethod.class) + }, + responses = { + @OpenApiResponse(status = STATUS_404, description = "The provided combination of " + + "parameters did not find a forecast spec."), + }, + method = HttpMethod.DELETE, + tags = TAG + ) + @Override + public void delete(@NotNull Context ctx, @NotNull String name) { + String office = ctx.pathParam(OFFICE); + super.delete(ctx, name, office); + } + + @OpenApi( + description = "Used to query multiple forecast specs", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast specs to be included in the " + + "response."), + }, + queryParams = { + @OpenApiParam(name = ID_MASK, description = "Posix " + + "regular expression that specifies " + + "the spec IDs to be included in the response."), + @OpenApiParam(name = DESIGNATOR_MASK, description = "Posix " + + "regular expression that specifies the " + + "designator of the forecast spec whose data to be included in the response. " + + "Default behavior when this parameter is not provided is to search for forecast " + + "specifications with a null designator. "), + @OpenApiParam(name = SOURCE_ENTITY, description = "Specifies the source identity " + + "of the forecast spec whose data is to be included in the response. Interpreted as a regular expression."), + @OpenApiParam(name = SOURCE_ENTITY_LIKE, description = "Specifies the source entity using LIKE-style matching. If provided, this parameter is used instead of the regular expression parameter 'source-entity'.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, + description = "A list of elements of the data set you've selected.", + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON)}), + @OpenApiResponse(status = STATUS_400, description = "Invalid parameter combination"), + @OpenApiResponse(status = STATUS_501, description = "Requested format is not " + + "implemented") + }, + method = HttpMethod.GET, + tags = TAG + ) + @Override + public void getAll(@NotNull Context ctx) { + String office = ctx.pathParam(OFFICE); + super.getAll(ctx, office); + } + + @OpenApi( + description = "Used to query a single forecast spec record", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec whose data is to be included in the " + + "response."), + @OpenApiParam(name = NAME, required = true, description = "Specifies the " + + "spec id of the forecast spec whose data is to be included in the response."), + }, + queryParams = { + @OpenApiParam(name = DESIGNATOR, description = "Specifies the " + + "designator of the forecast spec whose data to be included in the response.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, + description = "Returns the requested forecast spec", + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON)}), + @OpenApiResponse(status = STATUS_400, description = "Invalid parameter combination"), + @OpenApiResponse(status = STATUS_404, description = "The provided combination of " + + "parameters did not find a forecast spec."), + @OpenApiResponse(status = STATUS_501, description = "Requested format is not " + + "implemented") + }, + method = HttpMethod.GET, + tags = TAG + ) + @Override + public void getOne(@NotNull Context ctx, @NotNull String name) { + String office = ctx.pathParam(OFFICE); + super.getOne(ctx, name, office); + } + + @OpenApi( + description = "Update a forecast spec with provided values", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec to be updated."), + @OpenApiParam(name = NAME, description = "Forecast spec id to be updated") + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON) + }, + required = true), + responses = { + @OpenApiResponse(status = STATUS_404, description = "Based on the combination of " + + "inputs provided the forecast spec was not found.") + }, + method = HttpMethod.PATCH, + tags = TAG + ) + @Override + public void update(@NotNull Context ctx, @NotNull String name) { + String officeFromPath = ctx.pathParam(OFFICE); + validateOffice(ctx, officeFromPath); + super.update(ctx, name); + } + + private void validateOffice(@NotNull Context ctx, String officeFromPath) { + if(officeFromPath == null) { + throw new IllegalArgumentException("Office ID is required in the path parameter."); + } + ForecastSpecV2 body = deserializeForecastSpec(ctx); + String officeFromBody = body.getSpecId().getOfficeId(); + if(!(officeFromPath.equalsIgnoreCase(officeFromBody))) { + throw new IllegalArgumentException("Office ID in path parameter does not match office ID in request body."); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/ForecastTimeseriesController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastTimeseriesController.java similarity index 75% rename from cwms-data-api/src/main/java/cwms/cda/api/ForecastTimeseriesController.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastTimeseriesController.java index 4aed7436b9..e19ceeac75 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/ForecastTimeseriesController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastTimeseriesController.java @@ -1,34 +1,22 @@ -package cwms.cda.api; +package cwms.cda.api.forecast; import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; import cwms.cda.api.errors.CdaError; import cwms.cda.data.dao.JooqDao; -import cwms.cda.data.dto.TimeSeries; -import cwms.cda.formatters.Formats; import io.javalin.apibuilder.CrudHandler; 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 javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; import static com.codahale.metrics.MetricRegistry.name; -import static cwms.cda.api.Controllers.FORECAST_DATE; -import static cwms.cda.api.Controllers.GET_ONE; -import static cwms.cda.api.Controllers.ISSUE_DATE; -import static cwms.cda.api.Controllers.LOCATION_ID; -import static cwms.cda.api.Controllers.NAME; -import static cwms.cda.api.Controllers.NOT_SUPPORTED_YET; -import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.RESULTS; import static cwms.cda.api.Controllers.SIZE; -import static cwms.cda.api.Controllers.TIMESERIES_ID; public class ForecastTimeseriesController implements CrudHandler { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastInstanceDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastInstanceDao.java similarity index 99% rename from cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastInstanceDao.java rename to cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastInstanceDao.java index 4a868a0244..e6d92a8831 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastInstanceDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastInstanceDao.java @@ -1,9 +1,12 @@ -package cwms.cda.data.dao; +package cwms.cda.data.dao.forecast; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import cwms.cda.api.Controllers; import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dao.BlobDao; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dao.StreamConsumer; import cwms.cda.data.dto.forecast.ForecastInstance; import cwms.cda.data.dto.forecast.ForecastSpec; import cwms.cda.formatters.json.JsonV2; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java new file mode 100644 index 0000000000..8bff16f969 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java @@ -0,0 +1,145 @@ +package cwms.cda.data.dao.forecast; + +import cwms.cda.data.dao.DeleteRule; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dto.CwmsDTOBase; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.TableField; +import org.jooq.impl.DSL; +import usace.cwms.db.jooq.codegen.packages.CWMS_FCST_PACKAGE; + +import java.util.List; + +/** + * Shared logic for the forecast spec DAOs. + * + *

{@link ForecastSpecDaoV1} backs the V1 API, where a forecast spec has a single + * {@code location-id}. {@link ForecastSpecDaoV2} backs the V2 API, where a forecast + * spec has a {@code List} (each with its own sort order and a + * primary-location flag). Everything below does not care which of those two shapes + * {@code T} is -- deleting a spec is the same statement either way, and the + * office/spec-id/designator/source-entity filtering used by both DAOs' "get" queries + * is identical -- so it lives here once instead of being copy-pasted between the two + * DAOs and risking drift. + * + *

What does NOT live here, because it genuinely differs between V1 and V2, is: + * building/mapping the spec projection itself (V1 joins a single location column in; + * V2 fetches locations separately and reassembles them, see {@link ForecastSpecDaoV2}), + * and {@code create}/{@code update}, since storing a spec's locations is shaped + * differently for a single id vs. a list. + * + * @param the forecast spec DTO type this instance works with + */ +public abstract class ForecastSpecDao extends JooqDao { + + protected ForecastSpecDao(DSLContext dsl) { + super(dsl); + } + + protected abstract ViewWrapper getViewWrapper(); + + protected static class ViewWrapper { + protected final TableField OFFICE_ID; + protected final TableField FCST_SPEC_ID; + protected final TableField FCST_DESIGNATOR; + protected final TableField ENTITY_ID; + protected final TableField FCST_SPEC_CODE; + + public ViewWrapper(TableField officeId, TableField specId, + TableField designator, TableField entityId, + TableField fcstSpecCode) { + this.OFFICE_ID = officeId; + this.FCST_SPEC_ID = specId; + this.FCST_DESIGNATOR = designator; + this.ENTITY_ID = entityId; + this.FCST_SPEC_CODE = fcstSpecCode; + } + } + + /** + * Deletes a forecast spec. What locations look like is irrelevant to delete, + * so V1 and V2 share this method unchanged. + */ + public void delete(String office, String specId, String designator, DeleteRule deleteRule) { + connection(dsl, conn -> { + setOffice(conn, office); + CWMS_FCST_PACKAGE.call_DELETE_FCST_SPEC(DSL.using(conn).configuration(), specId, designator, + deleteRule.getRule(), office); + }); + } + + /** + * Source-entity filter shared by both DAOs' {@code getForecastSpecs} (plural, filtered + * listing) queries: LIKE-style matching when {@code entityLike} is given, otherwise a + * regex match against {@code sourceEntityRegex}. + */ + protected static Condition buildEntityCondition(ViewWrapper spec, String sourceEntityRegex, + String entityLike) { + if (entityLike != null) { + return spec.ENTITY_ID.likeIgnoreCase(entityLike); + } + return JooqDao.caseInsensitiveLikeRegex(spec.ENTITY_ID, sourceEntityRegex); + } + + /** + * Designator filter for {@code getForecastSpecs} (plural): designator is a nullable + * column, and a null filter means "specs with no designator" rather than "any + * designator." A non-null filter is treated as a mask/regex, matching the + * {@code DESIGNATOR_MASK} query param both controllers pass through here. + */ + protected static Condition buildDesignatorMaskCondition(ViewWrapper spec, String designatorMask) { + if (designatorMask == null) { + return spec.FCST_DESIGNATOR.isNull(); + } + return JooqDao.caseInsensitiveLikeRegex(spec.FCST_DESIGNATOR, designatorMask); + } + + /** + * Designator filter for {@code getForecastSpec} (singular, fetch-by-key): unlike the + * plural listing query, this is an exact match, matching the plain {@code DESIGNATOR} + * query param both controllers pass through here. + */ + protected static Condition buildExactDesignatorCondition(ViewWrapper spec, String designator) { + if (designator == null) { + return spec.FCST_DESIGNATOR.isNull(); + } + return spec.FCST_DESIGNATOR.eq(designator); + } + + /** + * The full office/spec-id/designator/source-entity filter used by both DAOs' + * {@code getForecastSpecs} (plural) queries. Only the projected columns and how + * locations are joined/fetched differ between V1 and V2. + */ + protected static Condition buildSpecListCondition(ViewWrapper spec, String office, String specIdRegex, + String designatorMask, String sourceEntityRegex, String entityLike) { + return JooqDao.caseInsensitiveLikeRegex(spec.OFFICE_ID, office) + .and(JooqDao.caseInsensitiveLikeRegex(spec.FCST_SPEC_ID, specIdRegex)) + .and(buildEntityCondition(spec, sourceEntityRegex, entityLike)) + .and(buildDesignatorMaskCondition(spec, designatorMask)); + } + + /** + * The office/spec-id/designator key filter used by both DAOs' {@code getForecastSpec} + * (singular, fetch-by-key) queries. Office and spec id are exact matches (office + * upper-cased, matching the existing V1 behavior); designator uses the exact-match + * rule above rather than the mask used by the plural listing query. + */ + protected static Condition buildSpecKeyCondition(ViewWrapper spec, String office, String specId, + String designator) { + return spec.OFFICE_ID.eq(office.toUpperCase()) + .and(spec.FCST_SPEC_ID.eq(specId)) + .and(buildExactDesignatorCondition(spec, designator)); + } + + + public abstract List getForecastSpecs(String office, String specIdRegex, String designator, + String sourceEntityRegex, String entityLike); + + public abstract T getForecastSpec(String office, String specId, String designator); + + public abstract void create(T forecastSpec); + + public abstract void update(T forecastSpec); +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV1.java similarity index 69% rename from cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDao.java rename to cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV1.java index c6be63f124..c50e57e1c6 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV1.java @@ -1,18 +1,16 @@ -package cwms.cda.data.dao; +package cwms.cda.data.dao.forecast; import cwms.cda.api.errors.NotFoundException; import cwms.cda.data.dto.forecast.ForecastSpec; import org.jetbrains.annotations.NotNull; import org.jooq.SelectConditionStep; -import org.jooq.TableField; import usace.cwms.db.jooq.codegen.packages.CWMS_FCST_PACKAGE; import usace.cwms.db.jooq.codegen.tables.AV_FCST_LOCATION; import usace.cwms.db.jooq.codegen.tables.AV_FCST_SPEC; import usace.cwms.db.jooq.codegen.tables.AV_FCST_TIME_SERIES; import org.jooq.DSLContext; -import org.jooq.Condition; import org.jooq.Record2; import org.jooq.Record7; import org.jooq.SelectOnConditionStep; @@ -26,13 +24,27 @@ import static java.lang.String.format; import static java.util.stream.Collectors.toList; -public final class ForecastSpecDao extends JooqDao { +/** + * V1 forecast spec DAO: a forecast spec has a single {@code location-id}. See + * {@link ForecastSpecDaoV2} for the V2 shape ({@code List}), and + * {@link ForecastSpecDao} for the logic (delete, and the office/spec-id/ + * designator/source-entity filters) shared between the two. + */ +public final class ForecastSpecDaoV1 extends ForecastSpecDao { - public ForecastSpecDao(DSLContext dsl) { + public ForecastSpecDaoV1(DSLContext dsl) { super(dsl); } + @Override + protected ViewWrapper getViewWrapper() { + AV_FCST_SPEC view = AV_FCST_SPEC.AV_FCST_SPEC; + return new ViewWrapper(view.OFFICE_ID, view.FCST_SPEC_ID, view.FCST_DESIGNATOR, view.ENTITY_ID, + view.FCST_SPEC_CODE); + } + + @Override public void create(ForecastSpec forecastSpec) { connection(dsl, conn -> { @@ -49,41 +61,15 @@ public void create(ForecastSpec forecastSpec) { }); } - public void delete(String office, String specId, String designator, DeleteRule deleteRule) { - connection(dsl, conn -> { - setOffice(conn, office); - CWMS_FCST_PACKAGE.call_DELETE_FCST_SPEC(DSL.using(conn).configuration(), specId, designator, - deleteRule.getRule(), office); - }); - } - + @Override public List getForecastSpecs(String office, String specIdRegex, String designator, String sourceEntityRegex, String entityLike) { - AV_FCST_SPEC spec = AV_FCST_SPEC.AV_FCST_SPEC; + ViewWrapper wrapper = getViewWrapper(); SelectConditionStep> query = forecastSpecQuery(dsl) - .where(JooqDao.caseInsensitiveLikeRegex(spec.OFFICE_ID, office)) - .and(JooqDao.caseInsensitiveLikeRegex(spec.FCST_SPEC_ID, specIdRegex)) - .and(buildEntityCondition(spec, sourceEntityRegex, entityLike)); - //Designator is a nullable column in the database. - if(designator == null) { - query = query.and(spec.FCST_DESIGNATOR.isNull()); - } else { - query = query.and(JooqDao.caseInsensitiveLikeRegex(spec.FCST_DESIGNATOR, designator)); - } + .where(buildSpecListCondition(wrapper, office, specIdRegex, designator, sourceEntityRegex, entityLike)); return query.fetch() - .map(ForecastSpecDao::map); - } - - private Condition buildEntityCondition(AV_FCST_SPEC spec, - String sourceEntityRegex, - String entityLike) { - // If entityLike is provided, use case-insensitive LIKE - if (entityLike != null) { - return spec.ENTITY_ID.likeIgnoreCase(entityLike); - } - // Fallback to regex behavior - return JooqDao.caseInsensitiveLikeRegex(spec.ENTITY_ID, sourceEntityRegex); + .map(ForecastSpecDaoV1::map); } private static SelectOnConditionStep> query = forecastSpecQuery(dsl) - .where(spec.OFFICE_ID.eq(office.toUpperCase())) - .and(spec.FCST_SPEC_ID.eq(name)); - if(designator != null) { - query = query.and(spec.FCST_DESIGNATOR.eq(designator)); - } else { - query = query.and(spec.FCST_DESIGNATOR.isNull()); - } + .where(buildSpecKeyCondition(wrapper, office, name, designator)); Record7 fetch = query.fetchOne(); if (fetch == null) { throw new NotFoundException( @@ -144,6 +125,7 @@ public ForecastSpec getForecastSpec(@NotNull String office, String name, String return map(fetch); } + @Override public void update(ForecastSpec forecastSpec) { //Will throw NotFoundException is spec does not exist getForecastSpec(forecastSpec.getOfficeId(), forecastSpec.getSpecId(), forecastSpec.getDesignator()); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV2.java new file mode 100644 index 0000000000..b3d82c01e4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDaoV2.java @@ -0,0 +1,218 @@ +package cwms.cda.data.dao.forecast; + +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.forecast.ForecastLocation; +import cwms.cda.data.dto.forecast.ForecastSpecV2; + +import org.jooq.Condition; +import org.jooq.Record2; +import org.jooq.Record5; +import org.jooq.Record6; +import org.jooq.SelectConditionStep; +import org.jooq.SelectOnConditionStep; +import org.jooq.Table; +import usace.cwms.db.jooq.codegen_latest.packages.CWMS_FCST_PACKAGE; +import usace.cwms.db.jooq.codegen_latest.packages.cwms_fcst.RETRIEVE_FCST_SPEC_WITH_LOCATIONS; +import usace.cwms.db.jooq.codegen_latest.tables.AV_FCST_LOCATION; +import usace.cwms.db.jooq.codegen_latest.tables.AV_FCST_SPEC; +import usace.cwms.db.jooq.codegen_latest.tables.AV_FCST_TIME_SERIES; + +import org.jooq.DSLContext; +import org.jooq.impl.DSL; +import usace.cwms.db.jooq.codegen_latest.udt.records.FCST_LOCATION_T; +import usace.cwms.db.jooq.codegen_latest.udt.records.FCST_LOCATION_TAB_T; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.toList; + +public final class ForecastSpecDaoV2 extends ForecastSpecDao { + + public ForecastSpecDaoV2(DSLContext dsl) { + super(dsl); + } + + @Override + protected ViewWrapper getViewWrapper() { + AV_FCST_SPEC view = AV_FCST_SPEC.AV_FCST_SPEC; + return new ViewWrapper(view.OFFICE_ID, view.FCST_SPEC_ID, view.FCST_DESIGNATOR, view.ENTITY_ID, + view.FCST_SPEC_CODE); + } + + @Override + public void create(ForecastSpecV2 forecastSpec) { + + connection(dsl, conn -> { + setOffice(conn, forecastSpec.getSpecId().getOfficeId()); + + String timeSeriesIds = null; + if (forecastSpec.getTimeSeriesIds() != null) { + timeSeriesIds = String.join("\n", forecastSpec.getTimeSeriesIds()); + } + + FCST_LOCATION_TAB_T locations = new FCST_LOCATION_TAB_T(); + if (forecastSpec.getLocationIds() != null) { + for (ForecastLocation fcstLocation : forecastSpec.getLocationIds()) { + FCST_LOCATION_T locationRecord = new FCST_LOCATION_T(); + locationRecord.setLOCATION_ID(fcstLocation.getLocationId()); + locationRecord.setSORT_ORDER(BigDecimal.valueOf(fcstLocation.getSortOrder())); + locations.add(locationRecord); + } + } + CWMS_FCST_PACKAGE.call_STORE_FCST_SPEC_WITH_LOCATIONS(DSL.using(conn).configuration(), + forecastSpec.getSpecId().getName(), forecastSpec.getDesignator(), forecastSpec.getSourceEntityId(), forecastSpec.getDescription(), + locations, timeSeriesIds, "F", "F", forecastSpec.getSpecId().getOfficeId()); + }); + } + + @Override + public List getForecastSpecs(String office, String specIdRegex, + String designator, String sourceEntityRegex, String entityLike) { + + ViewWrapper wrapper = getViewWrapper(); + Condition condition = buildSpecListCondition(wrapper, office, specIdRegex, designator, sourceEntityRegex, + entityLike); + + SelectConditionStep> query = + forecastSpecQuery(dsl).where(condition); + List specs = query.fetch().map(ForecastSpecDaoV2::map); + + Map> locationsByKey = fetchLocationsFor(condition); + + List results = new ArrayList<>(); + for (ForecastSpecV2 s : specs) { + results.add(withLocations(s, locationsByKey)); + } + return results; + } + + @Override + public ForecastSpecV2 getForecastSpec(String office, String specId, String designator) { + return connectionResult(dsl, conn -> { + RETRIEVE_FCST_SPEC_WITH_LOCATIONS retrieved = CWMS_FCST_PACKAGE.call_RETRIEVE_FCST_SPEC_WITH_LOCATIONS(DSL.using(conn).configuration(), specId, designator, office); + List tsIds = new ArrayList<>(); + if(retrieved.getP_TIMESERIES_IDS() != null && !retrieved.getP_TIMESERIES_IDS().isEmpty()) { + tsIds = List.of(retrieved.getP_TIMESERIES_IDS().split("\\r?\\n")); + } + return new ForecastSpecV2.Builder() + .withSpecId(new CwmsId.Builder() + .withOfficeId(office) + .withName(retrieved.getName()) + .build()) + .withDescription(retrieved.getP_DESCRIPTION()) + .withDesignator(designator) + .withTimeSeriesIds(tsIds) + .withSourceEntityId(retrieved.getP_ENTITY_ID()) + .withLocationIds(retrieved.getP_LOCATION_IDS().stream() + .map(loc -> new ForecastLocation.Builder() + .withLocationId(loc.getLOCATION_ID()) + .withSortOrder(loc.getSORT_ORDER().intValue()) + .build()) + .collect(toList())) + .build(); + }); + } + + /** + * Spec-level projection: identical to V1's, minus the single location-id column. + * One row per spec; locations are fetched and attached separately (see class Javadoc). + */ + private static SelectOnConditionStep> + forecastSpecQuery(DSLContext dsl) { + AV_FCST_SPEC spec = AV_FCST_SPEC.AV_FCST_SPEC; + AV_FCST_TIME_SERIES timeSeries = AV_FCST_TIME_SERIES.AV_FCST_TIME_SERIES; + //Group all the timeseries ids into a "\n" delimited list + Table> tsidTable = dsl.select(timeSeries.FCST_SPEC_CODE, + DSL.listAgg(timeSeries.CWMS_TS_ID, "\n") + .withinGroupOrderBy(timeSeries.CWMS_TS_ID) + .as("time_series_list")) + .from(timeSeries) + .groupBy(timeSeries.FCST_SPEC_CODE) + .asTable("tsids"); + return dsl.select(spec.FCST_SPEC_ID, spec.DESCRIPTION, spec.FCST_DESIGNATOR, + spec.OFFICE_ID, tsidTable.field("time_series_list", String.class), spec.ENTITY_ID) + .from(spec) + .leftJoin(tsidTable) + .on(spec.FCST_SPEC_CODE.eq(tsidTable.field("FCST_SPEC_CODE", String.class))); + } + + private static ForecastSpecV2 map(Record6 r) { + List timeSeriesIdentifiers = new ArrayList<>(); + if (r.value5() != null) { + timeSeriesIdentifiers = Arrays.stream(r.value5().split("\n")).collect(toList()); + } + return new ForecastSpecV2.Builder() + .withSpecId(new CwmsId.Builder() + .withOfficeId(r.value4()) + .withName(r.value1()) + .build()) + .withDescription(r.value2()) + .withDesignator(r.value3()) + .withTimeSeriesIds(timeSeriesIdentifiers) + .withSourceEntityId(r.value6()) + .build(); + } + + /** + * Fetches every location row for specs matching {@code specCondition}, joined against + * {@code AV_FCST_SPEC} so each row also carries its spec's business key, and grouped by + * that key. Ordered by sort order within each spec so the grouped lists come out in + * the right order without any further sorting in Java. + */ + private Map> fetchLocationsFor(Condition specCondition) { + AV_FCST_SPEC spec = AV_FCST_SPEC.AV_FCST_SPEC; + AV_FCST_LOCATION loc = AV_FCST_LOCATION.AV_FCST_LOCATION; + + // Only SORT_ORDER is selected from AV_FCST_LOCATION, not its derived IS_PRIMARY + // column: FCST_LOCATION_T (used by create/getForecastSpec) has no is-primary + // attribute either, so sort order -1 is the single source of truth for "primary" + // across this whole feature. ForecastLocation.Builder derives isPrimary from + // sortOrder automatically, keeping this in sync with getForecastSpec's mapping. + List> rows = dsl.select( + spec.OFFICE_ID, spec.FCST_SPEC_ID, spec.FCST_DESIGNATOR, + loc.LOCATION_ID, loc.SORT_ORDER) + .from(spec) + .join(loc) + .on(spec.FCST_SPEC_CODE.eq(loc.FCST_SPEC_CODE)) + .where(specCondition) + .orderBy(spec.FCST_SPEC_ID, loc.SORT_ORDER) + .fetch(); + + Map> locationsByKey = new HashMap<>(); + for (Record5 row : rows) { + String key = specKey(row.value1(), row.value2(), row.value3()); + ForecastLocation location = new ForecastLocation.Builder() + .withLocationId(row.value4()) + .withSortOrder(row.value5().intValue()) + .build(); + locationsByKey.computeIfAbsent(key, k -> new ArrayList<>()).add(location); + } + return locationsByKey; + } + + private static ForecastSpecV2 withLocations(ForecastSpecV2 spec, Map> locationsByKey) { + String key = specKey(spec.getSpecId().getOfficeId(), spec.getSpecId().getName(), spec.getDesignator()); + List locations = locationsByKey.get(key); + return new ForecastSpecV2.Builder() + .from(spec) + .withLocationIds(locations) + .build(); + } + + /** Composite business key for a spec: office + spec id + designator (nullable). */ + private static String specKey(String office, String specId, String designator) { + return office + ' ' + specId + ' ' + (designator == null ? "" : designator); + } + + @Override + public void update(ForecastSpecV2 forecastSpec) { + //Will throw NotFoundException is spec does not exist + getForecastSpec(forecastSpec.getSpecId().getOfficeId(), forecastSpec.getSpecId().getName(), forecastSpec.getDesignator()); + create(forecastSpec); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java new file mode 100644 index 0000000000..595369cf66 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java @@ -0,0 +1,106 @@ +package cwms.cda.data.dto.forecast; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = ForecastLocation.Builder.class) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV1.class, aliases = {Formats.DEFAULT}) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public class ForecastLocation extends CwmsDTOBase { + @JsonProperty(required = true) + private final String locationId; + + @JsonProperty(required = true) + @Schema(example = "-1") + private final Integer sortOrder; + + @JsonProperty("is-primary") + @Schema(example = "true") + private final Boolean isPrimary; + + private ForecastLocation(Builder builder) { + this.locationId = builder.locationId; + this.sortOrder = builder.sortOrder; + this.isPrimary = builder.isPrimary; + } + + public String getLocationId() { + return locationId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + @JsonProperty("is-primary") + public Boolean isPrimary() { + return isPrimary; + } + + public static final class Builder { + private String locationId; + private Integer sortOrder; + private Boolean isPrimary; + + public Builder() { + } + + public Builder withLocationId(String locationId) { + this.locationId = locationId; + return this; + } + + public Builder withSortOrder(Integer sortOrder) { + if(sortOrder != null) { + if(sortOrder == -1) { + if(isPrimary == null) { + this.isPrimary = true; + } else if(!isPrimary) { + throw new IllegalArgumentException("isPrimary must be true if sortOrder is -1"); + } + } else { + if(isPrimary == null) { + this.isPrimary = false; + } else if(isPrimary) { + throw new IllegalArgumentException("isPrimary must be false if sortOrder is not -1"); + } + } + } + this.sortOrder = sortOrder; + return this; + } + + @JsonProperty("is-primary") + public Builder withIsPrimary(Boolean isPrimary) { + if(isPrimary != null) { + if(isPrimary) { + if(this.sortOrder == null) { + this.sortOrder = -1; + } else if(this.sortOrder != -1) { + throw new IllegalArgumentException("sortOrder must be -1 if isPrimary is true"); + } + } else { + if(this.sortOrder != null && this.sortOrder == -1) { + throw new IllegalArgumentException("sortOrder cannot be -1 if isPrimary is false"); + } + } + } + this.isPrimary = isPrimary; + return this; + } + + public ForecastLocation build() { + return new ForecastLocation(this); + } + } + +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java new file mode 100644 index 0000000000..04f1bd7efd --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java @@ -0,0 +1,124 @@ +package cwms.cda.data.dto.forecast; + +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.CwmsDTOBase; +import cwms.cda.data.dto.CwmsId; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; + +import java.util.List; + +@JsonRootName("forecast-spec") +@FormattableWith(contentType = Formats.JSON, formatter = JsonV1.class, aliases = {Formats.DEFAULT}) +@JsonDeserialize(builder = ForecastSpecV2.Builder.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public class ForecastSpecV2 extends CwmsDTOBase { + @JsonProperty(required = true) + private final CwmsId specId; + private final String designator; + private final List locationIds; + private final String sourceEntityId; + private final String description; + private final List timeSeriesIds; + + + private ForecastSpecV2(Builder builder) { + this.specId = builder.specId; + this.designator = builder.designator; + this.locationIds = builder.locationIds; + this.sourceEntityId = builder.sourceEntityId; + this.description = builder.description; + this.timeSeriesIds = builder.timeSeriesIds; + } + + public CwmsId getSpecId() { + return specId; + } + + public List getLocationIds() { + return locationIds; + } + + public String getSourceEntityId() { + return sourceEntityId; + } + + public String getDesignator() { + return designator; + } + + public String getDescription() { + return description; + } + + public List getTimeSeriesIds() { + return timeSeriesIds; + } + + public static class Builder { + private CwmsId specId; + private String designator; + private List locationIds; + private String sourceEntityId; + private String description; + private List timeSeriesIds; + + public Builder() { + + } + + public Builder withSpecId(CwmsId specId) { + this.specId = specId; + return this; + } + + public Builder withDesignator(String designator) { + this.designator = designator; + return this; + } + + public Builder withLocationIds(List locationIds) { + this.locationIds = locationIds; + return this; + } + + public Builder withSourceEntityId(String sourceEntityId) { + this.sourceEntityId = sourceEntityId; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withTimeSeriesIds(List timeSeriesIds) { + this.timeSeriesIds = timeSeriesIds; + return this; + } + + @JsonIgnore + public Builder from(ForecastSpecV2 forecastSpec) { + this.specId = forecastSpec.getSpecId(); + this.designator = forecastSpec.getDesignator(); + this.locationIds = forecastSpec.getLocationIds(); + this.sourceEntityId = forecastSpec.getSourceEntityId(); + this.description = forecastSpec.getDescription(); + this.timeSeriesIds = forecastSpec.getTimeSeriesIds(); + return this; + } + + public ForecastSpecV2 build() { + return new ForecastSpecV2(this); + } + } + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/ForecastInstanceControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/ForecastInstanceControllerTestIT.java index baecb267fa..88b23b5e0a 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/ForecastInstanceControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastInstanceControllerTestIT.java @@ -14,7 +14,6 @@ import org.apache.http.client.utils.URIBuilder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -31,7 +30,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import static cwms.cda.api.ForecastSpecControllerTestIT.*; +import static cwms.cda.api.ForecastSpecControllerV1TestIT.*; import static cwms.cda.security.ApiKeyIdentityProvider.AUTH_HEADER; import static io.restassured.RestAssured.given; import static java.util.stream.Collectors.toMap; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV1TestIT.java similarity index 99% rename from cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerTestIT.java rename to cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV1TestIT.java index 3f05a89864..f490ad1247 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV1TestIT.java @@ -37,7 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; @Tag("integration") -final class ForecastSpecControllerTestIT extends DataApiTestIT { +final class ForecastSpecControllerV1TestIT extends DataApiTestIT { private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); private static final String OFFICE = "SPK"; private static final String SPEC_ID = "TEST-SPEC"; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java new file mode 100644 index 0000000000..8359042245 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java @@ -0,0 +1,879 @@ +package cwms.cda.api; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.ApiServlet; +import cwms.cda.data.dao.DeleteRule; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.formatters.Formats; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.MinimumSchema; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import usace.cwms.db.jooq.codegen.packages.CWMS_FCST_PACKAGE; + +import org.apache.commons.io.IOUtils; +import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; +import org.jooq.util.oracle.OracleDSL; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; + +import static cwms.cda.api.Controllers.DESIGNATOR; +import static cwms.cda.api.Controllers.ID_MASK; +import static cwms.cda.security.ApiKeyIdentityProvider.AUTH_HEADER; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isEmptyOrNullString; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Tag("integration") +@MinimumSchema(260716) +final class ForecastSpecControllerV2TestIT extends DataApiTestIT { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String OFFICE = "SPK"; + private static final String SPEC_ID = "TEST-SPEC-V2"; + private static final String locationId = "TsBinTestLoc"; + private static final String locationId2 = "TsBinTestLoc2"; + private static final String designator = "designator"; + + // v2 primary-resource standard: office is a path segment, not a query param. + public static final String PATH = "/v2/forecast-spec/" + OFFICE + "/"; + + @BeforeAll + static void create() throws Exception { + createLocation(locationId, true, OFFICE); + createLocation(locationId2, true, OFFICE); + createTimeSeries(locationId); + createTimeseries(OFFICE, "TsBinTestLoc.Elev.Inst.~1Day.0.SPK-cavi-fct"); + createTimeseries(OFFICE, "TsBinTestLoc.Flow-Outflow.Inst.~1Day.0.SPK-cavi-fct"); + createTimeseries(OFFICE, "TsBinTestLoc2.Elev.Inst.~1Day.0.SPK-cavi-fct"); + createTimeseries(OFFICE, "TsBinTestLoc2.Flow-Outflow.Inst.~1Day.0.SPK-cavi-fct"); + } + + static void createTimeSeries(String locationId) throws SQLException { + //This shouldn't be needed after db update + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid1"); + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid2"); + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid3"); + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid4"); + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid5"); + createTimeseries(OFFICE, locationId + ".Flow.Ave.1Day.1Day.tsid6"); + } + + @AfterEach + void tearDown() throws Exception { + truncateFcstTimeSeries(); + deleteSpec(); + } + + static void truncateFcstTimeSeries() throws SQLException { + //fixing circular reference between spec, time series, and locations + CwmsDataApiSetupCallback.getDatabaseLink() + .connection(c -> { + OracleDSL.using(c).truncateTable(DSL.table("CWMS_20.AT_FCST_TIME_SERIES")) + .execute(); + OracleDSL.using(c).truncateTable(DSL.table("CWMS_20.AT_FCST_INFO")) + .execute(); + OracleDSL.using(c).truncateTable(DSL.table("CWMS_20.AT_FCST_INST")) + .execute(); + }, "CWMS_20"); + } + + static void deleteSpec() throws SQLException { + try { + CwmsDataApiSetupCallback.getDatabaseLink() + .connection(c -> { + CWMS_FCST_PACKAGE.call_DELETE_FCST_SPEC(OracleDSL.using(c).configuration(), SPEC_ID, + "designator", DeleteRule.DELETE_ALL.getRule(), OFFICE); + CWMS_FCST_PACKAGE.call_DELETE_FCST_SPEC(OracleDSL.using(c).configuration(), + SPEC_ID + "-NULL-DESIGNATOR", null, DeleteRule.DELETE_ALL.getRule(), OFFICE); + }); + } catch (DataAccessException e) { + LOGGER.atFine().withCause(e) + .log("Couldn't clean up forecast spec before executing tests. Probably didn't exist"); + } + } + + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_get_create_get(String format) throws IOException { + + // Structure of test: + // 1)Retrieve a ForecastSpec and assert that it does not exist + // 2)Create the ForecastSpec, with two locations (one primary, one not) + // 3)Retrieve the ForecastSpec and assert that it exists, and that the locations + // (including sort orders and which one is primary) round-tripped correctly + + // Step 1) + // Retrieve a ForecastSpec and assert that it does not exist + //Read + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + + // Step 2) + // Create the ForecastSpec + + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 3) + // Retrieve the spec and assert that it exists, with its locations intact. + // TsBinTestLoc2 is primary (sort-order -1), so it sorts first. + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("designator", equalTo(designator)) + .body("time-series-ids.size()", equalTo(3)) + .body("location-ids.size()", equalTo(2)) + .body("location-ids[0].location-id", equalTo(locationId2)) + .body("location-ids[0].sort-order", equalTo(-1)) + .body("location-ids[0].is-primary", equalTo(true)) + .body("location-ids[1].location-id", equalTo(locationId)) + .body("location-ids[1].sort-order", equalTo(1)) + .body("location-ids[1].is-primary", equalTo(false)) + ; + + + } + + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_get_create_get_null_designator(String format) throws IOException { + + // Structure of test: + // 1)Retrieve a ForecastSpec and assert that it does not exist + // 2)Create the ForecastSpec + // 3)Retrieve the ForecastSpec and assert that it exists + // 4)Delete the ForecastSpec if it exists + + // Step 1) + // Retrieve a ForecastSpec and assert that it does not exist + //Read + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID + "-NULL-DESIGNATOR") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + + // Step 2) + // Create the ForecastSpec + + InputStream resource = this.getClass() + .getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create_null_designator.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 3) + // Retrieve the spec and assert that it exists + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID + "-NULL-DESIGNATOR") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("designator", isEmptyOrNullString()) + .body("time-series-ids.size()", equalTo(3)) + .body("location-ids.size()", equalTo(1)) + .body("location-ids[0].location-id", equalTo(locationId)) + .body("location-ids[0].is-primary", equalTo(true)) + ; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(ID_MASK, SPEC_ID + "-NULL-DESIGNATOR") + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("[0].designator", isEmptyOrNullString()) + .body("[0].time-series-ids.size()", equalTo(3)) + ; + + // Step 4) + // Delete the spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete(PATH + SPEC_ID + "-NULL-DESIGNATOR") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)) + ; + } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_create_get_delete_get(String format) throws Exception { + + // Structure of test: + // + // 1)Create the spec + // 2)Retrieve the spec and assert that it exists + // 3)Delete the spec + // 4)Retrieve the spec and assert that it does not exist + + + // Step 1) + // Create the spec + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 2) + // Retrieve the spec and assert that it exists + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("designator", equalTo(designator)) + .body("time-series-ids.size()", equalTo(3)) + .body("location-ids.size()", equalTo(2)) + ; + truncateFcstTimeSeries(); + // Step 3) + // Delete the spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.NAME, SPEC_ID) + .queryParam(DESIGNATOR, designator) + .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + + // Step 4) + // Retrieve the spec and assert that it does not exist + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void create_getAll_delete_getAll(String format) throws Exception { + + // Structure of test: + // 1) Create two specs + // 2) Call getAll and verify a list/array is returned containing both, each with its locations + // 3) Delete both specs + // 4) Call getAll again and verify they are not returned + + // Step 1) Create two specs + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + String specId = SPEC_ID + "TEST"; + tsData = tsData.replace("\"name\": \"" + SPEC_ID + "\"", "\"name\": \"" + specId + "\""); + // First spec uses specId as-is. Second spec will replace the name with specId + "-2" + String specId2 = specId + "-2"; + String tsData2 = tsData.replace("\"name\": \"" + specId + "\"", "\"name\": \"" + specId2 + "\""); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Create first spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Create second spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData2) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 2) getAll should return a list containing both specs when filtered by office and designator + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(Controllers.DESIGNATOR_MASK, "*") + .queryParam(Controllers.ID_MASK, specId + "*") + .queryParam(Controllers.SOURCE_ENTITY, ".*") + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + // verify it is an array with 2 elements and contains both spec-ids, each with its locations + .body("size()", equalTo(2)) + .body("[0].designator", equalTo(designator)) + .body("[1].designator", equalTo(designator)) + .body("[0].location-ids.size()", equalTo(2)) + .body("[1].location-ids.size()", equalTo(2)) + ; + + // Step 3) Delete both specs + truncateFcstTimeSeries(); + + // Step 4) Verify getAll no longer returns the deleted specs + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, "*") + .queryParam(ID_MASK, specId + "*") + .queryParam(Controllers.SOURCE_ENTITY, ".*") + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + // Expect empty array + .body("size()", equalTo(0)) + ; + } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void create_getAll_with_entity_like_delete_getAll(String format) throws Exception { + + // Structure of test: + // 1) Create two specs + // 2) Call getAll and verify a list/array is returned containing both + // 3) Delete both specs + // 4) Call getAll again and verify they are not returned + + // Step 1) Create two specs + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + String specId = SPEC_ID + "TEST"; + tsData = tsData.replace("\"name\": \"" + SPEC_ID + "\"", "\"name\": \"" + specId + "\""); + // First spec uses specId as-is. Second spec will replace the name with specId + "-2" + String specId2 = specId + "-2"; + String tsData2 = tsData.replace("\"name\": \"" + specId + "\"", "\"name\": \"" + specId2 + "\""); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Create first spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Create second spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData2) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 2) getAll should return a list containing both specs when filtered by office and designator + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(Controllers.DESIGNATOR_MASK, "*") + .queryParam(Controllers.ID_MASK, specId + "*") + .queryParam(Controllers.SOURCE_ENTITY_LIKE, "%") + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + // verify it is an array with 2 elements and contains both spec-ids + .body("size()", equalTo(2)) + .body("[0].designator", equalTo(designator)) + .body("[1].designator", equalTo(designator)) + ; + + // Step 3) Delete both specs + truncateFcstTimeSeries(); + + // Step 4) Verify getAll no longer returns the deleted specs + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, "*") + .queryParam(ID_MASK, specId + "*") + .queryParam(Controllers.SOURCE_ENTITY, ".*") + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + // Expect empty array + .body("size()", equalTo(0)) + ; + } + + @Test + void test_create_get_delete_get_permissions_issue() throws Exception { + + // Create the spec + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_save.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_OTHER_NORMAL_SAME_ROLES; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + truncateFcstTimeSeries(); + // Delete the spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.NAME, "SPK-Daily-UKY-Test-V2") + .queryParam(DESIGNATOR, designator) + .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete(PATH + "SPK-Daily-UKY-Test-V2") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + + // Retrieve the spec and assert that it does not exist + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + "SPK-Daily-UKY-Test-V2") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_create_get_delete_get_lrts(String format) throws Exception { + // Structure of test: + // 1) Create the spec + // 2) Retrieve the spec and assert that it exists + // 3) Delete the spec + // 4) Retrieve the spec and assert that it does not exist + + String specId = "TEST-SPEC-V2-LRTS"; + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + // Step 1) + // Create the spec + InputStream resource = this.getClass() + .getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create_lrts.json"); + assertNotNull(resource); + String specData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(specData); + + createTimeseriesWithNewLRTSInterval(OFFICE, "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid1", 0); + createTimeseriesWithNewLRTSInterval(OFFICE, "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid2", 0); + createTimeseriesWithNewLRTSInterval(OFFICE, "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid3", 0); + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(specData) + .header(AUTH_HEADER, user.toHeaderValue()) + .header(ApiServlet.IS_NEW_LRTS, true) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 2) + // Retrieve the spec and assert that it exists + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .header(ApiServlet.IS_NEW_LRTS, true) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + specId) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("designator", equalTo(designator)) + .body("time-series-ids.size()", equalTo(3)) + .body("time-series-ids[0]", equalTo("TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid1")) + .body("time-series-ids[1]", equalTo("TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid2")) + .body("time-series-ids[2]", equalTo("TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid3")) + .body("location-ids.size()", equalTo(1)) + .body("location-ids[0].location-id", equalTo(locationId)) + .body("location-ids[0].is-primary", equalTo(true)) + ; + + truncateFcstTimeSeries(); + + // Step 3) + // Delete the spec + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.NAME, specId) + .queryParam(DESIGNATOR, designator) + .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) + .when() + .redirects().follow(true) + .redirects().max(3) + .delete(PATH + specId) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NO_CONTENT)); + + // Step 4) + // Retrieve the spec and assert that it does not exist + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + specId) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + } + + @ParameterizedTest + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) + void test_create_get_update_get(String format) throws IOException { + + // Structure of test: + // 1)Retrieve spec + // 2)Create the spec, with TsBinTestLoc2 as the primary location + // 3)Retrieve the spec and assert that it exists, with locations as created + // 4)Update the spec, swapping which location is primary and their sort orders -- + // this exercises that an update fully replaces the location set (not merges it) + // 5)Retrieve the spec and assert that both the other fields and the location list + // (including the swapped sort orders / primary flag) reflect the update + + + // Step 1) + // Retrieve a spec and assert that it does not exist + //Read + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + ; + + // Step 2) + // Create the ForecastSpec + + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_create.json"); + assertNotNull(resource); + String tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .post(PATH) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Step 3) + // Retrieve the spec and assert that it exists, with TsBinTestLoc2 as primary + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("source-entity-id", equalTo("USACE")) + .body("location-ids.size()", equalTo(2)) + .body("location-ids[0].location-id", equalTo(locationId2)) + .body("location-ids[0].is-primary", equalTo(true)) + .body("location-ids[1].location-id", equalTo(locationId)) + .body("location-ids[1].is-primary", equalTo(false)) + ; + + // Step 4) + // Update the spec: swap which location is primary and their sort orders + resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/forecast_spec_v2_update.json"); + assertNotNull(resource); + tsData = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(tsData); + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .contentType(Formats.JSON) + .body(tsData) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + + // Step 5) + // Retrieve the spec and assert it changed: source-entity-id, description, time-series-ids, + // and -- the point of this test -- that TsBinTestLoc (not TsBinTestLoc2) is now primary, + // with the sort orders from the update, not the original create. + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(format) + .queryParam(DESIGNATOR, designator) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(PATH + SPEC_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("source-entity-id", equalTo("USGS")) + .body("location-ids.size()", equalTo(2)) + .body("location-ids[0].location-id", equalTo(locationId)) + .body("location-ids[0].sort-order", equalTo(-1)) + .body("location-ids[0].is-primary", equalTo(true)) + .body("location-ids[1].location-id", equalTo(locationId2)) + .body("location-ids[1].sort-order", equalTo(1)) + .body("location-ids[1].is-primary", equalTo(false)) + ; + } + +} 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/data/dto/forecast/ForecastLocationTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastLocationTest.java new file mode 100644 index 0000000000..1ac1807a21 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastLocationTest.java @@ -0,0 +1,114 @@ +package cwms.cda.data.dto.forecast; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import cwms.cda.api.errors.FieldException; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import cwms.cda.helpers.DTOMatch; + +import org.junit.jupiter.api.Test; + +public class ForecastLocationTest { + + @Test + void testRoundTripJson() { + ForecastLocation l1 = new ForecastLocation.Builder() + .withLocationId("location") + .withSortOrder(-1) + .withIsPrimary(true) + .build(); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastLocation.class); + + String jsonString = Formats.format(contentType, l1); + assertNotNull(jsonString); + + ForecastLocation l2 = Formats.parseContent(contentType, jsonString, ForecastLocation.class); + assertNotNull(l2); + + DTOMatch.assertMatch(l1, l2); + } + + @Test + void testMissingRequired() { + assertThrows(FieldException.class, () -> new ForecastLocation.Builder() + .build().validate()); + assertThrows(FieldException.class, () -> new ForecastLocation.Builder() + .withSortOrder(-1) + .build().validate()); + assertThrows(FieldException.class, () -> new ForecastLocation.Builder() + .withLocationId("loc") + .build().validate()); + } + + @Test + void testInvalidPrimary() { + assertThrows(IllegalArgumentException.class, () -> new ForecastLocation.Builder() + .withLocationId("loc") + .withSortOrder(-1) + .withIsPrimary(false) + .build()); + + assertThrows(IllegalArgumentException.class, () -> new ForecastLocation.Builder() + .withLocationId("loc") + .withSortOrder(1) + .withIsPrimary(true) + .build()); + + assertThrows(IllegalArgumentException.class, () -> new ForecastLocation.Builder() + .withLocationId("loc") + .withIsPrimary(true) + .withSortOrder(1) + .build()); + + assertThrows(IllegalArgumentException.class, () -> new ForecastLocation.Builder() + .withLocationId("loc") + .withIsPrimary(false) + .withSortOrder(-1) + .build()); + } + + @Test + void testGetters() { + ForecastLocation location = new ForecastLocation.Builder() + .withLocationId("location") + .withSortOrder(-1) + .withIsPrimary(true) + .build(); + + assertEquals("location", location.getLocationId()); + assertEquals(-1, location.getSortOrder()); + assertEquals(Boolean.TRUE, location.isPrimary()); + + location = new ForecastLocation.Builder() + .withLocationId("location") + .withIsPrimary(true) + .build(); + + assertEquals("location", location.getLocationId()); + assertEquals(-1, location.getSortOrder()); + assertEquals(Boolean.TRUE, location.isPrimary()); + + location = new ForecastLocation.Builder() + .withLocationId("location") + .withSortOrder(-1) + .build(); + + assertEquals("location", location.getLocationId()); + assertEquals(-1, location.getSortOrder()); + assertEquals(Boolean.TRUE, location.isPrimary()); + + location = new ForecastLocation.Builder() + .withLocationId("location") + .withSortOrder(1) + .build(); + + assertEquals("location", location.getLocationId()); + assertEquals(1, location.getSortOrder()); + assertEquals(Boolean.FALSE, location.isPrimary()); + + } + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java new file mode 100644 index 0000000000..0263b6a76f --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java @@ -0,0 +1,113 @@ +package cwms.cda.data.dto.forecast; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import cwms.cda.api.errors.FieldException; +import cwms.cda.helpers.DTOMatch; + +import cwms.cda.data.dto.CwmsId; +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.ArrayList; +import java.util.List; + +import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +public class ForecastSpecV2Test { + + @Test + void testRoundTripJson() { + ForecastSpecV2 s1 = buildForecastSpecV2(); + + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); + + String jsonString = Formats.format(contentType, s1); + assertNotNull(jsonString); + + ForecastSpecV2 s2 = Formats.parseContent(contentType, jsonString, ForecastSpecV2.class); + assertNotNull(s2); + + DTOMatch.assertMatch(s1, s2); + } + + @Test + void testFormatsSerialization() { + ForecastSpecV2 s1 = buildForecastSpecV2(); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); + String jsonStr = Formats.format(contentType, s1); + assertNotNull(jsonStr); + } + + @Test + void testMissingRequired() { + assertThrows(FieldException.class, () -> new ForecastSpecV2.Builder().build().validate()); + } + + @Test + void testJsonFile() throws IOException { + String json; + try (InputStream stream = getClass().getResourceAsStream("forecast_spec_v2_test.json")) { + assertNotNull(stream); + json = IOUtils.toString(stream, StandardCharsets.UTF_8); + } + + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); + ForecastSpecV2 fi = Formats.parseContent(contentType, json, ForecastSpecV2.class); + + assertNotNull(fi); + DTOMatch.assertMatch(fi, buildForecastSpecV2()); + } + + @Test + void testLocationsPreserved() { + ForecastSpecV2 s1 = buildForecastSpecV2(); + + List locations = s1.getLocationIds(); + assertNotNull(locations); + DTOMatch.assertMatch(new ForecastLocation.Builder() + .withLocationId("location1") + .withIsPrimary(true) + .build(), locations.get(0)); + DTOMatch.assertMatch(new ForecastLocation.Builder() + .withLocationId("location2") + .withSortOrder(2) + .withIsPrimary(false) + .build(), locations.get(1)); + } + + @NotNull + private ForecastSpecV2 buildForecastSpecV2() { + List tsids = new ArrayList<>(); + tsids.add("tsid1"); + tsids.add("tsid2"); + tsids.add("tsid3"); + + List locations = new ArrayList<>(); + locations.add(new ForecastLocation.Builder() + .withLocationId("location1") + .withIsPrimary(true) + .build()); + locations.add(new ForecastLocation.Builder() + .withLocationId("location2") + .withSortOrder(2) + .withIsPrimary(false) + .build()); + + return new ForecastSpecV2.Builder() + .withSpecId(new CwmsId.Builder() + .withName("spec") + .withOfficeId("office") + .build()) + .withLocationIds(locations) + .withSourceEntityId("sourceEntity").withDesignator("designator") + .withDescription("description") + .withTimeSeriesIds(tsids) + .build(); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java b/cwms-data-api/src/test/java/cwms/cda/helpers/DTOMatch.java index edaa8561a1..928f134bcf 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 @@ -52,6 +52,8 @@ import cwms.cda.data.dto.rating.RatingEffectiveDatesMap; import cwms.cda.data.dto.rating.RatingSpecEffectiveDates; import cwms.cda.data.dto.stream.StreamLocationNode; +import cwms.cda.data.dto.forecast.ForecastLocation; +import cwms.cda.data.dto.forecast.ForecastSpecV2; import cwms.cda.data.dto.CwmsId; import cwms.cda.data.dto.Location; @@ -96,6 +98,10 @@ private DTOMatch() { } public static void assertMatch(CwmsId first, CwmsId second, String variableName) { + if (first == null || second == null) { + Assertions.assertEquals(first, second, variableName + " null mismatch"); + return; + } assertAll( () -> Assertions.assertEquals(first.getOfficeId(), second.getOfficeId(),variableName + " is not the same. Office ID differs"), () -> Assertions.assertEquals(first.getName(), second.getName(),variableName + " is not the same. Name differs") @@ -754,6 +760,37 @@ public static void assertMatch(LocationToPublishedDataList list, LocationToPubli ); } + public static void assertMatch(ForecastLocation first, ForecastLocation second) { + if (first == null || second == null) { + assertEquals(first, second, "ForecastLocation null mismatch"); + return; + } + assertAll( + () -> assertEquals(first.getLocationId(), second.getLocationId(), "Location ID does not match"), + () -> assertEquals(first.getSortOrder(), second.getSortOrder(), "Sort order does not match"), + () -> assertEquals(first.isPrimary(), second.isPrimary(), "Primary flag does not match") + ); + } + + public static void assertMatch(ForecastSpecV2 first, ForecastSpecV2 second) { + if (first == null || second == null) { + assertEquals(first, second, "ForecastSpecV2 null mismatch"); + return; + } + assertAll( + () -> assertMatch(first.getSpecId(), second.getSpecId()), + () -> assertEquals(first.getDesignator(), second.getDesignator(), "Designator does not match"), + () -> assertEquals(first.getSourceEntityId(), second.getSourceEntityId(), "Source entity ID does not match"), + () -> assertEquals(first.getDescription(), second.getDescription(), "Description does not match"), + () -> { + if (first.getLocationIds() != null || second.getLocationIds() != null) { + assertMatch(first.getLocationIds(), second.getLocationIds(), DTOMatch::assertMatch); + } + }, + () -> assertEquals(first.getTimeSeriesIds(), second.getTimeSeriesIds(), "Time series IDs do not match") + ); + } + 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/api/spk/forecast_spec_v2_create.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create.json new file mode 100644 index 0000000000..6e43e84017 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create.json @@ -0,0 +1,26 @@ +{ + "spec-id": { + "office-id": "SPK", + "name": "TEST-SPEC-V2" + }, + "designator": "designator", + "location-ids": [ + { + "location-id": "TsBinTestLoc", + "sort-order": 1, + "is-primary": false + }, + { + "location-id": "TsBinTestLoc2", + "sort-order": -1, + "is-primary": true + } + ], + "source-entity-id": "USACE", + "description": "description", + "time-series-ids": [ + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid1", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid2", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid3" + ] +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_lrts.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_lrts.json new file mode 100644 index 0000000000..adb5de8bfe --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_lrts.json @@ -0,0 +1,21 @@ +{ + "spec-id": { + "office-id": "SPK", + "name": "TEST-SPEC-V2-LRTS" + }, + "designator": "designator", + "location-ids": [ + { + "location-id": "TsBinTestLoc", + "sort-order": -1, + "is-primary": true + } + ], + "source-entity-id": "USACE", + "description": "description", + "time-series-ids": [ + "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid1", + "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid2", + "TsBinTestLoc.Flow.Ave.1DayLocal.1Day.tsid3" + ] +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_null_designator.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_null_designator.json new file mode 100644 index 0000000000..637d54fae9 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_null_designator.json @@ -0,0 +1,20 @@ +{ + "spec-id": { + "office-id": "SPK", + "name": "TEST-SPEC-V2-NULL-DESIGNATOR" + }, + "location-ids": [ + { + "location-id": "TsBinTestLoc", + "sort-order": -1, + "is-primary": true + } + ], + "source-entity-id": "USACE", + "description": "description", + "time-series-ids": [ + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid1", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid2", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid3" + ] +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_save.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_save.json new file mode 100644 index 0000000000..e2e8157c88 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_save.json @@ -0,0 +1,27 @@ +{ + "spec-id": { + "office-id": "SPK", + "name": "SPK-Daily-UKY-Test-V2" + }, + "designator": "designator", + "location-ids": [ + { + "location-id": "TsBinTestLoc", + "sort-order": -1, + "is-primary": true + }, + { + "location-id": "TsBinTestLoc2", + "sort-order": 1, + "is-primary": false + } + ], + "source-entity-id": "USACE", + "description": "3-day forecast of pool elevation and outflow for flood control projects in the Upper Kentucky watershed", + "time-series-ids": [ + "TsBinTestLoc.Elev.Inst.~1Day.0.SPK-cavi-fct", + "TsBinTestLoc.Flow-Outflow.Inst.~1Day.0.SPK-cavi-fct", + "TsBinTestLoc2.Elev.Inst.~1Day.0.SPK-cavi-fct", + "TsBinTestLoc2.Flow-Outflow.Inst.~1Day.0.SPK-cavi-fct" + ] +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_update.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_update.json new file mode 100644 index 0000000000..9acbb4869a --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_update.json @@ -0,0 +1,26 @@ +{ + "spec-id": { + "office-id": "SPK", + "name": "TEST-SPEC-V2" + }, + "designator": "designator", + "location-ids": [ + { + "location-id": "TsBinTestLoc2", + "sort-order": 1, + "is-primary": false + }, + { + "location-id": "TsBinTestLoc", + "sort-order": -1, + "is-primary": true + } + ], + "source-entity-id": "USGS", + "description": "different description", + "time-series-ids": [ + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid1", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid2", + "TsBinTestLoc.Flow.Ave.1Day.1Day.tsid3" + ] +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/forecast/forecast_spec_v2_test.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/forecast/forecast_spec_v2_test.json new file mode 100644 index 0000000000..74ccf1a7fc --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/forecast/forecast_spec_v2_test.json @@ -0,0 +1,26 @@ +{ + "spec-id":{ + "name":"spec", + "office-id":"office" + }, + "location-ids": [ + { + "location-id": "location1", + "sort-order": -1, + "is-primary": true + }, + { + "location-id": "location2", + "sort-order": 2, + "is-primary": false + } + ], + "source-entity-id": "sourceEntity", + "designator": "designator", + "description": "description", + "time-series-ids": [ + "tsid1", + "tsid2", + "tsid3" + ] +}