From be94f6df581bd7766f9990f87b25e71dc7d82264 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Fri, 4 Sep 2026 14:36:24 -0700 Subject: [PATCH 1/2] CDA-130 - Adds support for partial patches. Implements for text-timeseries. updates open api doc test to check abstractions of accessors for parameters --- .../cda/ApiServletRouteConfiguration.java | 15 +- .../main/java/cwms/cda/api/Controllers.java | 1 + .../api/enums/CollectionPatchStrategy.java | 37 ++ .../TextTimeSeriesController.java | 175 ++++++++ .../TextTimeSeriesControllerV1.java} | 162 ++----- .../TextTimeSeriesControllerV2.java | 225 ++++++++++ .../TextTimeSeriesValueController.java | 7 +- .../RegularTimeSeriesTextDao.java | 22 + .../dao/texttimeseries/TimeSeriesTextDao.java | 6 + .../RegularTextTimeSeriesRow.java | 11 +- .../dto/texttimeseries/TextTimeSeries.java | 10 +- .../java/cwms/cda/formatters/Formats.java | 244 ++++++++++ .../cda/formatters/ObjectMapperFormatter.java | 7 + .../formatters/annotations/Identifier.java | 11 + .../java/cwms/cda/formatters/json/JsonV1.java | 9 +- .../java/cwms/cda/formatters/json/JsonV2.java | 10 +- .../java/cwms/cda/api/OpenApiDocTest.java | 117 ++++- .../api/TextTimeSeriesControllerTestIT.java | 114 +---- .../api/TextTimeSeriesControllerV1TestIT.java | 126 ++++++ .../api/TextTimeSeriesControllerV2TestIT.java | 250 +++++++++++ .../java/cwms/cda/formatters/FormatsTest.java | 234 ++++++++++ .../java/helpers/OpenApiTestHelperTest.java | 2 +- .../api/spk/text_ts_update_reg_partial.json | 8 + docs/source/decisions/0011-patch.rst | 280 ------------ docs/source/decisions/0017-patch.rst | 425 ++++++++++++++++++ docs/source/decisions/index.rst | 1 + 26 files changed, 1962 insertions(+), 547 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/enums/CollectionPatchStrategy.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesController.java rename cwms-data-api/src/main/java/cwms/cda/api/{TextTimeSeriesController.java => texttimeseries/TextTimeSeriesControllerV1.java} (55%) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV2.java rename cwms-data-api/src/main/java/cwms/cda/api/{ => texttimeseries}/TextTimeSeriesValueController.java (96%) create mode 100644 cwms-data-api/src/main/java/cwms/cda/formatters/ObjectMapperFormatter.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV1TestIT.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV2TestIT.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/text_ts_update_reg_partial.json delete mode 100644 docs/source/decisions/0011-patch.rst create mode 100644 docs/source/decisions/0017-patch.rst 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 11dba183f2..4492b10cc8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServletRouteConfiguration.java @@ -55,8 +55,9 @@ 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.texttimeseries.TextTimeSeriesControllerV1; +import cwms.cda.api.texttimeseries.TextTimeSeriesControllerV2; +import cwms.cda.api.texttimeseries.TextTimeSeriesValueController; import cwms.cda.api.TimeSeriesCategoryController; import cwms.cda.api.TimeSeriesController; import cwms.cda.api.TimeSeriesFilteredController; @@ -237,11 +238,17 @@ public static void configureRoutes(MetricRegistry metrics, RouteRole[] requiredR 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 textTsPathTemplate = "/timeseries/text/{%s}"; + String textTsPath = format(textTsPathTemplate, NAME); + cdaCrudCache(textTsPath, new TextTimeSeriesControllerV1(metrics), requiredRoles,5, TimeUnit.MINUTES); + cdaCrudCache(formatV2(textTsPathTemplate, NAME), + new TextTimeSeriesControllerV2(metrics), requiredRoles, 5, TimeUnit.MINUTES); String textValuePath = textTsPath + "/value"; get(textValuePath, new TextTimeSeriesValueController(metrics)); addCacheControl(textValuePath, 1, TimeUnit.DAYS); + String textValuePathV2 = formatV2(textTsPathTemplate, NAME) + "/value"; + get(textValuePathV2, new TextTimeSeriesValueController(metrics)); + addCacheControl(textValuePathV2, 1, TimeUnit.DAYS); String binTsPath = format("/timeseries/binary/{%s}", NAME); cdaCrudCache(binTsPath, new BinaryTimeSeriesController(metrics), requiredRoles,5, TimeUnit.MINUTES); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index b3f58a0a3d..7f5f70d18a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -89,6 +89,7 @@ public final class Controllers { public static final String FAIL_IF_EXISTS = "fail-if-exists"; public static final String CREATE_POOL_NAME = "create-pool-name"; public static final String IGNORE_NULLS = "ignore-nulls"; + public static final String COLLECTION_MERGE_STRATEGY = "collection-merge-strategy"; public static final String EFFECTIVE_DATE = "effective-date"; public static final String DATE = "date"; public static final String LEVEL_ID = "level-id"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/enums/CollectionPatchStrategy.java b/cwms-data-api/src/main/java/cwms/cda/api/enums/CollectionPatchStrategy.java new file mode 100644 index 0000000000..ed90b31192 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/enums/CollectionPatchStrategy.java @@ -0,0 +1,37 @@ +package cwms.cda.api.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "Collection Patch Strategy", + description = CollectionPatchStrategy.DESCRIPTION +) +public enum CollectionPatchStrategy { + + OVERWRITE, + MERGE; + + public static final String DESCRIPTION = "Controls how a PATCH request body's collection " + + "fields are applied. OVERWRITE: the collection becomes exactly what " + + "the body contains -- anything within the request's time window that isn't named " + + "in the body is removed. MERGE: items named in the body are matched to " + + "existing items by their identity field(s) -- the field(s) marked @Identifier, " + + "or, when the collection's element type has none of those, whichever field(s) are " + + "marked @JsonProperty(required = true) -- and updated in place, preserving that " + + "item's own omitted fields; a null or absent identity field on the incoming item " + + "never matches anything, so an unmatched identity is added as new, and every other " + + "existing item is left untouched."; + + public static CollectionPatchStrategy strategyFor(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("Cannot determine collection-patch strategy for null or empty"); + } + String normalized = value.trim().toUpperCase().replace('-', '_'); + for (CollectionPatchStrategy strategy : values()) { + if (strategy.name().equals(normalized)) { + return strategy; + } + } + throw new UnsupportedOperationException("Unsupported collection-patch strategy: " + value); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesController.java b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesController.java new file mode 100644 index 0000000000..b82c7c8ef0 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesController.java @@ -0,0 +1,175 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.texttimeseries; + +import static cwms.cda.api.Controllers.DATE; +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.VERSION_DATE; +import static cwms.cda.api.Controllers.requiredInstant; +import static cwms.cda.api.Controllers.requiredParam; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.Timer; +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.BaseCrudHandler; +import cwms.cda.api.Controllers; +import cwms.cda.api.errors.CdaError; +import cwms.cda.api.errors.ExceptionTraceSupport; +import cwms.cda.data.dao.texttimeseries.TimeSeriesTextDao; +import cwms.cda.data.dto.texttimeseries.TextTimeSeries; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import cwms.cda.helpers.ReplaceUtils; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.time.Instant; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.client.utils.URIBuilder; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public abstract class TextTimeSeriesController extends BaseCrudHandler { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + public static final String TAG = "Text-TimeSeries"; + + public static final String REPLACE_ALL = "replace-all"; + + public static final boolean DEFAULT_CREATE_REPLACE_ALL = false; + public static final boolean DEFAULT_UPDATE_REPLACE_ALL = true; + + protected TextTimeSeriesController(com.codahale.metrics.MetricRegistry metrics) { + super(metrics); + } + + @NotNull + protected TimeSeriesTextDao getDao(DSLContext dsl) { + return new TimeSeriesTextDao(dsl); + } + + protected abstract String getOffice(@NotNull Context ctx); + + @Override + public void getAll(@NotNull Context ctx) { + + String office = getOffice(ctx); + String tsId = requiredParam(ctx, NAME); + Instant begin = requiredInstant(ctx, Controllers.BEGIN); + Instant end = requiredInstant(ctx, Controllers.END); + Instant version = Controllers.queryParamAsInstant(ctx, VERSION_DATE); + int kiloByteLimit = Integer.parseInt(System.getProperty("cda.api.ts.text.max.length.kB", "64")); + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); + try (Timer.Context ignored = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + TimeSeriesTextDao dao = getDao(dsl); + + String textMask = "*"; + + String dateToken = "{date_token}"; + String path = ctx.path(); + if (!path.endsWith("/")) { + path += "/"; + } + path += tsId + "/value"; + String url = new URIBuilder(ctx.fullUrl()) + .setPath(path) + .clearParameters() + .addParameter(OFFICE, office) + .addParameter(VERSION_DATE, ctx.queryParam(VERSION_DATE)) + .addParameter(DATE, dateToken) + .build() + .toString(); + ReplaceUtils.OperatorBuilder urlBuilder = new ReplaceUtils.OperatorBuilder() + .withTemplate(url) + .withOperatorKey(URLEncoder.encode(dateToken, "UTF-8")); + TextTimeSeries textTimeSeries = dao.retrieveFromDao(office, tsId, textMask, + begin, end, version, kiloByteLimit, urlBuilder); + + ctx.contentType(contentType.toString()); + + String result = Formats.format(contentType, textTimeSeries); + + ctx.status(HttpServletResponse.SC_OK); + + byte[] bytes = result.getBytes(); + ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); + ctx.res.getOutputStream().write(bytes); + } catch (URISyntaxException | IOException ex) { + CdaError re = ExceptionTraceSupport.buildError(ctx, + "Failed to process request: " + ex.getLocalizedMessage(), ex); + logger.atSevere().withCause(ex).log("%s", re); + ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); + } + + } + + @Override + public void getOne(@NotNull Context ctx, @NotNull String templateId) { + ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented()); + } + + @Override + public void create(@NotNull Context ctx) { + try (Timer.Context ignored = markAndTime(Controllers.CREATE)) { + DSLContext dsl = getDslContext(ctx); + + String formatHeader = ctx.req.getContentType(); + + ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); + TextTimeSeries tts = Formats.parseContent(contentType, ctx.bodyAsInputStream(), TextTimeSeries.class); + TimeSeriesTextDao dao = getDao(dsl); + + boolean replaceAll = ctx.queryParamAsClass(REPLACE_ALL, Boolean.class) + .getOrDefault(DEFAULT_CREATE_REPLACE_ALL); + dao.create(tts, replaceAll); + ctx.status(HttpServletResponse.SC_CREATED); + } + } + + @Override + public void delete(@NotNull Context ctx, @NotNull String textTimeSeriesId) { + try (Timer.Context ignored = markAndTime(Controllers.DELETE)) { + DSLContext dsl = getDslContext(ctx); + String office = getOffice(ctx); + String mask = requiredParam(ctx, Controllers.TEXT_MASK); + + + Instant begin = requiredInstant(ctx, Controllers.BEGIN); + Instant end = requiredInstant(ctx, Controllers.END); + Instant version = Controllers.queryParamAsInstant(ctx, VERSION_DATE); + + TimeSeriesTextDao dao2 = getDao(dsl); + + dao2.delete(office, textTimeSeriesId, mask, begin, end, version); + + ctx.status(HttpServletResponse.SC_NO_CONTENT); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesController.java b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV1.java similarity index 55% rename from cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesController.java rename to cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV1.java index ae9fa73034..785532f407 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV1.java @@ -1,7 +1,7 @@ /* * MIT License * - * Copyright (c) 2024 Hydrologic Engineering Center + * Copyright (c) 2026 Hydrologic Engineering Center * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -22,36 +22,26 @@ * SOFTWARE. */ -package cwms.cda.api; +package cwms.cda.api.texttimeseries; import static cwms.cda.api.Controllers.BEGIN; -import static cwms.cda.api.Controllers.CREATE; -import static cwms.cda.api.Controllers.DATE; -import static cwms.cda.api.Controllers.DELETE; import static cwms.cda.api.Controllers.END; -import static cwms.cda.api.Controllers.GET_ALL; import static cwms.cda.api.Controllers.NAME; import static cwms.cda.api.Controllers.OFFICE; import static cwms.cda.api.Controllers.STATUS_200; import static cwms.cda.api.Controllers.TIMEZONE; import static cwms.cda.api.Controllers.UPDATE; import static cwms.cda.api.Controllers.VERSION_DATE; -import static cwms.cda.api.Controllers.queryParamAsInstant; -import static cwms.cda.api.Controllers.requiredInstant; import static cwms.cda.api.Controllers.requiredParam; import static cwms.cda.data.dao.JooqDao.getDslContext; 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.api.Controllers; import cwms.cda.data.dao.texttimeseries.TimeSeriesTextDao; import cwms.cda.data.dto.texttimeseries.TextTimeSeries; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; -import cwms.cda.helpers.ReplaceUtils; -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; @@ -59,33 +49,18 @@ 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.net.URISyntaxException; -import java.net.URLEncoder; -import java.time.Instant; -import javax.servlet.http.HttpServletResponse; -import org.apache.http.client.utils.URIBuilder; import org.jetbrains.annotations.NotNull; import org.jooq.DSLContext; +public final class TextTimeSeriesControllerV1 extends TextTimeSeriesController { - -public class TextTimeSeriesController extends BaseCrudHandler { - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - static final String TAG = "Text-TimeSeries"; - - public static final String REPLACE_ALL = "replace-all"; - - public static final boolean DEFAULT_CREATE_REPLACE_ALL = false; - public static final boolean DEFAULT_UPDATE_REPLACE_ALL = true; - - public TextTimeSeriesController(MetricRegistry metrics) { + public TextTimeSeriesControllerV1(MetricRegistry metrics) { super(metrics); } - @NotNull - protected TimeSeriesTextDao getDao(DSLContext dsl) { - return new TimeSeriesTextDao(dsl); + @Override + protected String getOffice(@NotNull Context ctx) { + return requiredParam(ctx, OFFICE); } @OpenApi( @@ -116,64 +91,13 @@ protected TimeSeriesTextDao getDao(DSLContext dsl) { ) @Override public void getAll(@NotNull Context ctx) { - - String office = requiredParam(ctx, OFFICE); - String tsId = requiredParam(ctx, NAME); - Instant begin = requiredInstant(ctx, BEGIN); - Instant end = requiredInstant(ctx, END); - Instant version = queryParamAsInstant(ctx, VERSION_DATE); - int kiloByteLimit = Integer.parseInt(System.getProperty("cda.api.ts.text.max.length.kB", "64")); - String formatHeader = ctx.header(Header.ACCEPT); - ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); - try (Timer.Context ignored = markAndTime(GET_ALL)) { - DSLContext dsl = getDslContext(ctx); - TimeSeriesTextDao dao = getDao(dsl); - - String textMask = "*"; - - String dateToken = "{date_token}"; - String path = ctx.path(); - if (!path.endsWith("/")) { - path += "/"; - } - path += tsId + "/value"; - String url = new URIBuilder(ctx.fullUrl()) - .setPath(path) - .clearParameters() - .addParameter(OFFICE, office) - .addParameter(VERSION_DATE, ctx.queryParam(VERSION_DATE)) - .addParameter(DATE, dateToken) - .build() - .toString(); - ReplaceUtils.OperatorBuilder urlBuilder = new ReplaceUtils.OperatorBuilder() - .withTemplate(url) - .withOperatorKey(URLEncoder.encode(dateToken, "UTF-8")); - TextTimeSeries textTimeSeries = dao.retrieveFromDao(office, tsId, textMask, - begin, end, version, kiloByteLimit, urlBuilder); - - ctx.contentType(contentType.toString()); - - String result = Formats.format(contentType, textTimeSeries); - - ctx.status(HttpServletResponse.SC_OK); - - byte[] bytes = result.getBytes(); - ctx.header(Header.CONTENT_LENGTH, String.valueOf(bytes.length)); - ctx.res.getOutputStream().write(bytes); - } catch (URISyntaxException | IOException ex) { - CdaError re = ExceptionTraceSupport.buildError(ctx, - "Failed to process request: " + ex.getLocalizedMessage(), ex); - logger.atSevere().withCause(ex).log("%s", re); - ctx.status(HttpServletResponse.SC_INTERNAL_SERVER_ERROR).json(re); - } - + super.getAll(ctx); } - @OpenApi(ignore = true) @Override public void getOne(@NotNull Context ctx, @NotNull String templateId) { - ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented()); + super.getOne(ctx, templateId); } @OpenApi( @@ -192,46 +116,34 @@ public void getOne(@NotNull Context ctx, @NotNull String templateId) { ) @Override public void create(@NotNull Context ctx) { - try (Timer.Context ignored = markAndTime(CREATE)) { - DSLContext dsl = getDslContext(ctx); - - String formatHeader = ctx.req.getContentType(); - - ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); - TextTimeSeries tts = Formats.parseContent(contentType, ctx.bodyAsInputStream(), TextTimeSeries.class); - TimeSeriesTextDao dao = getDao(dsl); - - boolean replaceAll = ctx.queryParamAsClass(REPLACE_ALL, Boolean.class).getOrDefault(DEFAULT_CREATE_REPLACE_ALL); - dao.create(tts, replaceAll); - ctx.status(HttpServletResponse.SC_CREATED); - } + super.create(ctx); } @OpenApi( - description = "Updates a text timeseries", - pathParams = { - @OpenApiParam(name = NAME, description = "The id of the text timeseries to be updated"), - }, - queryParams = { - @OpenApiParam(name = REPLACE_ALL, type = Boolean.class, description = "Whether to " - + "replace any and all existing text with the specified text. " - + "Default is:" + DEFAULT_UPDATE_REPLACE_ALL) - }, - requestBody = @OpenApiRequestBody( - content = { - @OpenApiContent(from = TextTimeSeries.class, type = Formats.JSONV2), + description = "Updates a text timeseries", + pathParams = { + @OpenApiParam(name = NAME, description = "The id of the text timeseries to be updated"), }, - required = true - ), - method = HttpMethod.PATCH, - tags = {TAG} + queryParams = { + @OpenApiParam(name = REPLACE_ALL, type = Boolean.class, description = "Whether to " + + "replace any and all existing text with the specified text. " + + "Default is:" + DEFAULT_UPDATE_REPLACE_ALL) + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TextTimeSeries.class, type = Formats.JSONV2), + }, + required = true + ), + method = HttpMethod.PATCH, + tags = {TAG} ) @Override public void update(@NotNull Context ctx, @NotNull String oldTextTimeSeriesId) { logUnusedPathParameter(ctx, NAME, "Body contains required information"); try (Timer.Context ignored = markAndTime(UPDATE)) { boolean replaceAll = ctx.queryParamAsClass(REPLACE_ALL, Boolean.class) - .getOrDefault(DEFAULT_UPDATE_REPLACE_ALL); + .getOrDefault(DEFAULT_UPDATE_REPLACE_ALL); String formatHeader = ctx.req.getContentType(); ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); TextTimeSeries tts = Formats.parseContent(contentType, ctx.bodyAsInputStream(), TextTimeSeries.class); @@ -242,7 +154,6 @@ public void update(@NotNull Context ctx, @NotNull String oldTextTimeSeriesId) { } } - @OpenApi( description = "Deletes requested text timeseries id", pathParams = { @@ -272,21 +183,6 @@ public void update(@NotNull Context ctx, @NotNull String oldTextTimeSeriesId) { ) @Override public void delete(@NotNull Context ctx, @NotNull String textTimeSeriesId) { - try (Timer.Context ignored = markAndTime(DELETE)) { - DSLContext dsl = getDslContext(ctx); - String office = requiredParam(ctx, OFFICE); - String mask = requiredParam(ctx, Controllers.TEXT_MASK); - - - Instant begin = requiredInstant(ctx, BEGIN); - Instant end = requiredInstant(ctx, END); - Instant version = queryParamAsInstant(ctx, VERSION_DATE); - - TimeSeriesTextDao dao = getDao(dsl); - - dao.delete(office, textTimeSeriesId, mask, begin, end, version); - - ctx.status(HttpServletResponse.SC_NO_CONTENT); - } + super.delete(ctx, textTimeSeriesId); } } diff --git a/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV2.java b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV2.java new file mode 100644 index 0000000000..f59586c3ca --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesControllerV2.java @@ -0,0 +1,225 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.texttimeseries; + +import static cwms.cda.api.Controllers.BEGIN; +import static cwms.cda.api.Controllers.END; +import static cwms.cda.api.Controllers.NAME; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.TIMEZONE; +import static cwms.cda.api.Controllers.UPDATE; +import static cwms.cda.api.Controllers.VERSION_DATE; +import static cwms.cda.api.Controllers.queryParamAsInstant; +import static cwms.cda.api.Controllers.requiredInstant; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.api.enums.CollectionPatchStrategy; +import cwms.cda.data.dao.texttimeseries.TimeSeriesTextDao; +import cwms.cda.data.dto.texttimeseries.TextTimeSeries; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.http.Context; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiRequestBody; +import java.time.Instant; + +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public final class TextTimeSeriesControllerV2 extends TextTimeSeriesController { + + public TextTimeSeriesControllerV2(MetricRegistry metrics) { + super(metrics); + } + + @Override + protected String getOffice(@NotNull Context ctx) { + return ctx.pathParam(OFFICE); + } + + @OpenApi( + summary = "Retrieve text time series values for a provided time window and date version." + + "If individual values exceed 64 kilobytes, a URL to a separate download is provided " + + "instead of being included in the returned payload from this request.", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning office of " + + "the Text TimeSeries whose data is to be included in the response."), + }, + queryParams = { + @OpenApiParam(name = NAME, required = true, description = "Specifies the ts-id of the " + + "text timeseries"), + @OpenApiParam(name = TIMEZONE, description = "Specifies " + + "the time zone of the values of the begin and end fields (unless " + + "otherwise specified). If this field is not specified, " + + "the default time zone of UTC shall be used."), + @OpenApiParam(name = VERSION_DATE, description = "Specifies the version date of the " + + "text timeseries. If not specified, the latest version will be used."), + @OpenApiParam(name = BEGIN, required = true, description = "The start of the time window"), + @OpenApiParam(name = END, required = true, description = "The end of the time window.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, + content = { + @OpenApiContent(type = Formats.JSON, from = TextTimeSeries.class) + } + )}, + tags = {TAG} + ) + @Override + public void getAll(@NotNull Context ctx) { + super.getAll(ctx); + } + + @OpenApi(ignore = true) + @Override + public void getOne(@NotNull Context ctx, @NotNull String templateId) { + super.getOne(ctx, templateId); + } + + @OpenApi( + description = "Create new TextTimeSeries", + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TextTimeSeries.class, type = Formats.JSON) + }, + required = true), + queryParams = { + @OpenApiParam(name = REPLACE_ALL, type = Boolean.class, description = "Whether to " + + "replace any and all existing text with the specified text for matching entries. " + + "Default is " + DEFAULT_CREATE_REPLACE_ALL)}, + method = HttpMethod.POST, + tags = {TAG} + ) + @Override + public void create(@NotNull Context ctx) { + super.create(ctx); + } + + @OpenApi( + description = "Updates a text timeseries. The request body may be a full or a partial " + + "TextTimeSeries representation: the current resource (identified " + + "by the path and the " + BEGIN + "/" + END + " window) is retrieved, the request " + + "body is merged onto it, and the result is stored -- so any field omitted from " + + "the body is left unchanged, and a field explicitly set to null clears that " + + "field. Each entry in regular-text-values must include date-time. Additionally, to patch a specific " + + "existing row, data-entry-date must also be provided -- that's what identifies which entry is being patched; " + + "every other row field may be omitted. " + + Controllers.COLLECTION_MERGE_STRATEGY + " controls how " + + "regular-text-values is applied. Data outside the " + BEGIN + "/" + END + " window is never read or " + + "written, regardless of strategy. An omitted regular-text-values, or one given " + + "as an empty array, leaves the collection entirely untouched under every " + + "strategy -- nothing in the window is deleted, replaced, or inserted on its " + + "behalf; to remove every value in the window, use DELETE.", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the owning " + + "office of the text timeseries to be updated."), + @OpenApiParam(name = NAME, description = "The id of the text timeseries to be updated"), + }, + queryParams = { + @OpenApiParam(name = BEGIN, required = true, description = "The start of the time " + + "window containing the date-times named in the request body."), + @OpenApiParam(name = END, required = true, description = "The end of the time window " + + "containing the date-times named in the request body."), + @OpenApiParam(name = TIMEZONE, description = "Specifies " + + "the time zone of the values of the begin and end fields (unless " + + "otherwise specified). If this field is not specified, " + + "the default time zone of UTC shall be used."), + @OpenApiParam(name = VERSION_DATE, description = "Specifies the version date of the " + + "text timeseries. If not specified, the latest version will be used."), + @OpenApiParam(name = Controllers.COLLECTION_MERGE_STRATEGY, type = CollectionPatchStrategy.class, + description = CollectionPatchStrategy.DESCRIPTION) + }, + requestBody = @OpenApiRequestBody( + content = { + @OpenApiContent(from = TextTimeSeries.class, type = Formats.JSON), + }, + required = true + ), + method = HttpMethod.PATCH, + tags = {TAG} + ) + @Override + public void update(@NotNull Context ctx, @NotNull String tsId) { + try (Timer.Context ignored = markAndTime(UPDATE)) { + CollectionPatchStrategy mergeStrategy = CollectionPatchStrategy.strategyFor( + ctx.queryParam(Controllers.COLLECTION_MERGE_STRATEGY)); + String office = getOffice(ctx); + Instant begin = requiredInstant(ctx, BEGIN); + Instant end = requiredInstant(ctx, END); + Instant version = queryParamAsInstant(ctx, VERSION_DATE); + + String formatHeader = ctx.req.getContentType(); + ContentType contentType = Formats.parseHeader(formatHeader, TextTimeSeries.class); + DSLContext dsl = getDslContext(ctx); + TimeSeriesTextDao dao = getDao(dsl); + + TextTimeSeries existing = dao.retrieveFromDao(office, tsId, "*", begin, end, version, + Integer.MAX_VALUE, null); + TextTimeSeries updated = Formats.parsePatchContent( + contentType, existing, ctx.bodyAsInputStream(), TextTimeSeries.class, mergeStrategy); + dao.update(updated, "*", begin, end, version, false); + } + } + + @OpenApi( + description = "Deletes requested text timeseries id", + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Specifies the " + + "owning office of the timeseries identifier to be deleted"), + @OpenApiParam(name = NAME, description = "The time series identifier to be deleted"), + }, + queryParams = { + @OpenApiParam(name = Controllers.TEXT_MASK, required = true, description = "The " + + "standard text pattern to match. " + + "Use glob-style wildcard characters instead of sql-style wildcard " + + "characters for pattern matching." + + " For StandardTextTimeSeries this should be the Standard_Text_Id (such" + + " as 'E' for ESTIMATED)"), + @OpenApiParam(name = TIMEZONE, description = "Specifies " + + "the time zone of the values of the begin and end fields (unless " + + "otherwise specified). If this field is not specified, " + + "the default time zone of UTC shall be used."), + @OpenApiParam(name = BEGIN, required = true, description = "The start of the time" + + " window"), + @OpenApiParam(name = END, required = true, description = "The end of the time window."), + @OpenApiParam(name = VERSION_DATE, description = "The version date for the time " + + "series. If not specified, maximum version date is used.") + }, + method = HttpMethod.DELETE, + tags = {TAG} + ) + @Override + public void delete(@NotNull Context ctx, @NotNull String textTimeSeriesId) { + super.delete(ctx, textTimeSeriesId); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesValueController.java b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesValueController.java similarity index 96% rename from cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesValueController.java rename to cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesValueController.java index 1f3cd343cf..72cac81d26 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TextTimeSeriesValueController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/texttimeseries/TextTimeSeriesValueController.java @@ -22,23 +22,22 @@ * SOFTWARE. */ -package cwms.cda.api; +package cwms.cda.api.texttimeseries; -import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; +import cwms.cda.api.BaseHandler; +import cwms.cda.api.RangeRequestUtil; import cwms.cda.data.dao.ClobDao; import cwms.cda.data.dao.StreamConsumer; import io.javalin.core.util.Header; import io.javalin.http.Context; -import io.javalin.http.Handler; 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.OpenApiResponse; import org.jooq.DSLContext; -import static com.codahale.metrics.MetricRegistry.name; import static cwms.cda.api.Controllers.*; import static cwms.cda.data.dao.JooqDao.getDslContext; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/RegularTimeSeriesTextDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/RegularTimeSeriesTextDao.java index 2e9883243a..984a0b5e54 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/RegularTimeSeriesTextDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/RegularTimeSeriesTextDao.java @@ -237,4 +237,26 @@ public void delete(String officeId, String tsId, String textMask, }); } + public void updateRows(TextTimeSeries tts, String textMask, + @NotNull Instant startTime, @NotNull Instant endTime, Instant versionDate, boolean replaceExisting) { + connection(dsl, connection -> { + DSLContext dslContext = getDslContext(connection, tts.getOfficeId()); + dslContext.transaction((Configuration trx) -> { + Configuration config = trx.dsl().configuration(); + CWMS_TEXT_PACKAGE.call_DELETE_TS_TEXT(config, tts.getName(), textMask, + Timestamp.from(startTime), + Timestamp.from(endTime), + versionDate == null ? null : Timestamp.from(versionDate), + "UTC", "T", null, + null, tts.getOfficeId()); + Collection regRows = tts.getRegularTextValues(); + if(regRows != null) { + for (RegularTextTimeSeriesRow regRow : regRows) { + storeRow(config, tts.getOfficeId(), tts.getName(), replaceExisting, regRow, tts.getVersionDate()); + } + } + }); + }); + } + } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/TimeSeriesTextDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/TimeSeriesTextDao.java index 717bd0e3dd..2961895053 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/TimeSeriesTextDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/texttimeseries/TimeSeriesTextDao.java @@ -75,6 +75,12 @@ public void delete(String officeId, String textTimeSeriesId, String textMask, start, end, versionDate); } + + public void update(TextTimeSeries tts, String textMask, @NotNull Instant start, @NotNull Instant end, @Nullable Instant version, boolean replaceExisting) { + RegularTimeSeriesTextDao dao = getRegularDao(); + dao.updateRows(tts, textMask, start, end, version, replaceExisting); + } + @NotNull private RegularTimeSeriesTextDao getRegularDao(){ return new RegularTimeSeriesTextDao(dsl); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/RegularTextTimeSeriesRow.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/RegularTextTimeSeriesRow.java index 3a0e24e767..d92cbe500a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/RegularTextTimeSeriesRow.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/RegularTextTimeSeriesRow.java @@ -1,19 +1,28 @@ package cwms.cda.data.dto.texttimeseries; 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 com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.annotations.Identifier; import java.time.Instant; import java.util.Objects; @JsonDeserialize(builder = RegularTextTimeSeriesRow.Builder.class) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) -public class RegularTextTimeSeriesRow implements TextTimeSeriesRow { +public class RegularTextTimeSeriesRow extends CwmsDTOBase implements TextTimeSeriesRow { + // Together, date-time and data-entry-date are this row's identity (see Identifier): two rows + // can share the same date-time and are only distinguished by their data-entry-date, so a + // PATCH MERGE match requires both to agree -- not date-time alone. + @JsonProperty(required = true) + @Identifier private final Instant dateTime; + @Identifier private final Instant dataEntryDate; private final String textValue; private final String filename; diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/TextTimeSeries.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/TextTimeSeries.java index bedcf79061..18f1dc56e4 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/TextTimeSeries.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/texttimeseries/TextTimeSeries.java @@ -7,8 +7,8 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import cwms.cda.api.enums.VersionType; -import cwms.cda.api.errors.FieldException; import cwms.cda.data.dto.CwmsDTO; +import cwms.cda.data.dto.CwmsDTOValidator; import cwms.cda.data.dto.binarytimeseries.DateDateComparator; import cwms.cda.formatters.Formats; import cwms.cda.formatters.annotations.FormattableWith; @@ -29,7 +29,7 @@ @JsonDeserialize(builder = TextTimeSeries.Builder.class) @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) -@FormattableWith(contentType = Formats.JSONV2, formatter = JsonV2.class, aliases = {Formats.DEFAULT, Formats.JSON}) +@FormattableWith(contentType = Formats.JSON, formatter = JsonV2.class, aliases = {Formats.DEFAULT, Formats.JSONV2}) public class TextTimeSeries extends CwmsDTO { @@ -98,6 +98,12 @@ public Collection getRegularTextValues() { } } + @Override + protected void validateInternal(CwmsDTOValidator validator) { + super.validateInternal(validator); + validator.validateCollection(getRegularTextValues()); + } + @JsonPOJOBuilder @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/Formats.java b/cwms-data-api/src/main/java/cwms/cda/formatters/Formats.java index e01fdf9d63..102050bf71 100644 --- a/cwms-data-api/src/main/java/cwms/cda/formatters/Formats.java +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/Formats.java @@ -24,12 +24,26 @@ package cwms.cda.formatters; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.flogger.FluentLogger; +import cwms.cda.api.enums.CollectionPatchStrategy; import cwms.cda.data.dto.CwmsDTOBase; import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.annotations.Identifier; +import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -196,6 +210,236 @@ private List parseContentListFromType(ContentType typ } } + public static T parsePatchContent(ContentType contentType, T existing, String body, Class rootType) { + return parsePatchContent(contentType, existing, body, rootType, CollectionPatchStrategy.MERGE); + } + + public static T parsePatchContent(ContentType contentType, T existing, String body, + Class rootType, CollectionPatchStrategy strategy) { + return formats.applyJsonMergePatch(contentType, existing, rootType, strategy, om -> om.readTree(body)); + } + + public static T parsePatchContent(ContentType contentType, T existing, InputStream body, + Class rootType) { + return parsePatchContent(contentType, existing, body, rootType, CollectionPatchStrategy.MERGE); + } + + public static T parsePatchContent(ContentType contentType, T existing, InputStream body, + Class rootType, CollectionPatchStrategy strategy) { + return formats.applyJsonMergePatch(contentType, existing, rootType, strategy, om -> om.readTree(body)); + } + + private T applyJsonMergePatch(ContentType contentType, T existing, Class rootType, + CollectionPatchStrategy strategy, ThrowingFunction patchTreeReader) { + try { + OutputFormatter formatter = getOutputFormatter(contentType, rootType); + if (!(formatter instanceof ObjectMapperFormatter)) { + throw new FormattingException("Unable to apply PATCH content to existing " + + rootType.getSimpleName() + " because the formatter is not an ObjectMapperFormatter"); + } + ObjectMapper om = ((ObjectMapperFormatter) formatter).getObjectMapper(); + + JsonNode patchTree = patchTreeReader.apply(om); + JsonNode existingTree = om.valueToTree(existing); + prepareCollectionsForMerge(om.constructType(rootType), existingTree, patchTree, strategy, om); + + JsonNode merged = om.readerForUpdating(existingTree).readValue(patchTree.traverse(om)); + T result = om.treeToValue(merged, rootType); + result.validate(); + return result; + } catch (IOException e) { + throw new FormattingException("Unable to apply PATCH content to existing " + rootType.getSimpleName(), e); + } + } + + @FunctionalInterface + private interface ThrowingFunction { + R apply(T t) throws IOException; + } + + private static void prepareCollectionsForMerge(JavaType nodeType, JsonNode existingNode, JsonNode patchNode, + CollectionPatchStrategy strategy, ObjectMapper om) { + if (!(existingNode instanceof ObjectNode) || !patchNode.isObject()) { + return; + } + ObjectNode existingObject = (ObjectNode) existingNode; + ObjectNode patchObject = (ObjectNode) patchNode; + Map propertyTypes = propertyTypesByName(nodeType, om); + + // Snapshot the key names: the MERGE branch below removes entries from + // patchObject as it goes, which would otherwise disturb an in-progress field iterator. + List patchFieldNames = new ArrayList<>(); + patchNode.fieldNames().forEachRemaining(patchFieldNames::add); + + for (String key : patchFieldNames) { + JsonNode existingValue = existingObject.get(key); + JsonNode patchValue = patchObject.get(key); + if (existingValue == null || patchValue == null || patchValue.isEmpty()) { + continue; + } + JavaType propertyType = propertyTypes.get(key); + if (existingValue.isArray() && patchValue.isArray()) { + switch (strategy) { + case OVERWRITE: + existingObject.remove(key); + break; + case MERGE: + JavaType elementType = propertyType == null ? null : propertyType.getContentType(); + ArrayNode merged = mergeArrayByIdentity((ArrayNode) existingValue, (ArrayNode) patchValue, + elementType, om); + existingObject.set(key, merged); + patchObject.remove(key); + break; + default: + throw new FormattingException("Unsupported CollectionPatchStrategy: " + strategy); + } + } else if (existingValue.isObject() && patchValue.isObject()) { + prepareCollectionsForMerge(propertyType, existingValue, patchValue, strategy, om); + } + } + } + + private static ArrayNode mergeArrayByIdentity(ArrayNode existingArray, ArrayNode patchArray, + JavaType elementType, ObjectMapper om) { + Map identityFields = elementType == null ? Collections.emptyMap() + : findIdentityFieldTypes(elementType, om); + if (identityFields.isEmpty()) { + throw new FormattingException("Cannot apply MERGE to a collection of " + + (elementType == null ? "an unknown type" : elementType.getRawClass().getSimpleName()) + + " because it has no @" + Identifier.class.getSimpleName() + + " or @JsonProperty(required = true) field to match elements by"); + } + + ArrayNode result = om.createArrayNode(); + List remainingExisting = new ArrayList<>(); + existingArray.forEach(remainingExisting::add); + + for (JsonNode patchItem : patchArray) { + JsonNode matched = null; + Iterator remaining = remainingExisting.iterator(); + while (remaining.hasNext()) { + JsonNode candidate = remaining.next(); + if (matchesIdentity(candidate, patchItem, identityFields, om)) { + matched = candidate; + remaining.remove(); + break; + } + } + if (matched != null) { + result.add(mergeItemFields(matched, patchItem)); + } else { + result.add(patchItem); + } + } + for (JsonNode untouched : remainingExisting) { + result.add(untouched); + } + return result; + } + + private static JsonNode mergeItemFields(JsonNode existingItem, JsonNode patchItem) { + if (!existingItem.isObject() || !patchItem.isObject()) { + return patchItem; + } + ObjectNode result = existingItem.deepCopy(); + Iterator> fields = patchItem.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String fieldName = entry.getKey(); + JsonNode patchFieldValue = entry.getValue(); + JsonNode existingFieldValue = result.get(fieldName); + if (existingFieldValue != null && existingFieldValue.isObject() && patchFieldValue.isObject()) { + result.set(fieldName, mergeItemFields(existingFieldValue, patchFieldValue)); + } else { + result.set(fieldName, patchFieldValue); + } + } + return result; + } + + private static boolean matchesIdentity(JsonNode existingItem, JsonNode patchItem, + Map identityFields, ObjectMapper om) { + for (Map.Entry field : identityFields.entrySet()) { + JsonNode existingValue = existingItem.get(field.getKey()); + JsonNode patchValue = patchItem.get(field.getKey()); + if (existingValue == null || existingValue.isNull() || patchValue == null || patchValue.isNull()) { + return false; + } + Object existingConverted; + Object patchConverted; + try { + existingConverted = om.convertValue(existingValue, field.getValue()); + patchConverted = om.convertValue(patchValue, field.getValue()); + } catch (IllegalArgumentException e) { + // Couldn't convert one side to the declared type -- fall back to a direct + // JsonNode comparison rather than treating this as an unconditional non-match. + if (!existingValue.equals(patchValue)) { + return false; + } + continue; + } + if (!Objects.equals(existingConverted, patchConverted)) { + return false; + } + } + return true; + } + + + private static Map findIdentityFieldTypes(JavaType elementType, ObjectMapper om) { + // Fields explicitly marked @Identifier take priority as a composite identity (every + // marked field must match, together) -- see Identifier's own javadoc for why an + // element type might need more than one field to be uniquely identified (e.g. a + // text-timeseries row, where date-time alone isn't enough). Only when a type has none of + // its own do we fall back to whichever field(s) are @JsonProperty(required = true), which + // is how MERGE identified elements before @Identifier existed. + Map explicitIdentity = new LinkedHashMap<>(); + Map requiredFieldFallback = new LinkedHashMap<>(); + try { + BeanDescription beanDescription = om.getSerializationConfig().introspect(elementType); + for (BeanPropertyDefinition prop : beanDescription.findProperties()) { + AnnotatedMember member = prop.getField(); + if (member == null) { + member = prop.getPrimaryMember(); + } + if (member == null) { + continue; + } + if (member.hasAnnotation(Identifier.class)) { + explicitIdentity.put(prop.getName(), member.getType()); + } + JsonProperty jsonProperty = member.getAnnotation(JsonProperty.class); + if (jsonProperty != null && jsonProperty.required()) { + requiredFieldFallback.put(prop.getName(), member.getType()); + } + } + } catch (Exception e) { + logger.atFine().withCause(e).log("Unable to introspect %s for PATCH identity fields", elementType); + } + return explicitIdentity.isEmpty() ? requiredFieldFallback : explicitIdentity; + } + + + private static Map propertyTypesByName(JavaType nodeType, ObjectMapper om) { + Map types = new HashMap<>(); + if (nodeType == null || nodeType.isCollectionLikeType() || nodeType.isArrayType() + || nodeType.isMapLikeType() || nodeType.isPrimitive()) { + return types; + } + try { + BeanDescription beanDescription = om.getSerializationConfig().introspect(nodeType); + for (BeanPropertyDefinition prop : beanDescription.findProperties()) { + AnnotatedMember member = prop.getPrimaryMember(); + if (member != null) { + types.put(prop.getName(), member.getType()); + } + } + } catch (Exception e) { + logger.atFine().withCause(e).log("Unable to introspect %s for PATCH collection merge", nodeType); + } + return types; + } + private OutputFormatter getOutputFormatterInternal(ContentType type, Class klass) { OutputFormatter outputFormatter = null; diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/ObjectMapperFormatter.java b/cwms-data-api/src/main/java/cwms/cda/formatters/ObjectMapperFormatter.java new file mode 100644 index 0000000000..2accd440b5 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/ObjectMapperFormatter.java @@ -0,0 +1,7 @@ +package cwms.cda.formatters; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public interface ObjectMapperFormatter extends OutputFormatter { + ObjectMapper getObjectMapper(); +} diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java b/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java new file mode 100644 index 0000000000..3a1b83f671 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java @@ -0,0 +1,11 @@ +package cwms.cda.formatters.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Identifier { +} diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV1.java b/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV1.java index 22e966ca5a..86f1d1285c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV1.java +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV1.java @@ -14,8 +14,8 @@ import cwms.cda.data.dto.measurement.Measurement; import cwms.cda.formatters.Formats; import cwms.cda.formatters.FormattingException; +import cwms.cda.formatters.ObjectMapperFormatter; import cwms.cda.formatters.OfficeFormatV1; -import cwms.cda.formatters.OutputFormatter; import cwms.cda.formatters.annotations.FormattableWith; import cwms.cda.formatters.json.adapters.FlexibleInstantDeserializer; import cwms.cda.formatters.json.adapters.ZoneIdDeserializer; @@ -32,7 +32,7 @@ /** * A Formatter for the calls that returned JSON generated by CWMS itself inside of Oracle. */ -public class JsonV1 implements OutputFormatter { +public class JsonV1 implements ObjectMapperFormatter { // ObjectMapper are thread-safe and can be shared across instances private static final ObjectMapper OBJECT_MAPPER = buildObjectMapper(); private final ObjectMapper om; @@ -200,4 +200,9 @@ private boolean isFormattableWith(Class klass) { } return false; } + + @Override + public ObjectMapper getObjectMapper() { + return om; + } } diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV2.java b/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV2.java index ba8ea986e4..887d8fa6f9 100644 --- a/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV2.java +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/json/JsonV2.java @@ -34,10 +34,9 @@ import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import cwms.cda.data.dto.CwmsDTOBase; -import cwms.cda.data.dto.measurement.Measurement; import cwms.cda.formatters.Formats; import cwms.cda.formatters.FormattingException; -import cwms.cda.formatters.OutputFormatter; +import cwms.cda.formatters.ObjectMapperFormatter; import cwms.cda.formatters.json.adapters.FlexibleInstantDeserializer; import cwms.cda.formatters.json.adapters.ZoneIdDeserializer; import java.io.IOException; @@ -50,7 +49,7 @@ /** * Formatter for CDA generated JSON. */ -public class JsonV2 implements OutputFormatter { +public class JsonV2 implements ObjectMapperFormatter { private static final ObjectMapper OBJECT_MAPPER = buildObjectMapper(); private final ObjectMapper om; @@ -138,4 +137,9 @@ public List parseContentList(String content, Class throw new FormattingException(String.format(DESERIALIZE_CONTENT_MESSAGE, content, type), e); } } + + @Override + public ObjectMapper getObjectMapper() { + return om; + } } 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 a5269a6f02..f568fa4094 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,7 +32,6 @@ 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; @@ -115,13 +114,17 @@ private Executable testIgnoredMethod(CompilationUnit unit, OpenApiDocInfo testIn // `ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented());` MethodDeclaration method = getMethodDeclaration(unit, testInfo.getMethod()); - Optional statusCall = method.findAll(MethodCallExpr.class) - .stream() + // An ignored method's own body is often just `super.getOne(ctx, templateId);`, delegating + // to the abstract base class's real `ctx.status(...).json(...)` implementation -- so the + // calls have to be collected the same way testMethod's parseParamInfo does (following + // super-delegation), not just read off this method's own body. + List methodCalls = collectMethodCallExprs(method, clazz, new HashSet<>()); + + Optional statusCall = methodCalls.stream() .filter(exp -> exp.getNameAsString().equals("status")) .findFirst(); - Optional jsonCall = method.findAll(MethodCallExpr.class) - .stream() + Optional jsonCall = methodCalls.stream() .filter(exp -> exp.getNameAsString().equals("json")) .findFirst(); @@ -283,7 +286,7 @@ private OpenApiParamUsage parseParamInfo(CompilationUnit unit, Class clazz, M MethodDeclaration methodDeclaration = getMethodDeclaration(unit, method); String context = methodDeclaration.getParameter(0).getNameAsString(); - List methodCalls = collectMethodCallExprs(methodDeclaration, new HashSet<>()); + List methodCalls = collectMethodCallExprs(methodDeclaration, clazz, 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"); @@ -344,43 +347,62 @@ private OpenApiParamUsage parseParamInfo(CompilationUnit unit, Class clazz, M return new OpenApiParamUsage(pathParams, queryParams, resourceId); } - private List collectMethodCallExprs(MethodDeclaration methodDeclaration, Set visited) { + private List collectMethodCallExprs(MethodDeclaration methodDeclaration, Class clazz, Set visited) { + return collectMethodCallExprs(methodDeclaration, clazz, clazz, visited); + } + + private List collectMethodCallExprs(MethodDeclaration methodDeclaration, Class currentClass, + Class concreteClazz, 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) { + if (isSuperDelegation) { + MethodDeclaration delegate = resolveSuperDelegate(call, currentClass, visited); + if (delegate != null) { + calls.addAll(collectMethodCallExprs(delegate, currentClass.getSuperclass(), concreteClazz, visited)); + } continue; } - MethodDeclaration delegate = resolveSuperDelegate(call, visited); - if (delegate != null) { - calls.addAll(collectMethodCallExprs(delegate, visited)); + // Not a super(...) delegation to this same method, but it could still be an + // unqualified (or this.-qualified) call to a template-method hook -- e.g. a + // controller's own getOffice(ctx), which a shared/base-class method calls to + // extract OFFICE, but which each concrete controller implements differently (path + // param vs. query param). Only those calls are worth resolving further; anything + // with an explicit scope (ctx.pathParam(...), Controllers.requiredParam(...), etc.) + // is already collected directly above and doesn't need to be followed. + boolean isUnqualifiedOrThisCall = !call.getScope().isPresent() + || call.getScope().filter(Expression::isThisExpr).isPresent(); + if (isUnqualifiedOrThisCall) { + MethodDeclaration override = resolveAbstractOverride(call, concreteClazz, visited); + if (override != null) { + calls.addAll(collectMethodCallExprs(override, concreteClazz, concreteClazz, visited)); + } } } return calls; } - private MethodDeclaration resolveSuperDelegate(MethodCallExpr superCall, Set visited) { + private MethodDeclaration resolveSuperDelegate(MethodCallExpr superCall, Class currentClass, Set visited) { try { - ResolvedMethodDeclaration resolved = superCall.resolve(); - String declaringClassName = resolved.declaringType().getQualifiedName(); - String visitKey = declaringClassName + "#" + resolved.getName() + "/" + resolved.getNumberOfParams(); + + String methodName = superCall.getNameAsString(); + int paramCount = superCall.getArguments().size(); + Class superclass = currentClass.getSuperclass(); + if (superclass == null) { + return null; + } + + String visitKey = superclass.getName() + "#" + methodName + "/" + paramCount; 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); + return findMethodInClass(superclass, methodName, paramCount); } catch (Exception ex) { LOGGER.atWarning().withCause(ex).log( "Unable to resolve super delegation call '%s' while checking parameter usage; " @@ -390,6 +412,55 @@ private MethodDeclaration resolveSuperDelegate(MethodCallExpr superCall, Set clazz, Set visited) { + try { + + String methodName = call.getNameAsString(); + int paramCount = call.getArguments().size(); + + if (!declaresAbstractSomewhereInHierarchy(clazz, methodName, paramCount)) { + return null; + } + + String visitKey = clazz.getName() + "#" + methodName + "/" + paramCount; + if (!visited.add(visitKey)) { + return null; + } + return findMethodInClass(clazz, methodName, paramCount); + } catch (Throwable t) { + LOGGER.atFine().withCause(t).log( + "Unable to resolve call '%s' to an overriding implementation while checking " + + "parameter usage; parameters only read by an overriding method will " + + "not be detected.", + call); + return null; + } + } + + private boolean declaresAbstractSomewhereInHierarchy(Class clazz, String methodName, int paramCount) { + for (Class current = clazz; current != null; current = current.getSuperclass()) { + for (java.lang.reflect.Method m : current.getDeclaredMethods()) { + if (m.getName().equals(methodName) && m.getParameterCount() == paramCount + && java.lang.reflect.Modifier.isAbstract(m.getModifiers())) { + return true; + } + } + } + return false; + } + + private MethodDeclaration findMethodInClass(Class declaringClass, String methodName, int paramCount) + throws IOException { + CompilationUnit unit = OpenApiTestHelper.readCompilationUnit(declaringClass); + return unit.findAll(MethodDeclaration.class) + .stream() + .filter(m -> m.getNameAsString().equals(methodName)) + .filter(m -> m.getParameters().size() == paramCount) + .findFirst() + .orElse(null); + } + private OpenApiParamUsageInfo findTsParamsFromUsage(MethodCallExpr call) { boolean isRightFunc = call.getScope() .filter(Expression::isFieldAccessExpr) diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerTestIT.java index ccce8306a2..31da11e160 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerTestIT.java @@ -88,7 +88,7 @@ public class TextTimeSeriesControllerTestIT extends DataApiTestIT { // store_reg_text_timeseries.sql private static final String locationId = "TsTextTestLoc"; - private static final String tsId = locationId + ".Flow.Inst.1Hour.0.raw"; + protected static final String tsId = locationId + ".Flow.Inst.1Hour.0.raw"; public static final String AUTHORIZATION = "Authorization"; private static String LARGE_STRING; @@ -149,7 +149,7 @@ void test_create_regular(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -173,7 +173,7 @@ void test_create_regular(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .post("/timeseries/text") + .post(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -190,7 +190,7 @@ void test_create_regular(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -202,6 +202,10 @@ void test_create_regular(String format) throws Exception { .statusCode(is(HttpServletResponse.SC_OK)); } + protected @NotNull String getPath() { + return "/timeseries/text"; + } + @ParameterizedTest @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) void test_create_local_regular_new_LRTS_identifier(String format) throws Exception { @@ -230,7 +234,7 @@ void test_create_local_regular_new_LRTS_identifier(String format) throws Excepti .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat(); @@ -257,7 +261,7 @@ void test_create_local_regular_new_LRTS_identifier(String format) throws Excepti .when() .redirects().follow(true) .redirects().max(3) - .post("/timeseries/text") + .post(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -275,7 +279,7 @@ void test_create_local_regular_new_LRTS_identifier(String format) throws Excepti .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -316,7 +320,7 @@ void test_create_local_regular_new_LRTS_identifier(String format) throws Excepti .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL, true) .assertThat() @@ -337,7 +341,7 @@ void test_create_local_regular_new_LRTS_identifier(String format) throws Excepti .when() .redirects().follow(true) .redirects().max(3) - .post("/timeseries/text") + .post(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat(); @@ -363,7 +367,7 @@ void test_retrieve_regular() { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -375,84 +379,6 @@ void test_retrieve_regular() { } - @ParameterizedTest - @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) - void test_update_regular(String format) throws Exception { - // The basic structure of the test is to: - // 1)retrieve and verify - // 2)update - // 3)retrieve and verify - String startStr = "2005-01-01T03:00:00Z"; - String endStr = "2005-01-01T07:00:00Z"; - - given() - .log().ifValidationFails(LogDetail.ALL,true) - .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) - .queryParam(Controllers.NAME, tsId) - .queryParam(Controllers.BEGIN,startStr) - .queryParam(Controllers.END,endStr) - .when() - .redirects().follow(true) - .redirects().max(3) - .get("/timeseries/text") - .then() - .log().ifValidationFails(LogDetail.ALL,true) - .assertThat() - .body("standard-text-catalog", nullValue()) - .body("standard-text-values", nullValue()) - .body("regular-text-values", notNullValue()) - .body("regular-text-values.size()", equalTo(5)) - .statusCode(is(HttpServletResponse.SC_OK)); - - - //2) update - InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/text_ts_update_reg.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) - .queryParam(TextTimeSeriesController.REPLACE_ALL, "true") - .contentType(Formats.JSONV2) - .body(tsData) - .header(AUTHORIZATION, user.toHeaderValue()) - .when() - .redirects().follow(true) - .redirects().max(3) - .patch("/timeseries/text/" + tsId) - .then() - .log().ifValidationFails(LogDetail.ALL,true) - .assertThat() - .statusCode(is(HttpServletResponse.SC_OK)); - - //3)retrieve and verify - given() - .log().ifValidationFails(LogDetail.ALL,true) - .accept(format) - .queryParam(Controllers.OFFICE, OFFICE) - .queryParam(Controllers.NAME, tsId) - .queryParam(Controllers.BEGIN,startStr) - .queryParam(Controllers.END,endStr) - .when() - .redirects().follow(true) - .redirects().max(3) - .get("/timeseries/text") - .then() - .log().ifValidationFails(LogDetail.ALL,true) - .assertThat() - .body("standard-text-catalog", nullValue()) - .body("standard-text-values", nullValue()) - .body("regular-text-values", notNullValue()) - .body("regular-text-values.size()", equalTo(5)) - .body("regular-text-values[0].text-value", equalTo("still great")) - .statusCode(is(HttpServletResponse.SC_OK)); - - } - - @ParameterizedTest @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) void test_delete_regular(String format) { @@ -478,7 +404,7 @@ void test_delete_regular(String format) { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL, true) .assertThat() @@ -522,7 +448,7 @@ void test_delete_regular(String format) { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL, true) .assertThat() @@ -556,7 +482,7 @@ void test_large_data_url(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -575,7 +501,7 @@ void test_large_data_url(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .post("/timeseries/text") + .post(getPath()) .then() .log().ifValidationFails(LogDetail.ALL,true) .assertThat() @@ -592,7 +518,7 @@ void test_large_data_url(String format) throws Exception { .when() .redirects().follow(true) .redirects().max(3) - .get("/timeseries/text") + .get(getPath()) .then() .log().ifValidationFails(LogDetail.ALL, true) .assertThat() @@ -606,7 +532,7 @@ void test_large_data_url(String format) throws Exception { .path("regular-text-values[0].value-url"); // Use the URL returned in the JSON to download the large String URIBuilder builder = new URIBuilder(valueUrl); - assertTrue(builder.getPath().contains("timeseries/text/" + tsId + "/value")); + assertTrue(builder.getPath().contains(getPath() + "/" + tsId + "/value")); assertTrue(builder.getQueryParams().stream() .anyMatch(v -> v.getName().equals(Controllers.OFFICE) && v.getValue().equals(OFFICE))); assertTrue(builder.getQueryParams().stream() diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV1TestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV1TestIT.java new file mode 100644 index 0000000000..e8dfc55f4e --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV1TestIT.java @@ -0,0 +1,126 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import cwms.cda.api.texttimeseries.TextTimeSeriesController; +import cwms.cda.formatters.Formats; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Tag("integration") +final class TextTimeSeriesControllerV1TestIT extends TextTimeSeriesControllerTestIT { + + @ParameterizedTest + @ValueSource(strings = {Formats.JSONV2, Formats.DEFAULT}) + void test_update_regular(String format) throws Exception { + // The basic structure of the test is to: + // 1)retrieve and verify + // 2)update + // 3)retrieve and verify + String startStr = "2005-01-01T03:00:00Z"; + String endStr = "2005-01-01T07:00:00Z"; + + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(format) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN,startStr) + .queryParam(Controllers.END,endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/timeseries/text") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .body("standard-text-catalog", nullValue()) + .body("standard-text-values", nullValue()) + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(5)) + .statusCode(is(HttpServletResponse.SC_OK)); + + + //2) update + InputStream resource = this.getClass().getResourceAsStream("/cwms/cda/api/spk/text_ts_update_reg.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) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(TextTimeSeriesController.REPLACE_ALL, "true") + .contentType(Formats.JSONV2) + .body(tsData) + .header(AUTHORIZATION, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/timeseries/text/" + tsId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + //3)retrieve and verify + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(format) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN,startStr) + .queryParam(Controllers.END,endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/timeseries/text") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .body("standard-text-catalog", nullValue()) + .body("standard-text-values", nullValue()) + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(5)) + .body("regular-text-values[0].text-value", equalTo("still great")) + .statusCode(is(HttpServletResponse.SC_OK)); + + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV2TestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV2TestIT.java new file mode 100644 index 0000000000..444c6280d6 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/TextTimeSeriesControllerV2TestIT.java @@ -0,0 +1,250 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import cwms.cda.formatters.Formats; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; +import io.restassured.response.Response; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("integration") +final class TextTimeSeriesControllerV2TestIT extends TextTimeSeriesControllerTestIT { + + @Override + protected @NotNull String getPath() { + return "v2/timeseries/text/" + OFFICE; + } + + @Test + void test_update_regular_partial_patch_overwrite() throws Exception { + // the request body names + // only the essential identifying element of the row being changed (date-time) and the + // field actually being changed (text-value) -- no office-id, no name, no other row + // field. + // + // Structure of the test is: + // 1) retrieve and verify baseline state -- 5 rows, all sharing the same text-value + // (see store_reg_text_timeseries.sql) + // 2) PATCH one row's text-value via the v2 endpoint with a minimal, partial body and + // collection-merge-strategy=overwrite (the default; passed explicitly here for + // clarity) + // 3) retrieve and verify: per ADR-0017, OVERWRITE means the collection + // becomes exactly what the body named, within the begin/end window -- so the 4 rows + // that were in the window but weren't named in the body are removed, leaving only the + // one row the body actually patched. (Contrast with + // test_update_regular_partial_patch_merge below, which patches the same row + // without disturbing the other 4.) + String startStr = "2005-01-01T03:00:00Z"; + String endStr = "2005-01-01T07:00:00Z"; + + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(getPath()) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(5)) + .body("regular-text-values.text-value", everyItem(equalTo(EXPECTED_TEXT_VALUE))) + .statusCode(is(HttpServletResponse.SC_OK)); + + // 2) partial PATCH on v2 -- body has only the row's identifier (date-time) and the new + // text-value; no office-id/name, no other row field. office-id/name aren't needed here: + // unlike the body, the resource itself is identified by the office/name path segments, + // so nothing required is missing -- date-time is the only identifier Jackson actually + // requires (see RegularTextTimeSeriesRow). + InputStream resource = this.getClass() + .getResourceAsStream("/cwms/cda/api/spk/text_ts_update_reg_partial.json"); + assertNotNull(resource); + String partialBody = IOUtils.toString(resource, StandardCharsets.UTF_8); + assertNotNull(partialBody); + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .queryParam(Controllers.COLLECTION_MERGE_STRATEGY, "overwrite") + .contentType(Formats.JSON) + .body(partialBody) + .header(AUTHORIZATION, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch(getPath() + "/" + tsId) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + // 3) retrieve and verify: only the one named row remains -- the other 4, though within + // the begin/end window, were removed because OVERWRITE means the window's collection + // now consists of exactly what the body named. + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(getPath()) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(1)) + .body("regular-text-values[0].text-value", equalTo("partially patched")) + .statusCode(is(HttpServletResponse.SC_OK)); + } + + @Test + void test_update_regular_partial_patch_merge() throws Exception { + // Unlike the other two strategies' tests, this one can't reuse the static + // text_ts_update_reg_partial.json fixture as-is: MERGE's identity for a text-timeseries + // row is the pair (date-time, data-entry-date), not date-time alone (see Identifier + // and ADR-0017's "MERGE's identity" row), and data-entry-date is assigned by the database + // when the row is stored -- it can't be hard-coded into a fixture ahead of time. So this + // test reads the target row's actual data-entry-date back from the initial GET and builds + // the PATCH body around it, instead of loading the fixture file. + // + // collection-merge-strategy=merge instead of overwrite. Per ADR-0017, MERGE matches the + // named row by that composite identity and updates just that row in place, leaving every + // other existing row -- in or out of the window -- untouched. So unlike OVERWRITE, the + // other 4 rows in the window survive. + String startStr = "2005-01-01T03:00:00Z"; + String endStr = "2005-01-01T07:00:00Z"; + + Response initial = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(getPath()) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(5)) + .body("regular-text-values.text-value", everyItem(equalTo(EXPECTED_TEXT_VALUE))) + .statusCode(is(HttpServletResponse.SC_OK)) + .extract().response(); + + // Find the row at startStr and capture its server-assigned data-entry-date so the PATCH + // body below can name this specific row's full identity, not just its date-time. + long targetDateTimeMillis = Instant.parse(startStr).toEpochMilli(); + List> initialRows = initial.jsonPath().getList("regular-text-values"); + Object targetDataEntryDate = initialRows.stream() + .filter(row -> targetDateTimeMillis == ((Number) row.get("date-time")).longValue()) + .map(row -> row.get("data-entry-date")) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Could not find a row at " + startStr + " in the initial GET response")); + + // 2) partial PATCH on v2 with collection-merge-strategy=merge. Names the target row's + // full identity -- date-time and data-entry-date -- and its new text-value; no + // office-id/name, no other row field. + String partialBody = "{\"regular-text-values\":[{\"date-time\":\"" + targetDateTimeMillis + + "\",\"data-entry-date\":\"" + targetDataEntryDate + + "\",\"text-value\":\"partially patched\"}]}"; + + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .queryParam(Controllers.COLLECTION_MERGE_STRATEGY, "merge") + .contentType(Formats.JSON) + .body(partialBody) + .header(AUTHORIZATION, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch(getPath() + "/" + tsId) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + // 3) retrieve and verify: only the targeted row changed, the other 4 rows kept the + // text-value they already had, and the row count is unaffected. + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.JSON) + .queryParam(Controllers.OFFICE, OFFICE) + .queryParam(Controllers.NAME, tsId) + .queryParam(Controllers.BEGIN, startStr) + .queryParam(Controllers.END, endStr) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(getPath()) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .body("regular-text-values", notNullValue()) + .body("regular-text-values.size()", equalTo(5)) + .body("regular-text-values.text-value", containsInAnyOrder( + "partially patched", EXPECTED_TEXT_VALUE, EXPECTED_TEXT_VALUE, + EXPECTED_TEXT_VALUE, EXPECTED_TEXT_VALUE)) + .statusCode(is(HttpServletResponse.SC_OK)); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/formatters/FormatsTest.java b/cwms-data-api/src/test/java/cwms/cda/formatters/FormatsTest.java index d91a66a791..548469f70b 100644 --- a/cwms-data-api/src/test/java/cwms/cda/formatters/FormatsTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/formatters/FormatsTest.java @@ -3,6 +3,9 @@ import static org.junit.jupiter.api.Assertions.*; +import cwms.cda.api.enums.CollectionPatchStrategy; +import cwms.cda.api.enums.VersionType; +import cwms.cda.api.errors.RequiredFieldException; import cwms.cda.data.dto.Blob; import cwms.cda.data.dto.Blobs; import cwms.cda.data.dto.Catalog; @@ -17,10 +20,17 @@ import cwms.cda.data.dto.basinconnectivity.Basin; import cwms.cda.data.dto.project.LockRevokerRights; import cwms.cda.data.dto.project.Project; +import cwms.cda.data.dto.texttimeseries.RegularTextTimeSeriesRow; +import cwms.cda.data.dto.texttimeseries.TextTimeSeries; import cwms.cda.formatters.json.JsonV2; import cwms.cda.formatters.xml.XMLv2Office; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -30,6 +40,230 @@ class FormatsTest { public static final String FIREFOX_HEADER = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; + @Test + void testParsePatchContentPreservesFieldsOmittedFromBody() { + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withDateVersionType(VersionType.UNVERSIONED) + .build(); + + // Body only contains rows -- no office-id, name, time-zone, or date-version-type. + String patchBody = "{\"regular-text-values\":[{\"date-time\":\"2024-01-01T00:00:00Z\"," + + "\"text-value\":\"updated\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class); + + assertEquals("SPK", patched.getOfficeId(), "office-id omitted from body should be preserved"); + assertEquals("TsTestLoc.Flow.Inst.1Hour.0.raw", patched.getName(), "name omitted from body should be preserved"); + assertEquals("UTC", patched.getTimeZone(), "time-zone omitted from body should be preserved"); + assertEquals(VersionType.UNVERSIONED, patched.getDateVersionType()); + assertNotNull(patched.getRegularTextValues()); + assertEquals(1, patched.getRegularTextValues().size()); + assertEquals("updated", patched.getRegularTextValues().iterator().next().getTextValue()); + } + + @Test + void testParsePatchContentArrayReplacedWithOverwrite() { + List existingRows = new ArrayList<>(); + existingRows.add(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T00:00:00Z")) + .withTextValue("original one") + .build()); + existingRows.add(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T01:00:00Z")) + .withTextValue("original two") + .build()); + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withDateVersionType(VersionType.UNVERSIONED) + .withRegularTextValues(existingRows) + .build(); + + // Body only contains rows -- no office-id, name, time-zone, or date-version-type. + String patchBody = "{\"regular-text-values\":[{\"date-time\":\"2024-01-01T00:00:00Z\"," + + "\"text-value\":\"updated\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class, CollectionPatchStrategy.OVERWRITE); + + assertEquals("SPK", patched.getOfficeId(), "office-id omitted from body should be preserved"); + assertEquals("TsTestLoc.Flow.Inst.1Hour.0.raw", patched.getName(), "name omitted from body should be preserved"); + assertEquals("UTC", patched.getTimeZone(), "time-zone omitted from body should be preserved"); + assertEquals(VersionType.UNVERSIONED, patched.getDateVersionType()); + assertNotNull(patched.getRegularTextValues()); + assertEquals(1, patched.getRegularTextValues().size()); + assertEquals("updated", patched.getRegularTextValues().iterator().next().getTextValue()); + } + + @Test + void testParsePatchContentExplicitNullClearsField() { + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withVersionDate(Instant.parse("2024-01-01T00:00:00Z")) + .build(); + + // Explicitly clears version-date while leaving time-zone untouched. + String patchBody = "{\"version-date\":null}"; + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class); + + assertNull(patched.getVersionDate(), "explicit null in the body should clear the field"); + assertEquals("UTC", patched.getTimeZone(), "fields omitted from the body should remain unchanged"); + } + + @Test + void testParsePatchContentFullPayloadBehavesLikeANormalParse() { + TextTimeSeries existing = new TextTimeSeries.Builder().build(); + + String patchBody = "{\"office-id\":\"SPK\",\"name\":\"TsTestLoc.Flow.Inst.1Hour.0.raw\"," + + "\"time-zone\":\"UTC\",\"regular-text-values\":[" + + "{\"date-time\":\"2024-01-01T00:00:00Z\",\"text-value\":\"v\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class); + + assertEquals("SPK", patched.getOfficeId()); + assertEquals("TsTestLoc.Flow.Inst.1Hour.0.raw", patched.getName()); + assertEquals("UTC", patched.getTimeZone()); + assertEquals(1, Objects.requireNonNull(patched.getRegularTextValues()).size()); + } + + @Test + void testParsePatchContentMissingRequiredFieldFailsValidation() { + // No office-id anywhere -- not on existing, and not in the body. + TextTimeSeries existing = new TextTimeSeries.Builder().withName("SomeName").build(); + + String patchBody = "{\"time-zone\":\"UTC\"}"; + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + + assertThrows(RequiredFieldException.class, + () -> Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class)); + } + + @Test + void testParsePatchContentMergeUpdatesMatchedItemPreservingOthers() { + // MERGE matches a patch item to an existing one by the field(s) marked @Identifier on + // the element type -- both date-time and data-entry-date together, for + // RegularTextTimeSeriesRow, since two rows can share a date-time and are only + // distinguished by data-entry-date -- and updates just that item in place, unlike + // OVERWRITE (see testParsePatchContentReplacesArrayInsteadOfAppending above), which would + // drop the row the body doesn't mention. + Instant firstDataEntryDate = Instant.parse("2023-12-31T00:00:00Z"); + Instant secondDataEntryDate = Instant.parse("2023-12-31T01:00:00Z"); + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withRegRow(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T00:00:00Z")) + .withDataEntryDate(firstDataEntryDate) + .withTextValue("original one") + .build()) + .withRegRow(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T01:00:00Z")) + .withDataEntryDate(secondDataEntryDate) + .withTextValue("original two") + .build()) + .build(); + + // Names the first row's full identity -- date-time and data-entry-date -- and its new + // text-value. + String patchBody = "{\"regular-text-values\":[{\"date-time\":\"2024-01-01T00:00:00Z\"," + + "\"data-entry-date\":\"" + firstDataEntryDate + "\",\"text-value\":\"patched\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class, + CollectionPatchStrategy.MERGE); + + assertNotNull(patched.getRegularTextValues()); + assertEquals(2, patched.getRegularTextValues().size(), + "MERGE should update the matched row in place, not drop the row the body doesn't mention"); + Map byDate = patched.getRegularTextValues().stream() + .collect(Collectors.toMap(RegularTextTimeSeriesRow::getDateTime, RegularTextTimeSeriesRow::getTextValue)); + assertEquals("patched", byDate.get(Instant.parse("2024-01-01T00:00:00Z"))); + assertEquals("original two", byDate.get(Instant.parse("2024-01-01T01:00:00Z")), + "the row not named in the body should be untouched"); + } + + @Test + void testParsePatchContentMergeWithoutAllIdentifiersMatching() { + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withRegRow(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T00:00:00Z")) + .withDataEntryDate(Instant.parse("2023-12-31T00:00:00Z")) + .withTextValue("original one") + .build()) + .build(); + + // Same date-time as the existing row, but no data-entry-date named. + String patchBody = "{\"regular-text-values\":[{\"date-time\":\"2024-01-01T00:00:00Z\"," + + "\"text-value\":\"second value at the same time\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class, + CollectionPatchStrategy.MERGE); + + assertEquals(2, Objects.requireNonNull(patched.getRegularTextValues()).size(), + "a null or absent identity field on the incoming item should never match, even " + + "against an existing item at the same date-time"); + } + + @Test + void testParsePatchContentMergeAddsUnmatchedIdentifierAsNew() { + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withRegRow(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T00:00:00Z")) + .withTextValue("original one") + .build()) + .build(); + + // Names a date-time that doesn't match anything existing. + String patchBody = "{\"regular-text-values\":[{\"date-time\":\"2024-01-01T02:00:00Z\"," + + "\"text-value\":\"brand new\"}]}"; + + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, patchBody, TextTimeSeries.class, + CollectionPatchStrategy.MERGE); + + assertEquals(2, Objects.requireNonNull(patched.getRegularTextValues()).size(), + "an identifier that doesn't match anything existing should be added, not replace anything"); + } + + @Test + void testParsePatchContentEmptyArrayDoesNotWipeExistingCollection() { + TextTimeSeries existing = new TextTimeSeries.Builder() + .withOfficeId("SPK") + .withName("TsTestLoc.Flow.Inst.1Hour.0.raw") + .withTimeZone("UTC") + .withRegRow(new RegularTextTimeSeriesRow.Builder() + .withDateTime(Instant.parse("2024-01-01T00:00:00Z")) + .withTextValue("original one") + .build()) + .build(); + ContentType contentType = Formats.parseHeader("application/json;version=2", TextTimeSeries.class); + + String emptyBody = "{\"regular-text-values\":[]}"; + TextTimeSeries patched = Formats.parsePatchContent(contentType, existing, emptyBody, TextTimeSeries.class, + CollectionPatchStrategy.OVERWRITE); + + assertEquals(1, Objects.requireNonNull(patched.getRegularTextValues()).size(), + "an explicitly empty regular-text-values must not wipe out the existing row"); + assertEquals("original one", patched.getRegularTextValues().iterator().next().getTextValue()); + } + @Test void testParseHeaderAndQueryParmJson() { ContentType contentType = Formats.parseHeaderAndQueryParm("application/json", null, LocationLevels.class); diff --git a/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java b/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java index 51cf1f3f94..8a6b8932ca 100644 --- a/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java +++ b/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java @@ -2,7 +2,7 @@ import cwms.cda.api.Controllers; import cwms.cda.api.OfficeController; -import cwms.cda.api.TextTimeSeriesValueController; +import cwms.cda.api.texttimeseries.TextTimeSeriesValueController; import cwms.cda.api.auth.users.UsersController; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.rating.RatingController; diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/text_ts_update_reg_partial.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/text_ts_update_reg_partial.json new file mode 100644 index 0000000000..ae96547033 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/text_ts_update_reg_partial.json @@ -0,0 +1,8 @@ +{ + "regular-text-values": [ + { + "date-time": "1104548400000", + "text-value": "partially patched" + } + ] +} diff --git a/docs/source/decisions/0011-patch.rst b/docs/source/decisions/0011-patch.rst deleted file mode 100644 index 0405347e5b..0000000000 --- a/docs/source/decisions/0011-patch.rst +++ /dev/null @@ -1,280 +0,0 @@ -##### -PATCH Handling Across CDA Endpoints -##### - - -Summary -======= - -This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson's ``ObjectMapper.readerForUpdating()``. - - -Opinions -======== - -Opinion 1 ---------- - -@brysonspilman - -Summary -~~~~~~~ - -PATCH requests should represent partial updates only. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to ``null``, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson's update capabilities. - -Key points -~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 20 25 55 - - * - Topic - - Decision - - Justification - * - Resource identification - - Resource identifiers SHALL be provided as path parameters - - Follows REST conventions and avoids duplication of resource identity between the URI and request body. - * - Request body - - Include only the fields to be modified - - PATCH semantics represent partial updates rather than full resource replacement. - * - Omitted fields - - Omitted properties SHALL remain unchanged - - Clients should only send the fields they intend to modify. - * - Explicit null values - - Properties explicitly provided with a value of ``null`` SHALL clear the corresponding field when permitted by the resource - - Distinguishes "remove this value" from "leave this value unchanged." - * - DTO reuse - - Existing DTOs SHALL be reused for PATCH operations - - Avoids introducing PATCH-specific DTOs or wrapper types across the API. - * - Update implementation - - Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ``ObjectMapper.readerForUpdating()`` - - Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. - * - Validation - - Validate the resulting object after the update has been applied - - Validation should be performed against the final resource state. - * - PUT semantics - - PUT remains the mechanism for complete resource replacement - - Preserves the standard semantic distinction between PUT and PATCH. - * - Implementation tradeoff - - PATCH operations require retrieval of the existing resource prior to applying updates - - The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit ``null`` values. - * - Backwards compatibility - - Existing DTOs and serialization formats remain unchanged - - Minimizes implementation effort and avoids widespread API changes. - -Example -~~~~~~~ - -Example endpoint: - -.. code-block:: text - - PATCH /entity/{entity-id} - -Request: - -.. code-block:: text - - PATCH /entity/MyEntity - -Request body: - -.. code-block:: json - - { - "long-name": "Updated Entity Long Name", - "parent-entity-id": "NewParent" - } - -Implementation flow: - -1. Retrieve the existing ``Entity`` identified by ``entity-id``. -2. Populate the existing DTO. -3. Apply the incoming JSON using ``ObjectMapper.readerForUpdating(existingDto)``. -4. Validate the resulting object. -5. Persist the updated resource. - -For example, given the existing resource: - -.. code-block:: json - - { - "id": { - "office-id": "SWT", - "name": "MyEntity" - }, - "parent-entity-id": "ParentA", - "category-id": "Reservoir", - "long-name": "Original Long Name" - } - -and the PATCH request: - -.. code-block:: json - - { - "category-id": "Dam", - "parent-entity-id": null - } - -the resulting object after applying the PATCH becomes: - -.. code-block:: json - - { - "id": { - "office-id": "SWT", - "name": "MyEntity" - }, - "parent-entity-id": null, - "category-id": "Dam", - "long-name": "Original Long Name" - } - -Only the properties present in the request body are modified. The ``id`` remains unchanged because it is derived from the request path, and ``long-name`` remains unchanged because it was omitted from the PATCH payload. - -Existing endpoints that support PATCH -===================================== - -.. list-table:: - :header-rows: 1 - :widths: 20 25 20 35 - - * - Endpoint Path - - Controller - - Support Level - - Notes - * - /entity/{entity-id} - - EntityController - - Full-patch - - Reuses existing DTO and updates fields. - * - /locations/{location-id} - - LocationController - - Full-patch - - Supports partial updates and renaming if the name in the body differs. - * - /timeseries/{timeseries} - - TimeSeriesController - - Full-patch - - Used to store/update time series data. - * - /levels/{level-id} - - LevelsController - - Full-patch - - Supports partial updates and renaming. - * - /clobs/{clob-id} - - ClobController - - Full-patch - - Supports updating clob value/description; allows ignore-nulls. - * - /location/{location-id}/vertical-datum - - VerticalDatumController - - Full-patch - - Updates vertical datum information for a location. - * - /ratings/{rating-id} - - RatingController - - Full-patch - - Updates/stores RatingSet data. - * - /timeseries/text/{name} - - TextTimeSeriesController - - Full-patch - - Updates text time series values. - * - /timeseries/binary/{name} - - BinaryTimeSeriesController - - Full-patch - - Updates binary time series values. - * - /forecast-instance/{name} - - ForecastInstanceController - - Full-patch - - Updates notes, max age, and files for a forecast instance. - * - /forecast-spec/{name} - - ForecastSpecController - - Full-patch - - Updates forecast specification values. - * - /properties/{name} - - PropertyController - - Full-patch - - Updates property values. - * - /stream-locations/{name} - - StreamLocationController - - Full-patch - - Updates stream location attributes. - * - /timeseries/category/{category-id} - - TimeSeriesCategoryController - - Full-patch - - Supports renaming and updating descriptions. - * - /lookup-types/{name} - - LookupTypeController - - Full-patch - - Updates lookup type display values and tooltips. - * - /basins/{name} - - BasinController - - Rename-only - - Primarily used for renaming the basin via the name query parameter. - * - /projects/{name} - - ProjectController - - Rename-only - - Renames a project using the name query parameter. - * - /projects/embankments/{name} - - EmbankmentController - - Rename-only - - Renames an embankment. - * - /projects/turbines/{name} - - TurbineController - - Rename-only - - Renames a turbine. - * - /projects/locks/{name} - - LockController - - Rename-only - - Renames a lock. - * - /projects/outlets/{name} - - OutletController - - Rename-only - - Renames an outlet. - * - /streams/{name} - - StreamController - - Rename-only - - Renames a stream. - * - /stream-reaches/{name} - - StreamReachController - - Rename-only - - Renames a stream reach. - * - /specified-levels/{specified-level-id} - - SpecifiedLevelController - - Rename-only - - Renames a specified level ID. - * - /timeseries/group/{group-id} - - TimeSeriesGroupController - - Rename/ Specific fields - - Supports renaming and assigning/unassigning time series. - * - /location/group/{group-id} - - LocationGroupController - - Rename / Specific fields - - Supports renaming and assigning/unassigning locations. - * - /timeseries/identifier-descriptor/{name} - - TimeSeriesIdentifierDescriptorController - - Rename / Specific fields - - Supports renaming and updating snap tolerances. - * - /projects/{office}/{project-id}/water-user/{water-user} - - WaterUserUpdateController - - Rename-only - - Renames a water user. - * - /projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} - - WaterContractUpdateController - - Rename-only - - Renames a water contract. - -Decision Status -=============== - -(Status: accepted) - - -References -========== - -Related Pattern: HTTP PATCH - -Jackson ``ObjectMapper.readerForUpdating()`` - -RFC 5789 - PATCH Method for HTTP - -RFC 7396 - JSON Merge Patch \ No newline at end of file diff --git a/docs/source/decisions/0017-patch.rst b/docs/source/decisions/0017-patch.rst new file mode 100644 index 0000000000..da9a5788c6 --- /dev/null +++ b/docs/source/decisions/0017-patch.rst @@ -0,0 +1,425 @@ +##### +PATCH Handling Across CDA Endpoints +##### + + +Summary +======= + +This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson's ``ObjectMapper.readerForUpdating()``. + + +Opinions +======== + +Opinion 1 +--------- + +@brysonspilman + +Summary +~~~~~~~ + +PATCH requests should represent both partial and total updates. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to ``null``, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson's update capabilities. + +That mechanism covers scalar fields well: Jackson's tree merge (``ObjectMapper.readerForUpdating()``) +naturally leaves an omitted scalar field unchanged and overwrites one that's present, because it +can compare the existing value and the incoming value directly. A collection-typed field (the +rows of a time series, for example) doesn't have that luxury on its own -- Jackson's merge has no +built-in way to match an element of the existing array against an element of the incoming array +by identity, so its only two well-defined behaviors for an array field are to replace it wholesale +or to append the incoming elements onto the existing ones. The second of those isn't safe to +expose as its own strategy: a plain append can leave two items sharing the same identity sitting +side by side in the collection, which is exactly what these DTOs' identity fields (see MERGE +below) exist to rule out. Rather than an endpoint silently committing to either native behavior, +PATCH endpoints whose body may include a collection field SHALL expose a +``collection-merge-strategy`` query parameter so the caller picks between wholesale replacement +and an identity-aware merge -- see OVERWRITE and MERGE below. + +For endpoints scoped by a time window (``begin``/``end``, as the time-series endpoints are), that +window bounds the entire operation: it's what determines which existing rows are retrieved prior +to the merge, and consequently the only rows that can ever be read or affected by the PATCH at +all. Data outside the window is never touched, regardless of merge strategy. + +Key points +~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 25 55 + + * - Topic + - Decision + - Justification + * - Resource identification + - Resource identifiers SHALL be provided as path parameters + - Follows REST conventions and avoids duplication of resource identity between the URI and request body. + * - Request body + - Include only the fields to be modified + - PATCH semantics represent partial updates rather than full resource replacement. + * - Omitted fields + - Omitted properties SHALL remain unchanged + - Clients should only send the fields they intend to modify. + * - Explicit null values + - Properties explicitly provided with a value of ``null`` SHALL clear the corresponding field when permitted by the resource + - Distinguishes "remove this value" from "leave this value unchanged." + * - DTO reuse + - Existing DTOs SHALL be reused for PATCH operations + - Avoids introducing PATCH-specific DTOs or wrapper types across the API. + * - Update implementation + - Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ``ObjectMapper.readerForUpdating()`` + - Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. + * - Validation + - Validate the resulting object after the update has been applied + - Validation should be performed against the final resource state. + * - PUT semantics + - PUT remains the mechanism for complete resource replacement + - Preserves the standard semantic distinction between PUT and PATCH. + * - Implementation tradeoff + - PATCH operations require retrieval of the existing resource prior to applying updates + - The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit ``null`` values. + * - Backwards compatibility + - Existing DTOs and serialization formats remain unchanged + - Minimizes implementation effort and avoids widespread API changes. + * - Collection merge control + - PATCH endpoints with a collection field SHALL accept a ``collection-merge-strategy`` query + parameter with values ``OVERWRITE`` (default) and ``MERGE`` + - Names the operation precisely rather than reusing a bare "replace" that could be misread as + describing HTTP semantics rather than this specific per-row behavior. + * - OVERWRITE + - The collection becomes exactly the set of items named in the request body. Any existing + item that falls within the request's time window but isn't named in the body is removed. + - Matches "replace" as most callers mean it for a bounded window: the window's collection now + looks exactly like what was sent, not like what was sent plus leftovers. + * - MERGE + - Items named in the request body are matched against existing items by that collection's + identity field(s), then updated in place -- preserving that one item's own fields the + body omits, the same way a top-level PATCH preserves an omitted scalar field. An unmatched + identity is added as new. Every other existing item, in or out of the request's time + window, is left exactly as it was. + - The precise, surgical option: change just the named item(s) without disturbing anything + else in the collection, including items in-window that OVERWRITE would otherwise remove. + * - MERGE's identity + - An item's identity is whichever field(s) of its class are annotated ``@Identifier``, + taken together as one composite key -- e.g. both ``date-time`` and ``data-entry-date`` on a + text-timeseries row, since two rows can share the same ``date-time`` and are only + distinguished by ``data-entry-date``. When an element type has no ``@Identifier`` field + at all, identity falls back to whichever field(s) are marked + ``@JsonProperty(required = true)`` instead -- e.g. just ``date-time``, for a type with no + need of a composite key. ``Formats.parsePatchContent`` finds either via ordinary Jackson + bean introspection on the collection's declared element type, generically for any + ``CwmsDTOBase``. A null or absent identity field on the incoming item never matches + anything, even an existing item whose own value for that field is also null. + - A single field isn't always enough to say two items are "the same" one -- text-timeseries + rows are the concrete case: ``date-time`` alone can't tell two rows at the same time apart, + but the pair can. ``@Identifier`` lets a DTO opt into a composite key for exactly that + case while every other DTO keeps the simpler, existing ``@JsonProperty(required = true)`` + behavior unchanged. Treating a null identity field as "never matches" rather than "ignore + this field" matters specifically for a field like ``data-entry-date`` that a client + wouldn't normally supply at all (the database assigns it) -- omitting it means "add this as + a new row," not "match whatever's at this date-time." A collection whose element type has + neither kind of field can't use MERGE (the request fails outright rather than guessing at a + key). + * - Time window scope + - The ``begin``/``end`` window bounds what is retrieved as "existing" prior to the merge and + what gets deleted-and-restored by the storage step (see Storage implementation below), + under every strategy. Data outside the window is never read or written, regardless of + strategy. + - The window is what the existing resource retrieval is already scoped to; keeping the + storage step's reach to that same scope (and no further) keeps the window's meaning + consistent between GET and PATCH. + * - Superseding replace-all (query parameter) + - The old ``replace-all`` boolean query parameter is removed from the text-timeseries PATCH + endpoint (it remains as-is for POST). ``collection-merge-strategy`` now fully determines, + per row, whether a value at an already-populated date-time is overwritten or left in place + alongside the new one. + - The old ``replace-all`` and the new ``collection-merge-strategy`` were overlapping, + similarly-named knobs answering the same underlying question at different layers + (store-call collision handling vs. body-level merge behavior); keeping both invited the two + being set inconsistently with each other. + * - Storage implementation + - Storage is uniform across both strategies: the controller deletes everything in the + ``begin``/``end`` window and stores exactly the merged collection's rows with + ``replaceAll=true``, both within a single transaction (``TimeSeriesTextDao.update``, backed + by one checked-out connection) -- not a per-strategy delete/diff path, and not two + independent DAO calls. + - The merge already computes the correct final row set per strategy -- OVERWRITE's named + items only, MERGE's matched-and-updated items plus every untouched existing item carried + through, plus any unmatched incoming item added as new (possibly sharing a date-time with + an existing item it didn't match, since its identity -- date-time and data-entry-date + together -- differs) -- so uniformly clearing and restoring the window reaches the right + end state regardless of strategy. The store call can't do this alone: it only ever touches + the date-times it's given, never removing one it isn't, which is why the delete is still + required. Running both in one transaction also means a failure partway through can't leave + the window deleted but not repopulated, the way two independent calls could. Because the + window is fully cleared first, a new row that happens to share a date-time with something + already there is stored into an empty slot rather than colliding with anything, so + ``replaceAll=true`` is safe for every strategy -- there's nothing left in the window for it + to overwrite by the time any row is stored. + * - Absent or empty collection + - A PATCH body that omits the collection field entirely, or names it with an empty array, + still goes through the same delete-and-restore -- but the merge carries the existing rows + through unchanged, so the window's observable content afterward is identical to what it was + before. + - The merge already resolves this without a separate check: an absent or empty collection + field never changes what the merged DTO says the window should contain, so the same + uniform storage step reaches the correct (unchanged) result. The tradeoff is that every row + in the window is still deleted and re-stored even when nothing about the collection was + named in the body, rather than being left alone entirely. + +Example +~~~~~~~ + +Example endpoint: + +.. code-block:: text + + PATCH /entity/{entity-id} + +Request: + +.. code-block:: text + + PATCH /entity/MyEntity + +Request body: + +.. code-block:: json + + { + "long-name": "Updated Entity Long Name", + "parent-entity-id": "NewParent" + } + +Implementation flow: + +1. Retrieve the existing ``Entity`` identified by ``entity-id``. +2. Populate the existing DTO. +3. Apply the incoming JSON using ``ObjectMapper.readerForUpdating(existingDto)``. +4. Validate the resulting object. +5. Persist the updated resource. + +For example, given the existing resource: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": "ParentA", + "category-id": "Reservoir", + "long-name": "Original Long Name" + } + +and the PATCH request: + +.. code-block:: json + + { + "category-id": "Dam", + "parent-entity-id": null + } + +the resulting object after applying the PATCH becomes: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": null, + "category-id": "Dam", + "long-name": "Original Long Name" + } + +Only the properties present in the request body are modified. The ``id`` remains unchanged because it is derived from the request path, and ``long-name`` remains unchanged because it was omitted from the PATCH payload. + +Given a text time series with five existing values at hourly date-times ``01:00``-\ ``05:00``, +each with its own ``data-entry-date`` assigned by the database (say the ``03:00`` row's is +``...T00:00:05Z``), and the PATCH request: + +.. code-block:: text + + PATCH /timeseries/text/SPK/MyTs?begin=...T01:00:00Z&end=...T05:00:00Z&collection-merge-strategy=merge + +.. code-block:: json + + { + "regular-text-values": [ + {"date-time": "...T03:00:00Z", "data-entry-date": "...T00:00:05Z", "text-value": "updated"} + ] + } + +matches the existing ``03:00`` row by its composite (``date-time``, ``data-entry-date``) identity +-- both fields marked ``@Identifier`` on the row's class -- and updates just that row's +text-value, leaving the other four rows untouched. Omitting ``data-entry-date`` from the body +instead of supplying the row's actual value: + +.. code-block:: json + + { + "regular-text-values": [ + {"date-time": "...T03:00:00Z", "text-value": "updated"} + ] + } + +does not match the existing ``03:00`` row at all -- a null or absent identity field never +matches, even against an existing row whose own value happens to be null -- so MERGE adds this as +a sixth, distinct row alongside the original ``03:00`` value rather than updating it. The +identical first request with ``collection-merge-strategy=overwrite`` instead removes the other +four rows entirely, since OVERWRITE means the window's collection becomes exactly what the body +named. + +The same request with ``regular-text-values`` omitted from the body entirely, or sent as +``"regular-text-values": []``, changes nothing regardless of ``collection-merge-strategy`` -- all +five original values remain exactly as they were. + +Existing endpoints that support PATCH +===================================== + +.. list-table:: + :header-rows: 1 + :widths: 20 25 20 35 + + * - Endpoint Path + - Controller + - Support Level + - Notes + * - /entity/{entity-id} + - EntityController + - Full-patch + - Reuses existing DTO and updates fields. + * - /locations/{location-id} + - LocationController + - Full-patch + - Supports partial updates and renaming if the name in the body differs. + * - /timeseries/{timeseries} + - TimeSeriesController + - Full-patch + - Used to store/update time series data. + * - /levels/{level-id} + - LevelsController + - Full-patch + - Supports partial updates and renaming. + * - /clobs/{clob-id} + - ClobController + - Full-patch + - Supports updating clob value/description; allows ignore-nulls. + * - /location/{location-id}/vertical-datum + - VerticalDatumController + - Full-patch + - Updates vertical datum information for a location. + * - /ratings/{rating-id} + - RatingController + - Full-patch + - Updates/stores RatingSet data. + * - /timeseries/text/{name} + - TextTimeSeriesController + - Full-patch + - Updates text time series values; supports ``collection-merge-strategy`` for + ``regular-text-values``. + * - /timeseries/binary/{name} + - BinaryTimeSeriesController + - Full-patch + - Updates binary time series values. + * - /forecast-instance/{name} + - ForecastInstanceController + - Full-patch + - Updates notes, max age, and files for a forecast instance. + * - /forecast-spec/{name} + - ForecastSpecController + - Full-patch + - Updates forecast specification values. + * - /properties/{name} + - PropertyController + - Full-patch + - Updates property values. + * - /stream-locations/{name} + - StreamLocationController + - Full-patch + - Updates stream location attributes. + * - /timeseries/category/{category-id} + - TimeSeriesCategoryController + - Full-patch + - Supports renaming and updating descriptions. + * - /lookup-types/{name} + - LookupTypeController + - Full-patch + - Updates lookup type display values and tooltips. + * - /basins/{name} + - BasinController + - Rename-only + - Primarily used for renaming the basin via the name query parameter. + * - /projects/{name} + - ProjectController + - Rename-only + - Renames a project using the name query parameter. + * - /projects/embankments/{name} + - EmbankmentController + - Rename-only + - Renames an embankment. + * - /projects/turbines/{name} + - TurbineController + - Rename-only + - Renames a turbine. + * - /projects/locks/{name} + - LockController + - Rename-only + - Renames a lock. + * - /projects/outlets/{name} + - OutletController + - Rename-only + - Renames an outlet. + * - /streams/{name} + - StreamController + - Rename-only + - Renames a stream. + * - /stream-reaches/{name} + - StreamReachController + - Rename-only + - Renames a stream reach. + * - /specified-levels/{specified-level-id} + - SpecifiedLevelController + - Rename-only + - Renames a specified level ID. + * - /timeseries/group/{group-id} + - TimeSeriesGroupController + - Rename/ Specific fields + - Supports renaming and assigning/unassigning time series. + * - /location/group/{group-id} + - LocationGroupController + - Rename / Specific fields + - Supports renaming and assigning/unassigning locations. + * - /timeseries/identifier-descriptor/{name} + - TimeSeriesIdentifierDescriptorController + - Rename / Specific fields + - Supports renaming and updating snap tolerances. + * - /projects/{office}/{project-id}/water-user/{water-user} + - WaterUserUpdateController + - Rename-only + - Renames a water user. + * - /projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} + - WaterContractUpdateController + - Rename-only + - Renames a water contract. + +Decision Status +=============== + +(Status: accepted) + + +References +========== + +Related Pattern: HTTP PATCH + +Jackson ``ObjectMapper.readerForUpdating()`` + +RFC 5789 - PATCH Method for HTTP + +RFC 7396 - JSON Merge Patch \ No newline at end of file diff --git a/docs/source/decisions/index.rst b/docs/source/decisions/index.rst index 48d992482d..2be2ce8653 100644 --- a/docs/source/decisions/index.rst +++ b/docs/source/decisions/index.rst @@ -33,3 +33,4 @@ Some decisions may also be a proposal and marked appropriately. Data Event Message Formats - Forecasts <./0014-queue-messages-forecast.rst> Data Event Message Formats - Ratings <./0015-queue-messages-rating.rst> Data Event Message Formats - Levels <./0016-queue-messages-levels.rst> + PATCH Handling Across CDA Endpoints <./0017-patch.rst> From f7d639477e6de0bda486da2a223051b0da2ef23d Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Fri, 18 Sep 2026 10:05:52 -0700 Subject: [PATCH 2/2] CDA-130 - Updated @Identifier javadoc, and added reverted name of patch adr --- .../formatters/annotations/Identifier.java | 5 + docs/source/decisions/0011-patch.rst | 280 +++++++++++++++++ docs/source/decisions/0017-patch.rst | 281 +++++++++++++++++- 3 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 docs/source/decisions/0011-patch.rst diff --git a/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java b/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java index 3a1b83f671..fd99bb5f1d 100644 --- a/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java +++ b/cwms-data-api/src/main/java/cwms/cda/formatters/annotations/Identifier.java @@ -5,6 +5,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks a field as an identifier for the object. This is used to determine which fields are used to uniquely identify an object + * when performing operations such as PATCH MERGE on collections. Fields marked with this annotation will be used to match + * existing objects in the collection to the incoming data. + **/ @Target({ElementType.FIELD, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Identifier { diff --git a/docs/source/decisions/0011-patch.rst b/docs/source/decisions/0011-patch.rst new file mode 100644 index 0000000000..8b31dc7374 --- /dev/null +++ b/docs/source/decisions/0011-patch.rst @@ -0,0 +1,280 @@ +##### +PATCH Handling Across CDA Endpoints +##### + + +Summary +======= + +This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson's ``ObjectMapper.readerForUpdating()``. + + +Opinions +======== + +Opinion 1 +--------- + +@brysonspilman + +Summary +~~~~~~~ + +PATCH requests should represent partial updates only. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to ``null``, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson's update capabilities. + +Key points +~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 25 55 + + * - Topic + - Decision + - Justification + * - Resource identification + - Resource identifiers SHALL be provided as path parameters + - Follows REST conventions and avoids duplication of resource identity between the URI and request body. + * - Request body + - Include only the fields to be modified + - PATCH semantics represent partial updates rather than full resource replacement. + * - Omitted fields + - Omitted properties SHALL remain unchanged + - Clients should only send the fields they intend to modify. + * - Explicit null values + - Properties explicitly provided with a value of ``null`` SHALL clear the corresponding field when permitted by the resource + - Distinguishes "remove this value" from "leave this value unchanged." + * - DTO reuse + - Existing DTOs SHALL be reused for PATCH operations + - Avoids introducing PATCH-specific DTOs or wrapper types across the API. + * - Update implementation + - Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ``ObjectMapper.readerForUpdating()`` + - Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. + * - Validation + - Validate the resulting object after the update has been applied + - Validation should be performed against the final resource state. + * - PUT semantics + - PUT remains the mechanism for complete resource replacement + - Preserves the standard semantic distinction between PUT and PATCH. + * - Implementation tradeoff + - PATCH operations require retrieval of the existing resource prior to applying updates + - The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit ``null`` values. + * - Backwards compatibility + - Existing DTOs and serialization formats remain unchanged + - Minimizes implementation effort and avoids widespread API changes. + +Example +~~~~~~~ + +Example endpoint: + +.. code-block:: text + + PATCH /entity/{entity-id} + +Request: + +.. code-block:: text + + PATCH /entity/MyEntity + +Request body: + +.. code-block:: json + + { + "long-name": "Updated Entity Long Name", + "parent-entity-id": "NewParent" + } + +Implementation flow: + +1. Retrieve the existing ``Entity`` identified by ``entity-id``. +2. Populate the existing DTO. +3. Apply the incoming JSON using ``ObjectMapper.readerForUpdating(existingDto)``. +4. Validate the resulting object. +5. Persist the updated resource. + +For example, given the existing resource: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": "ParentA", + "category-id": "Reservoir", + "long-name": "Original Long Name" + } + +and the PATCH request: + +.. code-block:: json + + { + "category-id": "Dam", + "parent-entity-id": null + } + +the resulting object after applying the PATCH becomes: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": null, + "category-id": "Dam", + "long-name": "Original Long Name" + } + +Only the properties present in the request body are modified. The ``id`` remains unchanged because it is derived from the request path, and ``long-name`` remains unchanged because it was omitted from the PATCH payload. + +Existing endpoints that support PATCH +===================================== + +.. list-table:: + :header-rows: 1 + :widths: 20 25 20 35 + + * - Endpoint Path + - Controller + - Support Level + - Notes + * - /entity/{entity-id} + - EntityController + - Full-patch + - Reuses existing DTO and updates fields. + * - /locations/{location-id} + - LocationController + - Full-patch + - Supports partial updates and renaming if the name in the body differs. + * - /timeseries/{timeseries} + - TimeSeriesController + - Full-patch + - Used to store/update time series data. + * - /levels/{level-id} + - LevelsController + - Full-patch + - Supports partial updates and renaming. + * - /clobs/{clob-id} + - ClobController + - Full-patch + - Supports updating clob value/description; allows ignore-nulls. + * - /location/{location-id}/vertical-datum + - VerticalDatumController + - Full-patch + - Updates vertical datum information for a location. + * - /ratings/{rating-id} + - RatingController + - Full-patch + - Updates/stores RatingSet data. + * - /timeseries/text/{name} + - TextTimeSeriesController + - Full-patch + - Updates text time series values. + * - /timeseries/binary/{name} + - BinaryTimeSeriesController + - Full-patch + - Updates binary time series values. + * - /forecast-instance/{name} + - ForecastInstanceController + - Full-patch + - Updates notes, max age, and files for a forecast instance. + * - /forecast-spec/{name} + - ForecastSpecController + - Full-patch + - Updates forecast specification values. + * - /properties/{name} + - PropertyController + - Full-patch + - Updates property values. + * - /stream-locations/{name} + - StreamLocationController + - Full-patch + - Updates stream location attributes. + * - /timeseries/category/{category-id} + - TimeSeriesCategoryController + - Full-patch + - Supports renaming and updating descriptions. + * - /lookup-types/{name} + - LookupTypeController + - Full-patch + - Updates lookup type display values and tooltips. + * - /basins/{name} + - BasinController + - Rename-only + - Primarily used for renaming the basin via the name query parameter. + * - /projects/{name} + - ProjectController + - Rename-only + - Renames a project using the name query parameter. + * - /projects/embankments/{name} + - EmbankmentController + - Rename-only + - Renames an embankment. + * - /projects/turbines/{name} + - TurbineController + - Rename-only + - Renames a turbine. + * - /projects/locks/{name} + - LockController + - Rename-only + - Renames a lock. + * - /projects/outlets/{name} + - OutletController + - Rename-only + - Renames an outlet. + * - /streams/{name} + - StreamController + - Rename-only + - Renames a stream. + * - /stream-reaches/{name} + - StreamReachController + - Rename-only + - Renames a stream reach. + * - /specified-levels/{specified-level-id} + - SpecifiedLevelController + - Rename-only + - Renames a specified level ID. + * - /timeseries/group/{group-id} + - TimeSeriesGroupController + - Rename/ Specific fields + - Supports renaming and assigning/unassigning time series. + * - /location/group/{group-id} + - LocationGroupController + - Rename / Specific fields + - Supports renaming and assigning/unassigning locations. + * - /timeseries/identifier-descriptor/{name} + - TimeSeriesIdentifierDescriptorController + - Rename / Specific fields + - Supports renaming and updating snap tolerances. + * - /projects/{office}/{project-id}/water-user/{water-user} + - WaterUserUpdateController + - Rename-only + - Renames a water user. + * - /projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} + - WaterContractUpdateController + - Rename-only + - Renames a water contract. + +Decision Status +=============== + +(Status: Superseded by 0017-patch.rst) + + +References +========== + +Related Pattern: HTTP PATCH + +Jackson ``ObjectMapper.readerForUpdating()`` + +RFC 5789 - PATCH Method for HTTP + +RFC 7396 - JSON Merge Patch \ No newline at end of file diff --git a/docs/source/decisions/0017-patch.rst b/docs/source/decisions/0017-patch.rst index da9a5788c6..e3648c8585 100644 --- a/docs/source/decisions/0017-patch.rst +++ b/docs/source/decisions/0017-patch.rst @@ -87,7 +87,286 @@ Key points - Names the operation precisely rather than reusing a bare "replace" that could be misread as describing HTTP semantics rather than this specific per-row behavior. * - OVERWRITE - - The collection becomes exactly the set of items named in the request body. Any existing + - The colle##### +PATCH Handling Across CDA Endpoints +##### + + +Summary +======= + +This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson's ``ObjectMapper.readerForUpdating()``. + + +Opinions +======== + +Opinion 1 +--------- + +@brysonspilman + +Summary +~~~~~~~ + +PATCH requests should represent partial updates only. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to ``null``, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson's update capabilities. + +Key points +~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 25 55 + + * - Topic + - Decision + - Justification + * - Resource identification + - Resource identifiers SHALL be provided as path parameters + - Follows REST conventions and avoids duplication of resource identity between the URI and request body. + * - Request body + - Include only the fields to be modified + - PATCH semantics represent partial updates rather than full resource replacement. + * - Omitted fields + - Omitted properties SHALL remain unchanged + - Clients should only send the fields they intend to modify. + * - Explicit null values + - Properties explicitly provided with a value of ``null`` SHALL clear the corresponding field when permitted by the resource + - Distinguishes "remove this value" from "leave this value unchanged." + * - DTO reuse + - Existing DTOs SHALL be reused for PATCH operations + - Avoids introducing PATCH-specific DTOs or wrapper types across the API. + * - Update implementation + - Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ``ObjectMapper.readerForUpdating()`` + - Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes. + * - Validation + - Validate the resulting object after the update has been applied + - Validation should be performed against the final resource state. + * - PUT semantics + - PUT remains the mechanism for complete resource replacement + - Preserves the standard semantic distinction between PUT and PATCH. + * - Implementation tradeoff + - PATCH operations require retrieval of the existing resource prior to applying updates + - The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit ``null`` values. + * - Backwards compatibility + - Existing DTOs and serialization formats remain unchanged + - Minimizes implementation effort and avoids widespread API changes. + +Example +~~~~~~~ + +Example endpoint: + +.. code-block:: text + + PATCH /entity/{entity-id} + +Request: + +.. code-block:: text + + PATCH /entity/MyEntity + +Request body: + +.. code-block:: json + + { + "long-name": "Updated Entity Long Name", + "parent-entity-id": "NewParent" + } + +Implementation flow: + +1. Retrieve the existing ``Entity`` identified by ``entity-id``. +2. Populate the existing DTO. +3. Apply the incoming JSON using ``ObjectMapper.readerForUpdating(existingDto)``. +4. Validate the resulting object. +5. Persist the updated resource. + +For example, given the existing resource: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": "ParentA", + "category-id": "Reservoir", + "long-name": "Original Long Name" + } + +and the PATCH request: + +.. code-block:: json + + { + "category-id": "Dam", + "parent-entity-id": null + } + +the resulting object after applying the PATCH becomes: + +.. code-block:: json + + { + "id": { + "office-id": "SWT", + "name": "MyEntity" + }, + "parent-entity-id": null, + "category-id": "Dam", + "long-name": "Original Long Name" + } + +Only the properties present in the request body are modified. The ``id`` remains unchanged because it is derived from the request path, and ``long-name`` remains unchanged because it was omitted from the PATCH payload. + +Existing endpoints that support PATCH +===================================== + +.. list-table:: + :header-rows: 1 + :widths: 20 25 20 35 + + * - Endpoint Path + - Controller + - Support Level + - Notes + * - /entity/{entity-id} + - EntityController + - Full-patch + - Reuses existing DTO and updates fields. + * - /locations/{location-id} + - LocationController + - Full-patch + - Supports partial updates and renaming if the name in the body differs. + * - /timeseries/{timeseries} + - TimeSeriesController + - Full-patch + - Used to store/update time series data. + * - /levels/{level-id} + - LevelsController + - Full-patch + - Supports partial updates and renaming. + * - /clobs/{clob-id} + - ClobController + - Full-patch + - Supports updating clob value/description; allows ignore-nulls. + * - /location/{location-id}/vertical-datum + - VerticalDatumController + - Full-patch + - Updates vertical datum information for a location. + * - /ratings/{rating-id} + - RatingController + - Full-patch + - Updates/stores RatingSet data. + * - /timeseries/text/{name} + - TextTimeSeriesController + - Full-patch + - Updates text time series values. + * - /timeseries/binary/{name} + - BinaryTimeSeriesController + - Full-patch + - Updates binary time series values. + * - /forecast-instance/{name} + - ForecastInstanceController + - Full-patch + - Updates notes, max age, and files for a forecast instance. + * - /forecast-spec/{name} + - ForecastSpecController + - Full-patch + - Updates forecast specification values. + * - /properties/{name} + - PropertyController + - Full-patch + - Updates property values. + * - /stream-locations/{name} + - StreamLocationController + - Full-patch + - Updates stream location attributes. + * - /timeseries/category/{category-id} + - TimeSeriesCategoryController + - Full-patch + - Supports renaming and updating descriptions. + * - /lookup-types/{name} + - LookupTypeController + - Full-patch + - Updates lookup type display values and tooltips. + * - /basins/{name} + - BasinController + - Rename-only + - Primarily used for renaming the basin via the name query parameter. + * - /projects/{name} + - ProjectController + - Rename-only + - Renames a project using the name query parameter. + * - /projects/embankments/{name} + - EmbankmentController + - Rename-only + - Renames an embankment. + * - /projects/turbines/{name} + - TurbineController + - Rename-only + - Renames a turbine. + * - /projects/locks/{name} + - LockController + - Rename-only + - Renames a lock. + * - /projects/outlets/{name} + - OutletController + - Rename-only + - Renames an outlet. + * - /streams/{name} + - StreamController + - Rename-only + - Renames a stream. + * - /stream-reaches/{name} + - StreamReachController + - Rename-only + - Renames a stream reach. + * - /specified-levels/{specified-level-id} + - SpecifiedLevelController + - Rename-only + - Renames a specified level ID. + * - /timeseries/group/{group-id} + - TimeSeriesGroupController + - Rename/ Specific fields + - Supports renaming and assigning/unassigning time series. + * - /location/group/{group-id} + - LocationGroupController + - Rename / Specific fields + - Supports renaming and assigning/unassigning locations. + * - /timeseries/identifier-descriptor/{name} + - TimeSeriesIdentifierDescriptorController + - Rename / Specific fields + - Supports renaming and updating snap tolerances. + * - /projects/{office}/{project-id}/water-user/{water-user} + - WaterUserUpdateController + - Rename-only + - Renames a water user. + * - /projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name} + - WaterContractUpdateController + - Rename-only + - Renames a water contract. + +Decision Status +=============== + +(Status: accepted) + + +References +========== + +Related Pattern: HTTP PATCH + +Jackson ``ObjectMapper.readerForUpdating()`` + +RFC 5789 - PATCH Method for HTTP + +RFC 7396 - JSON Merge Patchction becomes exactly the set of items named in the request body. Any existing item that falls within the request's time window but isn't named in the body is removed. - Matches "replace" as most callers mean it for a bounded window: the window's collection now looks exactly like what was sent, not like what was sent plus leftovers.