diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 45655d0d6..bba89191c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -133,6 +133,7 @@ import cwms.cda.api.rating.RatingTemplateController; import cwms.cda.api.rating.ReverseRateTimeSeriesController; import cwms.cda.api.rating.ReverseRateValuesController; +import cwms.cda.api.rss.RssHandler; import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCatalogController; import cwms.cda.api.timeseriesprofile.TimeSeriesProfileController; import cwms.cda.api.timeseriesprofile.TimeSeriesProfileCreateController; @@ -254,7 +255,8 @@ "/user/*", "/users/*", "/roles/*", - "/version/*" + "/version/*", + "/rss/*" }) public class ApiServlet extends HttpServlet { @@ -599,6 +601,7 @@ protected void configureRoutes() { addUserManagementHandlers(); get("/version/", new CdaVersionHandler(metrics), requiredRoles); + get(format("/rss/{%s}/{%s}", Controllers.OFFICE, Controllers.NAME), new RssHandler(metrics), requiredRoles); } private void addUserManagementHandlers() { 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 19334ae6a..3528a22e7 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 @@ -96,6 +96,7 @@ public final class Controllers { public static final String NAME = "name"; public static final String CASCADE_DELETE = "cascade-delete"; public static final String DATUM = "datum"; + public static final String SINCE = "since"; public static final String BEGIN = "begin"; public static final String END = "end"; public static final String TIMEZONE = "timezone"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/rss/RssHandler.java b/cwms-data-api/src/main/java/cwms/cda/api/rss/RssHandler.java new file mode 100644 index 000000000..43d5b8f65 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/rss/RssHandler.java @@ -0,0 +1,151 @@ +/* + * MIT License + * + * Copyright (c) 2025 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.rss; + +import static cwms.cda.api.Controllers.CURSOR; +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.PAGE; +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static cwms.cda.api.Controllers.SINCE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.STATUS_400; +import static cwms.cda.api.Controllers.STATUS_404; +import static cwms.cda.api.Controllers.queryParamAsClass; +import static cwms.cda.api.Controllers.queryParamAsInstant; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.BaseHandler; +import cwms.cda.api.errors.CdaError; +import cwms.cda.data.dao.rss.MessageDao; +import cwms.cda.data.dto.CwmsDTOPaginated; +import cwms.cda.data.dto.rss.RssFeed; +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.http.HttpCode; +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 java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.function.UnaryOperator; +import org.apache.http.client.utils.URIBuilder; +import org.jetbrains.annotations.NotNull; +import org.jooq.DSLContext; + +public final class RssHandler extends BaseHandler { + + private static final int DEFAULT_PAGE_SIZE = 500; + private static final String TAG = "RSS"; + + public RssHandler(MetricRegistry metrics) { + super(metrics); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = OFFICE, required = true, description = "Office id for feed."), + @OpenApiParam(name = NAME, required = true, description = "Specifies the name of the feed. " + + "eg TS_STORED, STATUS, REALTIME_OPS") + }, + queryParams = { + @OpenApiParam(name = SINCE, description = "The start the feed time window. " + + "The endpoint will not retrieve more than the last week of messages."), + @OpenApiParam(name = PAGE_SIZE, type = Integer.class, description = "The number of feed items to include."), + @OpenApiParam(name = PAGE, description = "This end point can return a lot of data, this " + + "identifies where in the request you are. This is an opaque" + + " value, and can be obtained from the 'next-page' value in " + + "the response.") + }, + responses = { + @OpenApiResponse(status = STATUS_200, content = { + @OpenApiContent(from = RssFeed.class, type = Formats.RSS) + }), + @OpenApiResponse(status = STATUS_404, description = "Unknown Feed") + }, + description = "Returns RSS feed items limited to the last week.", + tags = {TAG} + ) + @Override + public void handle(@NotNull Context ctx) throws Exception { + try (final Timer.Context ignored = markAndTime(GET_ALL)) { + DSLContext dsl = getDslContext(ctx); + String office = ctx.pathParam(OFFICE).toUpperCase(); + String name = ctx.pathParam(NAME); + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, RssFeed.class); + String cursor = URLDecoder.decode(queryParamAsClass(ctx, new String[]{PAGE, CURSOR}, String.class, ""), + StandardCharsets.UTF_8); + if (!CwmsDTOPaginated.CURSOR_CHECK.invoke(cursor)) { + ctx.json(new CdaError("cursor or page passed in but failed validation")) + .status(HttpCode.BAD_REQUEST); + return; + } + Instant since = queryParamAsInstant(ctx, SINCE); + int pageSize = queryParamAsClass(ctx, new String[]{PAGE_SIZE}, Integer.class, DEFAULT_PAGE_SIZE); + MessageDao dao = new MessageDao(dsl); + RssFeed feed = dao.retrieveFeed(cursor, pageSize, office, name, since, newLinkTemplate(ctx)); + String result = Formats.format(contentType, feed); + ctx.result(result); + ctx.contentType(contentType.toString()); + } + } + + private static String getHost(Context ctx) { + String scheme = ctx.header("X-Forwarded-Proto"); + if (scheme == null) { + scheme = "https"; + } + String host = ctx.header("X-Forwarded-Host"); + if (host == null) { + host = ctx.host(); + } + String path = ctx.path(); + return scheme + "://" + host + path; + } + + private UnaryOperator newLinkTemplate(Context ctx) + throws URISyntaxException { + String pageToken = "{page_token}"; + String url = new URIBuilder(getHost(ctx)) + .addParameter(PAGE, pageToken) + .build() + .toString(); + return new ReplaceUtils.OperatorBuilder() + .withTemplate(url) + .withOperatorKey(URLEncoder.encode(pageToken, StandardCharsets.UTF_8)) + .build(); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/AqTable.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/AqTable.java new file mode 100644 index 000000000..3573624cb --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/AqTable.java @@ -0,0 +1,31 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dao.rss; + +enum AqTable { + TS_STORED, + STATUS, + REALTIME_OPS +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageDao.java new file mode 100644 index 000000000..f6afa2897 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageDao.java @@ -0,0 +1,145 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dao.rss; + +import static java.util.stream.Collectors.toList; +import static org.jooq.impl.DSL.currentTimestamp; +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.partitionBy; +import static org.jooq.impl.DSL.rowNumber; +import static org.jooq.impl.DSL.select; +import static org.jooq.impl.DSL.table; + +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dao.JooqDao; +import cwms.cda.data.dto.CwmsDTOPaginated; +import cwms.cda.data.dto.rss.AtomLink; +import cwms.cda.data.dto.rss.RssChannel; +import cwms.cda.data.dto.rss.RssFeed; +import cwms.cda.data.dto.rss.RssItem; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.function.UnaryOperator; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Result; +import org.jooq.Table; + +public final class MessageDao extends JooqDao { + + + public MessageDao(DSLContext dsl) { + super(dsl); + } + + public RssFeed retrieveFeed(String cursor, int pageSize, String office, String name, + Instant since, UnaryOperator urlBuilder) { + AqTable aqTable = getAqTable(name); + String[] cursorSplit = CwmsDTOPaginated.decodeCursor(cursor); + int offset = 0; + if(cursorSplit.length == 2) { + offset = Integer.parseInt(cursorSplit[0]); + pageSize = Integer.parseInt(cursorSplit[1]); + since = null; + } + var items = retrieveMessages(offset, pageSize, since, office, aqTable) + .map(record -> { + Object userData = record.get("USER_DATA"); + return MessageUtil.extractPayload(userData).map(p -> rssItem(record, p)); + }).stream() + .filter(Optional::isPresent) + .map(Optional::get) + .collect(toList()); + AtomLink nextLink = null; + if(items.size() == pageSize) { + String nextCursor = CwmsDTOPaginated.encodeCursor(items.size() + offset, pageSize); + nextLink = new AtomLink("next", urlBuilder.apply(nextCursor)); + } + String description; + switch(aqTable) { + case TS_STORED: + description = " CWMS messages about time series operations, such as data stored and deleted"; + break; + case STATUS: + description = " CWMS general system and application status messages"; + break; + case REALTIME_OPS: + description = " CWMS application operational messages"; + break; + default: + description = null; + } + RssChannel channel = new RssChannel(name, nextLink, description, items); + return new RssFeed(channel); + } + + private static AqTable getAqTable(String name) { + try { + return AqTable.valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new NotFoundException(e); + } + } + + private static RssItem rssItem(Record record, String p) { + ZonedDateTime enqTimestamp = record.get("ENQ_TIMESTAMP", Timestamp.class) + .toInstant().atZone(ZoneOffset.UTC); + String msgId = record.get("MSG_ID", String.class); + return new RssItem(p, enqTimestamp, msgId); + } + + private Result retrieveMessages(int offset, int pageSize, Instant since, String office, AqTable name) { + Timestamp sinceTimestamp = since == null ? null : Timestamp.from(since); + Table t = table(name("CWMS_20", "AQ$" + office + "_" + name.name() + "_TABLE")).as("t"); + + Field MSG_ID = field("MSG_ID", String.class); + Field ENQ_TS = field("ENQ_TIMESTAMP", Timestamp.class); + Field USER_DATA = field("USER_DATA", Object.class); + Field rn = rowNumber() + .over(partitionBy(MSG_ID).orderBy(ENQ_TS.desc())) + .as("rn"); + var condition = ENQ_TS.ge(currentTimestamp().minus(7)); + if (sinceTimestamp != null) { + condition = condition.and(ENQ_TS.gt(sinceTimestamp)); + } + var inner = select(MSG_ID, ENQ_TS, USER_DATA, rn) + .from(t) + .where(condition) + .asTable("x"); + return dsl.select( MSG_ID, ENQ_TS, USER_DATA) + .from(inner) + .where(field(name("x", "rn"), Integer.class).eq(1)) + .orderBy(ENQ_TS.desc()) + .offset(offset) + .limit(pageSize) + .fetch(); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageUtil.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageUtil.java new file mode 100644 index 000000000..ca7fb380c --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/rss/MessageUtil.java @@ -0,0 +1,156 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dao.rss; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.flogger.FluentLogger; +import cwms.cda.formatters.json.JsonV2; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputFilter; +import java.io.ObjectInputStream; +import java.io.Reader; +import java.io.StringWriter; +import java.sql.Clob; +import java.sql.SQLException; +import java.sql.Struct; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.jooq.exception.DataAccessException; + +final class MessageUtil { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final ObjectMapper MAPPER = JsonV2.buildObjectMapper(); + + private static String extractTextMessage(Object userData) { + try { + Struct struct = (Struct) userData; + Object[] attrs = struct.getAttributes(); + + for (Object attr : attrs) { + if (attr instanceof String ) { + return (String) attr; + } + if (attr instanceof Clob) { + return clobToString((Clob) attr); + } + } + return null; + } catch (SQLException e) { + throw new RuntimeException("Error reading TEXT_MESSAGE", e); + } + } + + private static String clobToString(Clob clob) { + try (Reader r = clob.getCharacterStream(); + StringWriter w = new StringWriter()) { + r.transferTo(w); + return w.toString(); + } catch (Exception e) { + throw new RuntimeException("Error reading CLOB", e); + } + } + + private static Map extractMapMessage(Object userData) { + try { + Struct struct = (Struct) userData; + Object[] attrs = struct.getAttributes(); + for (Object attr : attrs) { + if (attr instanceof byte[]) { + try(var stream = new ObjectInputStream(new ByteArrayInputStream((byte[]) attr))) { + stream.setObjectInputFilter(MessageUtil::objectInputFilter); + Object deserialized = stream.readObject(); + if(deserialized instanceof Map) { + //noinspection rawtypes + return (Map) deserialized; + } + } + } + } + return Map.of(); + } catch (SQLException | IOException | ClassNotFoundException e) { + throw new RuntimeException("Error reading MAP_MESSAGE", e); + } + } + + static Optional extractPayload(Object userData) { + if (userData == null) { + return Optional.empty(); + } + try { + Struct struct = (Struct) userData; + String oracleType = struct.getSQLTypeName(); + if (oracleType.endsWith("JMS_TEXT_MESSAGE")) { + return Optional.ofNullable(extractTextMessage(userData)); + } + if (oracleType.endsWith("JMS_MAP_MESSAGE")) { + Map map = extractMapMessage(userData); + return Optional.ofNullable(MAPPER.writeValueAsString(map)); // JACKSON HERE + } + return Optional.empty(); + } catch (SQLException e) { + throw new DataAccessException("Error extracting message payload", e); + } catch (JsonProcessingException e) { + LOGGER.atWarning().withCause(e).log("Error extracting JMS payload", e); + return Optional.empty(); + } + } + + + private static ObjectInputFilter.Status objectInputFilter(ObjectInputFilter.FilterInfo info) { + if (info.depth() > 10) { + return ObjectInputFilter.Status.REJECTED; + } + if (info.references() > 10_000) { + return ObjectInputFilter.Status.REJECTED; + } + if (info.arrayLength() >= 0 && info.arrayLength() > 1_000_000) { + return ObjectInputFilter.Status.REJECTED; + } + Class c = info.serialClass(); + if (c == null) { + return ObjectInputFilter.Status.UNDECIDED; + } +// Only allow Maps + Strings + if (c == String.class) { + return ObjectInputFilter.Status.ALLOWED; + } + if (c == HashMap.class + || c == LinkedHashMap.class + || c == Map.Entry.class + || c == Map.Entry[].class) { + return ObjectInputFilter.Status.ALLOWED; + } + if (c == Integer.class || c == Long.class || c == Double.class || + c == Boolean.class || c == Short.class || c == Byte.class || + c == Float.class || c == Character.class || c == Number.class) { + return ObjectInputFilter.Status.ALLOWED; + } + return ObjectInputFilter.Status.REJECTED; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/AtomLink.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/AtomLink.java new file mode 100644 index 000000000..427a809f8 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/AtomLink.java @@ -0,0 +1,57 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; + +public class AtomLink { + + @JacksonXmlProperty(isAttribute = true, localName = "rel") + private final String rel; + + @JacksonXmlProperty(isAttribute = true, localName = "href") + private final String href; + + @JacksonXmlProperty(isAttribute = true, localName = "type") + private final String type = "application/rss+xml"; + + public AtomLink(String rel, String href) { + this.rel = rel; + this.href = href; + } + + public String getRel() { + return rel; + } + + public String getHref() { + return href; + } + + public String getType() { + return type; + } +} + diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/Guid.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/Guid.java new file mode 100644 index 000000000..64a307c4b --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/Guid.java @@ -0,0 +1,45 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText; + +public final class Guid { + + @JacksonXmlProperty(isAttribute = true, localName = "isPermaLink") + private final String isPermaLink = "false"; + + @JacksonXmlText + private final String guid; + + public Guid(String guid){ + this.guid = guid; + } + + public String getGuid() { + return guid; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssChannel.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssChannel.java new file mode 100644 index 000000000..05137c2a3 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssChannel.java @@ -0,0 +1,74 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public final class RssChannel { + + @JsonProperty(required = true) + @Schema(description = "Title of the RSS channel", required = true) + private final String title; + + @JacksonXmlProperty(localName = "atom:link") + @Schema(description = "Atom link for pagination to the next page of RSS items") + private final AtomLink nextLink; + + @JsonProperty(required = true) + @Schema(description = "Description of the RSS channel", required = true) + private final String description; + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "item") + @Schema(description = "List of RSS items in the channel") + private final List items; + + public RssChannel(String title, AtomLink nextLink, String description, List items) { + this.title = title; + this.nextLink = nextLink; + this.description = description; + this.items = items; + } + + public String getTitle() { + return title; + } + + public AtomLink getNextLink() { + return nextLink; + } + + public String getDescription() { + return description; + } + + public List getItems() { + return items; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssFeed.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssFeed.java new file mode 100644 index 000000000..1dc684764 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssFeed.java @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.xml.XMLv2; +import io.swagger.v3.oas.annotations.media.Schema; + +@FormattableWith(contentType = Formats.RSS, formatter = XMLv2.class, aliases = {Formats.DEFAULT, Formats.XML}) +@JacksonXmlRootElement(localName = "rss") +public class RssFeed extends CwmsDTOBase { + + @JacksonXmlProperty(isAttribute = true, localName = "version") + private final String version = "2.0"; + + @JacksonXmlProperty(isAttribute = true, localName = "xmlns:atom") + private final String atomNs = "http://www.w3.org/2005/Atom"; + + @Schema(description = "The RSS channel containing feed metadata and items") + @JacksonXmlProperty(localName = "channel") + private final RssChannel channel; + + public RssFeed(RssChannel channel) { + this.channel = channel; + } + + public String getVersion() { + return version; + } + + public RssChannel getChannel() { + return channel; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssItem.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssItem.java new file mode 100644 index 000000000..0a20d4512 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/rss/RssItem.java @@ -0,0 +1,71 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.ZonedDateTime; + +public final class RssItem { + @Schema(description = "Description of the RSS item content") + private final String description; + + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "EEE, dd MMM yyyy HH:mm:ss zzz", + locale = "en_US" + ) + @JacksonXmlProperty(localName = "pubDate") + @Schema( + description = "Publication date and time of the RSS item", + example = "Mon, 15 Dec 2025 12:00:00 EST", + type = "string", + format = "date-time" + ) + private final ZonedDateTime pubDate; + + @JacksonXmlProperty(localName = "guid") + @Schema(description = "Globally unique identifier for the RSS item") + private final Guid guid; + + public RssItem(String description, ZonedDateTime pubDate, String guid) { + this.description = description; + this.pubDate = pubDate; + this.guid = new Guid(guid); + } + + public String getDescription() { + return description; + } + + public ZonedDateTime getPubDate() { + return pubDate; + } + + public Guid getGuid() { + return guid; + } +} 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 c7342aaa7..b3aa63a5b 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 @@ -60,6 +60,7 @@ public class Formats { public static final String GEOJSON = "application/geo+json"; public static final String PGJSON = "application/vnd.pg+json"; public static final String NAMED_PGJSON = "application/vnd.named+pg+json"; + public static final String RSS = "application/rss+xml"; public static final String DEFAULT = "*/*"; public static final String JSON_LEGACY = "json"; @@ -76,7 +77,7 @@ public class Formats { static { contentTypeList.addAll( - Stream.of(DEFAULT, JSON, JSONV1, XML, XMLV1, XMLV2, WML2, JSONV2, + Stream.of(DEFAULT, JSON, JSONV1, XML, XMLV1, XMLV2, RSS, WML2, JSONV2, TAB, CSV, GEOJSON, PGJSON, NAMED_PGJSON) .map(ContentType::new) .collect(Collectors.toList())); diff --git a/cwms-data-api/src/main/java/cwms/cda/helpers/ReplaceUtils.java b/cwms-data-api/src/main/java/cwms/cda/helpers/ReplaceUtils.java index 1a2d19634..019bfb8c6 100644 --- a/cwms-data-api/src/main/java/cwms/cda/helpers/ReplaceUtils.java +++ b/cwms-data-api/src/main/java/cwms/cda/helpers/ReplaceUtils.java @@ -2,6 +2,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.UnaryOperator; @@ -101,11 +102,7 @@ public OperatorBuilder replace(String key, String value, boolean encode) { value = ""; } if (encode) { - try { - value = URLEncoder.encode(value, "UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } + value = URLEncoder.encode(value, StandardCharsets.UTF_8); } replacements.put(key, value); return this; diff --git a/cwms-data-api/src/test/java/cwms/cda/api/rss/RssHandlerIT.java b/cwms-data-api/src/test/java/cwms/cda/api/rss/RssHandlerIT.java new file mode 100644 index 000000000..bdf3d2b56 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/rss/RssHandlerIT.java @@ -0,0 +1,205 @@ +/* + * + * MIT License + * + * Copyright (c) 2024 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.rss; + +import static cwms.cda.api.Controllers.PAGE_SIZE; +import static cwms.cda.security.ApiKeyIdentityProvider.AUTH_HEADER; +import static io.restassured.RestAssured.given; +import static java.lang.String.format; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.DataApiTestIT; +import cwms.cda.formatters.Formats; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.TestAccounts; +import io.restassured.filter.log.LogDetail; +import io.restassured.path.xml.XmlPath; +import io.restassured.path.xml.config.XmlPathConfig; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.math.BigInteger; +import java.net.URI; +import java.sql.Timestamp; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import org.jooq.Configuration; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import usace.cwms.db.jooq.codegen.packages.CWMS_ENV_PACKAGE; +import usace.cwms.db.jooq.codegen.packages.CWMS_MSG_PACKAGE; + +@Tag("integration") +final class RssHandlerIT extends DataApiTestIT { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String OFFICE_ID = "SPK"; + private static final TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + + @BeforeEach + void setup() throws Exception { + CwmsDataApiSetupCallback.getDatabaseLink().connection(c -> { + Configuration configuration = DSL.using(c).configuration(); + //Need to have at least one subscriber for the messages to not automatically disappear from the table + configuration.dsl().execute( + "BEGIN " + + " BEGIN " + + " DBMS_AQADM.ADD_SUBSCRIBER(" + + " queue_name => ?, " + + " subscriber => sys.aq$_agent(?, NULL, NULL)" + + " ); " + + " EXCEPTION " + + " WHEN OTHERS THEN " + + " IF SQLCODE != -24034 THEN RAISE; END IF; " + // Ignore "Already a subscriber" + " END; " + + "END;", + "CWMS_20.SPK_STATUS", + "RSS_FEED_READER" + ); + CWMS_ENV_PACKAGE.call_SET_SESSION_OFFICE_ID(configuration, OFFICE_ID); + String text = "\n" + + " %s\n" + + ""; + for (int i = 0; i < 12; i++) { + BigInteger bigInteger = CWMS_MSG_PACKAGE.call_PUBLISH_STATUS_MESSAGE__2(configuration, text, true); + LOGGER.atFine().log("Created message test message: %s", bigInteger); + } + }); + } + + @Test + void test_rss_feed_with_pagination() { + // Page 1: verify core RSS elements + 5 items + next link exists + ExtractableResponse page1 = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.RSS) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(PAGE_SIZE, 5) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/rss/" + OFFICE_ID + "/status") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .extract(); + + String xmlBody = page1.asString(); + XmlPath xml = rssXml(xmlBody); + + assertNotNull(xml.getString("rss.@version")); + assertNotNull(xml.getString("rss.channel.title")); + assertNotNull(xml.getString("rss.channel.description")); + List items = xml.getList("rss.channel.item"); + assertNotNull(items); + assertEquals(5, items.size(), "Expected 5 elements on first page"); + assertNotNull(xml.getString("rss.channel.item[0].description")); + assertNotNull(xml.getString("rss.channel.item[0].pubDate")); + assertNotNull(xml.getString("rss.channel.item[0].guid")); + + String nextHref = nextLinkHref(xml); + assertNotNull(nextHref, "Expected an atom:link rel=\"next\" on the first page"); + + // Walk pages via nextLine + int pagesVisited = 1; + int maxPages = 500; + while (nextHref != null && pagesVisited < maxPages) { + String nextPath = toPathAndQuery(nextHref); + + ExtractableResponse nextPage = given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.RSS) + .header(AUTH_HEADER, user.toHeaderValue()) + .when() + .redirects().follow(true) + .redirects().max(3) + .get(nextPath) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .extract(); + + XmlPath nextXml = rssXml(nextPage.asString()); + + // Still a valid RSS document with items + assertNotNull(nextXml.getString("rss.channel.title")); + assertNotNull(nextXml.getList("rss.channel.item")); + + pagesVisited++; + nextHref = nextLinkHref(nextXml); + } + + assertTrue(pagesVisited > 1, "Expected to visit more than one page"); + assertTrue(pagesVisited < maxPages, "Hit maxPages guard before finding last page"); + assertNull(nextHref, "Expected last page to have no atom:link rel=\"next\""); + } + + @Test + void test_rss_feed_unknown_queue() { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(Formats.RSS) + .header(AUTH_HEADER, user.toHeaderValue()) + .queryParam(PAGE_SIZE, 5) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/rss/" + OFFICE_ID + "/answering-machine") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_NOT_FOUND)) + .extract(); + } + + private static XmlPath rssXml(String xmlBody) { + return new XmlPath(xmlBody) + .using(XmlPathConfig.xmlPathConfig() + .declaredNamespace("atom", "http://www.w3.org/2005/Atom")); + } + + private static String nextLinkHref(XmlPath xml) { + return xml.getString("rss.channel.'atom:link'.find { it.@rel == 'next' }.@href"); + } + + private static String toPathAndQuery(String href) { + URI uri = URI.create(href); + if (uri.getScheme() == null) { + return href; + } + String path = uri.getRawPath().replace("/cwms-data", ""); + String query = uri.getRawQuery(); + return query == null ? path : path + "?" + query; + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dto/rss/RssFeedTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dto/rss/RssFeedTest.java new file mode 100644 index 000000000..bb9cd040c --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/data/dto/rss/RssFeedTest.java @@ -0,0 +1,65 @@ +/* + * MIT License + * + * Copyright (c) 2025 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.rss; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +final class RssFeedTest { + @Test + void testSerialization() throws Exception { + InputStream resource = getClass().getResourceAsStream("/cwms/cda/data/dto/rss/ts_stored.xml"); + assertNotNull(resource); + String xmlOnDisk = IOUtils.toString(resource, StandardCharsets.UTF_8); + + ContentType contentType = new ContentType(Formats.RSS); + AtomLink next = + new AtomLink("next", "https://localhost:7001/swt-data/rss/swt/ts_stored?page=fHx8fDUwMHx8MQ%3D%3D"); + List items = List.of(new RssItem( + "{\"office_id\":\"SWT\",\"start_time\":1765033200000,\"ts_id\":\"AARK.Area.Inst.~1Day.0.TEST2\",\"ts_code\":263191,\"end_time\":1766847600000,\"store_time\":1765614311370,\"store_rule\":\"DELETE" + + " INSERT\",\"version_date\":-27079747200000,\"type\":\"TSDataStored\",\"millis\":1765585516312}", + Instant.parse("2025-12-13T00:25:19.600607Z").atZone(ZoneOffset.UTC), "45CB69B75AC67335E063400215ACD414")); + RssChannel channel = new RssChannel("TS_STORED", next, + "CWMS messages about time series operations, such as data stored and deleted", items); + RssFeed rssFeed = new RssFeed(channel); + String xmlSerialization = Formats.format(contentType, rssFeed); + XmlMapper xmlMapper = new XmlMapper(); + JsonNode node = xmlMapper.readTree(xmlSerialization); + JsonNode expected = xmlMapper.readTree(xmlOnDisk); + assertEquals(expected.toPrettyString(), node.toPrettyString()); + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rss/ts_stored.xml b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rss/ts_stored.xml new file mode 100644 index 000000000..685801b92 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rss/ts_stored.xml @@ -0,0 +1,13 @@ + + + TS_STORED + CWMS messages about time series operations, such as data stored and deleted + +