Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ff21a2a
bump fdp minor version in pom
dennisvang Mar 9, 2026
68bc64c
add new rdf4j-spring-boot-sparql-web dependency
dennisvang Mar 9, 2026
a3e915e
exclude logback from rdf4j-spring-boot-sparql-web
dennisvang Mar 9, 2026
3d531e0
provide custom name for ProfileController bean
dennisvang Mar 9, 2026
3a0cde9
scan for components only in the sparql package
dennisvang Mar 9, 2026
a95cf37
add SearchSparqlController, based on rdf4j's spring-boot-sparql-web
dennisvang Mar 9, 2026
72d598c
license and style for SearchSparqlController
dennisvang Mar 9, 2026
2d04254
add openapi tag for sparql controller
dennisvang Mar 10, 2026
4aad053
exclude all transitive dependencies from rdf4j-spring-boot-sparql-web
dennisvang Mar 10, 2026
c6b89a2
make enum classes explicit and rearrange input args
dennisvang Mar 10, 2026
34cb42d
handle empty graph uri values in sparql controller
dennisvang Mar 10, 2026
76d93d1
fall back on application/json if Accept header is */*
dennisvang Mar 10, 2026
0de0ebf
provide sparql post example values for openapi docs and swagger-ui
dennisvang Mar 10, 2026
6af6043
add rudimentary integration tests for basic SPARQL queries
dennisvang Mar 13, 2026
9978d5f
add naive tests to verify that sparql update operations are not allowed
dennisvang Mar 13, 2026
06dbd14
add license to sparql test file
dennisvang Mar 13, 2026
52c3105
mention whitelist in test
dennisvang Mar 13, 2026
1427896
Revert "provide custom name for ProfileController bean"
dennisvang Mar 16, 2026
4acc5b4
switch from multipart form to json data fro sparql post requests
dennisvang Mar 16, 2026
04cb238
adapt sparql endpoint tests to use JSON request body
dennisvang Mar 16, 2026
70a02fc
mention accept header values in javadoc
dennisvang Mar 16, 2026
770f559
rename TestSparqlPost to TestSearchSparqlController and rename test m…
dennisvang Mar 16, 2026
0b8a4e2
limit supported sparql output types to application/json, application/…
dennisvang Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</parent>
<groupId>nl.dtls</groupId>
<artifactId>fairdatapoint</artifactId>
<version>1.18.1</version>
<version>1.19.0</version>
<packaging>jar</packaging>

<name>FairDataPoint</name>
Expand Down Expand Up @@ -210,6 +210,20 @@
<artifactId>rdf4j-sail-nativerdf</artifactId>
<version>${rdf4j-runtime.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.rdf4j</groupId>
<artifactId>rdf4j-spring-boot-sparql-web</artifactId>
<version>${rdf4j-runtime.version}</version>
<exclusions>
<!-- Exclude all transitive dependencies:
They cause unwanted side effects, such as spring data rest exposing all repository methods as
rest endpoints. Moreover, we only need the class definitions. -->
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/nl/dtls/fairdatapoint/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <code>org.eclipse.rdf4j.http.server.readonly.sparql.QueryTypes.formats</code>.
* 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, Object> 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<SearchSparqlController.SparqlQuery> request = RequestEntity
.post(url)
.header(HttpHeaders.AUTHORIZATION, ALBERT_TOKEN)
.accept(MediaType.valueOf(acceptHeader))
.contentType(MediaType.APPLICATION_JSON)
.body(sparqlQuery);

// perform
ResponseEntity<String> response = client.exchange(request, String.class);

// evaluate
assertEquals(HttpStatus.OK, response.getStatusCode());
HashMap<String, Object> 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<String> response = client.exchange(request, String.class);

// evaluate
assertEquals(HttpStatus.OK, response.getStatusCode());
HashMap<String, Object> 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 <https://w3id.org/fdp/fdp-o#MetadataService> }",
"DESCRIBE ?s WHERE { ?s a <https://w3id.org/fdp/fdp-o#MetadataService> }"
})
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<String> response = client.exchange(request, String.class);

// evaluate
assertEquals(HttpStatus.OK, response.getStatusCode());
List<HashMap<String, Object>> responseBodyList = jsonMapper.readValue(
response.getBody(), new TypeReference<>() {}
);
assertFalse(responseBodyList.isEmpty());
}


/**
* Verify that <a href="https://www.w3.org/TR/sparql11-update/">SPARQL Update</a> operations are disallowed.
* The <code>SparqlQueryEvaluator</code> implements a whitelist in the <code>QueryTypes</code> 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: <http://purl.org/dc/terms/>
PREFIX ex: <http://example.org/>
""";

// 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<String> response = client.exchange(request, String.class);

// SPARQL Update operations should always be denied
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
Loading