Skip to content

Commit 264010f

Browse files
committed
New JSONGRDDLFilter feature
Embedding YouTube resources as Linked Data. Also added `resource-resolver.js`.
1 parent 66bb6e8 commit 264010f

14 files changed

Lines changed: 651 additions & 120 deletions

File tree

src/main/java/com/atomgraph/linkeddatahub/Application.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
import com.atomgraph.client.util.XsltResolver;
6969
import com.atomgraph.linkeddatahub.client.LinkedDataClient;
7070
import com.atomgraph.linkeddatahub.client.filter.ClientUriRewriteFilter;
71+
import com.atomgraph.linkeddatahub.client.filter.grddl.YouTubeGRDDLFilter;
7172
import com.atomgraph.linkeddatahub.imports.ImportExecutor;
7273
import com.atomgraph.linkeddatahub.io.HtmlJsonLDReaderFactory;
7374
import com.atomgraph.linkeddatahub.io.JsonLDReader;
@@ -839,6 +840,7 @@ public void init()
839840
registerContainerRequestFilters();
840841
registerContainerResponseFilters();
841842
registerExceptionMappers();
843+
registerClientFilters();
842844

843845
eventBus.register(this); // this system application will be receiving events about context changes
844846

@@ -1027,6 +1029,24 @@ protected void registerExceptionMappers()
10271029
register(MessagingExceptionMapper.class);
10281030
}
10291031

