diff --git a/pom.xml b/pom.xml
index 2297fd76f..8b8b7eee2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
nl.dtls
fairdatapoint
- 1.18.1
+ 1.19.0
jar
FairDataPoint
@@ -210,6 +210,20 @@
rdf4j-sail-nativerdf
${rdf4j-runtime.version}
+
+ org.eclipse.rdf4j
+ rdf4j-spring-boot-sparql-web
+ ${rdf4j-runtime.version}
+
+
+
+ *
+ *
+
+
+
io.jsonwebtoken
jjwt-api
diff --git a/src/main/java/nl/dtls/fairdatapoint/Application.java b/src/main/java/nl/dtls/fairdatapoint/Application.java
index c9ce5bb46..ef9aa5ca8 100644
--- a/src/main/java/nl/dtls/fairdatapoint/Application.java
+++ b/src/main/java/nl/dtls/fairdatapoint/Application.java
@@ -32,7 +32,10 @@
@SpringBootApplication
@EnableWebMvc
@EnableAsync
-@ComponentScan(basePackages = "nl.dtls.fairdatapoint.*")
+@ComponentScan(basePackages = {
+ "org.eclipse.rdf4j.http.server.readonly.sparql",
+ "nl.dtls.fairdatapoint.*"
+})
@ConfigurationPropertiesScan("nl.dtls.fairdatapoint.config.*")
public class Application {
diff --git a/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java b/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java
new file mode 100644
index 000000000..3550c429c
--- /dev/null
+++ b/src/main/java/nl/dtls/fairdatapoint/api/controller/search/SearchSparqlController.java
@@ -0,0 +1,152 @@
+/**
+ * The MIT License
+ * Copyright © 2017 DTL
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+// This code is mostly copied from rdf4j spring-boot-sparql-web, with some customizations:
+//
+// https://github.com/eclipse-rdf4j/rdf4j/blob/main/spring-components/spring-boot-sparql-web
+//
+// Copyright (c) 2021 Eclipse RDF4J contributors.
+//
+// All rights reserved. This program and the accompanying materials
+// are made available under the terms of the Eclipse Distribution License v1.0
+// which accompanies this distribution, and is available at
+// http://www.eclipse.org/org/documents/edl-v10.php.
+//
+// SPDX-License-Identifier: BSD-3-Clause
+
+package nl.dtls.fairdatapoint.api.controller.search;
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.rdf4j.http.server.readonly.sparql.EvaluateResult;
+import org.eclipse.rdf4j.http.server.readonly.sparql.SparqlQueryEvaluator;
+import org.eclipse.rdf4j.query.MalformedQueryException;
+import org.eclipse.rdf4j.repository.Repository;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+@Tag(name = "Search")
+@RestController
+public class SearchSparqlController {
+
+ private static final String[] ALL_GRAPHS = {};
+
+ private static final String JSON_MEDIA_TYPES = "application/json, application/ld+json";
+
+ private final Repository rdf4jRepository;
+
+ private final SparqlQueryEvaluator sparqlQueryEvaluator;
+
+ /**
+ * Constructor
+ */
+ public SearchSparqlController(Repository rdf4jRepository, SparqlQueryEvaluator sparqlQueryEvaluator) {
+ this.rdf4jRepository = rdf4jRepository;
+ this.sparqlQueryEvaluator = sparqlQueryEvaluator;
+ }
+
+ /**
+ * Allows authenticated users to POST a full SPARQL query.
+ * Method body copied from org.eclipse.rdf4j.http.server.readonly.QueryResponder.
+ * The "Accept" header is required, and allowable media types depend on the type of query,
+ * as defined in org.eclipse.rdf4j.http.server.readonly.sparql.QueryTypes.formats.
+ * However, to simplify things, we restrict the allowable media types to JSON and/or JSON-LD.
+ */
+ @PreAuthorize("isAuthenticated()")
+ @PostMapping(
+ path = "/search/sparql",
+ consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = { MediaType.APPLICATION_JSON_VALUE, "application/ld+json" }
+ )
+ public void sparqlPost(
+ @RequestHeader(value = HttpHeaders.ACCEPT, defaultValue = JSON_MEDIA_TYPES) String acceptHeader,
+ @RequestBody SparqlQuery sparqlQuery,
+ HttpServletResponse response
+ ) throws IOException {
+ // enforce default accept header for wildcard
+ final String accept = ("*/*".equals(acceptHeader)) ? JSON_MEDIA_TYPES : acceptHeader;
+ try {
+ final EvaluateResultHttpResponse result = new EvaluateResultHttpResponse(response);
+ sparqlQueryEvaluator.evaluate(
+ result,
+ rdf4jRepository,
+ sparqlQuery.query,
+ accept,
+ toArray(sparqlQuery.defaultGraphUri),
+ toArray(sparqlQuery.namedGraphUri)
+ );
+ }
+ catch (MalformedQueryException | IllegalStateException | IOException exception) {
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+ }
+ }
+
+ private String[] toArray(String graphUri) {
+ if (graphUri != null && !graphUri.isEmpty()) {
+ return new String[]{graphUri};
+ }
+ return ALL_GRAPHS;
+ }
+
+ /**
+ * Encapsulates the {@link HttpServletResponse}.
+ * Copied from org.eclipse.rdf4j.http.server.readonly.EvaluateResultHttpResponse.
+ */
+ protected static class EvaluateResultHttpResponse implements EvaluateResult {
+
+ private final HttpServletResponse response;
+
+ public EvaluateResultHttpResponse(HttpServletResponse response) {
+ this.response = response;
+ }
+
+ @Override
+ public void setContentType(String contentType) {
+ response.setContentType(contentType);
+ }
+
+ @Override
+ public String getContentType() {
+ return response.getContentType();
+ }
+
+ @Override
+ public OutputStream getOutputstream() throws IOException {
+ return response.getOutputStream();
+ }
+ }
+
+ /**
+ * Defines the content of the query request body, for JSON deserialization.
+ * @param query
+ * @param defaultGraphUri
+ * @param namedGraphUri
+ */
+ public record SparqlQuery(String query, String defaultGraphUri, String namedGraphUri) {
+ }
+}
diff --git a/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java b/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java
new file mode 100644
index 000000000..bcc6e8b47
--- /dev/null
+++ b/src/test/java/nl/dtls/fairdatapoint/acceptance/search/sparql/TestSearchSparqlController.java
@@ -0,0 +1,199 @@
+/**
+ * The MIT License
+ * Copyright © 2017 DTL
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package nl.dtls.fairdatapoint.acceptance.search.sparql;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import nl.dtls.fairdatapoint.WebIntegrationTest;
+import nl.dtls.fairdatapoint.api.controller.search.SearchSparqlController;
+import org.eclipse.rdf4j.rio.RDFFormat;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.http.*;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("POST /search/sparql")
+public class TestSearchSparqlController extends WebIntegrationTest {
+
+ private final URI url = URI.create("/search/sparql");
+
+ private final ObjectMapper jsonMapper = new ObjectMapper();
+
+ private final String querySelectAll = "SELECT * WHERE { ?s ?p ?o }";
+
+ @Test
+ public void postSparqlUnauthenticated() throws JsonProcessingException {
+ // prepare request
+ SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
+ querySelectAll, null, null);
+ RequestEntity> request = RequestEntity
+ .post(url)
+ .accept(MediaType.APPLICATION_JSON)
+ .body(sparqlQuery);
+
+ // perform
+ ResponseEntity response = client.exchange(request, String.class);
+
+ // evaluate
+ // TODO: this should actually be HttpStatus.UNAUTHORIZED, but FDP returns the wrong status code (see #704)
+ assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
+ HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
+ });
+ assertTrue(responseBodyMap.containsKey("error"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { MediaType.APPLICATION_JSON_VALUE, "*/*" })
+ public void postSparqlSelectAll(String acceptHeader) throws JsonProcessingException {
+ // prepare request
+ SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
+ querySelectAll, null, null);
+ RequestEntity request = RequestEntity
+ .post(url)
+ .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
+ .accept(MediaType.valueOf(acceptHeader))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(sparqlQuery);
+
+ // perform
+ ResponseEntity response = client.exchange(request, String.class);
+
+ // evaluate
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
+ });
+
+ // expected SPARQL SELECT result structure: https://www.w3.org/TR/sparql11-results-json/
+ assertEquals(Set.of("head", "results"), responseBodyMap.keySet());
+ if (responseBodyMap.get("results") instanceof HashMap, ?> results) {
+ assertEquals(Set.of("bindings"), results.keySet());
+ }
+ }
+
+ @Test
+ public void postSparqlAskAny() throws JsonProcessingException {
+ // prepare request
+ SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
+ "ASK { ?s ?p ?o }", null, null);
+ RequestEntity> request = RequestEntity
+ .post(url)
+ .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
+ .accept(MediaType.APPLICATION_JSON)
+ .body(sparqlQuery);
+
+ // perform
+ ResponseEntity response = client.exchange(request, String.class);
+
+ // evaluate
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ HashMap responseBodyMap = jsonMapper.readValue(response.getBody(), new TypeReference<>() {
+ });
+
+ // expected SPARQL ASK result structure: https://www.w3.org/TR/sparql11-results-json/
+ assertEquals(Set.of("head", "boolean"), responseBodyMap.keySet());
+ if (responseBodyMap.get("boolean") instanceof Boolean bool) {
+ assertTrue(bool);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "CONSTRUCT WHERE { ?s a }",
+ "DESCRIBE ?s WHERE { ?s a }"
+ })
+ public void postSparqlConstructOrDescribe(String query) throws JsonProcessingException {
+ // prepare request
+ SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
+ query, null, null);
+ RequestEntity> request = RequestEntity
+ .post(url)
+ .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
+ // QueryTypes.CONSTRUCT_OR_DESCRIBE does not support simple JSON, only application/ld+json (or ttl, n3)
+ .accept(MediaType.valueOf(RDFFormat.JSONLD.getDefaultMIMEType()))
+ .body(sparqlQuery);
+
+ // perform
+ ResponseEntity response = client.exchange(request, String.class);
+
+ // evaluate
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ List> responseBodyList = jsonMapper.readValue(
+ response.getBody(), new TypeReference<>() {}
+ );
+ assertFalse(responseBodyList.isEmpty());
+ }
+
+
+ /**
+ * Verify that SPARQL Update operations are disallowed.
+ * The SparqlQueryEvaluator implements a whitelist in the QueryTypes enum.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ // https://www.w3.org/TR/sparql11-update/#graphUpdate
+ "INSERT DATA { ex:test1 dc:title \"test\" }",
+ "INSERT { ?s dc:title ?ol } WHERE { ?s dc:title ?o . BIND( STRLANG(STR(?o), \"en\") AS ?ol ) . }",
+ "DELETE DATA { ?s dc:title ?o } WHERE { ?s dc:title ?o }",
+ "DELETE WHERE { ?s dc:title ?o }",
+ "LOAD dc:",
+ "CLEAR GRAPH ex:",
+ // https://www.w3.org/TR/sparql11-update/#graphManagement
+ "CREATE GRAPH ex:",
+ "DROP GRAPH ex:",
+ "COPY DEFAULT TO GRAPH ex:",
+ "MOVE DEFAULT TO GRAPH ex:",
+ "ADD DEFAULT TO GRAPH ex:"
+ })
+ public void postSparqlUpdateDenied(String update) throws JsonProcessingException {
+ // common prefixes (part of prologue in sparql grammar)
+ final String prologue = """
+ PREFIX dc:
+ PREFIX ex:
+ """;
+
+ // prepare request
+ SearchSparqlController.SparqlQuery sparqlQuery = new SearchSparqlController.SparqlQuery(
+ prologue + update, null, null);
+ RequestEntity> request = RequestEntity
+ .post(url)
+ .header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
+ .accept(MediaType.APPLICATION_JSON)
+ .body(sparqlQuery);
+
+ // perform
+ ResponseEntity response = client.exchange(request, String.class);
+
+ // SPARQL Update operations should always be denied
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+}