Skip to content

Commit 710f179

Browse files
committed
Scope JSONGRDDLFilter property key per subclass and re-check applicability
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d53d0be commit 710f179

3 files changed

Lines changed: 335 additions & 12 deletions

File tree

src/main/java/com/atomgraph/linkeddatahub/client/filter/JSONGRDDLFilter.java

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,41 +76,45 @@ public JSONGRDDLFilter(XsltCompiler xsltCompiler, String stylesheetPath) throws
7676
if (log.isDebugEnabled()) log.debug("Compiled GRDDL stylesheet from {} for {}", stylesheetPath, getClass().getSimpleName());
7777
}
7878

79-
private static final String ORIGINAL_URI_PROPERTY = "com.atomgraph.linkeddatahub.originalRequestURI";
80-
79+
private final String originalURIProperty = "com.atomgraph.linkeddatahub.originalRequestURI." + getClass().getName();
80+
8181
@Override
8282
public void filter(ClientRequestContext requestContext) throws IOException
8383
{
8484
URI requestURI = requestContext.getUri();
85-
85+
8686
// Check if this request should be processed by the GRDDL filter
8787
if (!isApplicable(requestURI))
8888
return;
89-
89+
9090
// Get the JSON API endpoint URL
9191
URI jsonURI = getJSONURI(requestURI);
9292
if (jsonURI == null)
9393
return;
94-
94+
9595
// Store original URI in request context for thread-safe response processing
96-
requestContext.setProperty(ORIGINAL_URI_PROPERTY, requestURI);
97-
96+
requestContext.setProperty(originalURIProperty, requestURI);
97+
9898
// Redirect request to JSON API endpoint
9999
requestContext.setUri(jsonURI);
100-
100+
101101
if (log.isDebugEnabled()) log.debug("Redirecting request from {} to {}", requestURI, jsonURI);
102102
}
103-
103+
104104
@Override
105105
public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) throws IOException
106106
{
107107
// Get the original URI from request context
108-
URI originalRequestURI = (URI) requestContext.getProperty(ORIGINAL_URI_PROPERTY);
109-
108+
URI originalRequestURI = (URI) requestContext.getProperty(originalURIProperty);
109+
110110
// Only process responses if we redirected the original request
111111
if (originalRequestURI == null)
112112
return;
113-
113+
114+
// Defensive re-check: same subclass registered twice, or future subclass with shared key
115+
if (!isApplicable(originalRequestURI))
116+
return;
117+
114118
// Check if response is JSON
115119
MediaType contentType = responseContext.getMediaType();
116120
if (contentType == null || !MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType))
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* Copyright 2025 Martynas Jusevičius <martynas@atomgraph.com>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
package com.atomgraph.linkeddatahub.client.filter;
18+
19+
import jakarta.ws.rs.client.Client;
20+
import jakarta.ws.rs.client.ClientRequestContext;
21+
import jakarta.ws.rs.client.ClientResponseContext;
22+
import jakarta.ws.rs.core.Configuration;
23+
import jakarta.ws.rs.core.Cookie;
24+
import jakarta.ws.rs.core.EntityTag;
25+
import jakarta.ws.rs.core.HttpHeaders;
26+
import jakarta.ws.rs.core.Link;
27+
import jakarta.ws.rs.core.MediaType;
28+
import jakarta.ws.rs.core.MultivaluedHashMap;
29+
import jakarta.ws.rs.core.MultivaluedMap;
30+
import jakarta.ws.rs.core.NewCookie;
31+
import jakarta.ws.rs.core.Response;
32+
import java.io.ByteArrayInputStream;
33+
import java.io.IOException;
34+
import java.io.InputStream;
35+
import java.io.OutputStream;
36+
import java.lang.annotation.Annotation;
37+
import java.lang.reflect.Type;
38+
import java.net.URI;
39+
import java.nio.charset.StandardCharsets;
40+
import java.util.Collection;
41+
import java.util.Date;
42+
import java.util.HashMap;
43+
import java.util.HashSet;
44+
import java.util.List;
45+
import java.util.Locale;
46+
import java.util.Map;
47+
import java.util.Set;
48+
import net.sf.saxon.s9api.Processor;
49+
import net.sf.saxon.s9api.SaxonApiException;
50+
import net.sf.saxon.s9api.XsltCompiler;
51+
import org.junit.jupiter.api.Test;
52+
import static org.junit.jupiter.api.Assertions.*;
53+
54+
/**
55+
* Unit tests for {@link JSONGRDDLFilter}.
56+
*
57+
* @author {@literal Martynas Jusevičius <martynas@atomgraph.com>}
58+
*/
59+
public class JSONGRDDLFilterTest
60+
{
61+
62+
private static final String STYLESHEET_PATH = "/com/atomgraph/linkeddatahub/client/filter/test-grddl.xsl";
63+
64+
private static XsltCompiler compiler()
65+
{
66+
return new Processor(false).newXsltCompiler();
67+
}
68+
69+
/** Concrete subclass parameterised by URI substring marker. Two distinct subclasses below exercise the per-subclass property key. */
70+
private static class MarkerGRDDLFilter extends JSONGRDDLFilter
71+
{
72+
private final String marker;
73+
74+
MarkerGRDDLFilter(XsltCompiler xc, String marker) throws SaxonApiException
75+
{
76+
super(xc, STYLESHEET_PATH);
77+
this.marker = marker;
78+
}
79+
80+
@Override protected boolean isApplicable(URI uri) { return uri.toString().contains(marker); }
81+
@Override protected URI getJSONURI(URI uri) { return URI.create("https://json.example.org/api?u=" + uri); }
82+
}
83+
84+
private static class TestGRDDLFilterA extends MarkerGRDDLFilter
85+
{
86+
TestGRDDLFilterA(XsltCompiler xc) throws SaxonApiException { super(xc, "youtube"); }
87+
}
88+
89+
private static class TestGRDDLFilterB extends MarkerGRDDLFilter
90+
{
91+
TestGRDDLFilterB(XsltCompiler xc) throws SaxonApiException { super(xc, "vimeo"); }
92+
}
93+
94+
@Test
95+
public void testRequestPassesThroughWhenNotApplicable() throws IOException, SaxonApiException
96+
{
97+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
98+
URI original = URI.create("https://example.org/page");
99+
StubRequestContext req = new StubRequestContext(original);
100+
101+
filter.filter(req);
102+
103+
assertEquals(original, req.getUri(), "URI must not be rewritten");
104+
assertTrue(req.getPropertyNames().isEmpty(), "No property must be set");
105+
}
106+
107+
@Test
108+
public void testRequestRedirectsWhenApplicable() throws IOException, SaxonApiException
109+
{
110+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
111+
URI original = URI.create("https://www.youtube.com/watch?v=abc");
112+
StubRequestContext req = new StubRequestContext(original);
113+
114+
filter.filter(req);
115+
116+
assertNotEquals(original, req.getUri(), "URI must be redirected");
117+
assertTrue(req.getUri().toString().startsWith("https://json.example.org/api?u="), "URI must be the JSON endpoint");
118+
assertEquals(original, req.getProperty(propertyKey(filter)), "Original URI must be stored under the subclass-scoped property");
119+
}
120+
121+
@Test
122+
public void testResponseSkippedWhenPropertyUnset() throws IOException, SaxonApiException
123+
{
124+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
125+
StubRequestContext req = new StubRequestContext(URI.create("https://json.example.org/api"));
126+
StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"a\":1}");
127+
InputStream before = res.getEntityStream();
128+
129+
filter.filter(req, res);
130+
131+
assertSame(before, res.getEntityStream(), "Entity must not be touched when property is unset");
132+
assertTrue(res.getHeaders().isEmpty(), "Headers must not be touched when property is unset");
133+
}
134+
135+
@Test
136+
public void testResponseSkippedWhenNotJSON() throws IOException, SaxonApiException
137+
{
138+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
139+
URI original = URI.create("https://www.youtube.com/watch?v=abc");
140+
StubRequestContext req = new StubRequestContext(original);
141+
filter.filter(req); // sets property
142+
StubResponseContext res = new StubResponseContext(MediaType.TEXT_HTML_TYPE, "<html/>");
143+
InputStream before = res.getEntityStream();
144+
145+
filter.filter(req, res);
146+
147+
assertSame(before, res.getEntityStream(), "Entity must not be touched for non-JSON responses");
148+
assertTrue(res.getHeaders().isEmpty(), "Headers must not be touched for non-JSON responses");
149+
}
150+
151+
@Test
152+
public void testResponseSkippedWhenIsApplicableNoLongerHolds() throws IOException, SaxonApiException
153+
{
154+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
155+
StubRequestContext req = new StubRequestContext(URI.create("https://json.example.org/api"));
156+
// Simulate a stale or alien property value under this filter's key — a URI this filter would not handle.
157+
req.setProperty(propertyKey(filter), URI.create("https://vimeo.com/123"));
158+
StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"a\":1}");
159+
InputStream before = res.getEntityStream();
160+
161+
filter.filter(req, res);
162+
163+
assertSame(before, res.getEntityStream(), "Defensive isApplicable re-check must skip non-matching original URIs");
164+
assertTrue(res.getHeaders().isEmpty(), "Defensive isApplicable re-check must leave headers untouched");
165+
}
166+
167+
@Test
168+
public void testResponseTransformsJSONToRDF() throws Exception
169+
{
170+
TestGRDDLFilterA filter = new TestGRDDLFilterA(compiler());
171+
URI original = URI.create("https://www.youtube.com/watch?v=abc");
172+
StubRequestContext req = new StubRequestContext(original);
173+
filter.filter(req); // sets property and redirects
174+
StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"title\":\"x\"}");
175+
176+
filter.filter(req, res);
177+
178+
assertEquals(com.atomgraph.core.MediaType.APPLICATION_RDF_XML, res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Content-Type must be set to RDF/XML");
179+
assertNotNull(res.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH), "Content-Length must be set");
180+
String body = new String(res.getEntityStream().readAllBytes(), StandardCharsets.UTF_8);
181+
assertTrue(body.contains("rdf:RDF"), "Body must be RDF/XML; got: " + body);
182+
assertTrue(body.contains(original.toString()), "Body must reference original request URI; got: " + body);
183+
assertTrue(body.contains("{&quot;title&quot;:&quot;x&quot;}") || body.contains("{\"title\":\"x\"}"), "Body must include the JSON payload (escaped or raw); got: " + body);
184+
}
185+
186+
/**
187+
* Two distinct {@link JSONGRDDLFilter} subclasses on one chain. When subclass A's request side matches and
188+
* stores its property, subclass B's response side must NOT see that property — the per-subclass property
189+
* key (Option B) is what isolates them.
190+
*/
191+
@Test
192+
public void testMultiSubclassPropertyIsolation() throws Exception
193+
{
194+
TestGRDDLFilterA filterA = new TestGRDDLFilterA(compiler());
195+
TestGRDDLFilterB filterB = new TestGRDDLFilterB(compiler());
196+
URI original = URI.create("https://www.youtube.com/watch?v=abc");
197+
StubRequestContext req = new StubRequestContext(original);
198+
199+
filterA.filter(req); // matches youtube → sets property under A's key
200+
filterB.filter(req); // input is now the JSON endpoint URI; B's isApplicable("vimeo") is false → no-op
201+
202+
StubResponseContext res = new StubResponseContext(MediaType.APPLICATION_JSON_TYPE, "{\"title\":\"x\"}");
203+
204+
// Filter B's response side: property under B's key is unset, so it must skip entirely.
205+
filterB.filter(req, res);
206+
assertNull(res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Filter B must not touch headers — its property is unset");
207+
String beforeBody = new String(((ByteArrayInputStream) res.getEntityStream()).readAllBytes(), StandardCharsets.UTF_8);
208+
assertEquals("{\"title\":\"x\"}", beforeBody, "Filter B must not touch the entity");
209+
210+
// Restore JSON entity for filter A.
211+
res.setEntityStream(new ByteArrayInputStream("{\"title\":\"x\"}".getBytes(StandardCharsets.UTF_8)));
212+
213+
// Filter A's response side: its property is set and its URI is applicable → must transform.
214+
filterA.filter(req, res);
215+
assertEquals(com.atomgraph.core.MediaType.APPLICATION_RDF_XML, res.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE), "Filter A must transform when its property is set");
216+
}
217+
218+
/** Reconstructs the per-subclass property key the same way {@link JSONGRDDLFilter} does, for direct stub manipulation. */
219+
private static String propertyKey(JSONGRDDLFilter filter)
220+
{
221+
return "com.atomgraph.linkeddatahub.originalRequestURI." + filter.getClass().getName();
222+
}
223+
224+
private static class StubRequestContext implements ClientRequestContext
225+
{
226+
private URI uri;
227+
private final Map<String, Object> properties = new HashMap<>();
228+
private final MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
229+
230+
StubRequestContext(URI uri) { this.uri = uri; }
231+
232+
@Override public URI getUri() { return uri; }
233+
@Override public void setUri(URI uri) { this.uri = uri; }
234+
@Override public Object getProperty(String name) { return properties.get(name); }
235+
@Override public Collection<String> getPropertyNames() { return properties.keySet(); }
236+
@Override public void setProperty(String name, Object object) { properties.put(name, object); }
237+
@Override public void removeProperty(String name) { properties.remove(name); }
238+
@Override public MultivaluedMap<String, Object> getHeaders() { return headers; }
239+
240+
@Override public String getMethod() { throw new UnsupportedOperationException(); }
241+
@Override public void setMethod(String method) { throw new UnsupportedOperationException(); }
242+
@Override public MultivaluedMap<String, String> getStringHeaders() { throw new UnsupportedOperationException(); }
243+
@Override public String getHeaderString(String name) { throw new UnsupportedOperationException(); }
244+
@Override public Date getDate() { throw new UnsupportedOperationException(); }
245+
@Override public Locale getLanguage() { throw new UnsupportedOperationException(); }
246+
@Override public MediaType getMediaType() { throw new UnsupportedOperationException(); }
247+
@Override public List<MediaType> getAcceptableMediaTypes() { throw new UnsupportedOperationException(); }
248+
@Override public List<Locale> getAcceptableLanguages() { throw new UnsupportedOperationException(); }
249+
@Override public Map<String, Cookie> getCookies() { throw new UnsupportedOperationException(); }
250+
@Override public boolean hasEntity() { throw new UnsupportedOperationException(); }
251+
@Override public Object getEntity() { throw new UnsupportedOperationException(); }
252+
@Override public Class<?> getEntityClass() { throw new UnsupportedOperationException(); }
253+
@Override public Type getEntityType() { throw new UnsupportedOperationException(); }
254+
@Override public void setEntity(Object entity) { throw new UnsupportedOperationException(); }
255+
@Override public void setEntity(Object entity, Annotation[] annotations, MediaType mediaType) { throw new UnsupportedOperationException(); }
256+
@Override public Annotation[] getEntityAnnotations() { throw new UnsupportedOperationException(); }
257+
@Override public OutputStream getEntityStream() { throw new UnsupportedOperationException(); }
258+
@Override public void setEntityStream(OutputStream outputStream) { throw new UnsupportedOperationException(); }
259+
@Override public Client getClient() { throw new UnsupportedOperationException(); }
260+
@Override public Configuration getConfiguration() { throw new UnsupportedOperationException(); }
261+
@Override public void abortWith(Response response) { throw new UnsupportedOperationException(); }
262+
}
263+
264+
private static class StubResponseContext implements ClientResponseContext
265+
{
266+
private InputStream entityStream;
267+
private final MediaType mediaType;
268+
private final MultivaluedMap<String, String> headers = new MultivaluedHashMap<>();
269+
270+
StubResponseContext(MediaType mediaType, String body)
271+
{
272+
this.mediaType = mediaType;
273+
this.entityStream = body == null ? null : new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
274+
}
275+
276+
@Override public MediaType getMediaType() { return mediaType; }
277+
@Override public InputStream getEntityStream() { return entityStream; }
278+
@Override public void setEntityStream(InputStream input) { this.entityStream = input; }
279+
@Override public MultivaluedMap<String, String> getHeaders() { return headers; }
280+
@Override public boolean hasEntity() { return entityStream != null; }
281+
282+
@Override public int getStatus() { throw new UnsupportedOperationException(); }
283+
@Override public void setStatus(int code) { throw new UnsupportedOperationException(); }
284+
@Override public Response.StatusType getStatusInfo() { throw new UnsupportedOperationException(); }
285+
@Override public void setStatusInfo(Response.StatusType statusInfo) { throw new UnsupportedOperationException(); }
286+
@Override public String getHeaderString(String name) { throw new UnsupportedOperationException(); }
287+
@Override public Set<String> getAllowedMethods() { return new HashSet<>(); }
288+
@Override public Date getDate() { throw new UnsupportedOperationException(); }
289+
@Override public Locale getLanguage() { throw new UnsupportedOperationException(); }
290+
@Override public int getLength() { throw new UnsupportedOperationException(); }
291+
@Override public Map<String, NewCookie> getCookies() { throw new UnsupportedOperationException(); }
292+
@Override public Date getLastModified() { throw new UnsupportedOperationException(); }
293+
@Override public EntityTag getEntityTag() { throw new UnsupportedOperationException(); }
294+
@Override public URI getLocation() { throw new UnsupportedOperationException(); }
295+
@Override public Set<Link> getLinks() { return new HashSet<>(); }
296+
@Override public boolean hasLink(String relation) { return false; }
297+
@Override public Link getLink(String relation) { return null; }
298+
@Override public Link.Builder getLinkBuilder(String relation) { return null; }
299+
}
300+
301+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xsl:stylesheet version="3.0"
3+
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
4+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
5+
xmlns:xs="http://www.w3.org/2001/XMLSchema">
6+
7+
<xsl:param name="json" as="xs:string?"/>
8+
<xsl:param name="request-uri" as="xs:string?"/>
9+
10+
<xsl:template name="xsl:initial-template">
11+
<rdf:RDF>
12+
<rdf:Description rdf:about="{$request-uri}">
13+
<rdf:value><xsl:value-of select="$json"/></rdf:value>
14+
</rdf:Description>
15+
</rdf:RDF>
16+
</xsl:template>
17+
18+
</xsl:stylesheet>

0 commit comments

Comments
 (0)