1032+
/**
1033+
* Registers JAX-RS client filters.
1034+
*/
1035+
protected void registerClientFilters()
1036+
{
1037+
try
1038+
{
1039+
// Register YouTube GRDDL filter
1040+
YouTubeGRDDLFilter youtubeFilter = new YouTubeGRDDLFilter(xsltComp);
1041+
client.register(youtubeFilter);
1042+
externalClient.register(youtubeFilter);
1043+
}
1044+
catch (SaxonApiException ex)
1045+
{
1046+
if (log.isErrorEnabled()) log.error("Failed to initialize GRDDL client filter");
1047+
}
1048+
}
1049+
10301050
/**
10311051
* Retrieves dataset from file URL.
10321052
*
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 java.io.ByteArrayInputStream;
20+
import java.io.ByteArrayOutputStream;
21+
import java.io.IOException;
22+
import java.io.InputStream;
23+
import java.net.URI;
24+
import java.nio.charset.StandardCharsets;
25+
import java.util.HashMap;
26+
import java.util.Map;
27+
import javax.xml.transform.Source;
28+
import javax.xml.transform.stream.StreamSource;
29+
import com.atomgraph.core.exception.BadGatewayException;
30+
import jakarta.ws.rs.client.ClientRequestContext;
31+
import jakarta.ws.rs.client.ClientRequestFilter;
32+
import jakarta.ws.rs.client.ClientResponseContext;
33+
import jakarta.ws.rs.client.ClientResponseFilter;
34+
import jakarta.ws.rs.core.HttpHeaders;
35+
import jakarta.ws.rs.core.MediaType;
36+
import net.sf.saxon.s9api.QName;
37+
import net.sf.saxon.s9api.SaxonApiException;
38+
import net.sf.saxon.s9api.Serializer;
39+
import net.sf.saxon.s9api.XdmAtomicValue;
40+
import net.sf.saxon.s9api.XdmValue;
41+
import net.sf.saxon.s9api.Xslt30Transformer;
42+
import net.sf.saxon.s9api.XsltCompiler;
43+
import net.sf.saxon.s9api.XsltExecutable;
44+
import org.slf4j.Logger;
45+
import org.slf4j.LoggerFactory;
46+
47+
/**
48+
* Abstract client filter that implements GRDDL pattern for JSON-based web services.
49+
* Redirects original URLs to JSON API endpoints and transforms JSON responses to RDF using XSLT 3.0.
50+
*
51+
* @see <a href="https://www.w3.org/TR/grddl/">Gleaning Resource Descriptions from Dialects of Languages (GRDDL)</a>
52+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
53+
*/
54+
public abstract class JSONGRDDLFilter implements ClientRequestFilter, ClientResponseFilter
55+
{
56+
57+
private static final Logger log = LoggerFactory.getLogger(JSONGRDDLFilter.class);
58+
59+
private final XsltExecutable xsltExecutable;
60+
61+
/**
62+
* Constructs GRDDL filter with XSLT compiler and stylesheet path.
63+
*
64+
* @param xsltCompiler XSLT compiler
65+
* @param stylesheetPath classpath resource path to XSLT stylesheet
66+
* @throws SaxonApiException if stylesheet compilation fails
67+
*/
68+
public JSONGRDDLFilter(XsltCompiler xsltCompiler, String stylesheetPath) throws SaxonApiException
69+
{
70+
if (xsltCompiler == null) throw new IllegalArgumentException("XsltCompiler cannot be null");
71+
if (stylesheetPath == null) throw new IllegalArgumentException("Stylesheet path cannot be null");
72+
73+
Source stylesheetSource = new StreamSource(getClass().getResourceAsStream(stylesheetPath));
74+
this.xsltExecutable = xsltCompiler.compile(stylesheetSource);
75+
76+
if (log.isDebugEnabled()) log.debug("Compiled GRDDL stylesheet from {} for {}", stylesheetPath, getClass().getSimpleName());
77+
}
78+
79+
private URI originalRequestURI; // Store original URI for response processing
80+
81+
@Override
82+
public void filter(ClientRequestContext requestContext) throws IOException
83+
{
84+
URI requestURI = requestContext.getUri();
85+
86+
// Check if this request should be processed by the GRDDL filter
87+
if (!isApplicable(requestURI))
88+
return;
89+
90+
// Get the JSON API endpoint URL
91+
URI jsonURI = getJSONURI(requestURI);
92+
if (jsonURI == null)
93+
return;
94+
95+
// Store original URI for response processing
96+
this.originalRequestURI = requestURI;
97+
98+
// Redirect request to JSON API endpoint
99+
requestContext.setUri(jsonURI);
100+
101+
if (log.isDebugEnabled()) log.debug("Redirecting request from {} to {}", requestURI, jsonURI);
102+
}
103+
104+
@Override
105+
public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) throws IOException
106+
{
107+
// Only process responses if we redirected the original request
108+
if (originalRequestURI == null)
109+
return;
110+
111+
// Check if response is JSON
112+
MediaType contentType = responseContext.getMediaType();
113+
if (contentType == null || !MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType))
114+
{
115+
originalRequestURI = null; // Reset for next request
116+
return;
117+
}
118+
119+
try (InputStream entityStream = responseContext.getEntityStream())
120+
{
121+
// Read the JSON response
122+
String jsonContent = new String(entityStream.readAllBytes(), StandardCharsets.UTF_8);
123+
124+
// Transform JSON to RDF/XML using XSLT 3.0
125+
String rdfXml = transformJSONToRDF(jsonContent, originalRequestURI);
126+
127+
// Replace response entity with RDF/XML
128+
responseContext.setEntityStream(new ByteArrayInputStream(rdfXml.getBytes(StandardCharsets.UTF_8)));
129+
responseContext.getHeaders().putSingle(HttpHeaders.CONTENT_TYPE, com.atomgraph.core.MediaType.APPLICATION_RDF_XML);
130+
responseContext.getHeaders().putSingle(HttpHeaders.CONTENT_LENGTH, String.valueOf(rdfXml.length()));
131+
132+
if (log.isDebugEnabled()) log.debug("Transformed JSON response to RDF for original URI: {}", originalRequestURI);
133+
}
134+
catch (Exception ex)
135+
{
136+
if (log.isErrorEnabled()) log.error("GRDDL transformation failed for URI: {}", originalRequestURI, ex);
137+
throw new BadGatewayException("Failed to transform JSON to RDF", ex);
138+
}
139+
finally
140+
{
141+
originalRequestURI = null; // Reset for next request
142+
}
143+
}
144+
145+
/**
146+
* Transforms JSON content to RDF/XML using XSLT 3.0 initial template.
147+
*
148+
* @param jsonContent JSON content as string
149+
* @param requestURI original request URI for context
150+
* @return RDF/XML as string
151+
* @throws SaxonApiException if Saxon processing fails
152+
* @throws java.io.IOException
153+
*/
154+
protected String transformJSONToRDF(String jsonContent, URI requestURI) throws SaxonApiException, IOException
155+
{
156+
Xslt30Transformer transformer = getXsltExecutable().load30();
157+
158+
// Set parameters - pass JSON as string parameter
159+
Map<QName, XdmValue> parameters = new HashMap<>();
160+
parameters.put(new QName("json"), new XdmAtomicValue(jsonContent));
161+
parameters.put(new QName("request-uri"), new XdmAtomicValue(requestURI.toString()));
162+
transformer.setStylesheetParameters(parameters);
163+
164+
// Transform using initial template
165+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
166+
Serializer serializer = transformer.newSerializer();
167+
serializer.setOutputStream(outputStream);
168+
serializer.setOutputProperty(Serializer.Property.METHOD, "xml");
169+
serializer.setOutputProperty(Serializer.Property.ENCODING, StandardCharsets.UTF_8.name());
170+
171+
transformer.callTemplate(null, serializer);
172+
173+
return outputStream.toString(StandardCharsets.UTF_8);
174+
}
175+
176+
/**
177+
* Determines if this filter is applicable to the given URI.
178+
*
179+
* @param requestURI the request URI
180+
* @return true if this filter should process the URI
181+
*/
182+
protected abstract boolean isApplicable(URI requestURI);
183+
184+
/**
185+
* Returns the JSON API endpoint URI for the given request URI.
186+
* For example, converts YouTube video URL to oEmbed endpoint URL.
187+
*
188+
* @param requestURI the original request URI
189+
* @return JSON API endpoint URI or null if not applicable
190+
*/
191+
protected abstract URI getJSONURI(URI requestURI);
192+
193+
/**
194+
* Returns the XSLT executable for transforming JSON to RDF.
195+
*
196+
* @return XSLT executable
197+
*/
198+
public XsltExecutable getXsltExecutable()
199+
{
200+
return xsltExecutable;
201+
}
202+
203+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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.grddl;
18+
19+
import com.atomgraph.linkeddatahub.client.filter.JSONGRDDLFilter;
20+
import java.net.URI;
21+
import java.net.URLEncoder;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.regex.Pattern;
24+
import net.sf.saxon.s9api.SaxonApiException;
25+
import net.sf.saxon.s9api.XsltCompiler;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
/**
30+
* Client filter that implements GRDDL pattern for YouTube videos.
31+
* Redirects YouTube URLs to oEmbed endpoints and transforms JSON responses to RDF.
32+
*
33+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
34+
*/
35+
public class YouTubeGRDDLFilter extends JSONGRDDLFilter
36+
{
37+
38+
private static final Logger log = LoggerFactory.getLogger(YouTubeGRDDLFilter.class);
39+
40+
// YouTube URL patterns
41+
private static final Pattern YOUTUBE_WATCH_PATTERN = Pattern.compile("https?://(?:www\\.)?youtube\\.com/watch\\?.*v=([a-zA-Z0-9_-]+)");
42+
private static final Pattern YOUTUBE_SHORT_PATTERN = Pattern.compile("https?://youtu\\.be/([a-zA-Z0-9_-]+)");
43+
44+
// YouTube oEmbed endpoint
45+
private static final String OEMBED_ENDPOINT = "https://www.youtube.com/oembed";
46+
47+
/**
48+
* Classpath resource path of XSLT stylesheet for transforming YouTube oEmbed JSON to RDF.
49+
*/
50+
public static final String YOUTUBE_XSLT_PATH = "/com/atomgraph/linkeddatahub/xsl/grddl/youtube.xsl";
51+
52+
/**
53+
* Constructs YouTube GRDDL filter.
54+
*
55+
* @param xsltCompiler XSLT compiler
56+
* @throws SaxonApiException if stylesheet compilation fails
57+
*/
58+
public YouTubeGRDDLFilter(XsltCompiler xsltCompiler) throws SaxonApiException
59+
{
60+
super(xsltCompiler, YOUTUBE_XSLT_PATH);
61+
}
62+
63+
@Override
64+
protected boolean isApplicable(URI requestURI)
65+
{
66+
return isYouTubeURL(requestURI.toString());
67+
}
68+
69+
@Override
70+
protected URI getJSONURI(URI requestURI)
71+
{
72+
String youtubeURL = requestURI.toString();
73+
74+
if (!isYouTubeURL(youtubeURL))
75+
return null;
76+
77+
try
78+
{
79+
// Encode the YouTube URL for the oEmbed endpoint
80+
String encodedURL = URLEncoder.encode(youtubeURL, StandardCharsets.UTF_8);
81+
String oembedURL = OEMBED_ENDPOINT + "?url=" + encodedURL + "&format=json";
82+
83+
if (log.isDebugEnabled()) log.debug("Converting YouTube URL {} to oEmbed URL {}", youtubeURL, oembedURL);
84+
85+
return URI.create(oembedURL);
86+
}
87+
catch (Exception ex)
88+
{
89+
if (log.isErrorEnabled()) log.error("Failed to create oEmbed URL for YouTube URL: {}", youtubeURL, ex);
90+
return null;
91+
}
92+
}
93+
94+
/**
95+
* Determines if the given URL is a YouTube video URL.
96+
* Supports both youtube.com/watch?v= and youtu.be/ formats.
97+
*
98+
* @param url the URL to check
99+
* @return true if it's a YouTube video URL
100+
*/
101+
public static boolean isYouTubeURL(String url)
102+
{
103+
return YOUTUBE_WATCH_PATTERN.matcher(url).matches() ||
104+
YOUTUBE_SHORT_PATTERN.matcher(url).matches();
105+
}
106+
107+
}

0 commit comments

Comments
 (0)