+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.client.filter.request;
+
+import com.atomgraph.client.MediaTypes;
+import com.atomgraph.client.util.HTMLMediaTypePredicate;
+import com.atomgraph.core.exception.BadGatewayException;
+import com.atomgraph.core.io.ModelProvider;
+import com.atomgraph.core.util.ModelUtils;
+import com.atomgraph.core.util.ResultSetUtils;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.NotAcceptableException;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.PreMatching;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.EntityTag;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Request;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Variant;
+import org.apache.jena.query.ResultSet;
+import org.apache.jena.query.ResultSetRewindable;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.RDFLanguages;
+import org.apache.jena.riot.RiotException;
+import org.apache.jena.riot.resultset.ResultSetReaderRegistry;
+import org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * JAX-RS request filter that intercepts {@code ?uri=} proxy requests and short-circuits the pipeline
+ * via {@link ContainerRequestContext#abortWith(Response)}. The proxy is a global transport function,
+ * not a document operation, so it is not modelled as a resource class: the filter forwards the
+ * request method, entity stream and conditional headers to the target verbatim - no local entity
+ * parsing on writes (an RDF/POST form body reaches the origin as
+ * {@code application/x-www-form-urlencoded} for the origin to parse) - and converts the target's
+ * response for the original caller.
+ *
+ * External HTTP responses are dispatched on upstream {@code Content-Type} via Jena's live RIOT
+ * registry (the same predicate {@code ModelProvider.isReadable} consults, and unlike the
+ * {@code MediaTypes} snapshot it includes langs registered after class-loading, such as RDF/POST):
+ * RDF langs parse into a {@link Model} and SPARQL results langs into a {@link ResultSet}, both
+ * re-served through content negotiation - including (X)HTML via the XSLT writers, which is this
+ * application's purpose. Error responses and non-RDF bodies relay verbatim: their bodies are
+ * diagnostic or opaque representations, not negotiable content, and the origin's status and
+ * validators must reach the client unchanged (a rejected write - 412 on a stale {@code If-Match},
+ * 401/403 on an unauthorized delta - must surface as that status).
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@PreMatching
+@Priority(Priorities.USER + 50) // after HttpMethodOverrideFilter (Priorities.USER), so ?_method= is already applied
+public class ProxyRequestFilter implements ContainerRequestFilter
+{
+
+ private static final Logger log = LoggerFactory.getLogger(ProxyRequestFilter.class);
+ private static final Pattern LINK_SPLITTER = Pattern.compile(",(?=\\s*<)");
+ /**
+ * End-to-end response headers forwarded verbatim from the upstream. Excludes hop-by-hop headers
+ * (RFC 7230 §6.1), framing headers re-emitted by the container, origin-bound security headers
+ * (CSP, HSTS, CORS), cookies, and {@code Content-Type}/{@code Link} which are set explicitly.
+ */
+ private static final Set FORWARDED_RESPONSE_HEADERS = Set.of(
+ HttpHeaders.ETAG,
+ HttpHeaders.LAST_MODIFIED,
+ HttpHeaders.CACHE_CONTROL,
+ HttpHeaders.VARY,
+ HttpHeaders.EXPIRES,
+ HttpHeaders.CONTENT_LANGUAGE,
+ HttpHeaders.CONTENT_DISPOSITION,
+ HttpHeaders.CONTENT_LOCATION,
+ HttpHeaders.LOCATION,
+ HttpHeaders.RETRY_AFTER,
+ "Age");
+ /**
+ * Conditional request headers forwarded verbatim to the upstream so preconditions are evaluated
+ * at the origin: {@code If-Match}/{@code If-Unmodified-Since} carry optimistic-concurrency
+ * validators on writes, {@code If-None-Match}/{@code If-Modified-Since} carry cache validation on
+ * reads. Excludes {@code Authorization}/{@code Cookie} and {@code Range}, whose byte offsets do
+ * not survive the Model re-serialization the proxy performs.
+ */
+ private static final Set FORWARDED_REQUEST_HEADERS = Set.of(
+ HttpHeaders.IF_MATCH,
+ HttpHeaders.IF_NONE_MATCH,
+ HttpHeaders.IF_MODIFIED_SINCE,
+ HttpHeaders.IF_UNMODIFIED_SINCE);
+
+ @Inject MediaTypes mediaTypes;
+ @Inject Client client;
+ @Context Request request;
+
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws IOException
+ {
+ URI targetURI = resolveTargetURI(requestContext);
+ if (targetURI == null) return; // not a proxy request - the root resource handles it
+
+ // the ?accept= query param overrides content negotiation (used by the RDF export links)
+ String acceptParam = requestContext.getUriInfo().getQueryParameters().getFirst("accept");
+ if (acceptParam != null) requestContext.getHeaders().putSingle(HttpHeaders.ACCEPT, acceptParam);
+
+ if (log.isDebugEnabled()) log.debug("Proxying {} {} → {}", requestContext.getMethod(), requestContext.getUriInfo().getRequestUri(), targetURI);
+ requestContext.abortWith(proxy(requestContext, getClient().target(targetURI)));
+ }
+
+ /**
+ * Resolves the proxy target URI from the {@code ?uri=} query parameter, with the
+ * {@code #fragment} stripped (servers do not receive fragment identifiers).
+ * Returns null if this request should not be proxied.
+ *
+ * @param requestContext the current request context
+ * @return target URI to proxy to, or null
+ */
+ protected URI resolveTargetURI(ContainerRequestContext requestContext)
+ {
+ String uriParam = requestContext.getUriInfo().getQueryParameters().getFirst("uri");
+ if (uriParam == null) return null;
+
+ URI targetURI = URI.create(uriParam);
+ if (targetURI.getFragment() != null)
+ {
+ try
+ {
+ targetURI = new URI(targetURI.getScheme(), targetURI.getAuthority(), targetURI.getPath(), targetURI.getQuery(), null);
+ }
+ catch (URISyntaxException ex)
+ {
+ // should not happen when only removing the fragment
+ }
+ }
+
+ return targetURI;
+ }
+
+ /**
+ * Forwards the current request to the target and converts the target's response.
+ *
+ * @param requestContext the current request context
+ * @param target proxy target
+ * @return response for the original caller
+ */
+ protected Response proxy(ContainerRequestContext requestContext, WebTarget target)
+ {
+ try
+ {
+ Invocation.Builder builder = target.request(getReadableMediaTypes());
+
+ // forward conditional request headers so preconditions reach the origin, which owns the
+ // validators - without this the origin sees an unconditional request and a proxied If-Match
+ // write silently loses its optimistic-concurrency guard
+ for (String name : FORWARDED_REQUEST_HEADERS)
+ {
+ String value = requestContext.getHeaderString(name);
+ if (value != null) builder.header(name, value);
+ }
+
+ Response clientResponse = requestContext.hasEntity()
+ ? builder.method(requestContext.getMethod(),
+ Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType()))
+ : builder.method(requestContext.getMethod());
+
+ try (clientResponse)
+ {
+ // special case for http <-> https 301/303 redirection, which the connector does not follow across schemes
+ if (("GET".equalsIgnoreCase(requestContext.getMethod()) || HttpMethod.HEAD.equalsIgnoreCase(requestContext.getMethod())) &&
+ (clientResponse.getStatusInfo().toEnum().equals(Response.Status.SEE_OTHER) || clientResponse.getStatusInfo().toEnum().equals(Response.Status.MOVED_PERMANENTLY)) &&
+ ((target.getUri().getScheme().equals("http") && clientResponse.getLocation().getScheme().equals("https")) ||
+ (target.getUri().getScheme().equals("https") && clientResponse.getLocation().getScheme().equals("http"))))
+ return proxy(requestContext, getClient().target(clientResponse.getLocation()));
+
+ return getResponse(clientResponse, target.getUri(), requestContext.getMethod());
+ }
+ }
+ catch (MessageBodyProviderNotFoundException ex)
+ {
+ if (log.isWarnEnabled()) log.warn("Proxied URI {} returned non-RDF media type", target.getUri());
+ throw new NotAcceptableException(ex);
+ }
+ catch (RiotException ex)
+ {
+ if (log.isWarnEnabled()) log.warn("Proxied URI {} returned body typed as RDF but unparseable", target.getUri());
+ throw new BadGatewayException(ex);
+ }
+ catch (ProcessingException ex)
+ {
+ if (log.isWarnEnabled()) log.warn("Could not dereference proxied URI: {}", target.getUri());
+ throw new BadGatewayException(ex);
+ }
+ }
+
+ /**
+ * Converts the proxy target's HTTP response into a JAX-RS response for the original caller.
+ * RDF and SPARQL results bodies parse and re-serve through content negotiation (including
+ * (X)HTML via the XSLT writers); HEAD, error and non-RDF responses relay verbatim.
+ *
+ * @param clientResponse response from the proxy target
+ * @param targetURI upstream URI (used as the parse base URI hint for {@code ModelProvider})
+ * @param method HTTP method
+ * @return JAX-RS response to return to the original caller
+ */
+ protected Response getResponse(Response clientResponse, URI targetURI, String method)
+ {
+ // HEAD responses have no body by HTTP semantics. Routing them through the typed branches
+ // below would parse an empty entity into an empty Model/ResultSet, then re-stamp
+ // ETag/Last-Modified off that empty value - producing validators that disagree with the
+ // upstream GET. Forward the upstream headers (including ETag) verbatim instead.
+ if (HttpMethod.HEAD.equalsIgnoreCase(method))
+ {
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus());
+ if (clientResponse.getMediaType() != null) rb.type(clientResponse.getMediaType());
+ return overlayHeaders(rb.build(), clientResponse, true);
+ }
+
+ if (clientResponse.getMediaType() == null)
+ {
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus());
+ return overlayHeaders(rb.build(), clientResponse, true);
+ }
+
+ // error responses relay verbatim: the body is a diagnostic representation, not negotiable
+ // content, so it must not go through the Model/ResultSet re-serialization branches - parsing a
+ // non-RDF or empty error body there throws and masks the origin's status as 502/406. A proxied
+ // write that the origin rejects (412 on a stale If-Match, 401/403 on an unauthorized delta)
+ // must reach the client as that status, with the origin's validators forwarded
+ Response.Status.Family family = clientResponse.getStatusInfo().getFamily();
+ if (family == Response.Status.Family.CLIENT_ERROR || family == Response.Status.Family.SERVER_ERROR)
+ {
+ clientResponse.bufferEntity();
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()).
+ type(clientResponse.getMediaType()).
+ entity(clientResponse.readEntity(InputStream.class));
+ return overlayHeaders(rb.build(), clientResponse, true);
+ }
+
+ // dispatch on the live Jena RIOT registry - the same predicate ModelProvider.isReadable uses,
+ // so any RDF lang Jersey can read into a Model routes to the Model branch, including langs
+ // registered after the MediaTypes static snapshot was captured (e.g. RDF/POST)
+ MediaType upstreamCT = clientResponse.getMediaType();
+ MediaType formatType = new MediaType(upstreamCT.getType(), upstreamCT.getSubtype()); // strip charset
+ Lang lang = RDFLanguages.contentTypeToLang(formatType.toString());
+
+ if (lang != null && ResultSetReaderRegistry.isRegistered(lang))
+ {
+ ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class);
+ return overlayHeaders(getResponse(results, clientResponse.getStatusInfo()), clientResponse, false);
+ }
+
+ if (lang != null)
+ {
+ // base URI hint so ModelProvider resolves relative IRIs against the upstream URI
+ clientResponse.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, targetURI.toString());
+ Model model = clientResponse.readEntity(Model.class);
+ // forward the origin's validators (replacing the ones the Model builder stamps off the re-serialized
+ // bytes): a client editing the proxied document sends If-Match through this proxy to the origin, which
+ // compares against its own ETag - a re-serialization validator would 412 every proxied write
+ return overlayHeaders(getResponse(model, clientResponse.getStatusInfo()), clientResponse, true);
+ }
+
+ // upstream is neither RDF nor SPARQL results - pipe raw bytes
+ // buffer so the stream remains readable after try-with-resources closes the client response
+ clientResponse.bufferEntity();
+ InputStream entity = clientResponse.readEntity(InputStream.class);
+
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()).
+ type(upstreamCT).
+ entity(entity);
+
+ return overlayHeaders(rb.build(), clientResponse, true);
+ }
+
+ /**
+ * Copies the upstream {@code Link} and end-to-end cache/content headers onto the given
+ * built response, replacing any locally stamped values. {@code ETag}/{@code Last-Modified}
+ * are skipped when {@code copyValidators} is {@code false} (the ResultSet branch), where the
+ * builder-stamped validators stand.
+ *
+ * @param response the response built by the typed or raw branch
+ * @param clientResponse upstream response to copy headers from
+ * @param copyValidators whether to forward {@code ETag} and {@code Last-Modified}
+ * @return response with overlaid upstream headers
+ */
+ protected Response overlayHeaders(Response response, Response clientResponse, boolean copyValidators)
+ {
+ Response.ResponseBuilder rb = Response.fromResponse(response);
+
+ // forward all Link headers from the external response so the client receives remote hypermedia
+ String linkHeader = clientResponse.getHeaderString(HttpHeaders.LINK);
+ if (linkHeader != null)
+ for (String part : LINK_SPLITTER.split(linkHeader))
+ rb.header(HttpHeaders.LINK, part.trim());
+
+ for (String name : FORWARDED_RESPONSE_HEADERS)
+ {
+ if (!copyValidators && (HttpHeaders.ETAG.equalsIgnoreCase(name) || HttpHeaders.LAST_MODIFIED.equalsIgnoreCase(name))) continue;
+ String value = clientResponse.getHeaderString(name);
+ if (value != null) rb.header(name, null).header(name, value); // replace, not append - the upstream value overlays any locally stamped one
+ }
+
+ return rb.build();
+ }
+
+ /**
+ * Builds a response for the given RDF model with content negotiation, including (X)HTML.
+ *
+ * @param model RDF model
+ * @param statusType response status
+ * @return JAX-RS response
+ */
+ protected Response getResponse(Model model, Response.StatusType statusType)
+ {
+ List variants = com.atomgraph.core.model.impl.Response.getVariants(getMediaTypes().getWritable(Model.class),
+ new ArrayList<>(),
+ new ArrayList<>());
+
+ return new com.atomgraph.core.model.impl.Response(getRequest(),
+ model,
+ null,
+ new EntityTag(Long.toHexString(ModelUtils.hashModel(model))),
+ variants,
+ new HTMLMediaTypePredicate()).
+ getResponseBuilder().
+ status(statusType).
+ build();
+ }
+
+ /**
+ * Builds a response for the given SPARQL result set with content negotiation, including (X)HTML.
+ *
+ * @param resultSet SPARQL results (rewindable so we can hash without consuming)
+ * @param statusType response status
+ * @return JAX-RS response
+ */
+ protected Response getResponse(ResultSetRewindable resultSet, Response.StatusType statusType)
+ {
+ long hash = ResultSetUtils.hashResultSet(resultSet);
+ resultSet.reset();
+
+ List variants = com.atomgraph.core.model.impl.Response.getVariants(getMediaTypes().getWritable(ResultSet.class),
+ new ArrayList<>(),
+ new ArrayList<>());
+
+ return new com.atomgraph.core.model.impl.Response(getRequest(),
+ resultSet,
+ null,
+ new EntityTag(Long.toHexString(hash)),
+ variants,
+ new HTMLMediaTypePredicate()).
+ getResponseBuilder().
+ status(statusType).
+ build();
+ }
+
+ /**
+ * Returns the outbound {@code Accept} types: everything readable as a model or SPARQL results.
+ *
+ * @return readable media types
+ */
+ protected MediaType[] getReadableMediaTypes()
+ {
+ List readable = new ArrayList<>();
+ readable.addAll(getMediaTypes().getReadable(Model.class));
+ readable.addAll(getMediaTypes().getReadable(ResultSet.class));
+ return readable.toArray(MediaType[]::new);
+ }
+
+ /**
+ * Returns the media types registry used for content negotiation and outbound {@code Accept} headers.
+ *
+ * @return media types
+ */
+ public MediaTypes getMediaTypes()
+ {
+ return mediaTypes;
+ }
+
+ /**
+ * Returns the HTTP client used to reach proxy targets.
+ *
+ * @return HTTP client
+ */
+ public Client getClient()
+ {
+ return client;
+ }
+
+ /**
+ * Returns the JAX-RS request.
+ *
+ * @return request
+ */
+ public Request getRequest()
+ {
+ return request;
+ }
+
+}
diff --git a/src/main/java/com/atomgraph/client/interceptor/RDFPostMediaTypeInterceptor.java b/src/main/java/com/atomgraph/client/interceptor/RDFPostMediaTypeInterceptor.java
new file mode 100644
index 00000000..5d019ded
--- /dev/null
+++ b/src/main/java/com/atomgraph/client/interceptor/RDFPostMediaTypeInterceptor.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright 2019 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.client.interceptor;
+
+import com.atomgraph.core.MediaType;
+import com.atomgraph.core.riot.lang.TokenizerRDFPost;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import jakarta.annotation.Priority;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.ext.ReaderInterceptor;
+import jakarta.ws.rs.ext.ReaderInterceptorContext;
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Request interceptor that fixes RDF/POST media type.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Priority(Priorities.ENTITY_CODER)
+public class RDFPostMediaTypeInterceptor implements ReaderInterceptor
+{
+
+ private static final Logger log = LoggerFactory.getLogger(RDFPostMediaTypeInterceptor.class);
+
+ @Override
+ public Object aroundReadFrom(ReaderInterceptorContext context) throws IOException, WebApplicationException
+ {
+ // cannot use the RDF/POST-specific MediaType.APPLICATION_RDF_URLENCODED_TYPE because browsers do not support it as form/@enctype: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype -->
+ if (context.getMediaType() != null && context.getMediaType().isCompatible(MediaType.APPLICATION_FORM_URLENCODED_TYPE))
+ {
+ StringWriter writer = new StringWriter();
+ IOUtils.copy(context.getInputStream(), writer, StandardCharsets.UTF_8);
+
+ String formData = writer.toString();
+
+ if (formData.startsWith(TokenizerRDFPost.RDF))
+ // replace the generic "application/x-www-form-urlencoded" media type with RDF/POST
+ context.setMediaType(MediaType.APPLICATION_RDF_URLENCODED_TYPE);
+
+ context.setInputStream(new ByteArrayInputStream(formData.getBytes(StandardCharsets.UTF_8))); // restore the request entity
+ }
+
+ return context.proceed();
+ }
+
+}
diff --git a/src/main/java/com/atomgraph/client/model/impl/ProxiedGraph.java b/src/main/java/com/atomgraph/client/model/impl/ProxiedGraph.java
deleted file mode 100644
index 063496d1..00000000
--- a/src/main/java/com/atomgraph/client/model/impl/ProxiedGraph.java
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * Copyright 2013 Martynas Jusevičius
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-package com.atomgraph.client.model.impl;
-
-import com.atomgraph.client.MediaTypes;
-import com.atomgraph.client.util.HTMLMediaTypePredicate;
-import com.atomgraph.client.vocabulary.AC;
-import java.net.URI;
-import java.util.ArrayList;
-import jakarta.ws.rs.DELETE;
-import jakarta.ws.rs.GET;
-import jakarta.ws.rs.POST;
-import jakarta.ws.rs.PUT;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.QueryParam;
-import jakarta.ws.rs.core.Context;
-import jakarta.ws.rs.core.HttpHeaders;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.Request;
-import jakarta.ws.rs.core.Response;
-import jakarta.ws.rs.core.Response.Status;
-import jakarta.ws.rs.core.UriInfo;
-import com.atomgraph.core.exception.BadGatewayException;
-import com.atomgraph.core.io.ModelProvider;
-import com.atomgraph.core.model.DirectGraphStore;
-import com.atomgraph.core.util.ModelUtils;
-import com.atomgraph.core.util.ResultSetUtils;
-import java.net.URISyntaxException;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Locale;
-import jakarta.inject.Inject;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.ws.rs.NotAcceptableException;
-import jakarta.ws.rs.NotFoundException;
-import jakarta.ws.rs.ProcessingException;
-import jakarta.ws.rs.client.Client;
-import jakarta.ws.rs.client.Entity;
-import jakarta.ws.rs.client.Invocation;
-import jakarta.ws.rs.client.WebTarget;
-import jakarta.ws.rs.core.EntityTag;
-import jakarta.ws.rs.core.UriBuilder;
-import jakarta.ws.rs.core.Variant;
-import org.apache.jena.query.ResultSet;
-import org.apache.jena.query.ResultSetRewindable;
-import org.apache.jena.rdf.model.Model;
-import org.apache.jena.riot.Lang;
-import org.apache.jena.riot.RDFLanguages;
-import org.apache.jena.riot.resultset.ResultSetReaderRegistry;
-import org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException;
-import org.glassfish.jersey.uri.UriComponent;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Resource that can publish Linked Data and (X)HTML as well as load RDF from remote sources.
- *
- * @author Martynas Jusevičius {@literal }
- */
-@Path("/")
-public class ProxiedGraph implements DirectGraphStore
-{
- private static final Logger log = LoggerFactory.getLogger(ProxiedGraph.class);
-
- private final UriInfo uriInfo;
- private final Request request;
- private final HttpHeaders httpHeaders;
- private final MediaTypes mediaTypes;
- private final MediaType accept;
- private final MediaType[] readableMediaTypes;
- private final Client client;
- private final WebTarget webTarget;
- private final URI endpoint;
- private final String query;
-
- private final HttpServletRequest httpServletRequest;
-
- /**
- * JAX-RS compatible resource constructor with injected initialization objects.
- *
- * @param uriInfo URI information
- * @param request request
- * @param httpHeaders HTTP headers
- * @param mediaTypes supported media types
- * @param uri RDF resource URI
- * @param endpoint SPARQL endpoint URI
- * @param query SPARQL query
- * @param accept response media type
- * @param mode layout mode
- * @param client HTTP client
- * @param httpServletRequest HTTP request
- */
- @Inject
- public ProxiedGraph(@Context UriInfo uriInfo, @Context Request request, @Context HttpHeaders httpHeaders, MediaTypes mediaTypes,
- @QueryParam("uri") URI uri, @QueryParam("endpoint") URI endpoint, @QueryParam("query") String query, @QueryParam("accept") MediaType accept, @QueryParam("mode") URI mode,
- Client client, @Context HttpServletRequest httpServletRequest)
- {
- this.uriInfo = uriInfo;
- this.request = request;
- this.httpHeaders = httpHeaders;
- this.mediaTypes = mediaTypes;
- this.endpoint = endpoint;
- this.query = query;
- this.accept = accept;
- this.client = client;
- List readableMediaTypesList = new ArrayList<>();
- readableMediaTypesList.addAll(mediaTypes.getReadable(Model.class));
- readableMediaTypesList.addAll(mediaTypes.getReadable(ResultSet.class));
- this.readableMediaTypes = readableMediaTypesList.toArray(MediaType[]::new);
-
- if (uri != null)
- {
- if (uri.getFragment() != null)
- try
- {
- // strip #fragment as we don't want to use it in the request to server
- uri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery(), null);
- }
- catch (URISyntaxException ex)
- {
- // should not happen
- }
-
- webTarget = client.target(uri);
- //webTarget.register(new RedirectFilter()); // TO-DO
- }
- else
- {
- webTarget = null;
- }
- this.httpServletRequest = httpServletRequest;
- }
-
- public URI getURI()
- {
- return getWebTarget().getUri();
- }
-
- public MediaType[] getReadableMediaTypes()
- {
- return readableMediaTypes;
- }
-
- public List getWritableMediaTypes(Class clazz)
- {
- // restrict writable MediaTypes to the requested one (usually by RDF export feature)
- if (getAcceptMediaType() != null) return Arrays.asList(getAcceptMediaType());
-
- return getMediaTypes().getWritable(clazz);
- }
-
- /**
- * Forwards GET request and returns response from remote resource.
- *
- * @return response
- */
- @GET
- @Override
- public Response get()
- {
- return get(getWebTarget());
- }
-
- public Response get(WebTarget target)
- {
- if (target == null)
- {
- // if SPARQL endpoint and query are provided, build a SPARQL Protocol URI and then redirect to a URI that proxies it
- if (getEndpoint() != null && getQuery() != null)
- {
- if (log.isDebugEnabled()) log.debug("Redirecting from endpoint/query URL to a proxied URL");
- String encodedQuery = UriComponent.encode(getQuery(), UriComponent.Type.UNRESERVED); // manually encode query string because UriBuilder::build will complain about {}
- URI sparqlUrl = UriBuilder.fromUri(getEndpoint()).queryParam(AC.query.getLocalName(), encodedQuery).build();
- String encodedSparqlUrl = UriComponent.encode(sparqlUrl.toString(), UriComponent.Type.UNRESERVED); // manually encode URL
- URI uri = getUriInfo().getBaseUriBuilder().queryParam(AC.uri.getLocalName(), encodedSparqlUrl).build();
-
- return Response.seeOther(uri).build();
- }
-
- throw new NotFoundException("Resource URI not supplied");
- }
-
- return get(target, getBuilder(target));
- }
-
- public Invocation.Builder getBuilder(WebTarget target)
- {
- return target.request(getReadableMediaTypes());
- }
-
- public Response get(WebTarget target, Invocation.Builder builder)
- {
- if (target == null) throw new NotFoundException("Resource URI not supplied"); // cannot throw Exception in constructor: https://github.com/eclipse-ee4j/jersey/issues/4436
-
- try (Response cr = builder.get())
- {
- // special case for http <-> https 301/303 redirection
- if ((cr.getStatusInfo().toEnum().equals(Status.SEE_OTHER) || cr.getStatusInfo().toEnum().equals(Status.MOVED_PERMANENTLY)) &&
- ((target.getUri().getScheme().equals("http") && cr.getLocation().getScheme().equals("https")) ||
- (target.getUri().getScheme().equals("https") && cr.getLocation().getScheme().equals("http"))) )
- return get(getClient().target(cr.getLocation()));
-
- cr.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, target.getUri().toString()); // provide a base URI hint to ModelProvider
-
- if (log.isDebugEnabled()) log.debug("GETing response from URI: {}", target.getUri());
-
- Response response = getResponse(cr);
-
- List linkValues = cr.getHeaders().get(HttpHeaders.LINK);
- if (linkValues != null) setLinks(linkValues, response);
-
- return response;
- }
- catch (MessageBodyProviderNotFoundException ex)
- {
- if (log.isWarnEnabled()) log.debug("Dereferenced URI {} returned non-RDF media type", ex);
- throw new NotAcceptableException(ex);
- }
- catch (ProcessingException ex)
- {
- if (log.isWarnEnabled()) log.debug("Could not dereference URI: {}", webTarget.getUri());
- throw new BadGatewayException(ex);
- }
- }
-
- public Response getResponse(Response clientResponse)
- {
- MediaType formatType = new MediaType(clientResponse.getMediaType().getType(), clientResponse.getMediaType().getSubtype()); // discard charset param
- Lang lang = RDFLanguages.contentTypeToLang(formatType.toString());
-
- // check if we got SPARQL results first
- if (lang != null && ResultSetReaderRegistry.isRegistered(lang))
- {
- ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class);
- return getResponse(results);
- }
-
- // fallback to RDF graph
- Model description = clientResponse.readEntity(Model.class);
- return getResponse(description);
- }
-
- /**
- * Returns response for the given RDF model.
- *
- * @param model RDF model
- * @return response object
- */
- public Response getResponse(Model model)
- {
- List variants = com.atomgraph.core.model.impl.Response.getVariants(getWritableMediaTypes(Model.class),
- getLanguages(),
- getEncodings());
-
- return new com.atomgraph.core.model.impl.Response(getRequest(),
- model,
- null,
- new EntityTag(Long.toHexString(ModelUtils.hashModel(model))),
- variants,
- new HTMLMediaTypePredicate()).
- getResponseBuilder().
- build();
- }
-
- /**
- * Returns response for the given SPARQL results.
- *
- * @param resultSet SPARQL results
- * @return response object
- */
- public Response getResponse(ResultSetRewindable resultSet)
- {
- long hash = ResultSetUtils.hashResultSet(resultSet);
- resultSet.reset();
-
- List variants = com.atomgraph.core.model.impl.Response.getVariants(getWritableMediaTypes(ResultSet.class),
- getLanguages(),
- getEncodings());
-
- return new com.atomgraph.core.model.impl.Response(getRequest(),
- resultSet,
- null,
- new EntityTag(Long.toHexString(hash)),
- variants,
- new HTMLMediaTypePredicate()).
- getResponseBuilder().
- build();
- }
-
- /**
- * Forwards Link header values.
- *
- * @param linkValues header values
- * @param response proxy response
- * @return the response
- */
- protected Response setLinks(List linkValues, Response response)
- {
- linkValues.forEach(linkValue -> {
- response.getHeaders().add(HttpHeaders.LINK, linkValue);
- });
-
- return response;
- }
-
- /**
- * Forwards POST request with RDF dataset body and returns RDF response from remote resource.
- *
- * @param model
- * @return response
- */
- @POST
- @Override
- public Response post(Model model)
- {
- if (getWebTarget() == null) throw new NotFoundException("Resource URI not supplied"); // cannot throw Exception in constructor: https://github.com/eclipse-ee4j/jersey/issues/4436
-
- if (log.isDebugEnabled()) log.debug("POSTing Dataset to URI: {}", getWebTarget().getUri());
- return getWebTarget().request().
- accept(getMediaTypes().getReadable(Model.class).toArray(jakarta.ws.rs.core.MediaType[]::new)).
- post(Entity.entity(model, com.atomgraph.core.MediaType.APPLICATION_NTRIPLES_TYPE));
- }
-
- /**
- * Forwards PUT request with RDF dataset body and returns response from remote resource.
- *
- * @param model RDF payload
- * @return response
- */
- @PUT
- @Override
- public Response put(Model model)
- {
- if (getWebTarget() == null) throw new NotFoundException("Resource URI not supplied"); // cannot throw Exception in constructor: https://github.com/eclipse-ee4j/jersey/issues/4436
-
- if (log.isDebugEnabled()) log.debug("PUTting Dataset to URI: {}", getWebTarget().getUri());
- return getWebTarget().request().
- accept(getMediaTypes().getReadable(Model.class).toArray(jakarta.ws.rs.core.MediaType[]::new)).
- put(Entity.entity(model, com.atomgraph.core.MediaType.APPLICATION_NTRIPLES_TYPE));
- }
-
- /**
- * Forwards DELETE request and returns response from remote resource.
- * @return response
- */
- @DELETE
- @Override
- public Response delete()
- {
- if (getWebTarget() == null) throw new NotFoundException("Resource URI not supplied"); // cannot throw Exception in constructor: https://github.com/eclipse-ee4j/jersey/issues/4436
-
- if (log.isDebugEnabled()) log.debug("DELETEing Dataset from URI: {}", getWebTarget().getUri());
- return getWebTarget().request().
- accept(getMediaTypes().getReadable(Model.class).toArray(jakarta.ws.rs.core.MediaType[]::new)).
- delete(Response.class);
- }
-
- public UriInfo getUriInfo()
- {
- return uriInfo;
- }
-
- public HttpHeaders getHttpHeaders()
- {
- return httpHeaders;
- }
-
- /**
- * Returns media type requested by the client ("accept" query string parameter).
- * This mechanism overrides the normally used content negotiation.
- *
- * @return media type parsed from query param
- */
- public MediaType getAcceptMediaType()
- {
- return accept;
- }
-
- public Client getClient()
- {
- return client;
- }
-
- public final WebTarget getWebTarget()
- {
- return webTarget;
- }
-
- public final URI getEndpoint()
- {
- return endpoint;
- }
-
- public final String getQuery()
- {
- return query;
- }
-
- public Request getRequest()
- {
- return request;
- }
-
- public MediaTypes getMediaTypes()
- {
- return mediaTypes;
- }
-
- public HttpServletRequest getHttpServletRequest()
- {
- return httpServletRequest;
- }
-
- /**
- * Returns a list of supported languages.
- *
- * @return list of languages
- */
- public List getLanguages()
- {
- return new ArrayList<>();
- }
-
- /**
- * Returns a list of supported HTTP encodings.
- * Note: this is different from content encodings such as UTF-8.
- *
- * @return list of encodings
- */
- public List getEncodings()
- {
- return new ArrayList<>();
- }
-
-}
diff --git a/src/main/java/com/atomgraph/client/resource/Root.java b/src/main/java/com/atomgraph/client/resource/Root.java
new file mode 100644
index 00000000..faf9206c
--- /dev/null
+++ b/src/main/java/com/atomgraph/client/resource/Root.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright 2025 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.client.resource;
+
+import com.atomgraph.client.vocabulary.AC;
+import java.net.URI;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriBuilder;
+import jakarta.ws.rs.core.UriInfo;
+import org.glassfish.jersey.uri.UriComponent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The application root. Proxy requests ({@code ?uri=}) never reach it - the
+ * {@link com.atomgraph.client.filter.request.ProxyRequestFilter} aborts them pre-matching - so it
+ * only handles the non-proxy cases: turning a SPARQL Protocol {@code ?endpoint=}/{@code ?query=}
+ * pair into a redirect to the proxied query URL, and 404 when no resource URI is supplied.
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+@Path("/")
+public class Root
+{
+
+ private static final Logger log = LoggerFactory.getLogger(Root.class);
+
+ /**
+ * Handles non-proxy GET requests.
+ *
+ * @param endpoint SPARQL endpoint URI
+ * @param query SPARQL query string
+ * @param uriInfo URI information
+ * @return redirect to the proxied SPARQL Protocol URL
+ */
+ @GET
+ public Response get(@QueryParam("endpoint") URI endpoint, @QueryParam("query") String query, @Context UriInfo uriInfo)
+ {
+ // if SPARQL endpoint and query are provided, build a SPARQL Protocol URI and then redirect to a URI that proxies it
+ if (endpoint != null && query != null)
+ {
+ if (log.isDebugEnabled()) log.debug("Redirecting from endpoint/query URL to a proxied URL");
+ String encodedQuery = UriComponent.encode(query, UriComponent.Type.UNRESERVED); // manually encode query string because UriBuilder::build will complain about {}
+ URI sparqlUrl = UriBuilder.fromUri(endpoint).queryParam(AC.query.getLocalName(), encodedQuery).build();
+ String encodedSparqlUrl = UriComponent.encode(sparqlUrl.toString(), UriComponent.Type.UNRESERVED); // manually encode URL
+ URI uri = uriInfo.getBaseUriBuilder().queryParam(AC.uri.getLocalName(), encodedSparqlUrl).build();
+
+ return Response.seeOther(uri).build();
+ }
+
+ throw new NotFoundException("Resource URI not supplied");
+ }
+
+}
diff --git a/src/main/java/com/atomgraph/client/util/Constructor.java b/src/main/java/com/atomgraph/client/util/Constructor.java
deleted file mode 100644
index 2d947902..00000000
--- a/src/main/java/com/atomgraph/client/util/Constructor.java
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * Copyright 2015 Martynas Jusevičius .
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.atomgraph.client.util;
-
-import com.atomgraph.client.exception.OntologyException;
-import com.atomgraph.client.vocabulary.SP;
-import com.atomgraph.client.vocabulary.SPIN;
-import org.apache.jena.ontapi.model.OntClass;
-import org.apache.jena.rdf.model.Model;
-import org.apache.jena.rdf.model.Property;
-import org.apache.jena.rdf.model.Resource;
-import org.apache.jena.rdf.model.Statement;
-import org.apache.jena.rdf.model.StmtIterator;
-import org.apache.jena.vocabulary.RDF;
-import org.apache.jena.query.Query;
-import org.apache.jena.query.QueryExecution;
-import org.apache.jena.query.QueryFactory;
-import org.apache.jena.query.QueryParseException;
-import org.apache.jena.rdf.model.RDFNode;
-import org.apache.jena.sparql.core.Var;
-import org.apache.jena.sparql.engine.binding.Binding;
-import org.apache.jena.sparql.engine.binding.BindingFactory;
-import org.apache.jena.sparql.expr.ExprVar;
-import org.apache.jena.sparql.syntax.ElementBind;
-import org.apache.jena.sparql.syntax.ElementGroup;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- *
- * @author Martynas Jusevičius {@literal }
- */
-public class Constructor
-{
-
- private static final Logger log = LoggerFactory.getLogger(Constructor.class);
-
- public Resource construct(OntClass forClass, Model targetModel, String baseURI)
- {
- return construct(forClass, targetModel, baseURI, null);
- }
-
- public Resource construct(OntClass forClass, Model targetModel, String baseURI, String resourceURI)
- {
- if (targetModel == null) throw new IllegalArgumentException("Model cannot be null");
-
- final Resource resource;
- if (resourceURI == null) resource = targetModel.createResource(); // blank node
- else resource = targetModel.createResource(resourceURI); // URI resource
-
- return constructInstance(forClass, SPIN.constructor, resource, baseURI).
- addProperty(RDF.type, forClass);
- }
-
- /**
- * Constructs new anonymous individual of an ontology class.
- * It walks up the superclass chains and executes SPIN constructors.
- *
- * @param forClass class for which to construct new instance
- * @param property property that attaches CONSTRUCT query resource to class resource, usually spin:constructor
- * @param instance the instance resource
- * @param baseURI base URI of the query
- * @return the instance resource with constructed properties
- */
- public Resource constructInstance(OntClass forClass, Property property, Resource instance, String baseURI)
- {
- if (forClass == null) throw new IllegalArgumentException("OntClass cannot be null");
- if (instance == null) throw new IllegalArgumentException("Instance Resource cannot be null");
- if (baseURI == null) throw new IllegalArgumentException("Base URI cannot be null");
-
- StmtIterator constructorIt = forClass.listProperties(property);
- try
- {
- while (constructorIt.hasNext()) // traverse all constructors
- {
- RDFNode constructor = constructorIt.next().getObject();
- if (!constructor.isResource())
- {
- if (log.isErrorEnabled()) log.error("Constructor is invoked but {} is not defined for class <{}>", property, forClass.getURI());
- throw new OntologyException("Constructor property not defined", forClass, property);
- }
-
- Statement queryText = constructor.asResource().getProperty(SP.text);
- if (queryText == null || !queryText.getObject().isLiteral())
- {
- if (log.isErrorEnabled()) log.error("Constructor resource <{}> does not have sp:text property", constructor);
- throw new OntologyException("Query property not defined", constructor.asResource(), SP.text);
- }
-
- try
- {
- Query query = QueryFactory.create(queryText.getString(), baseURI);
-
- // Inject BIND(?_this_bind AS ?this) into the WHERE clause.
- // This keeps ?this as a variable in the CONSTRUCT template (not a written blank node),
- // so the template picks up the concrete node value — preserving blank node identity.
- // See: https://github.com/apache/jena/issues/3267
- Var bindVar = Var.alloc("_this_bind");
- ElementGroup group = new ElementGroup();
- group.addElement(query.getQueryPattern());
- group.addElement(new ElementBind(Var.alloc(SPIN.THIS_VAR_NAME), new ExprVar(bindVar)));
- query.setQueryPattern(group);
-
- Binding binding = BindingFactory.binding(bindVar, instance.asNode());
-
- try (QueryExecution qex = QueryExecution.create().
- query(query).
- model(instance.getModel()).
- substitution(binding).
- build())
- {
- instance.getModel().add(qex.execConstruct());
- }
- }
- catch (QueryParseException ex)
- {
- if (log.isErrorEnabled()) log.error("Constructor resource '{}' sp:text property contains an invalid SPARQL CONSTRUCT", constructor);
- throw new OntologyException("Invalid SPARQL CONSTRUCT", ex, constructor.asResource(), SP.text);
- }
- }
- }
- finally
- {
- constructorIt.close();
- }
-
- forClass.superClasses(true).forEach(superClass -> constructInstance(superClass, property, instance, baseURI));
-
- return instance;
- }
-
-}
diff --git a/src/main/java/com/atomgraph/client/vocabulary/AC.java b/src/main/java/com/atomgraph/client/vocabulary/AC.java
index 9d6aa1da..27e4affa 100644
--- a/src/main/java/com/atomgraph/client/vocabulary/AC.java
+++ b/src/main/java/com/atomgraph/client/vocabulary/AC.java
@@ -65,8 +65,6 @@ public static String getURI()
public static final Resource MapMode = m_model.createOntClass( NS + "MapMode" );
public static final Resource ReadMode = m_model.createOntClass( NS + "ReadMode" );
-
- public static final Resource ConstructMode = m_model.createOntClass( NS + "ConstructMode" );
public static final Property contextUri = m_model.createObjectProperty( NS + "contextUri" );
@@ -97,8 +95,6 @@ public static String getURI()
/** The language the representation is composed in, as opposed to the languages the reader accepts. */
public static final Property contentLang = m_model.createDataProperty( NS + "contentLang" );
- public static final Property forClass = m_model.createObjectProperty( NS + "forClass" );
-
public static final Property instance = m_model.createDataProperty( NS + "instance" );
// CONFIG - separate?
diff --git a/src/main/java/com/atomgraph/client/vocabulary/SPIN.java b/src/main/java/com/atomgraph/client/vocabulary/SPIN.java
deleted file mode 100644
index d02f5866..00000000
--- a/src/main/java/com/atomgraph/client/vocabulary/SPIN.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2018 Martynas Jusevičius .
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.atomgraph.client.vocabulary;
-
-import org.apache.jena.ontapi.OntModelFactory;
-import org.apache.jena.ontapi.OntSpecification;
-import org.apache.jena.ontapi.model.OntModel;
-
-import org.apache.jena.rdf.model.Property;
-import org.apache.jena.rdf.model.ResourceFactory;
-
-/**
- *
- * @author Martynas Jusevičius {@literal }
- */
-public class SPIN
-{
-
- static
- {
- org.apache.jena.sys.JenaSystem.init(); // ensure Jena (RDFS vocab) is initialized before ontapi touches it
- }
-
- public final static String THIS_VAR_NAME = "this";
-
- /** The RDF model that holds the vocabulary terms
*/
- private static OntModel m_model = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM);
-
- /** The namespace of the vocabulary as a string
*/
- public static final String NS = "http://spinrdf.org/spin#";
-
- /** The namespace of the vocabulary as a string
- * @see #NS */
- public static String getURI()
- {
- return NS;
- }
-
- public final static Property constructor = ResourceFactory.createProperty(NS + "constructor");
-
-}
diff --git a/src/main/java/com/atomgraph/client/writer/XSLTWriterBase.java b/src/main/java/com/atomgraph/client/writer/XSLTWriterBase.java
index e31ae36d..bb87b06d 100644
--- a/src/main/java/com/atomgraph/client/writer/XSLTWriterBase.java
+++ b/src/main/java/com/atomgraph/client/writer/XSLTWriterBase.java
@@ -167,9 +167,6 @@ public Map getParameters(MultivaluedMap langs = getHttpHeaders().getAcceptableLanguages().stream().
map(Locale::getLanguage).
diff --git a/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java b/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java
deleted file mode 100644
index c7168da2..00000000
--- a/src/main/java/com/atomgraph/client/writer/function/ConstructForClass.java
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright 2020 Martynas Jusevičius .
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.atomgraph.client.writer.function;
-
-import com.atomgraph.client.util.Constructor;
-import com.atomgraph.client.vocabulary.AC;
-import static com.atomgraph.client.writer.ModelXSLTWriter.checkURI;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import javax.xml.transform.stream.StreamSource;
-import net.sf.saxon.s9api.ExtensionFunction;
-import net.sf.saxon.s9api.ItemType;
-import net.sf.saxon.s9api.OccurrenceIndicator;
-import net.sf.saxon.s9api.Processor;
-import net.sf.saxon.s9api.QName;
-import net.sf.saxon.s9api.SaxonApiException;
-import net.sf.saxon.s9api.SequenceType;
-import net.sf.saxon.s9api.XdmValue;
-import com.atomgraph.client.util.jena.PrefixGraphRepository;
-import org.apache.jena.ontapi.OntModelFactory;
-import org.apache.jena.ontapi.OntSpecification;
-import org.apache.jena.ontapi.model.OntClass;
-import org.apache.jena.ontapi.model.OntModel;
-import org.apache.jena.rdf.model.Model;
-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;
-
-/**
- * ac:construct() XSLT function that constructs instances for given classes from their constructors.
- * Plugs into the Saxon processor.
- *
- * @author Martynas Jusevičius {@literal }
- * @see Integrated extension functions
- */
-public class ConstructForClass implements ExtensionFunction
-{
-
- private static final Logger log = LoggerFactory.getLogger(ConstructForClass.class);
-
- private final Processor processor;
- private final PrefixGraphRepository repository;
-
- public ConstructForClass(Processor processor, PrefixGraphRepository repository)
- {
- this.processor = processor;
- this.repository = repository;
- }
-
- @Override
- public QName getName()
- {
- return new QName(AC.NS, "construct");
- }
-
- @Override
- public SequenceType getResultType()
- {
- return SequenceType.makeSequenceType(ItemType.DOCUMENT_NODE, OccurrenceIndicator.ZERO_OR_MORE);
- }
-
- @Override
- public SequenceType[] getArgumentTypes()
- {
- return new SequenceType[]
- {
- SequenceType.makeSequenceType(ItemType.ANY_URI, OccurrenceIndicator.ONE),
- SequenceType.makeSequenceType(ItemType.ANY_URI, OccurrenceIndicator.ZERO_OR_MORE),
- SequenceType.makeSequenceType(ItemType.ANY_URI, OccurrenceIndicator.ONE),
- };
- }
-
- @Override
- public XdmValue call(XdmValue[] arguments) throws SaxonApiException
- {
- try
- {
- String ontology = arguments[0].itemAt(0).getStringValue();
- String base = arguments[2].itemAt(0).getStringValue();
-
- Model instances = ModelFactory.createDefaultModel();
- try
- {
- OntModel ontModel = OntModelFactory.createModel(getRepository().get(ontology), OntSpecification.OWL2_FULL_MEM, getRepository());
-
- arguments[1].stream().
- 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());
- }
-
- return getProcessor().newDocumentBuilder().build(getSource(instances));
- }
- catch (IOException ex)
- {
- throw new SaxonApiException(ex);
- }
- }
-
- public StreamSource getSource(Model model) throws IOException
- {
- if (model == null) throw new IllegalArgumentException("Model cannot be null");
-
- try (ByteArrayOutputStream stream = new ByteArrayOutputStream())
- {
- RDFWriter.create().
- format(RDFFormat.RDFXML_PLAIN).
- source(model).
- output(stream);
- return new StreamSource(new ByteArrayInputStream(stream.toByteArray()));
- }
- }
-
- public Processor getProcessor()
- {
- return processor;
- }
-
- public PrefixGraphRepository getRepository()
- {
- return repository;
- }
-
-}
diff --git a/src/main/resources/com/atomgraph/client/ns.ttl b/src/main/resources/com/atomgraph/client/ns.ttl
index 5074815a..c2bf87e7 100644
--- a/src/main/resources/com/atomgraph/client/ns.ttl
+++ b/src/main/resources/com/atomgraph/client/ns.ttl
@@ -93,10 +93,6 @@
# UI keywords
-# rename to :Create?
-:ConstructMode rdfs:label "Create" ;
- rdfs:isDefinedBy : .
-
:Delete rdfs:label "Delete" ;
rdfs:isDefinedBy : .
diff --git a/src/main/resources/com/atomgraph/client/translations.rdf b/src/main/resources/com/atomgraph/client/translations.rdf
deleted file mode 100644
index b6023008..00000000
--- a/src/main/resources/com/atomgraph/client/translations.rdf
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
- Literal
- Literal
-
-
- Resource
- Recurso
-
-
- Language tag
- Etiqueta de idioma
-
-
- Datatype
- Tipo de dato
-
-
- Add another statement
- Añadir otra sentencia
-
-
- Remove this statement
- Eliminar esta sentencia
-
-
- Save
- Guardar
-
-
- Edit
- Editar
-
-
- Delete
- Eliminar
-
-
- Export
- Exportar
-
-
- Query
- Consultar
-
-
- Endpoint
- Punto de acceso
-
-
- Are you sure?
- ¿Está seguro?
-
-
- Go
- Ir
-
-
- Query editor
- Editor de consultas
-
-
- Resources
- Recursos
-
-
- Query results
- Resultados de la consulta
-
-
diff --git a/src/main/resources/prefix-mapping.n3 b/src/main/resources/prefix-mapping.n3
index 9bc05236..7da27a8a 100644
--- a/src/main/resources/prefix-mapping.n3
+++ b/src/main/resources/prefix-mapping.n3
@@ -3,7 +3,6 @@
[] lm:mapping
[ lm:name "https://w3id.org/atomgraph/client#" ; lm:altName "com/atomgraph/client/ns.ttl" ] ,
- [ lm:name "https://w3id.org/atomgraph/client/xsl/translations.rdf" ; lm:altName "com/atomgraph/client/translations.rdf" ] ,
[ lm:name "http://rdfs.org/sioc/ns#" ; lm:altName "com/atomgraph/client/sioc.owl" ] ,
[ lm:name "http://rdfs.org/ns/void#" ; lm:altName "com/atomgraph/client/void.owl" ] ,
[ lm:name "https://www.w3.org/1999/xhtml/vocab#" ; lm:altName "com/atomgraph/client/xhv.ttl" ] ,
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/container.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/container.xsl
index c4f4021f..59574616 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/container.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/container.xsl
@@ -16,7 +16,6 @@ limitations under the License.
-->
-
@@ -143,14 +142,14 @@ exclude-result-prefixes="#all">
-
+
- Resource
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/document.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/document.xsl
index 39d88402..a43d6587 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/document.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/document.xsl
@@ -16,7 +16,6 @@ limitations under the License.
-->
-
@@ -68,7 +67,8 @@ exclude-result-prefixes="#all">
-
+
+
@@ -97,16 +97,9 @@ exclude-result-prefixes="#all">
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -128,7 +121,7 @@ exclude-result-prefixes="#all">
save
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/functions.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/functions.xsl
index b0e17ac7..902f4b53 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/functions.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/functions.xsl
@@ -69,26 +69,6 @@ exclude-result-prefixes="#all"
-
-
-
-
-
-
-
- Not implemented -- com.atomgraph.client.writer.function.ConstructForClass needs to be registered as an extension function
-
-
-
-
-
-
-
-
- Not implemented -- com.atomgraph.client.writer.function.Construct needs to be registered as an extension function
-
-
-
+
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
index 2375e486..5799b60f 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
@@ -17,7 +17,6 @@ limitations under the License.
-
@@ -48,32 +47,6 @@ xmlns:xhtml="http://www.w3.org/1999/xhtml"
exclude-result-prefixes="#all">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -560,7 +533,7 @@ exclude-result-prefixes="#all">
-
+
@@ -873,7 +846,7 @@ exclude-result-prefixes="#all">
-
+
add
@@ -882,7 +855,7 @@ exclude-result-prefixes="#all">
-
+
remove
@@ -988,13 +961,6 @@ exclude-result-prefixes="#all">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1152,239 +1168,44 @@ exclude-result-prefixes="#all">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -1402,7 +1223,7 @@ exclude-result-prefixes="#all">
-
+
@@ -1426,14 +1247,14 @@ exclude-result-prefixes="#all">
-
+
-
+
-
+
@@ -1466,88 +1287,51 @@ exclude-result-prefixes="#all">
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
- search
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- link
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- edit
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/imports/foaf.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/imports/foaf.xsl
index 71132a39..7d75a7d7 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/imports/foaf.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/imports/foaf.xsl
@@ -16,7 +16,6 @@ limitations under the License.
-->
-
]>
@@ -163,7 +162,7 @@ exclude-result-prefixes="#all">
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
index 1f10f3a4..2d32cf77 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
@@ -17,7 +17,6 @@ limitations under the License.
-
@@ -61,7 +60,6 @@ exclude-result-prefixes="#all">
-
@@ -96,18 +94,6 @@ exclude-result-prefixes="#all">
AtomGraph
-
- Previous
-
-
-
- Next
-
-
-
- Delete
-
-
@@ -202,14 +188,14 @@ exclude-result-prefixes="#all">
-
+
@@ -284,8 +270,8 @@ exclude-result-prefixes="#all">
@@ -375,7 +361,7 @@ exclude-result-prefixes="#all">
-
+
@@ -402,19 +388,19 @@ exclude-result-prefixes="#all">
edit
-
+
-
@@ -505,15 +491,15 @@ exclude-result-prefixes="#all">
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
index 357480a2..a62fda1e 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
@@ -237,8 +237,6 @@ exclude-result-prefixes="#all">
-
-
@@ -260,10 +258,7 @@ exclude-result-prefixes="#all">
-
- Template is not defined for resource ' ' with types ' '
-
-
+
@@ -273,25 +268,6 @@ exclude-result-prefixes="#all">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
index c337453d..19472ebc 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
@@ -17,7 +17,6 @@ limitations under the License.
-
@@ -121,7 +120,7 @@ LIMIT 100
-
+
@@ -165,7 +164,7 @@ LIMIT 100
play_arrow
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/translations.rdf b/src/main/webapp/static/com/atomgraph/client/xsl/translations.rdf
index b6023008..b0edfa5d 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/translations.rdf
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/translations.rdf
@@ -1,77 +1,100 @@
-
-
+
+
Literal
Literal
-
+
Resource
Recurso
-
+
Language tag
Etiqueta de idioma
-
+
Datatype
Tipo de dato
-
+
Add another statement
Añadir otra sentencia
-
+
Remove this statement
Eliminar esta sentencia
-
+
Save
Guardar
-
+
Edit
Editar
-
+
Delete
Eliminar
-
+
Export
Exportar
-
+
Query
Consultar
-
+
Endpoint
Punto de acceso
-
+
Are you sure?
¿Está seguro?
-
+
Go
Ir
-
+
Query editor
Editor de consultas
-
+
Resources
Recursos
-
+
Query results
Resultados de la consulta
+
+ Developed by
+ Desarrollado por
+
+
+ Apache License
+ Licencia Apache
+
+
+ Previous
+ Anterior
+
+
+ Next
+ Siguiente
+
+
+ RDF/XML
+ RDF/XML
+
+
+ Turtle
+ Turtle
+
diff --git a/src/test/java/com/atomgraph/client/filter/request/ProxyRequestFilterTest.java b/src/test/java/com/atomgraph/client/filter/request/ProxyRequestFilterTest.java
new file mode 100644
index 00000000..eaca8749
--- /dev/null
+++ b/src/test/java/com/atomgraph/client/filter/request/ProxyRequestFilterTest.java
@@ -0,0 +1,145 @@
+/**
+ * Copyright 2025 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.client.filter.request;
+
+import java.net.URI;
+import java.util.List;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.internal.MapPropertiesDelegate;
+import org.glassfish.jersey.server.ContainerRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class ProxyRequestFilterTest
+{
+
+ private ProxyRequestFilter filter;
+
+ @BeforeEach
+ public void setUp()
+ {
+ filter = new ProxyRequestFilter();
+ }
+
+ protected ContainerRequest getRequest(String requestUri)
+ {
+ return new ContainerRequest(URI.create("http://localhost:8080/"), URI.create(requestUri),
+ "GET", null, new MapPropertiesDelegate(), null);
+ }
+
+ @Test
+ public void testResolveTargetURIWithoutParam()
+ {
+ assertNull(filter.resolveTargetURI(getRequest("http://localhost:8080/")));
+ }
+
+ @Test
+ public void testResolveTargetURI()
+ {
+ assertEquals(URI.create("https://remote.example/doc"),
+ filter.resolveTargetURI(getRequest("http://localhost:8080/?uri=https%3A%2F%2Fremote.example%2Fdoc")));
+ }
+
+ @Test
+ public void testResolveTargetURIStripsFragment()
+ {
+ assertEquals(URI.create("https://remote.example/doc"),
+ filter.resolveTargetURI(getRequest("http://localhost:8080/?uri=https%3A%2F%2Fremote.example%2Fdoc%23this")));
+ }
+
+ @Test
+ public void testOverlayHeadersSplitsCombinedLink()
+ {
+ // upstream sends one comma-joined Link line; each link-value must come out as its own header
+ // value, because Link.valueOf() (the writer's parser) only reads a single link-value
+ Response upstream = Response.ok().
+ header(HttpHeaders.LINK, "; rel=https://www.w3.org/ns/ldt#ontology, ; rel=https://www.w3.org/ns/ldt#base").
+ build();
+
+ Response response = filter.overlayHeaders(Response.ok().build(), upstream, true);
+ List linkValues = response.getHeaders().get(HttpHeaders.LINK);
+
+ assertEquals(2, linkValues.size());
+ assertEquals("; rel=https://www.w3.org/ns/ldt#ontology", linkValues.get(0).toString());
+ assertEquals(" ; rel=https://www.w3.org/ns/ldt#base", linkValues.get(1).toString());
+ }
+
+ @Test
+ public void testOverlayHeadersForwardsEndToEndHeaders()
+ {
+ Response upstream = Response.ok().
+ header(HttpHeaders.ETAG, "\"123\"").
+ header(HttpHeaders.CACHE_CONTROL, "max-age=60").
+ header("X-Custom", "not-forwarded").
+ build();
+
+ Response response = filter.overlayHeaders(Response.ok().build(), upstream, true);
+
+ assertEquals("\"123\"", response.getHeaderString(HttpHeaders.ETAG));
+ assertEquals("max-age=60", response.getHeaderString(HttpHeaders.CACHE_CONTROL));
+ assertNull(response.getHeaderString("X-Custom"));
+ }
+
+ @Test
+ public void testOverlayHeadersSkipsValidators()
+ {
+ Response upstream = Response.ok().
+ header(HttpHeaders.ETAG, "\"123\"").
+ header(HttpHeaders.LAST_MODIFIED, "Tue, 09 Sep 2026 12:00:00 GMT").
+ header(HttpHeaders.CACHE_CONTROL, "max-age=60").
+ build();
+
+ Response response = filter.overlayHeaders(Response.ok().build(), upstream, false);
+
+ assertNull(response.getHeaderString(HttpHeaders.ETAG));
+ assertNull(response.getHeaderString(HttpHeaders.LAST_MODIFIED));
+ assertEquals("max-age=60", response.getHeaderString(HttpHeaders.CACHE_CONTROL));
+ }
+
+ @Test
+ public void testOverlayHeadersReplacesLocallyStampedValue()
+ {
+ Response upstream = Response.ok().header(HttpHeaders.ETAG, "\"origin\"").build();
+ Response local = Response.ok().header(HttpHeaders.ETAG, "\"local\"").build();
+
+ Response response = filter.overlayHeaders(local, upstream, true);
+
+ assertEquals(1, response.getHeaders().get(HttpHeaders.ETAG).size());
+ assertEquals("\"origin\"", response.getHeaderString(HttpHeaders.ETAG));
+ }
+
+ @Test
+ public void testProxyRequestsAbort() throws Exception
+ {
+ ContainerRequest request = getRequest("http://localhost:8080/?uri=https%3A%2F%2Fremote.example%2Fdoc");
+ assertTrue(filter.resolveTargetURI(request) != null);
+ // the non-proxy request passes through without aborting
+ ContainerRequest passThrough = getRequest("http://localhost:8080/?endpoint=https%3A%2F%2Fremote.example%2Fsparql&query=SELECT");
+ filter.filter(passThrough);
+ assertNull(passThrough.getAbortResponse());
+ }
+
+}
diff --git a/src/test/java/com/atomgraph/client/resource/RootTest.java b/src/test/java/com/atomgraph/client/resource/RootTest.java
new file mode 100644
index 00000000..662a005b
--- /dev/null
+++ b/src/test/java/com/atomgraph/client/resource/RootTest.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright 2025 Martynas Jusevičius
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.atomgraph.client.resource;
+
+import java.net.URI;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+import org.glassfish.jersey.internal.MapPropertiesDelegate;
+import org.glassfish.jersey.server.ContainerRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ *
+ * @author Martynas Jusevičius {@literal }
+ */
+public class RootTest
+{
+
+ private Root root;
+ private UriInfo uriInfo;
+
+ @BeforeEach
+ public void setUp()
+ {
+ root = new Root();
+ uriInfo = new ContainerRequest(URI.create("http://localhost:8080/"), URI.create("http://localhost:8080/"),
+ "GET", null, new MapPropertiesDelegate(), null).getUriInfo();
+ }
+
+ @Test
+ public void testEndpointQueryRedirect()
+ {
+ Response response = root.get(URI.create("https://remote.example/sparql"), "DESCRIBE-abc", uriInfo);
+
+ assertEquals(Response.Status.SEE_OTHER.getStatusCode(), response.getStatus());
+ // the redirect proxies the SPARQL Protocol URL through ?uri=
+ assertEquals("http://localhost:8080/?uri=https%3A%2F%2Fremote.example%2Fsparql%3Fquery%3DDESCRIBE-abc",
+ response.getLocation().toString());
+ }
+
+ @Test
+ public void testNoTargetNotFound()
+ {
+ assertThrows(NotFoundException.class, () -> root.get(null, null, uriInfo));
+ }
+
+}
diff --git a/src/test/java/com/atomgraph/client/util/ConstructorTest.java b/src/test/java/com/atomgraph/client/util/ConstructorTest.java
deleted file mode 100644
index 82492c7f..00000000
--- a/src/test/java/com/atomgraph/client/util/ConstructorTest.java
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Copyright 2018 Martynas Jusevičius .
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.atomgraph.client.util;
-
-import com.atomgraph.client.exception.OntologyException;
-import com.atomgraph.client.vocabulary.SP;
-import com.atomgraph.client.vocabulary.SPIN;
-import org.apache.jena.ontapi.OntModelFactory;
-import org.apache.jena.ontapi.OntSpecification;
-import org.apache.jena.ontapi.model.OntClass;
-import org.apache.jena.ontapi.model.OntModel;
-import org.apache.jena.query.QueryParseException;
-import org.apache.jena.rdf.model.Model;
-import org.apache.jena.rdf.model.ModelFactory;
-import org.apache.jena.rdf.model.Resource;
-import org.apache.jena.vocabulary.RDF;
-import org.apache.jena.vocabulary.RDFS;
-import org.apache.jena.vocabulary.XSD;
-import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
-import org.junit.jupiter.api.BeforeEach;
-
-/**
- *
- * @author Martynas Jusevičius {@literal }
- */
-public class ConstructorTest
-{
- public static final String ONTOLOGY_URI = "http://test/ontology#";
- public static final String SUPER_PROPERTY_LOCAL_NAME = "super", RESOURCE_PROPERTY_LOCAL_NAME = "resource", LITERAL_PROPERTY_LOCAL_NAME = "literal";
- public static final String SUPER_RESOURCE_URI = "http://super", RESOURCE_URI = "http://resource";
- public static final Resource DATATYPE = XSD.xboolean;
- public static final String SUPER_CONSTRUCT = "PREFIX ont: <" + ONTOLOGY_URI + ">\n" +
-"PREFIX xsd: \n" +
-"\n" +
-"CONSTRUCT { ?this ont:" + SUPER_PROPERTY_LOCAL_NAME + " <" + SUPER_RESOURCE_URI + "> }\n" +
-"WHERE {}";
- public static final String CONSTRUCT = "PREFIX ont: <" + ONTOLOGY_URI + ">\n" +
-"PREFIX xsd: \n" +
-"\n" +
-"CONSTRUCT { ?this ont:" + RESOURCE_PROPERTY_LOCAL_NAME + " <" + RESOURCE_URI + "> ; ont:" + LITERAL_PROPERTY_LOCAL_NAME + " [ a <" + DATATYPE.getURI() + "> ] }\n" +
-"WHERE {}";
-
- private OntModel ontModel;
- private OntClass forClass, noConstructorClass, invalidConstructorClass, invalidConstructClass;
- private Constructor constructor;
-
- @BeforeEach
- public void setUp()
- {
- ontModel = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM);
-
- OntClass superClass = ontModel.createOntClass(ONTOLOGY_URI + "super-class");
- superClass.addProperty(SPIN.constructor, ontModel.createResource().
- addProperty(SP.text, SUPER_CONSTRUCT));
-
- forClass = ontModel.createOntClass(ONTOLOGY_URI + "class");
- forClass.addProperty(RDFS.subClassOf, superClass).
- addProperty(SPIN.constructor, ontModel.createResource().
- addLiteral(SP.text, CONSTRUCT));
-
- noConstructorClass = ontModel.createOntClass(ONTOLOGY_URI + "no-constructor-class");
-
- invalidConstructorClass = ontModel.createOntClass(ONTOLOGY_URI + "invalid-constructor-class");
- invalidConstructorClass.addLiteral(SPIN.constructor, 123);
-
- invalidConstructClass = ontModel.createOntClass(ONTOLOGY_URI + "invalid-construct-class");
- invalidConstructClass.addProperty(SPIN.constructor, ontModel.createResource().
- addProperty(SP.text, "INVALID { SPARQL } QUERY"));
-
- constructor = new Constructor();
- }
-
- /**
- * Test of construct method, of class Constructor.
- */
- @Test
- public void testConstruct()
- {
- Model result = ModelFactory.createDefaultModel();
- constructor.construct(forClass, result, "http://base/");
-
- Model expected = ModelFactory.createDefaultModel();
- expected.createResource().
- addProperty(RDF.type, forClass).
- addProperty(expected.createProperty(ONTOLOGY_URI, SUPER_PROPERTY_LOCAL_NAME),
- expected.createResource(SUPER_RESOURCE_URI)).
- addProperty(expected.createProperty(ONTOLOGY_URI, RESOURCE_PROPERTY_LOCAL_NAME),
- expected.createResource(RESOURCE_URI)).
- addProperty(expected.createProperty(ONTOLOGY_URI, LITERAL_PROPERTY_LOCAL_NAME),
- expected.createResource().addProperty(RDF.type, DATATYPE));
-
- assertTrue(result.isIsomorphicWith(expected));
- }
-
- @Test
- public void testNoConstructorClass()
- {
- Model result = ModelFactory.createDefaultModel();
- constructor.construct(noConstructorClass, result, "http://base/");
-
- Model expected = ModelFactory.createDefaultModel();
- expected.createResource().addProperty(RDF.type, noConstructorClass);
-
- assertTrue(result.isIsomorphicWith(expected));
- }
-
- @Test
- public void testInvalidConstructorClass()
- {
- Model result = ModelFactory.createDefaultModel();
- assertThrows(OntologyException.class, () -> constructor.construct(invalidConstructorClass, result, "http://base/"));
- }
-
- @Test
- public void testInvalidConstructClass()
- {
- Model result = ModelFactory.createDefaultModel();
- OntologyException ex = assertThrows(OntologyException.class, () -> constructor.construct(invalidConstructClass, result, "http://base/"));
- assertEquals(QueryParseException.class, ex.getCause().getClass());
- }
-
-}
From a9fc69b2c1a5760c9c670d632e36d54c93b00d94 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?=
Date: Wed, 9 Sep 2026 15:50:50 +0200
Subject: [PATCH 19/41] The proxy's error branch re-serves RDF diagnostic
bodies through content negotiation instead of relaying them verbatim: the
verbatim relay was faithful but useless to this client's actual consumer -
the outbound Accept offers every readable RDF flavor, the origin answered a
rejected form PUT with a 403 typed application/rdf+thrift, and Firefox's
response to binary bytes on an error status is its own network-error page,
not the origin's diagnostic. The entity buffers first, and when the upstream
Content-Type maps to an RDF lang in the live RIOT registry the body parses
into a Model and re-serves with the origin's status kept - a browser gets the
rendered error page (the diagnostic is typed http:Response, which the layout
has dedicated templates for), an API client gets the RDF format it asked for
- while a body that is not the RDF its type claims falls back to the buffered
verbatim relay rather than masking the origin's status as 502, the trap the
buffering exists to avoid. LDH's ProxyRequestFilter deliberately keeps the
verbatim relay: its proxy consumer is SaxonJS reading status and RDF
programmatically; this one's is a person.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01EEXsDAWWcutTN5sDpih57k
---
.../filter/request/ProxyRequestFilter.java | 34 ++++++++++++++-----
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/src/main/java/com/atomgraph/client/filter/request/ProxyRequestFilter.java b/src/main/java/com/atomgraph/client/filter/request/ProxyRequestFilter.java
index 66aad139..5fa37155 100644
--- a/src/main/java/com/atomgraph/client/filter/request/ProxyRequestFilter.java
+++ b/src/main/java/com/atomgraph/client/filter/request/ProxyRequestFilter.java
@@ -75,10 +75,10 @@
* {@code MediaTypes} snapshot it includes langs registered after class-loading, such as RDF/POST):
* RDF langs parse into a {@link Model} and SPARQL results langs into a {@link ResultSet}, both
* re-served through content negotiation - including (X)HTML via the XSLT writers, which is this
- * application's purpose. Error responses and non-RDF bodies relay verbatim: their bodies are
- * diagnostic or opaque representations, not negotiable content, and the origin's status and
- * validators must reach the client unchanged (a rejected write - 412 on a stale {@code If-Match},
- * 401/403 on an unauthorized delta - must surface as that status).
+ * application's purpose. Error responses keep the origin's status and validators but their RDF
+ * diagnostic bodies re-serve through the same negotiation, so a browser gets the rendered error
+ * page rather than raw RDF bytes (a rejected write - 412 on a stale {@code If-Match}, 401/403 on
+ * an unauthorized delta - must surface as that status); non-RDF and unparseable bodies relay verbatim.
*
* @author Martynas Jusevičius {@literal }
*/
@@ -251,15 +251,31 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me
return overlayHeaders(rb.build(), clientResponse, true);
}
- // error responses relay verbatim: the body is a diagnostic representation, not negotiable
- // content, so it must not go through the Model/ResultSet re-serialization branches - parsing a
- // non-RDF or empty error body there throws and masks the origin's status as 502/406. A proxied
- // write that the origin rejects (412 on a stale If-Match, 401/403 on an unauthorized delta)
- // must reach the client as that status, with the origin's validators forwarded
+ // error responses keep the origin's status but re-serve an RDF diagnostic body through content
+ // negotiation - this client's consumer is a browser, and relaying e.g. RDF/Thrift bytes verbatim
+ // on a 403 gives it nothing it can render. The entity is buffered first so a body that is not
+ // the RDF its Content-Type claims falls back to the verbatim relay instead of masking the
+ // origin's status as 502/406. A proxied write that the origin rejects (412 on a stale If-Match,
+ // 401/403 on an unauthorized delta) must reach the client as that status either way, with the
+ // origin's validators forwarded
Response.Status.Family family = clientResponse.getStatusInfo().getFamily();
if (family == Response.Status.Family.CLIENT_ERROR || family == Response.Status.Family.SERVER_ERROR)
{
clientResponse.bufferEntity();
+
+ MediaType errorType = clientResponse.getMediaType();
+ if (RDFLanguages.contentTypeToLang(new MediaType(errorType.getType(), errorType.getSubtype()).toString()) != null)
+ try
+ {
+ clientResponse.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, targetURI.toString());
+ Model errorModel = clientResponse.readEntity(Model.class);
+ return overlayHeaders(getResponse(errorModel, clientResponse.getStatusInfo()), clientResponse, true);
+ }
+ catch (ProcessingException | RiotException ex)
+ {
+ if (log.isWarnEnabled()) log.warn("Error body from proxied URI {} typed as RDF but unparseable - relaying verbatim", targetURI);
+ }
+
Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()).
type(clientResponse.getMediaType()).
entity(clientResponse.readEntity(InputStream.class));
From ebdc0067376a9b07041db5c27296281ab6583e8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?=
Date: Wed, 9 Sep 2026 16:19:22 +0200
Subject: [PATCH 20/41] Two mode names catch up with the design system where
the markup already had: ac:Alert becomes ac:InlineAlert - the emitter builds
ldhc-alert anatomy, and InlineAlert is that component's export name, Toast
being the other alert-classed surface - and ac:ModeList/ac:ModeListItem
become ac:ModeSwitcher/ac:ModeSwitcherItem, ModeSwitcher being the design
system's name for the control the details menu of ac:Mode instances renders.
The NAVBAR ACTIONS and MODE LIST section comments take the same vocabulary
(HEADER ACTIONS, MODE SWITCHER). LinkedDataHub renames its shadows and
dispatch sites in lockstep.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01EEXsDAWWcutTN5sDpih57k
---
.../atomgraph/client/xsl/imports/default.xsl | 2 +-
.../com/atomgraph/client/xsl/layout.xsl | 20 +++++++++----------
.../com/atomgraph/client/xsl/resource.xsl | 2 +-
.../com/atomgraph/client/xsl/sparql.xsl | 2 +-
4 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
index 5799b60f..69e4067a 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
@@ -906,7 +906,7 @@ exclude-result-prefixes="#all">
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
index 2d32cf77..c82b9691 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
@@ -254,7 +254,7 @@ exclude-result-prefixes="#all">
-
+
@@ -380,7 +380,7 @@ exclude-result-prefixes="#all">
-
+
@@ -413,11 +413,11 @@ exclude-result-prefixes="#all">
-
+
-
+
-
+
@@ -432,7 +432,7 @@ exclude-result-prefixes="#all">
-
+
-
+
@@ -464,7 +464,7 @@ exclude-result-prefixes="#all">
-
+
@@ -472,7 +472,7 @@ exclude-result-prefixes="#all">
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
index a62fda1e..d8cd50de 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/resource.xsl
@@ -277,7 +277,7 @@ exclude-result-prefixes="#all">
-
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
index 19472ebc..a789deac 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/sparql.xsl
@@ -176,7 +176,7 @@ LIMIT 100
-
+
From 8e3729543d2d378d8fde0835c057a08006bcc1fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?=
Date: Wed, 9 Sep 2026 22:59:31 +0200
Subject: [PATCH 21/41] The field shell grows the rest of its anatomy, and
every Tag paints. ac:FieldShell takes the design's remaining slots as
parameters - a label (with its size axis and optional for-target) above the
box, a validation state (st-valid/st-invalid) on the box, help text in the
new field foot - and drops the $style passthrough, which existed only to hide
a shell inline where a state class belongs; ac:SelectShell takes the same
state axis. The Tag family stops relying on colors it never had: the language
tag, both @rdf:datatype leaves and the ac:AnnotationTag default gain the
co-accent axis (violet, the system's semantic-annotation hue) and wrap their
text in .ldhc-tag-lbl, the one carrier of the truncation treatment.
ac:InlineAlert announces by variant - role=alert only for va-negative, polite
status for the rest. xhtml:Anchor gains a $role parameter (a menu item needs
menuitem; the primitive's contract is every attribute a parameter). The
standalone delete button takes in-negative - in-destructive is the IconButton
axis, and .ldhc-btn.ap-outline resolved its border and ink against a variable
that was never set, so the danger treatment silently vanished. sparql.xsl's
list mode applies to the graph node rather than its descriptions, so the rows
get their back. The disclosure menus stay: these standalone
pages run without a CSR menu handler, and details is the no-script dropdown -
now said in a comment. The standalone statement-row and property-list
templates keep their pre-grid shape for now; LinkedDataHub overrides both,
and their alignment is a standalone-page pass of its own.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01EEXsDAWWcutTN5sDpih57k
---
.../atomgraph/client/xsl/imports/default.xsl | 54 ++++++++++++++-----
.../com/atomgraph/client/xsl/layout.xsl | 4 +-
.../com/atomgraph/client/xsl/sparql.xsl | 2 +-
3 files changed, 45 insertions(+), 15 deletions(-)
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
index 69e4067a..2ac9fc0a 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/imports/default.xsl
@@ -135,8 +135,10 @@ exclude-result-prefixes="#all">
-
-
+
+
+
+
@@ -183,6 +185,7 @@ exclude-result-prefixes="#all">
+
@@ -190,6 +193,9 @@ exclude-result-prefixes="#all">
+
+
+
@@ -425,7 +431,7 @@ exclude-result-prefixes="#all">
-
+
@@ -446,7 +452,7 @@ exclude-result-prefixes="#all">
-
+
@@ -873,14 +879,19 @@ exclude-result-prefixes="#all">
+ holds the pre-built input/textarea, $adorn an optional leading adornment; hidden inputs bypass the
+ chrome. $label renders the shell's own Label above the box, $state marks the box's validation state
+ (st-valid | st-invalid), $help renders in the field foot -->
+
+
+
+
-
@@ -888,14 +899,27 @@ exclude-result-prefixes="#all">
-
-
+
+
+
+
+
+
+
-
@@ -915,7 +939,8 @@ exclude-result-prefixes="#all">
-
+
+
@@ -951,8 +976,9 @@ exclude-result-prefixes="#all">
+
-
+
@@ -966,7 +992,7 @@ exclude-result-prefixes="#all">
-
+
@@ -978,7 +1004,9 @@ exclude-result-prefixes="#all">
-
+
+
+
diff --git a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
index c82b9691..505fe891 100644
--- a/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
+++ b/src/main/webapp/static/com/atomgraph/client/xsl/layout.xsl
@@ -394,7 +394,7 @@ exclude-result-prefixes="#all">