SparqlResultFormat.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.util;
import lombok.Getter;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* SPARQL tuple-result format enum + content negotiation. Each value carries the canonical
* MIME string, parsed Spring {@link MediaType}, and any compatibility aliases that should resolve
* to the value.
*
* <p>Switch expressions over this enum (e.g. {@code SparqlService.createWriter}) are
* exhaustive at compile time, so adding a result type fails to compile until every
* such switch has wired it up. The static-init blocks below catch the remaining invariants
* at class-load: {@code PARTIAL_WILDCARD_PREFERENCE} must list every value, and
* {@code ALIAS_TO_FORMAT} must not have any alias collisions.</p>
*/
public enum SparqlResultFormat {
BINARY("application/x-binary-rdf-results-table", List.of()),
SPARQL_RESULTS_JSON("application/sparql-results+json", List.of(MediaType.APPLICATION_JSON_VALUE)),
SPARQL_RESULTS_XML("application/sparql-results+xml", List.of(MediaType.APPLICATION_XML_VALUE)),
CSV("text/csv", List.of("application/sparql-results+csv")),
TSV("text/tab-separated-values", List.of());
private static final int ECHOED_VALUE_MAX_LENGTH = 64;
@Getter
private final String canonical;
@Getter
private final MediaType mediaType;
private final List<String> aliases;
SparqlResultFormat(String canonical, List<String> aliases) {
this.canonical = canonical;
this.mediaType = MediaType.parseMediaType(canonical);
this.aliases = aliases;
}
// BINARY first so application/* keeps preferring the most efficient SPARQL result format;
// CSV before TSV so text/* keeps resolving to text/csv.
private static final List<SparqlResultFormat> PARTIAL_WILDCARD_PREFERENCE =
List.of(BINARY, SPARQL_RESULTS_JSON, SPARQL_RESULTS_XML, CSV, TSV);
static {
// Fail at class-load if a future enum value forgets to register itself in
// PARTIAL_WILDCARD_PREFERENCE; otherwise the new value would silently be unreachable for
// `application/*` / `text/*` Accept resolution with no test that would catch it.
if (!EnumSet.copyOf(PARTIAL_WILDCARD_PREFERENCE).containsAll(EnumSet.allOf(SparqlResultFormat.class))) {
throw new IllegalStateException(
"PARTIAL_WILDCARD_PREFERENCE must include every SparqlResultFormat value");
}
}
private static final Map<String, SparqlResultFormat> ALIAS_TO_FORMAT = buildAliasMap();
private static Map<String, SparqlResultFormat> buildAliasMap() {
Map<String, SparqlResultFormat> map = new HashMap<>();
for (SparqlResultFormat f : values()) {
putUnique(map, f.canonical, f);
for (String alias : f.aliases) {
putUnique(map, alias, f);
}
}
return Map.copyOf(map);
}
private static void putUnique(Map<String, SparqlResultFormat> map, String key, SparqlResultFormat f) {
// Fail at class-load if a future value declares an alias matching another value's canonical
// or alias, instead of letting HashMap.put silently last-one-wins by enum-iteration order.
SparqlResultFormat prior = map.putIfAbsent(key, f);
if (prior != null && prior != f) {
throw new IllegalStateException(
"SparqlResultFormat alias collision: '" + key + "' maps to both " + prior + " and " + f);
}
}
public static SparqlResultFormat resolve(String formatParam, String acceptHeader) {
SparqlResultFormat fromFormat = fromFormatParam(formatParam);
if (fromFormat != null) {
return fromFormat;
}
return fromAcceptHeader(acceptHeader);
}
/**
* A missing {@code output} field defaults to {@link #SPARQL_RESULTS_JSON} (preserves
* legacy client compatibility); any unrecognised value triggers
* {@link BadRequestException}.
*/
public static SparqlResultFormat fromOutputForm(String output) {
if (output == null) {
return SPARQL_RESULTS_JSON;
}
return switch (output.trim().toLowerCase(Locale.ROOT)) {
case "json" -> SPARQL_RESULTS_JSON;
case "xml" -> SPARQL_RESULTS_XML;
case "csv" -> CSV;
case "tsv" -> TSV;
default -> throw new BadRequestException(
"Unsupported SPARQL output format: " + truncate(output) + " (supported: json, xml, csv, tsv)");
};
}
private static SparqlResultFormat fromFormatParam(String formatParam) {
if (formatParam == null || formatParam.isBlank()) {
return null;
}
// clients may send an unencoded '+', which servlet decoding turns into a space
String normalized = formatParam.trim().replace(' ', '+');
SparqlResultFormat mapped = ALIAS_TO_FORMAT.get(normalized.toLowerCase(Locale.ROOT));
if (mapped == null) {
throw new BadRequestException("Unsupported SPARQL format: " + truncate(formatParam));
}
return mapped;
}
private static SparqlResultFormat fromAcceptHeader(String acceptHeader) {
if (acceptHeader == null || acceptHeader.isBlank()) {
return BINARY;
}
List<MediaType> acceptTypes;
try {
acceptTypes = MediaType.parseMediaTypes(acceptHeader);
} catch (InvalidMediaTypeException e) {
throw new BadRequestException("Invalid Accept header: " + truncate(acceptHeader), e);
}
acceptTypes.sort(GraphUtil.QUALITY_THEN_SPECIFICITY);
for (MediaType type : acceptTypes) {
// RFC 7231 §5.3.1: q=0 means "explicitly unacceptable"; skip without matching.
// Must precede the wildcard branch so a `*/*;q=0` entry does not return BINARY.
if (type.getQualityValue() <= 0.0) {
continue;
}
if (type.isWildcardType()) {
return BINARY;
}
if (type.isWildcardSubtype()) {
String topLevel = type.getType().toLowerCase(Locale.ROOT) + "/";
for (SparqlResultFormat f : PARTIAL_WILDCARD_PREFERENCE) {
if (f.canonical.startsWith(topLevel)) {
return f;
}
}
continue;
}
String typeStr = (type.getType() + "/" + type.getSubtype()).toLowerCase(Locale.ROOT);
SparqlResultFormat mapped = ALIAS_TO_FORMAT.get(typeStr);
if (mapped != null) {
return mapped;
}
}
throw new CustomResponseException(
"None of the requested Accept types are supported by the SPARQL endpoint: " + truncate(acceptHeader),
HttpStatus.NOT_ACCEPTABLE);
}
private static String truncate(String value) {
// Same hardening as SparqlService.truncate: strip ASCII C0/C1 controls AND U+2028/U+2029
// line/paragraph separators so an attacker-supplied format/Accept value cannot forge log
// lines via the BadRequestException message that AppExceptionHandler echoes (CWE-117).
String visible = value.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", "?");
return visible.length() <= ECHOED_VALUE_MAX_LENGTH
? visible
: visible.substring(0, ECHOED_VALUE_MAX_LENGTH) + "…";
}
}