From 1354a6fb992ed2d02a6d62fa0441cb69bdbb7b18 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Tue, 9 Jun 2026 17:10:34 -0400 Subject: [PATCH 1/6] Add topology-assets: storage for view backgrounds and custom icons Custom topology views need server-side image storage for two upcoming features: background images (floor plans, rack diagrams) behind the canvas, and operator-uploaded node icons. Both are the same problem -- binary image bytes referenced by id from a view's definition document -- so one shared asset catalog serves both, distinguished by a 'kind'. The new features/topology-assets module stores the bytes in the generic binary key-value store (PostgresBlobStore, kvstore_bytea) and the catalog metadata as a JSON document in the JSON key-value store, keyed by the same generated UUID, so listings never drag image payloads along. The blob store is constructed internally from the DataSource rather than injected: the only BlobStore exposed as a shared service in the core context is thresholding's no-op, and registering a second one would make that lookup ambiguous. --- features/pom.xml | 3 + features/topology-assets/pom.xml | 105 ++++++++++ .../netmgt/topology/assets/TopologyAsset.java | 132 ++++++++++++ .../topology/assets/TopologyAssetDao.java | 61 ++++++ .../assets/impl/TopologyAssetKvStore.java | 189 ++++++++++++++++++ .../META-INF/opennms/component-dao.xml | 25 +++ 6 files changed, 515 insertions(+) create mode 100644 features/topology-assets/pom.xml create mode 100644 features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java create mode 100644 features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java create mode 100644 features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java create mode 100644 features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml diff --git a/features/pom.xml b/features/pom.xml index 10fcc8fedfc8..c4655aa800d8 100644 --- a/features/pom.xml +++ b/features/pom.xml @@ -20,6 +20,9 @@ topologies + + topology-assets + activemq diff --git a/features/topology-assets/pom.xml b/features/topology-assets/pom.xml new file mode 100644 index 000000000000..4289c4343d15 --- /dev/null +++ b/features/topology-assets/pom.xml @@ -0,0 +1,105 @@ + + + + org.opennms + org.opennms.features + 36.0.1-SNAPSHOT + + 4.0.0 + org.opennms.features.topology-assets + bundle + OpenNMS :: Features :: Topology Assets + + Storage for images referenced by custom topology views: background + images (floor plans, rack diagrams) and custom node icons. Bytes are + kept in the generic binary key-value store and the catalog metadata + in the JSON key-value store; this module owns the model and DAO. The + JAX-RS resource that exposes /api/v2/topology/assets lives in + opennms-webapp-rest. + + + + + org.apache.felix + maven-bundle-plugin + true + + + JavaSE-1.8 + ${project.artifactId} + ${project.version} + + + + + + + + org.opennms.dependencies + spring-dependencies + pom + provided + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.api + ${project.version} + provided + + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.blob.postgres + ${project.version} + + + com.google.code.gson + gson + ${gsonVersion} + provided + + + org.slf4j + slf4j-api + provided + + + com.google.guava + guava + + + junit + junit + test + + + org.hamcrest + hamcrest-library + test + + + org.mockito + mockito-core + test + + + org.opennms.core.test-api + org.opennms.core.test-api.lib + test + + + org.opennms.core.test-api + org.opennms.core.test-api.db + test + + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + + + diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java new file mode 100644 index 000000000000..cffa37fd13d8 --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java @@ -0,0 +1,132 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import java.util.Date; + +import com.google.common.base.MoreObjects; + +/** + * Catalog metadata for an image used by custom topology views: a background + * (floor plan, rack diagram) or a custom node icon. + * + *

This is the metadata only — the image bytes are stored separately in the + * binary key-value store and served by id (see {@code TopologyAssetKvStore}). + * Views reference an asset by its {@link #getId() id} from their (opaque) + * definition document, e.g. a background's {@code ref} or a node's icon. + * + *

{@link #getKind() kind} distinguishes the intended use + * ({@code background} vs {@code icon}) so pickers can list only what fits and + * the upload path can apply a size limit appropriate to each. Assets are a + * shared catalog like the views themselves; {@link #getOwner() owner} records + * who uploaded it (informational). + */ +public class TopologyAsset { + + public static final String KIND_BACKGROUND = "background"; + public static final String KIND_ICON = "icon"; + + private String id; + private String name; + private String kind; + private String mimeType; + private long sizeBytes; + private String owner; + private Date created; + private Date lastModified; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(long sizeBytes) { + this.sizeBytes = sizeBytes; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastModified() { + return lastModified; + } + + public void setLastModified(Date lastModified) { + this.lastModified = lastModified; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("name", name) + .add("kind", kind) + .add("mimeType", mimeType) + .add("sizeBytes", sizeBytes) + .add("owner", owner) + .toString(); + } +} diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java new file mode 100644 index 000000000000..a02c178480ad --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java @@ -0,0 +1,61 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import java.util.List; +import java.util.Optional; + +/** + * Access to the shared catalog of topology image assets (backgrounds and + * custom node icons). Metadata and bytes are stored and fetched separately so + * listings never drag image payloads along. + * + *

Validation (allowed MIME types, size limits per kind) is the caller's + * concern — the REST layer enforces it; this store is format-agnostic. + */ +public interface TopologyAssetDao { + + /** + * Stores a new asset. The id, byte size, and timestamps are assigned here; + * everything else (name, kind, MIME type, owner) comes filled in on + * {@code asset}. + * + * @return the stored metadata, with the generated id set. + */ + TopologyAsset save(TopologyAsset asset, byte[] bytes); + + /** The metadata for an asset, if it exists. */ + Optional get(String id); + + /** The image bytes for an asset, if it exists. */ + Optional getBytes(String id); + + /** All assets' metadata (never the bytes). */ + List findAll(); + + /** + * Removes an asset (metadata and bytes). + * + * @return whether an asset with that id existed. + */ + boolean delete(String id); +} diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java new file mode 100644 index 000000000000..fcbdd8ece040 --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java @@ -0,0 +1,189 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets.impl; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.opennms.features.distributed.kvstore.api.BlobStore; +import org.opennms.features.distributed.kvstore.api.JsonStore; +import org.opennms.features.distributed.kvstore.blob.postgres.PostgresBlobStore; +import org.opennms.netmgt.topology.assets.TopologyAsset; +import org.opennms.netmgt.topology.assets.TopologyAssetDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * Stores topology image assets in the generic key-value stores: the image + * bytes in the binary store ({@link BlobStore}, the {@code kvstore_bytea} + * table) and the catalog metadata as a JSON document in the {@link JsonStore} + * ({@code kvstore_jsonb}), both under the {@value #CONTEXT} context and keyed + * by the same generated UUID. Splitting the two keeps catalog listings cheap + * — enumerating metadata never touches the image payloads. + * + *

The {@link BlobStore} is constructed here from the {@link DataSource} + * rather than injected: the only BlobStore exposed as a shared bean in the + * core context is the no-op used by thresholding, and registering a second + * BlobStore service would make that lookup ambiguous for its consumers. + * Assets are a core-webapp feature, always backed by the OpenNMS database. + */ +public class TopologyAssetKvStore implements TopologyAssetDao { + + private static final Logger LOG = LoggerFactory.getLogger(TopologyAssetKvStore.class); + + static final String CONTEXT = "topology-assets"; + + private final JsonStore jsonStore; + private final BlobStore blobStore; + private final Gson gson = new Gson(); + + public TopologyAssetKvStore(final JsonStore jsonStore, final DataSource dataSource) { + this(jsonStore, new PostgresBlobStore(Objects.requireNonNull(dataSource))); + } + + /** For tests: inject the blob store directly. */ + TopologyAssetKvStore(final JsonStore jsonStore, final BlobStore blobStore) { + this.jsonStore = Objects.requireNonNull(jsonStore); + this.blobStore = Objects.requireNonNull(blobStore); + } + + @Override + public TopologyAsset save(final TopologyAsset asset, final byte[] bytes) { + Objects.requireNonNull(asset); + Objects.requireNonNull(bytes); + final Date now = new Date(); + asset.setId(UUID.randomUUID().toString()); + asset.setSizeBytes(bytes.length); + asset.setCreated(now); + asset.setLastModified(now); + // Bytes first: if the metadata write fails the orphaned blob is + // invisible (nothing lists it) and gets overwritten only by its own + // UUID key; the reverse order could list an asset with no bytes. + blobStore.put(asset.getId(), bytes, CONTEXT); + jsonStore.put(asset.getId(), gson.toJson(Doc.of(asset)), CONTEXT); + return asset; + } + + @Override + public Optional get(final String id) { + if (id == null) { + return Optional.empty(); + } + return jsonStore.get(id, CONTEXT).map(json -> deserialize(id, json)); + } + + @Override + public Optional getBytes(final String id) { + if (id == null) { + return Optional.empty(); + } + return blobStore.get(id, CONTEXT); + } + + @Override + public List findAll() { + final Map entries = jsonStore.enumerateContext(CONTEXT); + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + final List assets = new ArrayList<>(entries.size()); + for (final Map.Entry entry : entries.entrySet()) { + final TopologyAsset asset = deserialize(entry.getKey(), entry.getValue()); + if (asset != null) { + assets.add(asset); + } + } + return assets; + } + + @Override + public boolean delete(final String id) { + if (id == null || !jsonStore.get(id, CONTEXT).isPresent()) { + return false; + } + jsonStore.delete(id, CONTEXT); + blobStore.delete(id, CONTEXT); + return true; + } + + private TopologyAsset deserialize(final String id, final String json) { + try { + final Doc doc = gson.fromJson(json, Doc.class); + if (doc == null) { + return null; + } + return doc.toAsset(id); + } catch (final JsonSyntaxException e) { + LOG.warn("Skipping malformed topology asset document for key {}: {}", id, e.getMessage()); + return null; + } + } + + /** + * The stored metadata document. Timestamps travel as epoch millis so the + * JSON shape doesn't depend on gson's default (locale-bound) Date format. + */ + private static final class Doc { + String name; + String kind; + String mimeType; + long sizeBytes; + String owner; + Long created; + Long lastModified; + + static Doc of(final TopologyAsset asset) { + final Doc doc = new Doc(); + doc.name = asset.getName(); + doc.kind = asset.getKind(); + doc.mimeType = asset.getMimeType(); + doc.sizeBytes = asset.getSizeBytes(); + doc.owner = asset.getOwner(); + doc.created = asset.getCreated() == null ? null : asset.getCreated().getTime(); + doc.lastModified = asset.getLastModified() == null ? null : asset.getLastModified().getTime(); + return doc; + } + + TopologyAsset toAsset(final String id) { + final TopologyAsset asset = new TopologyAsset(); + asset.setId(id); + asset.setName(name); + asset.setKind(kind); + asset.setMimeType(mimeType); + asset.setSizeBytes(sizeBytes); + asset.setOwner(owner); + asset.setCreated(created == null ? null : new Date(created)); + asset.setLastModified(lastModified == null ? null : new Date(lastModified)); + return asset; + } + } +} diff --git a/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml b/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml new file mode 100644 index 000000000000..49f1b7a5487a --- /dev/null +++ b/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + From bfae03ea2f26c3f9ad23c22491d50d40ffa7080f Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Tue, 9 Jun 2026 17:10:34 -0400 Subject: [PATCH 2/6] Add /api/v2/topology/assets REST resource Upload, serve, list, and delete topology image assets. Uploads are the raw image bytes under their own content type (no multipart), with the name and kind as query parameters; raster types only (SVG is excluded for now -- it can carry active content and these are served from the application origin), and each kind has its own size cap: icons 512 KiB, backgrounds 10 MiB. Bytes are served with an ETag derived from the asset's last-modified time plus a max-age, so icons reused across many nodes revalidate as 304s. Access is the standard /api/v2 RBAC: any authenticated user reads, ROLE_REST/ROLE_ADMIN writes. --- opennms-webapp-rest/pom.xml | 6 + .../opennms/web/rest/v2/TopologyAssetDTO.java | 104 +++++++++ .../web/rest/v2/TopologyAssetRestService.java | 209 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml index 38b733ee39f6..e1b3965b6f6e 100644 --- a/opennms-webapp-rest/pom.xml +++ b/opennms-webapp-rest/pom.xml @@ -237,6 +237,12 @@ org.opennms.features.enlinkd.service.api ${onmsLibScope} + + org.opennms + org.opennms.features.topology-assets + ${project.version} + ${onmsLibScope} + org.opennms opennms-util diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java new file mode 100644 index 000000000000..12f23895ae2b --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java @@ -0,0 +1,104 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.util.Date; + +/** + * Wire shape of a topology image asset's metadata (the bytes are served + * separately, by id). See {@link TopologyAssetRestService}. + */ +public class TopologyAssetDTO { + + private String id; + private String name; + private String kind; + private String mimeType; + private long sizeBytes; + private String owner; + private Date created; + private Date lastModified; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(long sizeBytes) { + this.sizeBytes = sizeBytes; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastModified() { + return lastModified; + } + + public void setLastModified(Date lastModified) { + this.lastModified = lastModified; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java new file mode 100644 index 000000000000..2855c55d10b9 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java @@ -0,0 +1,209 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.EntityTag; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Request; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +import org.opennms.netmgt.topology.assets.TopologyAsset; +import org.opennms.netmgt.topology.assets.TopologyAssetDao; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.google.common.collect.ImmutableMap; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Upload, serve, list, and delete the images that custom topology views + * reference: view backgrounds (floor plans, rack diagrams) and custom node + * icons. Metadata travels as JSON; the bytes are posted/served raw under the + * image's own content type — no multipart. + * + *

Reads are open to any authenticated user; writes (POST/DELETE) require + * {@code ROLE_REST} or {@code ROLE_ADMIN} — enforced by the default + * {@code /api/v2/**} rules in the Spring Security configuration, so the + * resource carries no role annotations (same model as the topology views). + * + *

Only raster image types are accepted. SVG is deliberately excluded for + * now: it can carry active content, and these assets are served from the + * application origin; revisit with a sandboxed serving path if needed. Each + * {@code kind} has its own size cap — icons are reused per node and should be + * small, backgrounds are floor-plan-sized. + */ +@Component +@Path("topology/assets") +@Tag(name = "TopologyAssets", description = "Topology view image assets API") +public class TopologyAssetRestService { + + private static final Map MAX_SIZE_BY_KIND = ImmutableMap.of( + TopologyAsset.KIND_BACKGROUND, 10L * 1024 * 1024, + TopologyAsset.KIND_ICON, 512L * 1024); + + static final String[] ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}; + + @Autowired(required = false) + private TopologyAssetDao m_dao; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public List list(@QueryParam("kind") final String kind) { + return getDao().findAll().stream() + .filter(asset -> kind == null || kind.equals(asset.getKind())) + .map(TopologyAssetRestService::toDto) + .collect(Collectors.toList()); + } + + @POST + @Consumes({"image/png", "image/jpeg", "image/gif", "image/webp"}) + @Produces(MediaType.APPLICATION_JSON) + public Response upload(@Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Context final javax.ws.rs.core.HttpHeaders headers, + @QueryParam("name") final String name, + @QueryParam("kind") final String kind, + final byte[] bytes) { + if (name == null || name.trim().isEmpty()) { + throw webException(Response.Status.BAD_REQUEST, "An asset name is required (?name=)"); + } + final Long maxSize = kind == null ? null : MAX_SIZE_BY_KIND.get(kind); + if (maxSize == null) { + throw webException(Response.Status.BAD_REQUEST, + "An asset kind is required (?kind=): one of " + MAX_SIZE_BY_KIND.keySet()); + } + if (bytes == null || bytes.length == 0) { + throw webException(Response.Status.BAD_REQUEST, "The request body must be the image bytes"); + } + if (bytes.length > maxSize) { + throw webException(Response.Status.REQUEST_ENTITY_TOO_LARGE, + "A " + kind + " asset may be at most " + maxSize + " bytes"); + } + // @Consumes already gates the type; record it without any parameters. + final MediaType contentType = headers.getMediaType(); + final String mimeType = contentType.getType() + "/" + contentType.getSubtype(); + + final TopologyAsset asset = new TopologyAsset(); + asset.setName(name.trim()); + asset.setKind(kind); + asset.setMimeType(mimeType); + asset.setOwner(securityContext.getUserPrincipal() == null ? null : securityContext.getUserPrincipal().getName()); + + final TopologyAsset saved = getDao().save(asset, bytes); + return Response.created(uriInfo.getAbsolutePathBuilder().path(saved.getId()).build()) + .entity(toDto(saved)) + .build(); + } + + /** + * The image bytes, under the asset's own content type. ETag'd by the + * asset's last-modified time so browsers can revalidate cheaply — icons + * especially are reused by many nodes in a view. + */ + @GET + @Path("{id}") + public Response getBytes(@Context final Request request, @PathParam("id") final String id) { + final TopologyAsset asset = require(id); + final EntityTag etag = new EntityTag(String.valueOf(asset.getLastModified() == null ? 0L : asset.getLastModified().getTime())); + + final CacheControl cacheControl = new CacheControl(); + cacheControl.setMaxAge(3600); + + final Response.ResponseBuilder notModified = request.evaluatePreconditions(etag); + if (notModified != null) { + return notModified.cacheControl(cacheControl).tag(etag).build(); + } + + final Optional bytes = getDao().getBytes(id); + if (!bytes.isPresent()) { + throw webException(Response.Status.NOT_FOUND, "No bytes stored for asset '" + id + "'"); + } + return Response.ok(bytes.get(), asset.getMimeType()) + .cacheControl(cacheControl) + .tag(etag) + .build(); + } + + @GET + @Path("{id}/meta") + @Produces(MediaType.APPLICATION_JSON) + public TopologyAssetDTO getMeta(@PathParam("id") final String id) { + return toDto(require(id)); + } + + @DELETE + @Path("{id}") + public Response delete(@PathParam("id") final String id) { + if (!getDao().delete(id)) { + throw webException(Response.Status.NOT_FOUND, "No asset with id '" + id + "'"); + } + return Response.noContent().build(); + } + + private TopologyAsset require(final String id) { + return getDao().get(id) + .orElseThrow(() -> webException(Response.Status.NOT_FOUND, "No asset with id '" + id + "'")); + } + + private TopologyAssetDao getDao() { + if (m_dao == null) { + throw webException(Response.Status.SERVICE_UNAVAILABLE, "The topology asset store is not available"); + } + return m_dao; + } + + private static TopologyAssetDTO toDto(final TopologyAsset asset) { + final TopologyAssetDTO dto = new TopologyAssetDTO(); + dto.setId(asset.getId()); + dto.setName(asset.getName()); + dto.setKind(asset.getKind()); + dto.setMimeType(asset.getMimeType()); + dto.setSizeBytes(asset.getSizeBytes()); + dto.setOwner(asset.getOwner()); + dto.setCreated(asset.getCreated()); + dto.setLastModified(asset.getLastModified()); + return dto; + } + + private static WebApplicationException webException(final Response.Status status, final String message) { + return new WebApplicationException(Response.status(status).type(MediaType.TEXT_PLAIN).entity(message).build()); + } +} From f108544cf30fef7e6ddbba2632609cd41f276d01 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Tue, 9 Jun 2026 17:10:34 -0400 Subject: [PATCH 3/6] Deploy topology-assets on the runtime classpath Add the topology-assets jar and the Postgres BlobStore implementation to the base assembly -- only the no-op blob store shipped in lib until now. --- opennms-base-assembly/pom.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/opennms-base-assembly/pom.xml b/opennms-base-assembly/pom.xml index 9547e79cc28f..200342a139da 100644 --- a/opennms-base-assembly/pom.xml +++ b/opennms-base-assembly/pom.xml @@ -1199,6 +1199,19 @@ org.opennms.features.enlinkd.service.impl ${project.version} + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.blob.postgres + ${project.version} + org.opennms.features.enlinkd org.opennms.features.enlinkd.daemon From e573fe1c77de166e2f18f6d4d15665995245d795 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Tue, 9 Jun 2026 17:10:34 -0400 Subject: [PATCH 4/6] Add integration tests for topology-assets TopologyAssetDaoIT runs the DAO against a real temporary database -- metadata through the PostgresJsonStore and bytes through a PostgresBlobStore, the same pairing the production wiring builds. TopologyAssetRestServiceIT drives /api/v2/topology/assets through the CXF harness: the upload/serve/delete lifecycle, byte integrity, ETag revalidation (304), kind filtering, and the validation statuses (400 missing name/kind, 413 over the per-kind cap, 415 non-image type, 404 unknown id). --- .../topology/assets/TopologyAssetDaoIT.java | 140 ++++++++++++ .../rest/v2/TopologyAssetRestServiceIT.java | 213 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java create mode 100644 opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java diff --git a/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java b/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java new file mode 100644 index 000000000000..35957e23f5cd --- /dev/null +++ b/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java @@ -0,0 +1,140 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.db.DataSourceFactory; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.features.distributed.kvstore.api.JsonStore; +import org.opennms.netmgt.topology.assets.impl.TopologyAssetKvStore; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; + +/** + * Exercises the {@link TopologyAssetDao} against a real (temporary) database: + * metadata through the {@code PostgresJsonStore} ({@code kvstore_jsonb}) and + * bytes through a {@code PostgresBlobStore} ({@code kvstore_bytea}) — the same + * pairing the production wiring builds. + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-postgresJsonStore.xml" +}) +@JUnitConfigurationEnvironment +@JUnitTemporaryDatabase +public class TopologyAssetDaoIT { + + @Autowired + private JsonStore jsonStore; + + private TopologyAssetDao dao; + + private static final byte[] PNG_ISH = "not-really-a-png-but-bytes".getBytes(StandardCharsets.UTF_8); + + @Before + public void setUp() { + // Same construction as the production component-dao wiring: the blob + // store is built from the (here: temporary) datasource internally. + dao = new TopologyAssetKvStore(jsonStore, DataSourceFactory.getInstance()); + } + + private static TopologyAsset background(final String name) { + final TopologyAsset asset = new TopologyAsset(); + asset.setName(name); + asset.setKind(TopologyAsset.KIND_BACKGROUND); + asset.setMimeType("image/png"); + asset.setOwner("admin"); + return asset; + } + + @Test + @JUnitTemporaryDatabase + public void roundTripsMetadataAndBytes() { + assertThat(dao.findAll(), hasSize(0)); + + final TopologyAsset saved = dao.save(background("dc floor plan"), PNG_ISH); + assertNotNull(saved.getId()); + assertThat(saved.getSizeBytes(), is((long) PNG_ISH.length)); + assertNotNull(saved.getCreated()); + assertNotNull(saved.getLastModified()); + + final Optional read = dao.get(saved.getId()); + assertTrue(read.isPresent()); + assertThat(read.get().getName(), is("dc floor plan")); + assertThat(read.get().getKind(), is(TopologyAsset.KIND_BACKGROUND)); + assertThat(read.get().getMimeType(), is("image/png")); + assertThat(read.get().getOwner(), is("admin")); + assertThat(read.get().getSizeBytes(), is((long) PNG_ISH.length)); + assertThat(read.get().getCreated(), is(saved.getCreated())); + + final Optional bytes = dao.getBytes(saved.getId()); + assertTrue(bytes.isPresent()); + assertArrayEquals(PNG_ISH, bytes.get()); + } + + @Test + @JUnitTemporaryDatabase + public void listsMetadataForAllAssets() { + dao.save(background("a"), PNG_ISH); + final TopologyAsset icon = new TopologyAsset(); + icon.setName("router glyph"); + icon.setKind(TopologyAsset.KIND_ICON); + icon.setMimeType("image/webp"); + dao.save(icon, PNG_ISH); + + final List all = dao.findAll(); + assertThat(all, hasSize(2)); + assertTrue(all.stream().anyMatch(a -> TopologyAsset.KIND_ICON.equals(a.getKind()))); + assertTrue(all.stream().anyMatch(a -> TopologyAsset.KIND_BACKGROUND.equals(a.getKind()))); + } + + @Test + @JUnitTemporaryDatabase + public void deleteRemovesMetadataAndBytes() { + final TopologyAsset saved = dao.save(background("doomed"), PNG_ISH); + + assertTrue(dao.delete(saved.getId())); + assertFalse(dao.get(saved.getId()).isPresent()); + assertFalse(dao.getBytes(saved.getId()).isPresent()); + + // Deleting again (or a never-existing id) reports false, not an error. + assertFalse(dao.delete(saved.getId())); + assertFalse(dao.delete("no-such-asset")); + } +} diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java new file mode 100644 index 000000000000..79701bd53c20 --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java @@ -0,0 +1,213 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.map.ObjectMapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Drives {@code /api/v2/topology/assets} end to end through the CXF test + * harness: raw-body image upload, metadata listing/filtering, byte serving + * with ETag revalidation, deletion, and the validation/error statuses + * (400 missing name/kind, 413 over the per-kind cap, 415 non-image type, + * 404 unknown id). + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath*:/META-INF/opennms/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml", + "classpath:/META-INF/opennms/applicationContext-postgresJsonStore.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class TopologyAssetRestServiceIT extends AbstractSpringJerseyRestTestCase { + + private static final String BASE = "/topology/assets"; + + private static final byte[] IMAGE = "png-bytes-stand-in".getBytes(StandardCharsets.UTF_8); + + private final ObjectMapper m_mapper = new ObjectMapper(); + + public TopologyAssetRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void afterServletStart() throws Exception { + MockLogAppender.setupLogging(true, "WARN"); + setUser("admin", new String[] { "ROLE_ADMIN" }); + } + + /** + * POSTs raw image bytes. The harness's sendData() can't carry query + * parameters, so this sets the query string and body itself. + */ + private MockHttpServletResponse postAsset(final String query, final String contentType, final byte[] body, + final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(POST, BASE); + request.setQueryString(query); + for (final String pair : query.split("&")) { + final int eq = pair.indexOf('='); + if (eq > 0) { + request.addParameter(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + request.setContentType(contentType); + request.setContent(body); + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(response.getErrorMessage(), expectedStatus, response.getStatus()); + return response; + } + + private MockHttpServletResponse getRaw(final String url, final String ifNoneMatch, final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(GET, url); + if (ifNoneMatch != null) { + request.addHeader("If-None-Match", ifNoneMatch); + } + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(response.getErrorMessage(), expectedStatus, response.getStatus()); + return response; + } + + private static String lastPathSegment(final String location) { + return location == null ? null : location.substring(location.lastIndexOf('/') + 1); + } + + @Test + @JUnitTemporaryDatabase + public void uploadServeAndDeleteLifecycle() throws Exception { + // Empty catalog to start. + assertEquals("[]", sendRequest(GET, BASE, 200)); + + // Upload a background. + final MockHttpServletResponse post = postAsset("name=DC+floor+plan&kind=background", "image/png", IMAGE, 201); + final String id = lastPathSegment(post.getHeader("Location")); + assertNotNull("POST should return a Location with an id", id); + final JsonNode created = m_mapper.readTree(post.getContentAsString()); + assertEquals("DC floor plan", created.get("name").asText()); + assertEquals("background", created.get("kind").asText()); + assertEquals("image/png", created.get("mimeType").asText()); + assertEquals(IMAGE.length, created.get("sizeBytes").asInt()); + assertEquals("admin", created.get("owner").asText()); + + // Serve the bytes back: body, content type, cacheability. + final MockHttpServletResponse bytes = getRaw(BASE + "/" + id, null, 200); + assertArrayEquals(IMAGE, bytes.getContentAsByteArray()); + assertTrue(bytes.getContentType().startsWith("image/png")); + final String etag = bytes.getHeader("ETag"); + assertNotNull("byte responses must carry an ETag", etag); + assertNotNull(bytes.getHeader("Cache-Control")); + + // Conditional GET with the ETag revalidates as a 304 without a body. + final MockHttpServletResponse notModified = getRaw(BASE + "/" + id, etag, 304); + assertEquals(0, notModified.getContentAsByteArray().length); + + // Metadata endpoint and listing. + final JsonNode meta = m_mapper.readTree(sendRequest(GET, BASE + "/" + id + "/meta", 200)); + assertEquals(id, meta.get("id").asText()); + final JsonNode all = m_mapper.readTree(sendRequest(GET, BASE, 200)); + assertEquals(1, all.size()); + + // Delete removes metadata and bytes; a second delete is a 404. + sendRequest(DELETE, BASE + "/" + id, 204); + sendRequest(GET, BASE + "/" + id, 404); + sendRequest(DELETE, BASE + "/" + id, 404); + } + + @Test + @JUnitTemporaryDatabase + public void listsCanFilterByKind() throws Exception { + postAsset("name=floor&kind=background", "image/png", IMAGE, 201); + postAsset("name=glyph&kind=icon", "image/webp", IMAGE, 201); + + assertEquals(2, m_mapper.readTree(sendRequest(GET, BASE, 200)).size()); + + final MockHttpServletRequest request = createRequest(GET, BASE); + request.setQueryString("kind=icon"); + request.addParameter("kind", "icon"); + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(200, response.getStatus()); + final JsonNode icons = m_mapper.readTree(response.getContentAsString()); + assertEquals(1, icons.size()); + assertEquals("glyph", icons.get(0).get("name").asText()); + } + + @Test + @JUnitTemporaryDatabase + public void rejectsBadUploads() throws Exception { + // Missing name / missing or unknown kind. + postAsset("kind=background", "image/png", IMAGE, 400); + postAsset("name=x", "image/png", IMAGE, 400); + postAsset("name=x&kind=wallpaper", "image/png", IMAGE, 400); + + // Non-image content types are rejected by @Consumes. + postAsset("name=x&kind=icon", "text/plain", IMAGE, 415); + postAsset("name=x&kind=icon", "image/svg+xml", IMAGE, 415); + + // Per-kind size caps: this payload is over the icon cap (512 KiB) + // but under the background cap (10 MiB). + final byte[] big = new byte[600 * 1024]; + Arrays.fill(big, (byte) 'a'); + postAsset("name=big&kind=icon", "image/png", big, 413); + postAsset("name=big&kind=background", "image/png", big, 201); + + // Empty body. + postAsset("name=x&kind=icon", "image/png", new byte[0], 400); + + // Unknown asset id. + sendRequest(GET, BASE + "/no-such-id", 404); + sendRequest(GET, BASE + "/no-such-id/meta", 404); + } +} From 6cf22746c61cbfd908317651c1b57de1efc684d9 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Thu, 11 Jun 2026 14:27:48 -0400 Subject: [PATCH 5/6] NMS-19875: address review feedback - Make null-handling in TopologyAssetKvStore.get explicit (flatMap/ofNullable) so a malformed stored document reads as absent. (Optional.map already wrapped null results, so this is clarity, not a behavior change.) - Answer 415 with a clear message when an upload carries no Content-Type header at all, instead of an NPE-driven 500. - IT: the query-string helper now presents decoded parameter values (as a servlet container would) while the query string stays encoded. - IT: assert the empty catalog by parsed JSON size rather than the raw response string. --- .../netmgt/topology/assets/impl/TopologyAssetKvStore.java | 4 +++- .../org/opennms/web/rest/v2/TopologyAssetRestService.java | 6 ++++++ .../opennms/web/rest/v2/TopologyAssetRestServiceIT.java | 7 +++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java index fcbdd8ece040..ebe76f6a7f62 100644 --- a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java @@ -98,7 +98,9 @@ public Optional get(final String id) { if (id == null) { return Optional.empty(); } - return jsonStore.get(id, CONTEXT).map(json -> deserialize(id, json)); + // flatMap + ofNullable: a malformed stored document (deserialize -> null) + // reads as absent rather than surfacing a half-built asset. + return jsonStore.get(id, CONTEXT).flatMap(json -> Optional.ofNullable(deserialize(id, json))); } @Override diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java index 2855c55d10b9..c0cd31c11239 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java @@ -118,7 +118,13 @@ public Response upload(@Context final UriInfo uriInfo, "A " + kind + " asset may be at most " + maxSize + " bytes"); } // @Consumes already gates the type; record it without any parameters. + // A request with no Content-Type at all can still reach here, though -- + // answer 415 rather than NPE into a 500. final MediaType contentType = headers.getMediaType(); + if (contentType == null) { + throw webException(Response.Status.UNSUPPORTED_MEDIA_TYPE, + "A Content-Type header with the image type is required"); + } final String mimeType = contentType.getType() + "/" + contentType.getSubtype(); final TopologyAsset asset = new TopologyAsset(); diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java index 79701bd53c20..1aa0923fc40c 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java @@ -98,7 +98,10 @@ private MockHttpServletResponse postAsset(final String query, final String conte for (final String pair : query.split("&")) { final int eq = pair.indexOf('='); if (eq > 0) { - request.addParameter(pair.substring(0, eq), pair.substring(eq + 1)); + // The query string carries encoded values; parameters carry decoded + // ones (as a real servlet container would present them). + request.addParameter(pair.substring(0, eq), + java.net.URLDecoder.decode(pair.substring(eq + 1), java.nio.charset.StandardCharsets.UTF_8)); } } request.setContentType(contentType); @@ -128,7 +131,7 @@ private static String lastPathSegment(final String location) { @JUnitTemporaryDatabase public void uploadServeAndDeleteLifecycle() throws Exception { // Empty catalog to start. - assertEquals("[]", sendRequest(GET, BASE, 200)); + assertEquals(0, m_mapper.readTree(sendRequest(GET, BASE, 200)).size()); // Upload a background. final MockHttpServletResponse post = postAsset("name=DC+floor+plan&kind=background", "image/png", IMAGE, 201); From 9ade4f0eb117221bfea9fa899258a0c807460c3c Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Thu, 11 Jun 2026 17:47:20 -0400 Subject: [PATCH 6/6] NMS-19875: fix integration tests in modules that boot the shared CXF context The BSM and status REST ITs load the shared /api/v2 CXF context, which instantiates every v2 resource -- including the new topology-assets service. Its model jar and the jsonStore bean's module are declared provided in opennms-webapp-rest (the jars ship in $OPENNMS_HOME/lib), and provided dependencies are not transitive, so those modules' test classpaths lacked them: NoClassDefFoundError on TopologyAsset took down all 56 tests. Declare both as test-scope dependencies where the context is booted. --- features/bsm/rest/impl/pom.xml | 15 +++++++++++++++ features/status/rest/pom.xml | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/features/bsm/rest/impl/pom.xml b/features/bsm/rest/impl/pom.xml index 1ad5aeeaf89e..3df24ca3c11b 100644 --- a/features/bsm/rest/impl/pom.xml +++ b/features/bsm/rest/impl/pom.xml @@ -64,6 +64,21 @@ ${project.version} provided + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + test + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + org.opennms.dependencies cxf-dependencies diff --git a/features/status/rest/pom.xml b/features/status/rest/pom.xml index d4c5b952ac9f..396cbc77f98e 100644 --- a/features/status/rest/pom.xml +++ b/features/status/rest/pom.xml @@ -55,6 +55,21 @@ ${project.version} test + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + test + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + org.opennms.features.bsm org.opennms.features.bsm.service.impl