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+ }
0 commit comments