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 @@ + + + + + + + + + + + + + + +