diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf8ba9cf5..9d1d812a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## [5.5.3] - 2026-06-09
+### Changed
+- Dependency hygiene: exclude duplicate `jakarta.json` from `jena-arq`, align `slf4j-reload4j` to 2.0.17, drop unused `tomcat-coyote`
+
+### Fixed
+- `JSONGRDDLFilter` response-side gate scoped per subclass (instance-level property key) with defensive `isApplicable` re-check; prevents cross-fire when multiple subclasses share the client filter chain
+
## [5.5.2] - 2026-06-09
### Changed
- Left sidebar moved to CSR: `ldh:LeftSidebar` emits its own `left-sidebar` wrapper and is injected via `ixsl:append-content`; SSR `bs2:DocumentTree` placeholder dropped
diff --git a/pom.xml b/pom.xml
index 6a3842b5c..d0ab17ca9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
com.atomgraph
linkeddatahub
- 5.5.2
+ 5.5.3-SNAPSHOT
${packaging.type}
AtomGraph LinkedDataHub
@@ -46,7 +46,7 @@
https://github.com/AtomGraph/LinkedDataHub
scm:git:git://github.com/AtomGraph/LinkedDataHub.git
scm:git:git@github.com:AtomGraph/LinkedDataHub.git
- linkeddatahub-5.5.2
+ linkeddatahub-2.1.1
@@ -135,11 +135,30 @@
org.apache.jena
jena-arq
6.1.0
+
+
+
+ org.glassfish
+ jakarta.json
+
+
${project.groupId}
twirl
1.1.0
+
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+
+
+ org.slf4j
+ slf4j-reload4j
+ 2.0.17
${project.groupId}
@@ -168,13 +187,6 @@
jsoup
1.22.1
-
-
- org.apache.tomcat
- tomcat-coyote
- 10.1.52
- jar
-
org.junit.jupiter
junit-jupiter
diff --git a/src/main/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilter.java b/src/main/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilter.java
index 5dfe2e790..4029308b7 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilter.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilter.java
@@ -76,41 +76,45 @@ public JSONGRDDLFilter(XsltCompiler xsltCompiler, String stylesheetPath) throws
if (log.isDebugEnabled()) log.debug("Compiled GRDDL stylesheet from {} for {}", stylesheetPath, getClass().getSimpleName());
}
- private static final String ORIGINAL_URI_PROPERTY = "com.atomgraph.linkeddatahub.originalRequestURI";
-
+ private final String originalURIProperty = "com.atomgraph.linkeddatahub.originalRequestURI." + getClass().getName();
+
@Override
public void filter(ClientRequestContext requestContext) throws IOException
{
URI requestURI = requestContext.getUri();
-
+
// Check if this request should be processed by the GRDDL filter
if (!isApplicable(requestURI))
return;
-
+
// Get the JSON API endpoint URL
URI jsonURI = getJSONURI(requestURI);
if (jsonURI == null)
return;
-
+
// Store original URI in request context for thread-safe response processing
- requestContext.setProperty(ORIGINAL_URI_PROPERTY, requestURI);
-
+ requestContext.setProperty(originalURIProperty, requestURI);
+
// Redirect request to JSON API endpoint
requestContext.setUri(jsonURI);
-
+
if (log.isDebugEnabled()) log.debug("Redirecting request from {} to {}", requestURI, jsonURI);
}
-
+
@Override
public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) throws IOException
{
// Get the original URI from request context
- URI originalRequestURI = (URI) requestContext.getProperty(ORIGINAL_URI_PROPERTY);
-
+ URI originalRequestURI = (URI) requestContext.getProperty(originalURIProperty);
+
// Only process responses if we redirected the original request
if (originalRequestURI == null)
return;
-
+
+ // Defensive re-check: same subclass registered twice, or future subclass with shared key
+ if (!isApplicable(originalRequestURI))
+ return;
+
// Check if response is JSON
MediaType contentType = responseContext.getMediaType();
if (contentType == null || !MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType))
diff --git a/src/test/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilterTest.java b/src/test/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilterTest.java
new file mode 100644
index 000000000..ef3287c6e
--- /dev/null
+++ b/src/test/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilterTest.java
@@ -0,0 +1,301 @@
+/**
+ * Copyright 2025 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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 com.atomgraph.linkeddatahub.client.filter;
+
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientRequestContext;
+import jakarta.ws.rs.client.ClientResponseContext;
+import jakarta.ws.rs.core.Configuration;
+import jakarta.ws.rs.core.Cookie;
+import jakarta.ws.rs.core.EntityTag;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Link;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.NewCookie;
+import jakarta.ws.rs.core.Response;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import net.sf.saxon.s9api.Processor;
+import net.sf.saxon.s9api.SaxonApiException;
+import net.sf.saxon.s9api.XsltCompiler;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link JSONGRDDLFilter}.
+ *
+ * @author {@literal Martynas Jusevičius }
+ */
+public class JSONGRDDLFilterTest
+{
+
+ private static final String STYLESHEET_PATH = "/com/atomgraph/linkeddatahub/client/filter/test-grddl.xsl";
+
+ private static XsltCompiler compiler()
+ {
+ return new Processor(false).newXsltCompiler();
+ }
+
+ /** Concrete subclass parameterised by URI substring marker. Two distinct subclasses below exercise the per-subclass property key. */
+ private static class MarkerGRDDLFilter extends JSONGRDDLFilter
+ {
+ private final String marker;
+
+ MarkerGRDDLFilter(XsltCompiler xc, String marker) throws SaxonApiException
+ {
+ super(xc, STYLESHEET_PATH);
+ this.marker = marker;
+ }
+
+ @Override protected boolean isApplicable(URI uri) { return uri.toString().contains(marker); }
+ @Override protected URI getJSONURI(URI uri) { return URI.create("https://json.example.org/api?u=" + uri); }
+ }
+
+ private static class TestGRDDLFilterA extends MarkerGRDDLFilter
+ {
+ TestGRDDLFilterA(XsltCompiler xc) throws SaxonApiException { super(xc, "youtube"); }
+ }
+
+ private static class TestGRDDLFilterB extends MarkerGRDDLFilter
+ {
+ TestGRDDLFilterB(XsltCompiler xc) throws SaxonApiException { super(xc, "vimeo"); }
+ }
+
+ @Test
+ public void testRequestPassesThroughWhenNotApplicable() throws IOException, SaxonApiException
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ URI original = URI.create("https://example.org/page");
+ StubRequestContext req = new StubRequestContext(original);
+
+ filter.filter(req);
+
+ assertEquals(original, req.getUri(), "URI must not be rewritten");
+ assertTrue(req.getPropertyNames().isEmpty(), "No property must be set");
+ }
+
+ @Test
+ public void testRequestRedirectsWhenApplicable() throws IOException, SaxonApiException
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ URI original = URI.create("https://www.youtube.com/watch?v=abc");
+ StubRequestContext req = new StubRequestContext(original);
+
+ filter.filter(req);
+
+ assertNotEquals(original, req.getUri(), "URI must be redirected");
+ assertTrue(req.getUri().toString().startsWith("https://json.example.org/api?u="), "URI must be the JSON endpoint");
+ assertEquals(original, req.getProperty(propertyKey(filter)), "Original URI must be stored under the subclass-scoped property");
+ }
+
+ @Test
+ public void testResponseSkippedWhenPropertyUnset() throws IOException, SaxonApiException
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ StubRequestContext req = new StubRequestContext(URI.create("https://json.example.org/api"));
+ StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"a\":1}");
+ InputStream before = res.getEntityStream();
+
+ filter.filter(req, res);
+
+ assertSame(before, res.getEntityStream(), "Entity must not be touched when property is unset");
+ assertTrue(res.getHeaders().isEmpty(), "Headers must not be touched when property is unset");
+ }
+
+ @Test
+ public void testResponseSkippedWhenNotJSON() throws IOException, SaxonApiException
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ URI original = URI.create("https://www.youtube.com/watch?v=abc");
+ StubRequestContext req = new StubRequestContext(original);
+ filter.filter(req); // sets property
+ StubResponseContext res = new StubResponseContext(MediaType.TEXT_HTML_TYPE, "");
+ InputStream before = res.getEntityStream();
+
+ filter.filter(req, res);
+
+ assertSame(before, res.getEntityStream(), "Entity must not be touched for non-JSON responses");
+ assertTrue(res.getHeaders().isEmpty(), "Headers must not be touched for non-JSON responses");
+ }
+
+ @Test
+ public void testResponseSkippedWhenIsApplicableNoLongerHolds() throws IOException, SaxonApiException
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ StubRequestContext req = new StubRequestContext(URI.create("https://json.example.org/api"));
+ // Simulate a stale or alien property value under this filter's key — a URI this filter would not handle.
+ req.setProperty(propertyKey(filter), URI.create("https://vimeo.com/123"));
+ StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"a\":1}");
+ InputStream before = res.getEntityStream();
+
+ filter.filter(req, res);
+
+ assertSame(before, res.getEntityStream(), "Defensive isApplicable re-check must skip non-matching original URIs");
+ assertTrue(res.getHeaders().isEmpty(), "Defensive isApplicable re-check must leave headers untouched");
+ }
+
+ @Test
+ public void testResponseTransformsJSONToRDF() throws Exception
+ {
+ TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
+ URI original = URI.create("https://www.youtube.com/watch?v=abc");
+ StubRequestContext req = new StubRequestContext(original);
+ filter.filter(req); // sets property and redirects
+ StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"title\":\"x\"}");
+
+ filter.filter(req, res);
+
+ assertEquals(com.atomgraph.core.MediaType.APPLICATION_RDF_XML, res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Content-Type must be set to RDF/XML");
+ assertNotNull(res.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH), "Content-Length must be set");
+ String body = new String(res.getEntityStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertTrue(body.contains("rdf:RDF"), "Body must be RDF/XML; got: " + body);
+ assertTrue(body.contains(original.toString()), "Body must reference original request URI; got: " + body);
+ assertTrue(body.contains("{"title":"x"}") || body.contains("{\"title\":\"x\"}"), "Body must include the JSON payload (escaped or raw); got: " + body);
+ }
+
+ /**
+ * Two distinct {@link JSONGRDDLFilter} subclasses on one chain. When subclass A's request side matches and
+ * stores its property, subclass B's response side must NOT see that property — the per-subclass property
+ * key (Option B) is what isolates them.
+ */
+ @Test
+ public void testMultiSubclassPropertyIsolation() throws Exception
+ {
+ TestGRDDLFilterA filterA = new TestGRDDLFilterA(compiler());
+ TestGRDDLFilterB filterB = new TestGRDDLFilterB(compiler());
+ URI original = URI.create("https://www.youtube.com/watch?v=abc");
+ StubRequestContext req = new StubRequestContext(original);
+
+ filterA.filter(req); // matches youtube → sets property under A's key
+ filterB.filter(req); // input is now the JSON endpoint URI; B's isApplicable("vimeo") is false → no-op
+
+ StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"title\":\"x\"}");
+
+ // Filter B's response side: property under B's key is unset, so it must skip entirely.
+ filterB.filter(req, res);
+ assertNull(res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Filter B must not touch headers — its property is unset");
+ String beforeBody = new String(((ByteArrayInputStream) res.getEntityStream()).readAllBytes(), StandardCharsets.UTF_8);
+ assertEquals("{\"title\":\"x\"}", beforeBody, "Filter B must not touch the entity");
+
+ // Restore JSON entity for filter A.
+ res.setEntityStream(new ByteArrayInputStream("{\"title\":\"x\"}".getBytes(StandardCharsets.UTF_8)));
+
+ // Filter A's response side: its property is set and its URI is applicable → must transform.
+ filterA.filter(req, res);
+ assertEquals(com.atomgraph.core.MediaType.APPLICATION_RDF_XML, res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Filter A must transform when its property is set");
+ }
+
+ /** Reconstructs the per-subclass property key the same way {@link JSONGRDDLFilter} does, for direct stub manipulation. */
+ private static String propertyKey(JSONGRDDLFilter filter)
+ {
+ return "com.atomgraph.linkeddatahub.originalRequestURI." + filter.getClass().getName();
+ }
+
+ private static class StubRequestContext implements ClientRequestContext
+ {
+ private URI uri;
+ private final Map properties = new HashMap<>();
+ private final MultivaluedMap headers = new MultivaluedHashMap<>();
+
+ StubRequestContext(URI uri) { this.uri = uri; }
+
+ @Override public URI getUri() { return uri; }
+ @Override public void setUri(URI uri) { this.uri = uri; }
+ @Override public Object getProperty(String name) { return properties.get(name); }
+ @Override public Collection getPropertyNames() { return properties.keySet(); }
+ @Override public void setProperty(String name, Object object) { properties.put(name, object); }
+ @Override public void removeProperty(String name) { properties.remove(name); }
+ @Override public MultivaluedMap getHeaders() { return headers; }
+
+ @Override public String getMethod() { throw new UnsupportedOperationException(); }
+ @Override public void setMethod(String method) { throw new UnsupportedOperationException(); }
+ @Override public MultivaluedMap getStringHeaders() { throw new UnsupportedOperationException(); }
+ @Override public String getHeaderString(String name) { throw new UnsupportedOperationException(); }
+ @Override public Date getDate() { throw new UnsupportedOperationException(); }
+ @Override public Locale getLanguage() { throw new UnsupportedOperationException(); }
+ @Override public MediaType getMediaType() { throw new UnsupportedOperationException(); }
+ @Override public List getAcceptableMediaTypes() { throw new UnsupportedOperationException(); }
+ @Override public List getAcceptableLanguages() { throw new UnsupportedOperationException(); }
+ @Override public Map getCookies() { throw new UnsupportedOperationException(); }
+ @Override public boolean hasEntity() { throw new UnsupportedOperationException(); }
+ @Override public Object getEntity() { throw new UnsupportedOperationException(); }
+ @Override public Class> getEntityClass() { throw new UnsupportedOperationException(); }
+ @Override public Type getEntityType() { throw new UnsupportedOperationException(); }
+ @Override public void setEntity(Object entity) { throw new UnsupportedOperationException(); }
+ @Override public void setEntity(Object entity, Annotation[] annotations, MediaType mediaType) { throw new UnsupportedOperationException(); }
+ @Override public Annotation[] getEntityAnnotations() { throw new UnsupportedOperationException(); }
+ @Override public OutputStream getEntityStream() { throw new UnsupportedOperationException(); }
+ @Override public void setEntityStream(OutputStream outputStream) { throw new UnsupportedOperationException(); }
+ @Override public Client getClient() { throw new UnsupportedOperationException(); }
+ @Override public Configuration getConfiguration() { throw new UnsupportedOperationException(); }
+ @Override public void abortWith(Response response) { throw new UnsupportedOperationException(); }
+ }
+
+ private static class StubResponseContext implements ClientResponseContext
+ {
+ private InputStream entityStream;
+ private final MediaType mediaType;
+ private final MultivaluedMap headers = new MultivaluedHashMap<>();
+
+ StubResponseContext(MediaType mediaType, String body)
+ {
+ this.mediaType = mediaType;
+ this.entityStream = body == null ? null : new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override public MediaType getMediaType() { return mediaType; }
+ @Override public InputStream getEntityStream() { return entityStream; }
+ @Override public void setEntityStream(InputStream input) { this.entityStream = input; }
+ @Override public MultivaluedMap getHeaders() { return headers; }
+ @Override public boolean hasEntity() { return entityStream != null; }
+
+ @Override public int getStatus() { throw new UnsupportedOperationException(); }
+ @Override public void setStatus(int code) { throw new UnsupportedOperationException(); }
+ @Override public Response.StatusType getStatusInfo() { throw new UnsupportedOperationException(); }
+ @Override public void setStatusInfo(Response.StatusType statusInfo) { throw new UnsupportedOperationException(); }
+ @Override public String getHeaderString(String name) { throw new UnsupportedOperationException(); }
+ @Override public Set getAllowedMethods() { return new HashSet<>(); }
+ @Override public Date getDate() { throw new UnsupportedOperationException(); }
+ @Override public Locale getLanguage() { throw new UnsupportedOperationException(); }
+ @Override public int getLength() { throw new UnsupportedOperationException(); }
+ @Override public Map getCookies() { throw new UnsupportedOperationException(); }
+ @Override public Date getLastModified() { throw new UnsupportedOperationException(); }
+ @Override public EntityTag getEntityTag() { throw new UnsupportedOperationException(); }
+ @Override public URI getLocation() { throw new UnsupportedOperationException(); }
+ @Override public Set getLinks() { return new HashSet<>(); }
+ @Override public boolean hasLink(String relation) { return false; }
+ @Override public Link getLink(String relation) { return null; }
+ @Override public Link.Builder getLinkBuilder(String relation) { return null; }
+ }
+
+}
diff --git a/src/test/resources/com/atomgraph/linkeddatahub/client/filter/test-grddl.xsl b/src/test/resources/com/atomgraph/linkeddatahub/client/filter/test-grddl.xsl
new file mode 100644
index 000000000..e7c81c6a8
--- /dev/null
+++ b/src/test/resources/com/atomgraph/linkeddatahub/client/filter/test-grddl.xsl
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+