|
| 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("{"title":"x"}") || 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 | +} |
0 commit comments