SparqlController.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * 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 org.entrystore.rest.springboot.controller;

import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.service.SparqlService;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.entrystore.rest.springboot.util.SparqlResultFormat;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.nio.charset.StandardCharsets;

@RestController
@RequiredArgsConstructor
public class SparqlController {

	private static final int MAX_POST_REQUEST_SIZE = 32 * 1024;

	private final SparqlService sparqlService;

	@Operation(summary = "Executes a SPARQL tuple query against the public repository")
	@GetMapping("/sparql")
	public ResponseEntity<StreamingResponseBody> getSparql(
			@RequestParam("query") String query,
			@RequestParam(value = "format", required = false) String format,
			@RequestHeader(value = "Accept", required = false) String acceptHeader) {

		return runQuery(query, format, acceptHeader, null);
	}

	@Operation(summary = "Executes a SPARQL tuple query restricted to a single context's named graph")
	@GetMapping("/{context-id}/sparql")
	public ResponseEntity<StreamingResponseBody> getContextSparql(
			@PathVariable("context-id") String contextId,
			@RequestParam("query") String query,
			@RequestParam(value = "format", required = false) String format,
			@RequestHeader(value = "Accept", required = false) String acceptHeader) {

		return runQuery(query, format, acceptHeader, contextId);
	}

	@Operation(summary = "Executes a SPARQL tuple query (form-encoded POST) against the public repository")
	@PostMapping(path = "/sparql", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
	public ResponseEntity<StreamingResponseBody> postSparql(
			HttpServletRequest request,
			@RequestParam("query") String query,
			@RequestParam(value = "output", required = false) String output) {

		return runFormQuery(request, query, output, null);
	}

	@Operation(summary = "Executes a SPARQL tuple query (form-encoded POST) restricted to a single context's named graph")
	@PostMapping(path = "/{context-id}/sparql", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
	public ResponseEntity<StreamingResponseBody> postContextSparql(
			HttpServletRequest request,
			@PathVariable("context-id") String contextId,
			@RequestParam("query") String query,
			@RequestParam(value = "output", required = false) String output) {

		return runFormQuery(request, query, output, contextId);
	}

	private ResponseEntity<StreamingResponseBody> runQuery(String query, String format, String acceptHeader, String contextId) {
		return buildResponse(SparqlResultFormat.resolve(format, acceptHeader), query, contextId);
	}

	private ResponseEntity<StreamingResponseBody> runFormQuery(HttpServletRequest request, String query, String output, String contextId) {
		// @RequestParam binds from BOTH the form body AND the URL query string; without this guard a client
		// could ship a megabyte query in the URL while keeping the body under the 32 KB cap, defeating it.
		if (StringUtils.isNotEmpty(request.getQueryString())) {
			throw new BadRequestException("POST /sparql parameters must be supplied in the form body, not the URL query string");
		}
		HttpUtil.checkRequestSize(request, MAX_POST_REQUEST_SIZE);
		return buildResponse(SparqlResultFormat.fromOutputForm(output), query, contextId);
	}

	private ResponseEntity<StreamingResponseBody> buildResponse(SparqlResultFormat format, String query, String contextId) {
		StreamingResponseBody body = out -> sparqlService.runQuery(format, query, contextId, out);
		return ResponseEntity.ok().contentType(responseMediaType(format)).body(body);
	}

	private static MediaType responseMediaType(SparqlResultFormat format) {
		MediaType mediaType = format.getMediaType();
		// RDF4J writes UTF-8 (mandated by SPARQL 1.1 CSV/TSV); without the parameter clients default text/* to ISO-8859-1
		return "text".equals(mediaType.getType()) ? new MediaType(mediaType, StandardCharsets.UTF_8) : mediaType;
	}
}