From 39588291c4ccb63b0d01e7b0d01a4aeac69ac313 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Thu, 6 Aug 2026 16:31:44 -0700 Subject: [PATCH 1/9] CDA-98 - Creating /v2/forecast-spec endpoint using updated spec structure. Updates OpenApiDocTest to check parent controller class. Adds route configuration support for v2 endpoints. --- .../src/main/java/cwms/cda/ApiServlet.java | 10 +- .../cda/ApiServletV2RouteConfiguration.java | 25 + .../api/AbstractForecastSpecController.java | 180 ++++ .../cwms/cda/api/ForecastSpecController.java | 146 +-- .../cda/api/v2/ForecastSpecControllerV2.java | 181 ++++ .../cda/data/dao/AbstractForecastSpecDao.java | 143 +++ .../cwms/cda/data/dao/ForecastSpecDao.java | 61 +- .../cwms/cda/data/dao/ForecastSpecDaoV2.java | 218 +++++ .../cda/data/dto/v2/ForecastLocation.java | 99 ++ .../cwms/cda/data/dto/v2/ForecastSpecV2.java | 125 +++ .../api/ForecastSpecControllerV2TestIT.java | 899 ++++++++++++++++++ .../java/cwms/cda/api/OpenApiDocTest.java | 50 +- .../cda/data/dto/v2/ForecastLocationTest.java | 127 +++ .../cda/data/dto/v2/ForecastSpecV2Test.java | 121 +++ .../test/java/cwms/cda/helpers/DTOMatch.java | 37 + .../cda/api/spk/forecast_spec_v2_create.json | 26 + .../api/spk/forecast_spec_v2_create_lrts.json | 21 + ...recast_spec_v2_create_null_designator.json | 20 + .../cda/api/spk/forecast_spec_v2_save.json | 27 + .../cda/api/spk/forecast_spec_v2_update.json | 26 + .../data/dto/v2/forecast_spec_v2_test.json | 26 + 21 files changed, 2393 insertions(+), 175 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDaoV2.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_lrts.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_create_null_designator.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_save.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/forecast_spec_v2_update.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/v2/forecast_spec_v2_test.json diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index ad4b50f517..e452f70bb0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -207,7 +207,6 @@ 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; @@ -290,7 +289,8 @@ "/users/*", "/roles/*", "/version/*", - "/rss/*" + "/rss/*", + "/v2/*" }) public class ApiServlet extends HttpServlet { @@ -305,6 +305,7 @@ public class ApiServlet extends HttpServlet { public static final String RAW_DATA_SOURCE = "data_source"; public static final String DATABASE = "database"; public static final String IS_NEW_LRTS = "X-CWMS-LRTS-Formatting"; + public static final String FORECAST_SPEC_PATH = "/forecast-spec/{%s}"; // The VERSION should match the gradle version but not contain the patch version. // For example 2.4 not 2.4.13 @@ -618,8 +619,9 @@ protected void configureRoutes() { 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); + cdaCrudCache(format(FORECAST_SPEC_PATH, Controllers.NAME), + new ForecastSpecController(metrics), requiredRoles, 5, TimeUnit.MINUTES); + ApiServletV2RouteConfiguration.configureRoutes(metrics, requiredRoles); String forecastFilePath = format("/forecast-instance/{%s}/file-data", NAME); get(forecastFilePath, new ForecastFileController(metrics)); addCacheControl(forecastFilePath, 1, TimeUnit.DAYS); diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java new file mode 100644 index 0000000000..2efe01c833 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java @@ -0,0 +1,25 @@ +package cwms.cda; + +import static java.lang.String.format; + +import com.codahale.metrics.MetricRegistry; +import cwms.cda.api.Controllers; +import cwms.cda.api.v2.ForecastSpecControllerV2; +import io.javalin.core.security.RouteRole; +import java.util.concurrent.TimeUnit; + +public final class ApiServletV2RouteConfiguration { + + private ApiServletV2RouteConfiguration() { + throw new AssertionError("Utility class - do not instantiate"); + } + + public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredRoles) { + ApiServlet.cdaCrudCache(formatV2(ApiServlet.FORECAST_SPEC_PATH, Controllers.NAME), + new ForecastSpecControllerV2(metrics), requiredRoles, 5, TimeUnit.MINUTES); + } + + private static String formatV2(String path, Object... args) { + return format("/v2/" + path, args); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java b/cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java new file mode 100644 index 0000000000..b87045165c --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java @@ -0,0 +1,180 @@ +package cwms.cda.api; + +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.OFFICE; +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 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.AbstractForecastSpecDao; +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 AbstractForecastSpecController extends BaseCrudHandler { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + + protected static final String TAG = "Forecast"; + + protected AbstractForecastSpecController(MetricRegistry metrics) { + super(metrics); + } + + protected DSLContext getDslContext(Context ctx) { + return JooqDao.getDslContext(ctx); + } + + /** Builds the version-specific DAO for this request. */ + protected abstract AbstractForecastSpecDao 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); + AbstractForecastSpecDao dao = newDao(dsl); + T forecastSpec = deserializeForecastSpec(ctx); + + dao.create(forecastSpec); + + ctx.status(HttpServletResponse.SC_CREATED); + } + } + + @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); + AbstractForecastSpecDao dao = newDao(dsl); + + dao.delete(office, name, designator, deleteRule); + ctx.status(HttpServletResponse.SC_NO_CONTENT); + } + } + + @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); + AbstractForecastSpecDao 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"); + } + } + + @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); + AbstractForecastSpecDao 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); + AbstractForecastSpecDao 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); + } + + private 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/ForecastSpecController.java index cf60e209ee..c5e90ae462 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/ForecastSpecController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/ForecastSpecController.java @@ -1,11 +1,7 @@ package cwms.cda.api; -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,13 @@ 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.AbstractForecastSpecDao; import cwms.cda.data.dao.ForecastSpecDao; 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 +26,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 ForecastSpecController extends AbstractForecastSpecController { public ForecastSpecController(MetricRegistry metrics) { super(metrics); } - protected DSLContext getDslContext(Context ctx) { - return JooqDao.getDslContext(ctx); + @Override + protected AbstractForecastSpecDao newDao(DSLContext dsl) { + return new ForecastSpecDao(dsl); + } + + @Override + protected Class getDtoClass() { + return ForecastSpec.class; } @OpenApi( @@ -70,15 +59,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( @@ -105,33 +86,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); } @OpenApi( @@ -166,38 +121,7 @@ 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); - } + super.getAll(ctx); } @OpenApi( @@ -229,33 +153,7 @@ 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); - } + super.getOne(ctx, name); } @OpenApi( @@ -277,20 +175,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/v2/ForecastSpecControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java new file mode 100644 index 0000000000..ba0fbeea6e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java @@ -0,0 +1,181 @@ +package cwms.cda.api.v2; + +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.api.AbstractForecastSpecController; +import cwms.cda.data.dao.AbstractForecastSpecDao; +import cwms.cda.data.dao.ForecastSpecDaoV2; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dto.v2.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 AbstractForecastSpecController { + + public ForecastSpecControllerV2(MetricRegistry metrics) { + super(metrics); + } + + @Override + protected AbstractForecastSpecDao newDao(DSLContext dsl) { + return new ForecastSpecDaoV2(dsl); + } + + @Override + protected Class getDtoClass() { + return ForecastSpecV2.class; + } + + @OpenApi( + description = "Used to create and save forecast spec data", + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1) + }, + required = true + ), + method = HttpMethod.POST, + tags = TAG + ) + @Override + public void create(@NotNull Context ctx) { + super.create(ctx); + } + + @OpenApi( + description = "Used to delete forecast spec data based on unique fields", + pathParams = { + @OpenApiParam(name = NAME, required = true, description = "Specifies the " + + "spec id of the forecast spec whose data is to be deleted."), + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec whose data is to be deleted."), + @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) { + super.delete(ctx, name); + } + + @OpenApi( + description = "Used to query multiple forecast specs", + queryParams = { + @OpenApiParam(name = OFFICE, description = "Specifies the " + + "owning office of the forecast spec whose data is to be included in the " + + "response."), + @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.JSONV1)}), + @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) { + super.getAll(ctx); + } + + @OpenApi( + description = "Used to query a single forecast spec record", + pathParams = { + @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 = OFFICE, required = true, description = "Specifies the " + + "owning office of the forecast spec whose data is to be included in the " + + "response."), + @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.JSONV1)}), + @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) { + super.getOne(ctx, name); + } + + @OpenApi( + description = "Update a forecast spec with provided values", + pathParams = { + @OpenApiParam(name = NAME, description = "Forecast spec id to be updated") + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1) + }, + 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) { + super.update(ctx, name); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java new file mode 100644 index 0000000000..654ccf926f --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java @@ -0,0 +1,143 @@ +package cwms.cda.data.dao; + +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 ForecastSpecDao} 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 AbstractForecastSpecDao extends JooqDao { + + protected AbstractForecastSpecDao(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/ForecastSpecDao.java index c6be63f124..06ed9460cf 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/ForecastSpecDao.java @@ -12,7 +12,6 @@ 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 +25,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 AbstractForecastSpecDao} for the logic (delete, and the office/spec-id/ + * designator/source-entity filters) shared between the two. + */ +public final class ForecastSpecDao extends AbstractForecastSpecDao { public ForecastSpecDao(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,43 +62,17 @@ 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); - } - private static SelectOnConditionStep> forecastSpecQuery(DSLContext dsl) { AV_FCST_SPEC spec = AV_FCST_SPEC.AV_FCST_SPEC; @@ -124,17 +111,12 @@ private static ForecastSpec map(Record7> 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 +126,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/ForecastSpecDaoV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDaoV2.java new file mode 100644 index 0000000000..ac9f7e8592 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/ForecastSpecDaoV2.java @@ -0,0 +1,218 @@ +package cwms.cda.data.dao; + +import cwms.cda.data.dto.CwmsId; +import cwms.cda.data.dto.v2.ForecastLocation; +import cwms.cda.data.dto.v2.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 AbstractForecastSpecDao { + + 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/v2/ForecastLocation.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java new file mode 100644 index 0000000000..04d66bf69d --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java @@ -0,0 +1,99 @@ +package cwms.cda.data.dto.v2; + +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; + +@JsonDeserialize(builder = ForecastLocation.Builder.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public class ForecastLocation extends CwmsDTOBase { + @JsonProperty(required = true) + private final String locationId; + + @JsonProperty(required = true) + private final Integer sortOrder; + + @JsonProperty("is-primary") + 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/v2/ForecastSpecV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java new file mode 100644 index 0000000000..bc76644b43 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java @@ -0,0 +1,125 @@ +package cwms.cda.data.dto.v2; + +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.JsonV2; + +import java.util.ArrayList; +import java.util.List; + +@JsonRootName("forecast-spec") +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV2.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@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/ForecastSpecControllerV2TestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java new file mode 100644 index 0000000000..997824ed19 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java @@ -0,0 +1,899 @@ +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"; + + public static final String PATH = "/v2/forecast-spec/"; + + @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.JSONV1, 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(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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) + .queryParam(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .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) + .queryParam(Controllers.OFFICE, OFFICE) + .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(Controllers.OFFICE, OFFICE) + .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) + .queryParam(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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.JSONV1) + .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(Controllers.OFFICE, OFFICE) + .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.OFFICE, OFFICE) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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.JSONV1) + .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.JSONV1) + .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.OFFICE, OFFICE) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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.JSONV1) + .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.JSONV1) + .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.OFFICE, OFFICE) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .contentType(Formats.JSONV1) + .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.JSONV1) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .queryParam(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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.JSONV1) + .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(Controllers.OFFICE, OFFICE) + .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.OFFICE, OFFICE) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1, 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(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .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(Controllers.OFFICE, OFFICE) + .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.JSONV1) + .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(Controllers.OFFICE, OFFICE) + .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/v2/ForecastLocationTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java new file mode 100644 index 0000000000..c66d2a2d3a --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java @@ -0,0 +1,127 @@ +package cwms.cda.data.dto.v2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import cwms.cda.api.errors.FieldException; +import cwms.cda.helpers.DTOMatch; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import cwms.cda.formatters.json.JsonV2; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +public class ForecastLocationTest { + + @Test + void testRoundTripJson() throws JsonProcessingException { + ForecastLocation l1 = new ForecastLocation.Builder() + .withLocationId("location") + .withSortOrder(-1) + .withIsPrimary(true) + .build(); + + ObjectMapper om = buildObjectMapper(); + + String jsonString = om.writeValueAsString(l1); + assertNotNull(jsonString); + + ForecastLocation l2 = om.readValue(jsonString, ForecastLocation.class); + assertNotNull(l2); + + assertForecastLocationEquals(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()); + + } + + @NotNull + public static ObjectMapper buildObjectMapper() { + return JsonV2.buildObjectMapper(); + } + + void assertForecastLocationEquals(ForecastLocation l1, ForecastLocation l2) throws JsonProcessingException { + DTOMatch.assertMatch(l1, l2); + } + +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java new file mode 100644 index 0000000000..cb4a127f54 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java @@ -0,0 +1,121 @@ +package cwms.cda.data.dto.v2; + +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.json.JsonV1; +import cwms.cda.helpers.DTOMatch; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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() throws JsonProcessingException { + ForecastSpecV2 s1 = buildForecastSpecV2(); + + ObjectMapper om = buildObjectMapper(); + + String jsonString = om.writeValueAsString(s1); + assertNotNull(jsonString); + + ForecastSpecV2 s2 = om.readValue(jsonString, ForecastSpecV2.class); + assertNotNull(s2); + + DTOMatch.assertMatch(s1, s2); + } + + @Test + void testFormatsSerialization() { + ForecastSpecV2 s1 = buildForecastSpecV2(); + ContentType contentType = Formats.parseHeader(Formats.JSONV1, 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); + } + + ObjectMapper om = buildObjectMapper(); + ForecastSpecV2 fi = om.readValue(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(); + } + + @NotNull + public static ObjectMapper buildObjectMapper() { + return JsonV1.buildObjectMapper(); + } +} 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..025f2ee068 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.v2.ForecastLocation; +import cwms.cda.data.dto.v2.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/v2/forecast_spec_v2_test.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/v2/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/v2/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" + ] +} From 6e44be92fd4e673b2d083eb2a7db331821e8553d Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Thu, 20 Aug 2026 12:11:30 -0700 Subject: [PATCH 2/9] CDA-98 - Updated to not use versioned JSON content type. Fixed schema example for forecast location. --- .../java/cwms/cda/api/v2/ForecastSpecControllerV2.java | 8 ++++---- .../main/java/cwms/cda/data/dto/v2/ForecastLocation.java | 3 +++ .../main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java index ba0fbeea6e..3a84c7659b 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java @@ -51,7 +51,7 @@ protected Class getDtoClass() { description = "Used to create and save forecast spec data", requestBody = @OpenApiRequestBody( content = { - @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1) + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON) }, required = true ), @@ -112,7 +112,7 @@ public void delete(@NotNull Context ctx, @NotNull String name) { @OpenApiResponse(status = STATUS_200, description = "A list of elements of the data set you've selected.", content = { - @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1)}), + @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") @@ -142,7 +142,7 @@ public void getAll(@NotNull Context ctx) { @OpenApiResponse(status = STATUS_200, description = "Returns the requested forecast spec", content = { - @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1)}), + @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."), @@ -164,7 +164,7 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { }, requestBody = @OpenApiRequestBody( content = { - @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSONV1) + @OpenApiContent(from = ForecastSpecV2.class, type = Formats.JSON) }, required = true), responses = { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java index 04d66bf69d..c33eb46024 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonNaming; import cwms.cda.data.dto.CwmsDTOBase; +import io.swagger.v3.oas.annotations.media.Schema; @JsonDeserialize(builder = ForecastLocation.Builder.class) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -15,9 +16,11 @@ public class ForecastLocation extends CwmsDTOBase { 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) { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java index bc76644b43..dc965e41ae 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java @@ -17,7 +17,7 @@ import java.util.List; @JsonRootName("forecast-spec") -@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV2.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV2.class, aliases = {Formats.DEFAULT}) @JsonDeserialize(builder = ForecastSpecV2.Builder.class) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) From bbc99c00e2074183f45af0356c948b64785d0ecc Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Thu, 20 Aug 2026 12:24:05 -0700 Subject: [PATCH 3/9] CDA-98 - Updates tests to use JSON rather than JSONV1 --- .../cda/data/dto/v2/ForecastLocation.java | 4 ++ .../cwms/cda/data/dto/v2/ForecastSpecV2.java | 5 +-- .../api/ForecastSpecControllerV2TestIT.java | 42 +++++++++---------- .../cda/data/dto/v2/ForecastLocationTest.java | 27 ++++-------- .../cda/data/dto/v2/ForecastSpecV2Test.java | 19 +++------ 5 files changed, 40 insertions(+), 57 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java index c33eb46024..31dd043b86 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java @@ -6,9 +6,13 @@ 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 { diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java index dc965e41ae..870160faab 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java @@ -11,13 +11,12 @@ import cwms.cda.data.dto.CwmsId; import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; -import cwms.cda.formatters.json.JsonV2; +import cwms.cda.formatters.json.JsonV1; -import java.util.ArrayList; import java.util.List; @JsonRootName("forecast-spec") -@FormattableWith(contentType = Formats.JSON, formatter = JsonV2.class, aliases = {Formats.DEFAULT}) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV1.class, aliases = {Formats.DEFAULT}) @JsonDeserialize(builder = ForecastSpecV2.Builder.class) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) 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 index 997824ed19..fc6da18dea 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java @@ -106,7 +106,7 @@ static void deleteSpec() throws SQLException { @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_get_create_get(String format) throws IOException { // Structure of test: @@ -146,7 +146,7 @@ void test_get_create_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -191,7 +191,7 @@ void test_get_create_get(String format) throws IOException { @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_get_create_get_null_designator(String format) throws IOException { // Structure of test: @@ -231,7 +231,7 @@ void test_get_create_get_null_designator(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -300,7 +300,7 @@ void test_get_create_get_null_designator(String format) throws IOException { } @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_create_get_delete_get(String format) throws Exception { // Structure of test: @@ -323,7 +323,7 @@ void test_create_get_delete_get(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -393,7 +393,7 @@ void test_create_get_delete_get(String format) throws Exception { } @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void create_getAll_delete_getAll(String format) throws Exception { // Structure of test: @@ -420,7 +420,7 @@ void create_getAll_delete_getAll(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -436,7 +436,7 @@ void create_getAll_delete_getAll(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData2) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -497,7 +497,7 @@ void create_getAll_delete_getAll(String format) throws Exception { } @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void create_getAll_with_entity_like_delete_getAll(String format) throws Exception { // Structure of test: @@ -524,7 +524,7 @@ void create_getAll_with_entity_like_delete_getAll(String format) throws Exceptio given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -540,7 +540,7 @@ void create_getAll_with_entity_like_delete_getAll(String format) throws Exceptio given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData2) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -611,8 +611,8 @@ void test_create_get_delete_get_permissions_issue() throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) - .accept(Formats.JSONV1) - .contentType(Formats.JSONV1) + .accept(Formats.JSON) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -628,7 +628,7 @@ void test_create_get_delete_get_permissions_issue() throws Exception { // Delete the spec given() .log().ifValidationFails(LogDetail.ALL, true) - .accept(Formats.JSONV1) + .accept(Formats.JSON) .header(AUTH_HEADER, user.toHeaderValue()) .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.NAME, "SPK-Daily-UKY-Test-V2") @@ -646,7 +646,7 @@ void test_create_get_delete_get_permissions_issue() throws Exception { // Retrieve the spec and assert that it does not exist given() .log().ifValidationFails(LogDetail.ALL, true) - .accept(Formats.JSONV1) + .accept(Formats.JSON) .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() @@ -661,7 +661,7 @@ void test_create_get_delete_get_permissions_issue() throws Exception { } @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_create_get_delete_get_lrts(String format) throws Exception { // Structure of test: // 1) Create the spec @@ -687,7 +687,7 @@ void test_create_get_delete_get_lrts(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(specData) .header(AUTH_HEADER, user.toHeaderValue()) .header(ApiServlet.IS_NEW_LRTS, true) @@ -766,7 +766,7 @@ void test_create_get_delete_get_lrts(String format) throws Exception { } @ParameterizedTest - @ValueSource(strings = {Formats.JSONV1, Formats.DEFAULT}) + @ValueSource(strings = {Formats.JSON, Formats.DEFAULT}) void test_create_get_update_get(String format) throws IOException { // Structure of test: @@ -810,7 +810,7 @@ void test_create_get_update_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() @@ -855,7 +855,7 @@ void test_create_get_update_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .contentType(Formats.JSONV1) + .contentType(Formats.JSON) .body(tsData) .header(AUTH_HEADER, user.toHeaderValue()) .when() diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java index c66d2a2d3a..4c449e5320 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java @@ -2,37 +2,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; 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 com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import cwms.cda.formatters.json.JsonV2; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; public class ForecastLocationTest { @Test - void testRoundTripJson() throws JsonProcessingException { + void testRoundTripJson() { ForecastLocation l1 = new ForecastLocation.Builder() .withLocationId("location") .withSortOrder(-1) .withIsPrimary(true) .build(); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastLocation.class); - ObjectMapper om = buildObjectMapper(); - - String jsonString = om.writeValueAsString(l1); + String jsonString = Formats.format(contentType, l1); assertNotNull(jsonString); - ForecastLocation l2 = om.readValue(jsonString, ForecastLocation.class); + ForecastLocation l2 = Formats.parseContent(contentType, jsonString, ForecastLocation.class); assertNotNull(l2); - assertForecastLocationEquals(l1, l2); + DTOMatch.assertMatch(l1, l2); } @Test @@ -115,13 +111,4 @@ void testGetters() { } - @NotNull - public static ObjectMapper buildObjectMapper() { - return JsonV2.buildObjectMapper(); - } - - void assertForecastLocationEquals(ForecastLocation l1, ForecastLocation l2) throws JsonProcessingException { - DTOMatch.assertMatch(l1, l2); - } - } diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java index cb4a127f54..df7c5f3573 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java @@ -4,11 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import cwms.cda.api.errors.FieldException; -import cwms.cda.formatters.json.JsonV1; import cwms.cda.helpers.DTOMatch; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import cwms.cda.data.dto.CwmsId; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; @@ -28,12 +26,12 @@ public class ForecastSpecV2Test { void testRoundTripJson() throws JsonProcessingException { ForecastSpecV2 s1 = buildForecastSpecV2(); - ObjectMapper om = buildObjectMapper(); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); - String jsonString = om.writeValueAsString(s1); + String jsonString = Formats.format(contentType, s1); assertNotNull(jsonString); - ForecastSpecV2 s2 = om.readValue(jsonString, ForecastSpecV2.class); + ForecastSpecV2 s2 = Formats.parseContent(contentType, jsonString, ForecastSpecV2.class); assertNotNull(s2); DTOMatch.assertMatch(s1, s2); @@ -42,7 +40,7 @@ void testRoundTripJson() throws JsonProcessingException { @Test void testFormatsSerialization() { ForecastSpecV2 s1 = buildForecastSpecV2(); - ContentType contentType = Formats.parseHeader(Formats.JSONV1, ForecastSpecV2.class); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); String jsonStr = Formats.format(contentType, s1); assertNotNull(jsonStr); } @@ -60,8 +58,8 @@ void testJsonFile() throws IOException { json = IOUtils.toString(stream, StandardCharsets.UTF_8); } - ObjectMapper om = buildObjectMapper(); - ForecastSpecV2 fi = om.readValue(json, ForecastSpecV2.class); + ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); + ForecastSpecV2 fi = Formats.parseContent(contentType, json, ForecastSpecV2.class); assertNotNull(fi); DTOMatch.assertMatch(fi, buildForecastSpecV2()); @@ -113,9 +111,4 @@ private ForecastSpecV2 buildForecastSpecV2() { .withTimeSeriesIds(tsids) .build(); } - - @NotNull - public static ObjectMapper buildObjectMapper() { - return JsonV1.buildObjectMapper(); - } } From 457c722af03ae11feac53657933fc49cb17d0522 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Thu, 20 Aug 2026 16:33:25 -0700 Subject: [PATCH 4/9] CDA-98 - Updates packaging of forecast versions. Refactors route configuration into utility class. --- .../src/main/java/cwms/cda/ApiServlet.java | 676 +---------------- .../cda/ApiServletRouteConfiguration.java | 692 ++++++++++++++++++ .../cda/ApiServletV2RouteConfiguration.java | 25 - .../ForecastFileController.java | 7 +- .../ForecastInstanceController.java | 5 +- .../ForecastSpecController.java} | 23 +- .../ForecastSpecControllerV1.java} | 14 +- .../ForecastSpecControllerV2.java | 13 +- .../ForecastTimeseriesController.java | 18 +- .../{ => forecast}/ForecastInstanceDao.java | 5 +- .../ForecastSpecDao.java} | 10 +- .../ForecastSpecDaoV1.java} | 11 +- .../dao/{ => forecast}/ForecastSpecDaoV2.java | 8 +- .../{v2 => forecast}/ForecastLocation.java | 2 +- .../dto/{v2 => forecast}/ForecastSpecV2.java | 2 +- .../api/ForecastInstanceControllerTestIT.java | 3 +- ...va => ForecastSpecControllerV1TestIT.java} | 2 +- .../ForecastLocationTest.java | 2 +- .../{v2 => forecast}/ForecastSpecV2Test.java | 5 +- .../test/java/cwms/cda/helpers/DTOMatch.java | 4 +- .../forecast_spec_v2_test.json | 0 21 files changed, 765 insertions(+), 762 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java delete mode 100644 cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java rename cwms-data-api/src/main/java/cwms/cda/api/{ => forecast}/ForecastFileController.java (96%) rename cwms-data-api/src/main/java/cwms/cda/api/{ => forecast}/ForecastInstanceController.java (99%) rename cwms-data-api/src/main/java/cwms/cda/api/{AbstractForecastSpecController.java => forecast/ForecastSpecController.java} (91%) rename cwms-data-api/src/main/java/cwms/cda/api/{ForecastSpecController.java => forecast/ForecastSpecControllerV1.java} (95%) rename cwms-data-api/src/main/java/cwms/cda/api/{v2 => forecast}/ForecastSpecControllerV2.java (95%) rename cwms-data-api/src/main/java/cwms/cda/api/{ => forecast}/ForecastTimeseriesController.java (75%) rename cwms-data-api/src/main/java/cwms/cda/data/dao/{ => forecast}/ForecastInstanceDao.java (99%) rename cwms-data-api/src/main/java/cwms/cda/data/dao/{AbstractForecastSpecDao.java => forecast/ForecastSpecDao.java} (95%) rename cwms-data-api/src/main/java/cwms/cda/data/dao/{ForecastSpecDao.java => forecast/ForecastSpecDaoV1.java} (94%) rename cwms-data-api/src/main/java/cwms/cda/data/dao/{ => forecast}/ForecastSpecDaoV2.java (97%) rename cwms-data-api/src/main/java/cwms/cda/data/dto/{v2 => forecast}/ForecastLocation.java (98%) rename cwms-data-api/src/main/java/cwms/cda/data/dto/{v2 => forecast}/ForecastSpecV2.java (99%) rename cwms-data-api/src/test/java/cwms/cda/api/{ForecastSpecControllerTestIT.java => ForecastSpecControllerV1TestIT.java} (99%) rename cwms-data-api/src/test/java/cwms/cda/data/dto/{v2 => forecast}/ForecastLocationTest.java (99%) rename cwms-data-api/src/test/java/cwms/cda/data/dto/{v2 => forecast}/ForecastSpecV2Test.java (96%) rename cwms-data-api/src/test/resources/cwms/cda/data/dto/{v2 => forecast}/forecast_spec_v2_test.json (100%) 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 e452f70bb0..10869ed9f4 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,160 +33,28 @@ 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; @@ -223,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; @@ -239,8 +88,6 @@ 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; @@ -305,7 +152,6 @@ public class ApiServlet extends HttpServlet { public static final String RAW_DATA_SOURCE = "data_source"; public static final String DATABASE = "database"; public static final String IS_NEW_LRTS = "X-CWMS-LRTS-Formatting"; - public static final String FORECAST_SPEC_PATH = "/forecast-spec/{%s}"; // The VERSION should match the gradle version but not contain the patch version. // For example 2.4 not 2.4.13 @@ -457,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); @@ -470,515 +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_PATH, Controllers.NAME), - new ForecastSpecController(metrics), requiredRoles, 5, TimeUnit.MINUTES); - ApiServletV2RouteConfiguration.configureRoutes(metrics, requiredRoles); - 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"); @@ -999,7 +341,7 @@ private void getOpenApiOptions(JavalinConfig config) { schemeProcessor.apply(ctx, api); api.getPaths().forEach((key,path) -> { setSecurityRequirements(key,path, schemeProcessor.getSecurityRequirements()); - // yeah, we really need to figure out how to update everything, + // yeah, we really need to figure out how to update everything, // this is supported as an annotation in newer versions. if (key.startsWith("/rss")) { path.getGet().getResponses().forEach((p, r) -> { @@ -1014,7 +356,7 @@ private void getOpenApiOptions(JavalinConfig config) { try (ScanResult scanResult = new ClassGraph() .acceptPackages("cwms.cda.data.dto") .scan()) { - List> csvDtoClasses = + List> csvDtoClasses = scanResult.getClassesImplementing(CwmsCsvDTO.class.getName()) .loadClasses(CwmsCsvDTO.class); for (Class clazz : csvDtoClasses) { @@ -1055,11 +397,11 @@ private void getOpenApiOptions(JavalinConfig config) { doc.header(IS_NEW_LRTS, Boolean.class, p -> p.description( - "If True, will use use the new 'Local Regular Time Series" + "If True, will use use the new 'Local Regular Time Series" + " naming scheme. For example 1DayLocal. Instead of the original" + " PsuedoRegular based scheme, for example ~1DayLocal." + " NOTE: this parameter only applies to the input and output of" - + " Time Series names. It is added to all endpoints and will be ignored" + + " Time Series names. It is added to all endpoints and will be ignored" + " when not required. Default values is false if not set.") ); }) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java new file mode 100644 index 0000000000..3c460e14a0 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -0,0 +1,692 @@ +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"; + String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, + Controllers.PROJECT_ID, NAME); + String virtualOutletCreatePath = "/projects/virtual-outlets"; + cdaCrudCache(outletPath, new OutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); + post(gateChangeCreatePath, new GateChangeCreateController(metrics), requiredRoles); + get(gateChangePath, new GateChangeGetAllController(metrics)); + delete(gateChangePath, new GateChangeDeleteController(metrics), requiredRoles); + cdaCrudCache(virtualOutletPath, new VirtualOutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); + post(virtualOutletCreatePath, new VirtualOutletCreateController(metrics), requiredRoles); + + get("/projects/locations/", new ProjectChildLocationHandler(metrics)); + cdaCrudCache(format("/projects/{%s}", Controllers.NAME), + new ProjectController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(format("/properties/{%s}", Controllers.NAME), + new PropertyController(metrics), true, requiredRoles,1, TimeUnit.DAYS); + cdaCrudCache(format("/lookup-types/{%s}", Controllers.NAME), + new LookupTypeController(metrics), requiredRoles,1, TimeUnit.DAYS); + + addProjectLocksHandlers("/project-locks/{name}", requiredRoles, metrics); + addProjectLockRightsHandlers("/project-lock-rights/{project-id}", requiredRoles, metrics); + + addUserManagementHandlers(metrics, cdaAccessManager); + + get("/version/", new CdaVersionHandler(metrics), requiredRoles); + get(format("/rss/{%s}/{%s}", Controllers.OFFICE, Controllers.NAME), new RssHandler(metrics)); + } + + private static void addUserManagementHandlers(MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + RouteRole[] adminRoles = new RouteRole[] { new Role("CWMS User Admins")}; + RouteRole[] userRoles = new RouteRole[] {new Role(CWMS_USERS_ROLE), new Role(CAC_USER)}; + crud("/users/{user-name}", new UsersController(metrics), adminRoles); + get("/roles", new GetRolesController(metrics), adminRoles); + String userProfilePath = "/user/profile"; + get(userProfilePath, new UserProfileController(metrics), userRoles); + cdaAccessManager.addCustomAuthorizer(userProfilePath, ApiServletRouteConfiguration::hasAnyRole); + addUserListHandlers(userRoles, metrics, cdaAccessManager); + post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); + delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); + + } + + private static void addUserListHandlers(RouteRole[] userRoles, MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + String userListCandidatesPath = "/user/list-member-candidates"; + String userListsPath = "/user/list"; + String userListPath = "/user/list/{user-list-id}"; + String userListMembersPath = "/user/list/{user-list-id}/members"; + String userListMemberPath = "/user/list/{user-list-id}/members/{user-id}"; + if (FeatureContext.getFeatureManager().isActive(CdaFeatures.USER_LISTS)) { + get(userListCandidatesPath, new UserListCandidatesController(metrics), userRoles); + get(userListsPath, new UserListsController(metrics), userRoles); + post(userListsPath, new CreateUserListController(metrics), userRoles); + get(userListPath, new UserListController(metrics), userRoles); + patch(userListPath, new UpdateUserListController(metrics), userRoles); + delete(userListPath, new DeleteUserListController(metrics), userRoles); + get(userListMembersPath, new UserListMembersController(metrics), userRoles); + post(userListMembersPath, new AddUserListMemberController(metrics), userRoles); + delete(userListMemberPath, new UserListMemberController(metrics), userRoles); + } else { + get(userListCandidatesPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListsPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + post(userListsPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + patch(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + delete(userListPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + get(userListMembersPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + post(userListMembersPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + delete(userListMemberPath, ApiServletRouteConfiguration::userListsUnsupported, userRoles); + } + cdaAccessManager.addCustomAuthorizer(userListCandidatesPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListsPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListMembersPath, ApiServletRouteConfiguration::hasAnyRole); + cdaAccessManager.addCustomAuthorizer(userListMemberPath, ApiServletRouteConfiguration::hasAnyRole); + } + + private static void userListsUnsupported(Context ctx) { + ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED) + .json(new CdaError("User lists are not enabled for this CDA deployment.")); + } + + private static Boolean hasAnyRole(DataApiPrincipal p, Set roles) throws MissingRolesException { + boolean retVal = roles.stream().anyMatch(p.getRoles()::contains); + if(!retVal) { + List requiredRoleNames = roles.stream() + .map(Object::toString) + .collect(toList()); + throw new MissingRolesException(requiredRoleNames, "Missing one of the following roles {" + String.join(",", requiredRoleNames) + "}"); + } + return true; + } + + private static void addRatingHandlers(RouteRole[] requiredRoles, MetricRegistry metrics, CdaAccessManager cdaAccessManager) { + /** + * The POST handlers for /ratings/rate-* intentionally do not have + * require roles. Instead they are rate limited if not authenticated. + * POST is used as sending a body with GET is not standard and we cannot + * be sure clients, or future servers, would correctly support that. + */ + String rateValues = format("/ratings/rate-values/{%s}/{%s}", OFFICE, RATING_ID); + post(rateValues, new RateValuesController(metrics)); + String rateTs = format("/ratings/rate-ts/{%s}/{%s}", OFFICE, RATING_ID); + post(rateTs, new RateTimeSeriesController(metrics)); + String reverseRateValues = format("/ratings/reverse-rate-values/{%s}/{%s}", OFFICE, RATING_ID); + post(reverseRateValues, new ReverseRateValuesController(metrics)); + String reverseRateTs = format("/ratings/reverse-rate-ts/{%s}/{%s}", OFFICE, RATING_ID); + post(reverseRateTs, new ReverseRateTimeSeriesController(metrics)); + cdaCrudCache("/ratings/template/{template-id}", + new RatingTemplateController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/ratings/spec/{rating-id}", + new RatingSpecController(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache("/ratings/metadata/{rating-id}", + new RatingMetadataController(metrics), requiredRoles,5, TimeUnit.MINUTES); + get("/ratings/{rating-id}/latest", new RatingLatestController(metrics)); + get("/ratings/effective-dates", new RatingEffectiveDatesController(metrics)); + cdaCrudCache("/ratings/{rating-id}", + new RatingController(metrics), requiredRoles,5, TimeUnit.MINUTES); + addRateLimit(rateTs, requiredRoles, cdaAccessManager); + addRateLimit(reverseRateTs, requiredRoles, cdaAccessManager); + addRateLimit(reverseRateValues, requiredRoles, cdaAccessManager); + addRateLimit(rateValues, requiredRoles, cdaAccessManager); + } + + /** + * Add a rate limiter to a specified endpoint path, allowing authorized users to bypass the limit. + * + * @param path the path to add the rate limiter to. + * @param requiredRoles the user roles required to access the path. + * @param cdaAccessManager + */ + private static void addRateLimit(String path, RouteRole[] requiredRoles, CdaAccessManager cdaAccessManager) { + cdaAccessManager.addRateLimitedEndpoint(path, requiredRoles); + } + + private static void addAccountingHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path, new AccountingCatalogController(metrics)); + post(path, new AccountingCreateController(metrics), requiredRoles); + } + + private static void addProjectLocksHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + String pathWithoutResource = path.replace(getResourceId(path), ""); + + get(path, new ProjectLockGetOne(metrics), requiredRoles); + get(pathWithoutResource, new ProjectLockCatalog(metrics), requiredRoles); + post(pathWithoutResource + "deny", new ProjectLockRevokeDeny(metrics), requiredRoles); + post(pathWithoutResource, new ProjectLockRequest(metrics), requiredRoles); + post(pathWithoutResource + "release", new ProjectLockRelease(metrics), requiredRoles); + delete(path, new ProjectLockRevoke(metrics), requiredRoles); + } + + private static void addProjectLockRightsHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + String pathWithoutResource = path.replace(getResourceId(path), ""); + get(pathWithoutResource, new LockRevokerRightsCatalog(metrics), requiredRoles); + post(pathWithoutResource + "remove-all", new RemoveAllLockRevokerRights(metrics), requiredRoles); + post(pathWithoutResource + "update", new UpdateLockRevokerRights(metrics), requiredRoles); + + } + + + private static void addWaterUserHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path + format("/{%s}", WATER_USER), new WaterUserController(metrics), requiredRoles); + get(path, new WaterUserCatalogController(metrics), requiredRoles); + post(path, new WaterUserCreateController(metrics), requiredRoles); + patch(path + format("/{%s}", WATER_USER), new WaterUserUpdateController(metrics), requiredRoles); + delete(path + format("/{%s}", WATER_USER), new WaterUserDeleteController(metrics), requiredRoles); + } + + private static void addWaterContractHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + get(path + format("/{%s}", CONTRACT_NAME), new WaterContractController(metrics), requiredRoles); + get(path, new WaterContractCatalogController(metrics), requiredRoles); + post(path, new WaterContractCreateController(metrics), requiredRoles); + patch(path + format("/{%s}", CONTRACT_NAME), new WaterContractUpdateController(metrics), requiredRoles); + delete(path + format("/{%s}", CONTRACT_NAME), new WaterContractDeleteController(metrics), requiredRoles); + } + + private static void addWaterContractTypeHandlers(String path, RouteRole[] requiredRoles, MetricRegistry metrics) { + post(path, new WaterContractTypeCreateController(metrics), requiredRoles); + get(path, new WaterContractTypeCatalogController(metrics), requiredRoles); + delete(path + "/{display-value}", new WaterContractTypeDeleteController(metrics), requiredRoles); + } + + /** + * Given a path like "/location/category/{category-id}" this method returns "{category-id}". + * @param fullPath the full path to extract the resource id from. + * @return the resource id portion of the path. + * @throws IllegalArgumentException if the path does not contain a resource id. + */ + @NotNull + public static String getResourceId(String fullPath) { + String[] subPaths = Arrays.stream(fullPath.split("/")) + .filter(it -> !it.isEmpty()).toArray(String[]::new); + if (subPaths.length < 2) { + throw new IllegalArgumentException("CrudHandler requires a path like " + + "'/resource/{resource-id}' given: " + fullPath); + } + String resourceId = subPaths[subPaths.length - 1]; + if (!( + (resourceId.startsWith("{") && resourceId.endsWith("}")) + || + (resourceId.startsWith("<") && resourceId.endsWith(">")) + )) { + throw new IllegalArgumentException("CrudHandler requires a path-parameter at the " + + "end of the provided path, e.g. '/users/{user-id}' or '/users/' given: " + fullPath); + } + String resourceBase = subPaths[subPaths.length - 2]; + if (resourceBase.startsWith("{") || resourceBase.startsWith("<") + || resourceBase.endsWith("}") || resourceBase.endsWith(">")) { + throw new IllegalArgumentException("CrudHandler requires a resource base at the " + + "beginning of the provided path, e.g. '/users/{user-id}' given: " + fullPath); + } + return resourceId; + } + + /** + * This method delegates to the cdaCrud method but also adds an after filter for the specified + * path. If the request was a GET request and the response does not already include + * Cache-Control then the filter will add the Cache-Control max-age header with the specified + * number of seconds. + * Controllers can include their own Cache-Control headers via: + * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" + * This method lets the ApiServlet configure a default max-age for controllers that don't or + * forget to set their own. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param roles the required these roles are present to access post, patch + * @param duration the number of TimeUnit to cache GET responses. + * @param timeUnit the TimeUnit to use for duration. + */ + private static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, + @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { + cdaCrudCache(path, crudHandler, false, roles, duration, timeUnit); + } + + /** + * This method delegates to the cdaCrud method but also adds an after filter for the specified + * path. If the request was a GET request and the response does not already include + * Cache-Control then the filter will add the Cache-Control max-age header with the specified + * number of seconds. + * Controllers can include their own Cache-Control headers via: + * "ctx.header(Header.CACHE_CONTROL, " public, max-age=" + 60);" + * This method lets the ApiServlet configure a default max-age for controllers that don't or + * forget to set their own. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param getRequiresAuth if the get handlers should have an authorization check + * @param roles the required these roles are present to access post, patch + * @param duration the number of TimeUnit to cache GET responses. + * @param timeUnit the TimeUnit to use for duration. + */ + private static void cdaCrudCache(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, + @NotNull RouteRole[] roles, long duration, TimeUnit timeUnit) { + cdaCrud(path, crudHandler, getRequiresAuth, roles); + + // path like /offices/{office} will match /offices/SWT getOne style url + addCacheControl(path, duration, timeUnit); + + String pathWithoutResource = path.replace(getResourceId(path), ""); + // path like "/offices/" matches /offices getAll style url + addCacheControl(pathWithoutResource, duration, timeUnit); + } + + private static void addCacheControl(@NotNull String path, long duration, TimeUnit timeUnit) { + if (timeUnit != null && duration > 0) { + staticInstance().after(path, ctx -> { + String method = ctx.req.getMethod(); // "GET" + if (ctx.status() == HttpServletResponse.SC_OK + && "GET".equals(method) + && (!ctx.res.containsHeader(Header.CACHE_CONTROL))) { + // only set the cache control header if it is not already set. + ctx.header(Header.CACHE_CONTROL, "max-age=" + timeUnit.toSeconds(duration)); + } + }); + } + } + + /** + * This method is very similar to the ApiBuilder.crud method but the specified roles + * are only required for the post, patch and delete methods. getOne and getAll are always + * allowed. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param roles the accessmanager will require these roles are present to access post, patch + * and delete methods + */ + private static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, + @NotNull RouteRole... roles) { + cdaCrud(path, crudHandler, false, roles); + } + + /** + * This method is very similar to the ApiBuilder.crud method but the specified roles + * are only required for the post, patch and delete methods. getOne and getAll are always + * allowed. + * @param path where to register the routes. + * @param crudHandler the handler requests should be forwarded to. + * @param getRequiresAuth If all operations on this handler should have an authorization check + * @param roles the accessmanager will require these roles are present to access post, patch + * and delete methods + */ + private static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandler, boolean getRequiresAuth, + @NotNull RouteRole... roles) { + String fullPath = prefixPath(path); + String resourceId = getResourceId(fullPath); + + //noinspection KotlinInternalInJava + Map crudFunctions = CrudHandlerKt.getCrudFunctions(crudHandler, resourceId); + + Javalin instance = staticInstance(); + // getOne and getAll are assumed not to need authorization + String pathWithoutResource = fullPath.replace(resourceId, ""); + if (getRequiresAuth) { + instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE), roles); + instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL), roles); + } else { + instance.get(fullPath, crudFunctions.get(CrudFunction.GET_ONE)); + instance.get(pathWithoutResource, crudFunctions.get(CrudFunction.GET_ALL)); + } + + // create, update and delete need authorization. + instance.post(pathWithoutResource, crudFunctions.get(CrudFunction.CREATE), roles); + instance.patch(fullPath, crudFunctions.get(CrudFunction.UPDATE), roles); + instance.delete(fullPath, crudFunctions.get(CrudFunction.DELETE), roles); + } + + private static String formatV2(String path, Object... args) { + return format("/v2/" + path, args); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java deleted file mode 100644 index 2efe01c833..0000000000 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletV2RouteConfiguration.java +++ /dev/null @@ -1,25 +0,0 @@ -package cwms.cda; - -import static java.lang.String.format; - -import com.codahale.metrics.MetricRegistry; -import cwms.cda.api.Controllers; -import cwms.cda.api.v2.ForecastSpecControllerV2; -import io.javalin.core.security.RouteRole; -import java.util.concurrent.TimeUnit; - -public final class ApiServletV2RouteConfiguration { - - private ApiServletV2RouteConfiguration() { - throw new AssertionError("Utility class - do not instantiate"); - } - - public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredRoles) { - ApiServlet.cdaCrudCache(formatV2(ApiServlet.FORECAST_SPEC_PATH, Controllers.NAME), - new ForecastSpecControllerV2(metrics), requiredRoles, 5, TimeUnit.MINUTES); - } - - private static String formatV2(String path, Object... args) { - return format("/v2/" + path, args); - } -} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/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/AbstractForecastSpecController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java similarity index 91% rename from cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java index b87045165c..f71e47bad2 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/AbstractForecastSpecController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.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; @@ -18,9 +18,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.AbstractForecastSpecDao; +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; @@ -34,12 +35,12 @@ import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; -public abstract class AbstractForecastSpecController extends BaseCrudHandler { +public abstract class ForecastSpecController extends BaseCrudHandler { private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); - protected static final String TAG = "Forecast"; + static final String TAG = "Forecast"; - protected AbstractForecastSpecController(MetricRegistry metrics) { + protected ForecastSpecController(MetricRegistry metrics) { super(metrics); } @@ -48,7 +49,7 @@ protected DSLContext getDslContext(Context ctx) { } /** Builds the version-specific DAO for this request. */ - protected abstract AbstractForecastSpecDao newDao(DSLContext dsl); + protected abstract ForecastSpecDao newDao(DSLContext dsl); /** The DTO type this controller reads and writes */ protected abstract Class getDtoClass(); @@ -57,7 +58,7 @@ protected DSLContext getDslContext(Context ctx) { public void create(@NotNull Context ctx) { try (final Timer.Context ignored = markAndTime(CREATE)) { DSLContext dsl = getDslContext(ctx); - AbstractForecastSpecDao dao = newDao(dsl); + ForecastSpecDao dao = newDao(dsl); T forecastSpec = deserializeForecastSpec(ctx); dao.create(forecastSpec); @@ -90,7 +91,7 @@ public void delete(@NotNull Context ctx, @NotNull String name) { } try (final Timer.Context ignored = markAndTime(DELETE)) { DSLContext dsl = getDslContext(ctx); - AbstractForecastSpecDao dao = newDao(dsl); + ForecastSpecDao dao = newDao(dsl); dao.delete(office, name, designator, deleteRule); ctx.status(HttpServletResponse.SC_NO_CONTENT); @@ -107,7 +108,7 @@ public void getAll(@NotNull Context ctx) { String entityLike = ctx.queryParamAsClass(SOURCE_ENTITY_LIKE, String.class).allowNullable().get(); DSLContext dsl = getDslContext(ctx); - AbstractForecastSpecDao dao = newDao(dsl); + ForecastSpecDao dao = newDao(dsl); List specs = dao.getForecastSpecs(office, names, designator, sourceEntity, entityLike); @@ -124,7 +125,7 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); DSLContext dsl = getDslContext(ctx); - AbstractForecastSpecDao dao = newDao(dsl); + ForecastSpecDao dao = newDao(dsl); T spec = dao.getForecastSpec(office, name, designator); @@ -140,7 +141,7 @@ public void update(@NotNull Context ctx, @NotNull String name) { try (final Timer.Context ignored = markAndTime(UPDATE)) { T forecastSpec = deserializeForecastSpec(ctx); DSLContext dsl = getDslContext(ctx); - AbstractForecastSpecDao dao = newDao(dsl); + ForecastSpecDao dao = newDao(dsl); dao.update(forecastSpec); ctx.status(HttpServletResponse.SC_OK); } 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 95% 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 c5e90ae462..a5c80618c8 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,4 +1,4 @@ -package cwms.cda.api; +package cwms.cda.api.forecast; import static cwms.cda.api.Controllers.DESIGNATOR; import static cwms.cda.api.Controllers.DESIGNATOR_MASK; @@ -14,8 +14,8 @@ import static cwms.cda.api.Controllers.STATUS_501; import com.codahale.metrics.MetricRegistry; -import cwms.cda.data.dao.AbstractForecastSpecDao; -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.Formats; @@ -30,15 +30,15 @@ import org.jooq.DSLContext; -public final class ForecastSpecController extends AbstractForecastSpecController { +public final class ForecastSpecControllerV1 extends ForecastSpecController { - public ForecastSpecController(MetricRegistry metrics) { + public ForecastSpecControllerV1(MetricRegistry metrics) { super(metrics); } @Override - protected AbstractForecastSpecDao newDao(DSLContext dsl) { - return new ForecastSpecDao(dsl); + protected ForecastSpecDao newDao(DSLContext dsl) { + return new ForecastSpecDaoV1(dsl); } @Override diff --git a/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java similarity index 95% rename from cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java rename to cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java index 3a84c7659b..7cb8b48039 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/v2/ForecastSpecControllerV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java @@ -1,4 +1,4 @@ -package cwms.cda.api.v2; +package cwms.cda.api.forecast; import static cwms.cda.api.Controllers.DESIGNATOR; import static cwms.cda.api.Controllers.DESIGNATOR_MASK; @@ -14,11 +14,10 @@ import static cwms.cda.api.Controllers.STATUS_501; import com.codahale.metrics.MetricRegistry; -import cwms.cda.api.AbstractForecastSpecController; -import cwms.cda.data.dao.AbstractForecastSpecDao; -import cwms.cda.data.dao.ForecastSpecDaoV2; +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.v2.ForecastSpecV2; +import cwms.cda.data.dto.forecast.ForecastSpecV2; import cwms.cda.formatters.Formats; import io.javalin.http.Context; import io.javalin.plugin.openapi.annotations.HttpMethod; @@ -31,14 +30,14 @@ import org.jooq.DSLContext; -public final class ForecastSpecControllerV2 extends AbstractForecastSpecController { +public final class ForecastSpecControllerV2 extends ForecastSpecController { public ForecastSpecControllerV2(MetricRegistry metrics) { super(metrics); } @Override - protected AbstractForecastSpecDao newDao(DSLContext dsl) { + protected ForecastSpecDao newDao(DSLContext dsl) { return new ForecastSpecDaoV2(dsl); } 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/AbstractForecastSpecDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java similarity index 95% rename from cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java rename to cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java index 654ccf926f..8bff16f969 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/AbstractForecastSpecDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/forecast/ForecastSpecDao.java @@ -1,5 +1,7 @@ -package cwms.cda.data.dao; +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; @@ -12,7 +14,7 @@ /** * Shared logic for the forecast spec DAOs. * - *

{@link ForecastSpecDao} backs the V1 API, where a forecast spec has a single + *

{@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 @@ -29,9 +31,9 @@ * * @param the forecast spec DTO type this instance works with */ -public abstract class AbstractForecastSpecDao extends JooqDao { +public abstract class ForecastSpecDao extends JooqDao { - protected AbstractForecastSpecDao(DSLContext dsl) { + protected ForecastSpecDao(DSLContext dsl) { super(dsl); } 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 94% 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 06ed9460cf..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,11 +1,10 @@ -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; @@ -28,12 +27,12 @@ /** * V1 forecast spec DAO: a forecast spec has a single {@code location-id}. See * {@link ForecastSpecDaoV2} for the V2 shape ({@code List}), and - * {@link AbstractForecastSpecDao} for the logic (delete, and the office/spec-id/ + * {@link ForecastSpecDao} for the logic (delete, and the office/spec-id/ * designator/source-entity filters) shared between the two. */ -public final class ForecastSpecDao extends AbstractForecastSpecDao { +public final class ForecastSpecDaoV1 extends ForecastSpecDao { - public ForecastSpecDao(DSLContext dsl) { + public ForecastSpecDaoV1(DSLContext dsl) { super(dsl); } @@ -70,7 +69,7 @@ public List getForecastSpecs(String office, String specIdRegex, forecastSpecQuery(dsl) .where(buildSpecListCondition(wrapper, office, specIdRegex, designator, sourceEntityRegex, entityLike)); return query.fetch() - .map(ForecastSpecDao::map); + .map(ForecastSpecDaoV1::map); } private static SelectOnConditionStep { +public final class ForecastSpecDaoV2 extends ForecastSpecDao { public ForecastSpecDaoV2(DSLContext dsl) { super(dsl); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java similarity index 98% rename from cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java rename to cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java index 31dd043b86..595369cf66 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastLocation.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastLocation.java @@ -1,4 +1,4 @@ -package cwms.cda.data.dto.v2; +package cwms.cda.data.dto.forecast; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java similarity index 99% rename from cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java rename to cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java index 870160faab..04f1bd7efd 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/v2/ForecastSpecV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/forecast/ForecastSpecV2.java @@ -1,4 +1,4 @@ -package cwms.cda.data.dto.v2; +package cwms.cda.data.dto.forecast; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; 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/data/dto/v2/ForecastLocationTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastLocationTest.java similarity index 99% rename from cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java rename to cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastLocationTest.java index 4c449e5320..1ac1807a21 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastLocationTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastLocationTest.java @@ -1,4 +1,4 @@ -package cwms.cda.data.dto.v2; +package cwms.cda.data.dto.forecast; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java similarity index 96% rename from cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java rename to cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java index df7c5f3573..0263b6a76f 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dto/v2/ForecastSpecV2Test.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/forecast/ForecastSpecV2Test.java @@ -1,4 +1,4 @@ -package cwms.cda.data.dto.v2; +package cwms.cda.data.dto.forecast; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -6,7 +6,6 @@ import cwms.cda.api.errors.FieldException; import cwms.cda.helpers.DTOMatch; -import com.fasterxml.jackson.core.JsonProcessingException; import cwms.cda.data.dto.CwmsId; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; @@ -23,7 +22,7 @@ public class ForecastSpecV2Test { @Test - void testRoundTripJson() throws JsonProcessingException { + void testRoundTripJson() { ForecastSpecV2 s1 = buildForecastSpecV2(); ContentType contentType = Formats.parseHeader(Formats.JSON, ForecastSpecV2.class); 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 025f2ee068..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,8 +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.v2.ForecastLocation; -import cwms.cda.data.dto.v2.ForecastSpecV2; +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; diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/v2/forecast_spec_v2_test.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/forecast/forecast_spec_v2_test.json similarity index 100% rename from cwms-data-api/src/test/resources/cwms/cda/data/dto/v2/forecast_spec_v2_test.json rename to cwms-data-api/src/test/resources/cwms/cda/data/dto/forecast/forecast_spec_v2_test.json From 2768db2b6e044ac14c397fddbf9ce84715bbf7cf Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Fri, 21 Aug 2026 12:26:39 -0700 Subject: [PATCH 5/9] CDA-98 - updated getResourceId scope to be private --- .../src/main/java/cwms/cda/ApiServletRouteConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java index 3c460e14a0..8b8c1aba0c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -550,7 +550,7 @@ private static void addWaterContractTypeHandlers(String path, RouteRole[] requir * @throws IllegalArgumentException if the path does not contain a resource id. */ @NotNull - public static String getResourceId(String fullPath) { + private static String getResourceId(String fullPath) { String[] subPaths = Arrays.stream(fullPath.split("/")) .filter(it -> !it.isEmpty()).toArray(String[]::new); if (subPaths.length < 2) { From d47d5c98d82c6b70981402afa133acd23df946fc Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Tue, 25 Aug 2026 15:43:51 -0700 Subject: [PATCH 6/9] CDA-98 - Adds office as a path param for v2 --- .../cda/ApiServletRouteConfiguration.java | 21 ++++++++-- .../api/forecast/ForecastSpecController.java | 25 ++++++++++-- .../forecast/ForecastSpecControllerV2.java | 38 +++++++++++++++---- .../api/ForecastSpecControllerV2TestIT.java | 24 +----------- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java index 8b8c1aba0c..cc55aed986 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -566,9 +566,13 @@ private static String getResourceId(String fullPath) { 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(">")) { + // 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); } @@ -686,7 +690,16 @@ private static void cdaCrud(@NotNull String path, @NotNull CrudHandler crudHandl 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) { - return format("/v2/" + path, 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/forecast/ForecastSpecController.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecController.java index f71e47bad2..bc5d027ed5 100644 --- 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 @@ -54,6 +54,25 @@ protected DSLContext getDslContext(Context ctx) { /** The DTO type this controller reads and writes */ protected abstract Class getDtoClass(); + /** + * Where to read the required office from for delete/getOne. Defaults to the + * {@code office} query param. V2 (and future versions that put office in the path per + * the v2 primary-resource standard) should override this to read {@code ctx.pathParam} + * instead. + */ + protected String requireOffice(Context ctx) { + return requiredParam(ctx, OFFICE); + } + + /** + * Where to read the optional office filter from for getAll. Defaults to the + * {@code office} query param (nullable, meaning "any office"). V2 overrides this since + * office is a required path segment there, not an optional filter. + */ + protected String optionalOffice(Context ctx) { + return ctx.queryParam(OFFICE); + } + @Override public void create(@NotNull Context ctx) { try (final Timer.Context ignored = markAndTime(CREATE)) { @@ -69,7 +88,7 @@ public void create(@NotNull Context ctx) { @Override public void delete(@NotNull Context ctx, @NotNull String name) { - String office = requiredParam(ctx, OFFICE); + String office = requireOffice(ctx); String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); JooqDao.DeleteMethod deleteMethod = ctx.queryParamAsClass(METHOD, JooqDao.DeleteMethod.class) @@ -101,7 +120,7 @@ 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 office = optionalOffice(ctx); 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("*"); @@ -121,7 +140,7 @@ 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 office = requireOffice(ctx); String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); DSLContext dsl = getDslContext(ctx); 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 index 7cb8b48039..120e0a1fd6 100644 --- 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 @@ -46,8 +46,26 @@ protected Class getDtoClass() { return ForecastSpecV2.class; } + /** + * v2 primary-resource standard: office is a required path segment + * ({@code /v2/forecast-spec/{office}/{name}}), not a query param. + */ + @Override + protected String requireOffice(Context ctx) { + return ctx.pathParam(OFFICE); + } + + @Override + protected String optionalOffice(Context ctx) { + return ctx.pathParam(OFFICE); + } + @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) @@ -65,12 +83,12 @@ public void create(@NotNull Context 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 = OFFICE, required = true, description = "Specifies the " - + "owning office of the forecast spec whose data is to be deleted."), @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. " + @@ -91,10 +109,12 @@ public void delete(@NotNull Context ctx, @NotNull String name) { @OpenApi( description = "Used to query multiple forecast specs", - queryParams = { - @OpenApiParam(name = OFFICE, description = "Specifies the " - + "owning office of the forecast spec whose data is to be included in the " + 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."), @@ -127,13 +147,13 @@ public void getAll(@NotNull Context ctx) { @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 = OFFICE, required = true, description = "Specifies the " - + "owning office of the forecast spec whose data is to be included in the " - + "response."), @OpenApiParam(name = DESIGNATOR, description = "Specifies the " + "designator of the forecast spec whose data to be included in the response.") }, @@ -159,6 +179,8 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { @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( 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 index fc6da18dea..8359042245 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/ForecastSpecControllerV2TestIT.java @@ -47,7 +47,8 @@ final class ForecastSpecControllerV2TestIT extends DataApiTestIT { private static final String locationId2 = "TsBinTestLoc2"; private static final String designator = "designator"; - public static final String PATH = "/v2/forecast-spec/"; + // 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 { @@ -121,7 +122,6 @@ void test_get_create_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -165,7 +165,6 @@ void test_get_create_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -206,7 +205,6 @@ void test_get_create_get_null_designator(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .when() .redirects().follow(true) .redirects().max(3) @@ -249,7 +247,6 @@ void test_get_create_get_null_designator(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .when() .redirects().follow(true) .redirects().max(3) @@ -268,7 +265,6 @@ void test_get_create_get_null_designator(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(ID_MASK, SPEC_ID + "-NULL-DESIGNATOR") .when() .redirects().follow(true) @@ -286,7 +282,6 @@ void test_get_create_get_null_designator(String format) throws IOException { // Delete the spec given() .log().ifValidationFails(LogDetail.ALL, true) - .queryParam(Controllers.OFFICE, OFFICE) .header(AUTH_HEADER, user.toHeaderValue()) .when() .redirects().follow(true) @@ -340,7 +335,6 @@ void test_create_get_delete_get(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -361,7 +355,6 @@ void test_create_get_delete_get(String format) throws Exception { .log().ifValidationFails(LogDetail.ALL, true) .accept(format) .header(AUTH_HEADER, user.toHeaderValue()) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.NAME, SPEC_ID) .queryParam(DESIGNATOR, designator) .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) @@ -379,7 +372,6 @@ void test_create_get_delete_get(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -452,7 +444,6 @@ void create_getAll_delete_getAll(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.DESIGNATOR_MASK, "*") .queryParam(Controllers.ID_MASK, specId + "*") .queryParam(Controllers.SOURCE_ENTITY, ".*") @@ -479,7 +470,6 @@ void create_getAll_delete_getAll(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, "*") .queryParam(ID_MASK, specId + "*") .queryParam(Controllers.SOURCE_ENTITY, ".*") @@ -556,7 +546,6 @@ void create_getAll_with_entity_like_delete_getAll(String format) throws Exceptio given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.DESIGNATOR_MASK, "*") .queryParam(Controllers.ID_MASK, specId + "*") .queryParam(Controllers.SOURCE_ENTITY_LIKE, "%") @@ -581,7 +570,6 @@ void create_getAll_with_entity_like_delete_getAll(String format) throws Exceptio given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, "*") .queryParam(ID_MASK, specId + "*") .queryParam(Controllers.SOURCE_ENTITY, ".*") @@ -630,7 +618,6 @@ void test_create_get_delete_get_permissions_issue() throws Exception { .log().ifValidationFails(LogDetail.ALL, true) .accept(Formats.JSON) .header(AUTH_HEADER, user.toHeaderValue()) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.NAME, "SPK-Daily-UKY-Test-V2") .queryParam(DESIGNATOR, designator) .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) @@ -647,7 +634,6 @@ void test_create_get_delete_get_permissions_issue() throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(Formats.JSON) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -705,7 +691,6 @@ void test_create_get_delete_get_lrts(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .header(ApiServlet.IS_NEW_LRTS, true) .when() @@ -734,7 +719,6 @@ void test_create_get_delete_get_lrts(String format) throws Exception { .log().ifValidationFails(LogDetail.ALL, true) .accept(format) .header(AUTH_HEADER, user.toHeaderValue()) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(Controllers.NAME, specId) .queryParam(DESIGNATOR, designator) .queryParam(Controllers.METHOD, JooqDao.DeleteMethod.DELETE_ALL) @@ -752,7 +736,6 @@ void test_create_get_delete_get_lrts(String format) throws Exception { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -785,7 +768,6 @@ void test_create_get_update_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -827,7 +809,6 @@ void test_create_get_update_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) @@ -875,7 +856,6 @@ void test_create_get_update_get(String format) throws IOException { given() .log().ifValidationFails(LogDetail.ALL, true) .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) .queryParam(DESIGNATOR, designator) .when() .redirects().follow(true) From 03c2e7a1f7984579f1ffa21e60e2143f787adb06 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Tue, 25 Aug 2026 16:02:13 -0700 Subject: [PATCH 7/9] CDA-98 - Refactoring ApiServlet after rebase --- .../src/main/java/cwms/cda/ApiServlet.java | 10 +++---- .../cda/ApiServletRouteConfiguration.java | 26 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) 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 10869ed9f4..8409df4a8c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -88,9 +88,9 @@ import javax.servlet.http.HttpServletResponse; import javax.sql.DataSource; import org.apache.http.entity.ContentType; +import org.jooq.exception.DataAccessException; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; -import org.togglz.core.context.FeatureContext; /** @@ -341,7 +341,7 @@ private void getOpenApiOptions(JavalinConfig config) { schemeProcessor.apply(ctx, api); api.getPaths().forEach((key,path) -> { setSecurityRequirements(key,path, schemeProcessor.getSecurityRequirements()); - // yeah, we really need to figure out how to update everything, + // yeah, we really need to figure out how to update everything, // this is supported as an annotation in newer versions. if (key.startsWith("/rss")) { path.getGet().getResponses().forEach((p, r) -> { @@ -356,7 +356,7 @@ private void getOpenApiOptions(JavalinConfig config) { try (ScanResult scanResult = new ClassGraph() .acceptPackages("cwms.cda.data.dto") .scan()) { - List> csvDtoClasses = + List> csvDtoClasses = scanResult.getClassesImplementing(CwmsCsvDTO.class.getName()) .loadClasses(CwmsCsvDTO.class); for (Class clazz : csvDtoClasses) { @@ -397,11 +397,11 @@ private void getOpenApiOptions(JavalinConfig config) { doc.header(IS_NEW_LRTS, Boolean.class, p -> p.description( - "If True, will use use the new 'Local Regular Time Series" + "If True, will use use the new 'Local Regular Time Series" + " naming scheme. For example 1DayLocal. Instead of the original" + " PsuedoRegular based scheme, for example ~1DayLocal." + " NOTE: this parameter only applies to the input and output of" - + " Time Series names. It is added to all endpoints and will be ignored" + + " Time Series names. It is added to all endpoints and will be ignored" + " when not required. Default values is false if not set.") ); }) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java index cc55aed986..4472099152 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -363,14 +363,15 @@ public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredR String gateChangePath = format("/projects/{%s}/{%s}/gate-changes", OFFICE, Controllers.PROJECT_ID); String gateChangeCreatePath = "/projects/gate-changes"; - String virtualOutletPath = format("/projects/{%s}/{%s}/virtual-outlets/{%s}", OFFICE, - Controllers.PROJECT_ID, NAME); - String virtualOutletCreatePath = "/projects/virtual-outlets"; + cdaCrudCache(outletPath, new OutletController(metrics), requiredRoles, 1, TimeUnit.DAYS); post(gateChangeCreatePath, new GateChangeCreateController(metrics), requiredRoles); get(gateChangePath, new GateChangeGetAllController(metrics)); delete(gateChangePath, new GateChangeDeleteController(metrics), requiredRoles); + 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)); @@ -445,22 +446,25 @@ private static void userListsUnsupported(Context ctx) { private static Boolean hasAnyRole(DataApiPrincipal p, Set roles) throws MissingRolesException { boolean retVal = roles.stream().anyMatch(p.getRoles()::contains); - if(!retVal) { + if (!retVal) { List requiredRoleNames = roles.stream() .map(Object::toString) .collect(toList()); - throw new MissingRolesException(requiredRoleNames, "Missing one of the following roles {" + String.join(",", requiredRoleNames) + "}"); + 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) { - /** - * The POST handlers for /ratings/rate-* intentionally do not have - * require roles. Instead they are rate limited if not authenticated. - * POST is used as sending a body with GET is not standard and we cannot - * be sure clients, or future servers, would correctly support that. - */ + String rateValues = format("/ratings/rate-values/{%s}/{%s}", OFFICE, RATING_ID); post(rateValues, new RateValuesController(metrics)); String rateTs = format("/ratings/rate-ts/{%s}/{%s}", OFFICE, RATING_ID); From 337539c2fd649ece807e399392d947f38b6db5ba Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Tue, 25 Aug 2026 16:49:21 -0700 Subject: [PATCH 8/9] CDA-98 - Fixing failing OpenApi Static Analysis test --- .../api/forecast/ForecastSpecController.java | 33 ++----------------- .../forecast/ForecastSpecControllerV1.java | 10 ++++-- .../forecast/ForecastSpecControllerV2.java | 25 +++++--------- 3 files changed, 18 insertions(+), 50 deletions(-) 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 index bc5d027ed5..fd1ec73e4d 100644 --- 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 @@ -9,11 +9,9 @@ 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.UPDATE; -import static cwms.cda.api.Controllers.requiredParam; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; @@ -54,25 +52,6 @@ protected DSLContext getDslContext(Context ctx) { /** The DTO type this controller reads and writes */ protected abstract Class getDtoClass(); - /** - * Where to read the required office from for delete/getOne. Defaults to the - * {@code office} query param. V2 (and future versions that put office in the path per - * the v2 primary-resource standard) should override this to read {@code ctx.pathParam} - * instead. - */ - protected String requireOffice(Context ctx) { - return requiredParam(ctx, OFFICE); - } - - /** - * Where to read the optional office filter from for getAll. Defaults to the - * {@code office} query param (nullable, meaning "any office"). V2 overrides this since - * office is a required path segment there, not an optional filter. - */ - protected String optionalOffice(Context ctx) { - return ctx.queryParam(OFFICE); - } - @Override public void create(@NotNull Context ctx) { try (final Timer.Context ignored = markAndTime(CREATE)) { @@ -86,9 +65,7 @@ public void create(@NotNull Context ctx) { } } - @Override - public void delete(@NotNull Context ctx, @NotNull String name) { - String office = requireOffice(ctx); + 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) @@ -117,10 +94,8 @@ public void delete(@NotNull Context ctx, @NotNull String name) { } } - @Override - public void getAll(@NotNull Context ctx) { + protected void getAll(Context ctx, String office) { try (final Timer.Context ignored = markAndTime(GET_ALL)) { - String office = optionalOffice(ctx); 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("*"); @@ -137,10 +112,8 @@ public void getAll(@NotNull Context ctx) { } } - @Override - public void getOne(@NotNull Context ctx, @NotNull String name) { + protected void getOne(Context ctx, String name, String office) { try (final Timer.Context ignored = markAndTime(GET_ONE)) { - String office = requireOffice(ctx); String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get(); DSLContext dsl = getDslContext(ctx); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java index a5c80618c8..cc6682ead3 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV1.java @@ -12,6 +12,7 @@ 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.requiredParam; import com.codahale.metrics.MetricRegistry; import cwms.cda.data.dao.forecast.ForecastSpecDao; @@ -86,7 +87,8 @@ public void create(@NotNull Context ctx) { ) @Override public void delete(@NotNull Context ctx, @NotNull String name) { - super.delete(ctx, name); + String office = requiredParam(ctx, OFFICE); + super.delete(ctx, name, office); } @OpenApi( @@ -121,7 +123,8 @@ public void delete(@NotNull Context ctx, @NotNull String name) { ) @Override public void getAll(@NotNull Context ctx) { - super.getAll(ctx); + String office = ctx.queryParam(OFFICE); + super.getAll(ctx, office); } @OpenApi( @@ -153,7 +156,8 @@ public void getAll(@NotNull Context ctx) { ) @Override public void getOne(@NotNull Context ctx, @NotNull String name) { - super.getOne(ctx, name); + String office = requiredParam(ctx, OFFICE); + super.getOne(ctx, name, office); } @OpenApi( 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 index 120e0a1fd6..d28edf30c9 100644 --- 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 @@ -46,20 +46,6 @@ protected Class getDtoClass() { return ForecastSpecV2.class; } - /** - * v2 primary-resource standard: office is a required path segment - * ({@code /v2/forecast-spec/{office}/{name}}), not a query param. - */ - @Override - protected String requireOffice(Context ctx) { - return ctx.pathParam(OFFICE); - } - - @Override - protected String optionalOffice(Context ctx) { - return ctx.pathParam(OFFICE); - } - @OpenApi( description = "Used to create and save forecast spec data", pathParams = { @@ -77,6 +63,7 @@ protected String optionalOffice(Context ctx) { ) @Override public void create(@NotNull Context ctx) { + logUnusedPathParameter(ctx, OFFICE, "Body contains information"); super.create(ctx); } @@ -104,7 +91,8 @@ public void create(@NotNull Context ctx) { ) @Override public void delete(@NotNull Context ctx, @NotNull String name) { - super.delete(ctx, name); + String office = ctx.pathParam(OFFICE); + super.delete(ctx, name, office); } @OpenApi( @@ -141,7 +129,8 @@ public void delete(@NotNull Context ctx, @NotNull String name) { ) @Override public void getAll(@NotNull Context ctx) { - super.getAll(ctx); + String office = ctx.pathParam(OFFICE); + super.getAll(ctx, office); } @OpenApi( @@ -173,7 +162,8 @@ public void getAll(@NotNull Context ctx) { ) @Override public void getOne(@NotNull Context ctx, @NotNull String name) { - super.getOne(ctx, name); + String office = ctx.pathParam(OFFICE); + super.getOne(ctx, name, office); } @OpenApi( @@ -197,6 +187,7 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { ) @Override public void update(@NotNull Context ctx, @NotNull String name) { + logUnusedPathParameter(ctx, OFFICE, "Body contains information"); super.update(ctx, name); } } From 1cac31ec66fc697bbd99a4259005d711374a6e78 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Wed, 26 Aug 2026 10:54:01 -0700 Subject: [PATCH 9/9] CDA-98 - Validates office match between path param and body in v2 --- .../api/forecast/ForecastSpecController.java | 2 +- .../api/forecast/ForecastSpecControllerV2.java | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) 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 index fd1ec73e4d..92943b34c3 100644 --- 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 @@ -166,7 +166,7 @@ private void handleWriteFailure(Context ctx, IOException ex, String message) { ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(error); } - private T deserializeForecastSpec(Context ctx) { + 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/forecast/ForecastSpecControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/forecast/ForecastSpecControllerV2.java index d28edf30c9..04e44f7cbe 100644 --- 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 @@ -63,7 +63,8 @@ protected Class getDtoClass() { ) @Override public void create(@NotNull Context ctx) { - logUnusedPathParameter(ctx, OFFICE, "Body contains information"); + String officeFromPath = ctx.pathParam(OFFICE); + validateOffice(ctx, officeFromPath); super.create(ctx); } @@ -187,7 +188,19 @@ public void getOne(@NotNull Context ctx, @NotNull String name) { ) @Override public void update(@NotNull Context ctx, @NotNull String name) { - logUnusedPathParameter(ctx, OFFICE, "Body contains information"); + 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."); + } + } }