Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>core</artifactId>
<version>5.0.1</version>
<version>5.0.2-SNAPSHOT</version>
</dependency>
<dependency>
<!-- new Jena ontology API (org.apache.jena.ontapi); PrefixGraphRepository extends DocumentGraphRepository -->
Expand Down
5 changes: 2 additions & 3 deletions src/main/java/com/atomgraph/client/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
{
this(new MediaTypes(), getClient(new ClientConfig()),
servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI()) != null ? Integer.valueOf(servletConfig.getServletContext().getInitParameter(A.maxGetRequestSize.getURI())) : null,
servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI())) : false,
getResolver(servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()),
com.atomgraph.client.Application.getClient(new ClientConfig()),
new MediaTypes(),
Expand All @@ -114,7 +113,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti
);
}

public Application(final MediaTypes mediaTypes, final Client client, final Integer maxGetRequestSize, final boolean preemptiveAuth,
public Application(final MediaTypes mediaTypes, final Client client, final Integer maxGetRequestSize,
final RDFSourceResolver resolver, final Source stylesheet, final boolean cacheStylesheet, final boolean resolvingUncached)
{
this.mediaTypes = mediaTypes;
Expand All @@ -135,7 +134,7 @@ public Application(final MediaTypes mediaTypes, final Client client, final Integ
try
{
XsltCompiler xsltComp = xsltProc.newXsltCompiler();
xsltComp.setURIResolver(new StylesheetResolver(resolver.getRepository(), GraphStoreClient.create(client, mediaTypes)));
xsltComp.setURIResolver(new StylesheetResolver(client));
xsltExec = xsltComp.compile(stylesheet);
}
catch (SaxonApiException ex)
Expand Down
71 changes: 24 additions & 47 deletions src/main/java/com/atomgraph/client/util/StylesheetResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
*/
package com.atomgraph.client.util;

import com.atomgraph.core.client.GraphStoreClient;
import com.atomgraph.client.util.jena.PrefixGraphRepository;
import com.atomgraph.client.MediaType;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Response;
import java.io.ByteArrayInputStream;
import java.io.IOException;
Expand All @@ -28,65 +28,52 @@
import javax.xml.transform.URIResolver;
import javax.xml.transform.stream.StreamSource;
import org.apache.commons.io.IOUtils;
import org.apache.jena.atlas.web.TypedInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Resolves XSLT {@code xsl:import}/{@code xsl:include} URIs to raw stylesheet {@link Source}s
* during compilation. Mapped/classpath/file locations are opened via the repository's RIOT stream
* manager; HTTP via the {@link GraphStoreClient}. Unlike {@link RDFSourceResolver} the bytes are
* returned verbatim (stylesheets are XML, not RDF). Replaces the legacy {@code XsltResolver}
* (which extended the FileManager-based {@code DataManagerImpl}).
* during compilation. HTTP(S) locations are fetched with the SSL-configured JAX-RS {@link Client}
* (app stylesheets served over HTTPS need its trust store / client certificate), requesting
* {@code text/xsl}; local ({@code file:}/{@code jar:}/classpath) locations are handed back to Saxon
* by returning {@code null}, so its default resolver opens them. Bytes are returned verbatim
* (stylesheets are XML, not RDF).
*
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
*/
public class StylesheetResolver implements URIResolver
{

private static final Logger log = LoggerFactory.getLogger(StylesheetResolver.class);

private final PrefixGraphRepository repository;
private final GraphStoreClient gsc;
private final Client client;

/**
* Constructs the resolver.
*
* @param repository graph repository for URI→location mapping and classpath/file opening
* @param gsc Graph Store client for HTTP retrieval
* @param client SSL-configured JAX-RS client for HTTP(S) stylesheet retrieval
*/
public StylesheetResolver(PrefixGraphRepository repository, GraphStoreClient gsc)
public StylesheetResolver(Client client)
{
this.repository = repository;
this.gsc = gsc;
this.client = client;
}

@Override
public Source resolve(String href, String base) throws TransformerException
{
URI baseURI = URI.create(base);
URI uri = href.isEmpty() ? baseURI : baseURI.resolve(href);
String location = getRepository().resolve(uri.toString());

try
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme()))
return null; // non-HTTP (file:/jar:/classpath): let Saxon's default resolver open it

try (Response cr = getClient().target(uri).request().accept(MediaType.TEXT_XSL_TYPE).get())
{
if (location.startsWith("http://") || location.startsWith("https://"))
{
try (Response cr = getGraphStoreClient().getClient().target(URI.create(location)).request().get();
InputStream is = cr.readEntity(InputStream.class))
{
byte[] bytes = IOUtils.toByteArray(is); // buffer so we can close the Response
return new StreamSource(new ByteArrayInputStream(bytes), uri.toString());
}
}
if (!cr.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL))
throw new IOException("XSLT stylesheet could not be loaded over HTTP. Status: " + cr.getStatus() + ", URI: " + uri);

TypedInputStream in = getRepository().getStreamManager().open(location);
if (in == null)
// buffer the stylesheet stream so we can close the Response
try (InputStream is = cr.readEntity(InputStream.class))
{
if (log.isWarnEnabled()) log.warn("Could not resolve stylesheet location: {}", location);
return null;
byte[] bytes = IOUtils.toByteArray(is);
return new StreamSource(new ByteArrayInputStream(bytes), uri.toString());
}
return new StreamSource(in, uri.toString());
}
catch (IOException ex)
{
Expand All @@ -95,23 +82,13 @@ public Source resolve(String href, String base) throws TransformerException
}

/**
* Returns the graph repository.
*
* @return repository
*/
public PrefixGraphRepository getRepository()
{
return repository;
}

/**
* Returns the Graph Store client.
* Returns the JAX-RS client used for HTTP(S) retrieval.
*
* @return client
*/
public GraphStoreClient getGraphStoreClient()
public Client getClient()
{
return gsc;
return client;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
package com.atomgraph.client.util.jena;

import com.atomgraph.core.client.GraphStoreClient;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.apache.jena.graph.Graph;
import org.apache.jena.ontapi.impl.repositories.DocumentGraphRepository;
import org.apache.jena.rdf.model.Model;
Expand All @@ -44,7 +47,17 @@
* caching), the {@code PrefixMapper} ({@code LocationMapper} subclass with prefix matching), and
* the ontology {@code ModelGetter} used for {@code owl:imports} resolution. HTTP/HTTPS locations
* are loaded via the {@link GraphStoreClient}; other locations (classpath, file) via a RIOT
* {@link StreamManager}. Loaded graphs are cached by ID in the inherited repository store.
* {@link StreamManager}. Dynamically loaded graphs are cached by ID in a store obtained from
* {@link #createStore()} (overridable, e.g. to supply a bounded/evicting map).
*
* The repository is shared across request threads while the store is a plain unsynchronized map, so
* all store access is synchronized here; loading itself happens outside the lock.
*
* Bundled (non-HTTP mapped) documents are cached by their resolved location rather than by graph
* ID, so that every URI in a mapped namespace shares a single graph. This bounds the cache to the
* number of bundled files regardless of how many distinct URIs are requested — otherwise an actor
* minting distinct URIs under a mapped prefix (e.g. {@code http://xmlns.com/foaf/0.1/<n>}) would
* grow the store without bound.
*
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
*/
Expand All @@ -55,9 +68,24 @@ public class PrefixGraphRepository extends DocumentGraphRepository

private final Map<String, String> exactLocations = new HashMap<>();
private final Map<String, String> prefixLocations = new HashMap<>();
private final Map<String, Graph> store = createStore(); // dynamically loaded graphs, keyed by graph ID
private final Map<String, Graph> mappedGraphs = new HashMap<>(); // bundled documents cached by resolved location, shared across all URIs in the namespace
private final GraphStoreClient gsc;
private final StreamManager streamManager;

/**
* Creates the backing store for dynamically loaded graphs (keyed by graph ID). The default is an
* unbounded {@link HashMap}; subclasses may override to supply a bounded/evicting map. The returned
* map must not depend on subclass state — it is created during construction. The location-keyed
* bundled-document cache is separate and always unbounded (bounded by the number of bundled files).
*
* @return dynamic-graph store
*/
protected Map<String, Graph> createStore()
{
return new HashMap<>();
}

/**
* Constructs the repository with the Graph Store client used for HTTP loading.
*
Expand Down Expand Up @@ -137,13 +165,35 @@ public String resolve(String id)
for (Iterator<String> it = prefixLocations.keySet().iterator(); it.hasNext();)
{
String candidate = it.next();
if (id.startsWith(candidate) && (prefix == null || candidate.length() > prefix.length())) prefix = candidate;
if (matchesPrefix(id, candidate) && (prefix == null || candidate.length() > prefix.length())) prefix = candidate;
}
if (prefix != null) return prefixLocations.get(prefix);

return id;
}

/**
* Returns true if the id falls under the namespace prefix at a URI boundary: the id equals the
* prefix, the prefix already ends at a delimiter, or the character following the prefix in the id
* is a {@code /} or {@code #}. Prevents an unrelated URI (e.g. {@code …/foobar}) from matching a
* shorter prefix (e.g. {@code …/foo}) and resolving to the wrong mapped location.
*
* @param id graph ID
* @param prefix candidate namespace prefix
* @return true if the id is within the prefix namespace
*/
protected static boolean matchesPrefix(String id, String prefix)
{
if (!id.startsWith(prefix)) return false;
if (id.length() == prefix.length()) return true;

char last = prefix.charAt(prefix.length() - 1);
if (last == '/' || last == '#') return true;

char next = id.charAt(prefix.length());
return next == '/' || next == '#';
}

/**
* Returns the exact URI→location mappings (live view).
*
Expand Down Expand Up @@ -182,21 +232,87 @@ public boolean isMapped(String id)
* @param id graph ID
* @return true if cached
*/
public boolean isCached(String id)
public synchronized boolean isCached(String id)
{
return getIds().contains(id);
return store.containsKey(id) || mappedGraphs.containsKey(resolve(id));
}

@Override
public Graph get(String id)
{
if (getIds().contains(id)) return super.get(id); // already loaded into the store
String location;
synchronized (this)
{
Graph cached = store.get(id); // explicitly cached (e.g. a materialized ontology) or non-mapped
if (cached != null) return cached;
location = resolve(id);
Graph mapped = mappedGraphs.get(location);
if (mapped != null) return mapped; // bundled document already loaded — shared across the namespace
}

String location = resolve(id);
if (log.isDebugEnabled()) log.debug("Loading graph '{}' from location '{}'", id, location);
Graph graph = load(id, location);
put(id, graph);
return graph;
Graph graph = load(id, location); // I/O outside the lock — concurrent first loads may duplicate work

// bundled (non-HTTP mapped) documents are cached by location so every URI in the namespace shares one
// graph, bounding the store; everything else (HTTP, identity) is cached by ID
boolean mapped = !location.equals(id) && !location.startsWith("http://") && !location.startsWith("https://");

synchronized (this)
{
if (mapped) return mappedGraphs.computeIfAbsent(location, k -> graph); // loser of a race discards its graph

Graph raced = store.get(id);
if (raced != null) return raced; // lost the race — another thread loaded it first
store.put(id, graph);
return graph;
}
}

@Override
public synchronized Graph put(String id, Graph graph)
{
return store.put(id, graph);
}

@Override
public synchronized Graph remove(String id)
{
Graph removed = store.remove(id);
Graph mapped = mappedGraphs.remove(resolve(id));
return removed != null ? removed : mapped;
}

@Override
public synchronized void clear()
{
store.clear();
mappedGraphs.clear();
}

@Override
public synchronized boolean contains(String id)
{
return store.containsKey(id) || mappedGraphs.containsKey(resolve(id));
}

@Override
public synchronized long count()
{
return store.size() + mappedGraphs.size();
}

@Override
public synchronized Stream<String> ids()
{
return List.copyOf(store.keySet()).stream(); // snapshot under the lock — a live stream would read the store unsynchronized
}

@Override
public synchronized Stream<Graph> loadedGraphs()
{
List<Graph> loaded = new ArrayList<>(store.values());
loaded.addAll(mappedGraphs.values());
return loaded.stream(); // snapshot under the lock — a live stream would read the store unsynchronized
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.riot.RDFFormat;
import org.apache.jena.riot.RDFWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* <code>ac:construct()</code> XSLT function that constructs instances for given classes from their constructors.
Expand All @@ -49,7 +51,9 @@
*/
public class ConstructForClass implements ExtensionFunction
{


private static final Logger log = LoggerFactory.getLogger(ConstructForClass.class);

private final Processor processor;
private final PrefixGraphRepository repository;

Expand Down Expand Up @@ -91,13 +95,20 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException
String base = arguments[2].itemAt(0).getStringValue();

Model instances = ModelFactory.createDefaultModel();
OntModel ontModel = OntModelFactory.createModel(getRepository().get(ontology), OntSpecification.OWL2_FULL_MEM, getRepository());
try
{
OntModel ontModel = OntModelFactory.createModel(getRepository().get(ontology), OntSpecification.OWL2_FULL_MEM, getRepository());

arguments[1].stream().
<OntClass>map(forClass -> ontModel.getOntClass(checkURI(forClass.getStringValue()).toString())).
filter(forClass -> forClass != null).
forEach(forClass -> new Constructor().construct(forClass, instances, base));
}
catch (RuntimeException ex) // ontology or one of its imports could not be loaded — return empty result instead of failing the transform
{
if (log.isWarnEnabled()) log.warn("Could not construct instances for ontology '{}': {}", ontology, ex.toString());
}

arguments[1].stream().
<OntClass>map(forClass -> ontModel.getOntClass(checkURI(forClass.getStringValue()).toString())).
filter(forClass -> forClass != null).
forEach(forClass -> new Constructor().construct(forClass, instances, base));

return getProcessor().newDocumentBuilder().build(getSource(instances));
}
catch (IOException ex)
Expand Down
Loading