SearchService.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.service;
import com.rometools.rome.feed.synd.SyndFeed;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.solr.client.solrj.request.SolrQuery;
import org.apache.solr.common.SolrException;
import org.entrystore.AuthorizationException;
import org.entrystore.Entry;
import org.entrystore.EntryType;
import org.entrystore.GraphType;
import org.entrystore.Group;
import org.entrystore.Resource;
import org.entrystore.User;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.util.QueryResult;
import org.entrystore.repository.util.SolrSearchIndex;
import org.entrystore.rest.springboot.configuration.SyndicationProperties;
import org.entrystore.rest.springboot.model.api.FacetSettingsRequestParams;
import org.entrystore.rest.springboot.model.dto.FacetValueDto;
import org.entrystore.rest.springboot.model.dto.FacetValuesDto;
import org.entrystore.rest.springboot.model.dto.QueryResultsDto;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.util.HttpQueryRedactor;
import org.entrystore.rest.springboot.util.Syndication;
import org.jetbrains.annotations.NotNull;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class SearchService {
/** Page size applied when the caller omits {@code limit} or passes a negative value. */
public static final int DEFAULT_LIMIT = 50;
/** Facet page size when {@code facetLimit} is absent or below 1. */
private static final int DEFAULT_FACET_LIMIT = 100;
private final RepositoryManagerImpl repositoryManager;
private final SyndicationProperties syndicationProperties;
private final ResourceSerializationService resourceSerializationService;
@Value("${entrystore.solr.max-limit:100}")
private int solrMaxLimit;
@Value("${entrystore.solr.facet-max-limit:1000}")
private int solrMaxFacetLimit;
/**
* Valid SPARQL predicate: a full IRI ({@code <http://...>}), a prefixed name ({@code dc:title}),
* or the keyword {@code a} (shorthand for rdf:type).
*/
private static final Pattern VALID_SPARQL_PREDICATE = Pattern.compile(
"^(<[^<>\"\\s{}|^`\\\\]+>|[a-zA-Z][\\w.-]*:[a-zA-Z_][\\w.-]*[\\w]|a)$"
);
/**
* Clamps a requested page size to {@code entrystore.solr.max-limit}. Zero is allowed on purpose so a
* client can ask for the result count only; a negative value falls back to {@link #DEFAULT_LIMIT}.
*/
public int clampLimit(int limit) {
if (limit > solrMaxLimit) {
return solrMaxLimit;
}
if (limit < 0) {
return DEFAULT_LIMIT;
}
return limit;
}
/**
* Converts the bound facet request parameters into Solr facet settings, capping the facet limit at
* {@code entrystore.solr.facet-max-limit} and substituting {@link #DEFAULT_FACET_LIMIT} when it is absent or
* below 1; {@code facetMinCount} defaults to 1 and is otherwise passed through.
*/
public SolrSearchIndex.FacetSettings toFacetSettings(FacetSettingsRequestParams request) {
return request.toSolrFacetSettings(solrMaxFacetLimit, DEFAULT_FACET_LIMIT);
}
public List<Entry> findEntriesSparql(String queryValue) {
if (queryValue == null || !VALID_SPARQL_PREDICATE.matcher(queryValue).matches()) {
log.info("Rejected invalid SPARQL predicate input: '{}'", queryValue);
throw new BadRequestException("Invalid SPARQL predicate. Expected a full IRI (<http://...>) or prefixed name (prefix:name).");
}
try {
String query = "PREFIX dc:<http://purl.org/dc/terms/> " +
"SELECT ?x " +
"WHERE { " +
" ?x " + queryValue + " ?y }";
return repositoryManager.getContextManager().search(query, null, null);
} catch (AuthorizationException e) {
throw e;
} catch (Exception e) {
throw new BadRequestException("Exception processing SPARQL query", e);
}
}
/**
* Runs a Solr query. The row count is clamped with {@link #clampLimit(int)} here as well, so the
* {@code entrystore.solr.max-limit} cap holds for every caller.
*/
public QueryResultsDto findEntriesSolr(
String queryValue,
String sorting,
int offset,
int limit,
List<String> filterQueries,
SolrSearchIndex.FacetSettings facetSettings) {
try {
List<Entry> entries;
long results;
List<FacetValuesDto> responseFacetFields;
if (repositoryManager.getIndex() == null) {
throw new CustomResponseException("Solr search is deactivated", HttpStatus.SERVICE_UNAVAILABLE);
}
SolrQuery q = new SolrQuery(queryValue);
q.setStart(offset);
q.setRows(clampLimit(limit));
if (sorting != null) {
for (String string : sorting.split(",")) {
String[] fieldAndOrder = string.split(" ");
if (fieldAndOrder.length == 2) {
String field = fieldAndOrder[0];
if (field.startsWith("title.")) {
field = field.replace("title.", "title_sort.");
}
SolrQuery.ORDER order = SolrQuery.ORDER.asc;
try {
order = SolrQuery.ORDER.valueOf(fieldAndOrder[1].toLowerCase());
} catch (IllegalArgumentException iae) {
log.warn("Unable to parse sorting value, using ascending by default");
}
q.addSort(field, order);
}
}
} else {
q.addSort("score", SolrQuery.ORDER.desc);
q.addSort("modified", SolrQuery.ORDER.desc);
}
if (facetSettings.fields != null) {
LanguageAwareFacets.configure(q, facetSettings, solrMaxFacetLimit);
}
for (String fq : filterQueries) {
q.addFilterQuery(fq);
}
try {
SolrSearchIndex index = (SolrSearchIndex) repositoryManager.getIndex();
QueryResult qResult = index.sendQuery(q);
entries = new LinkedList<>(qResult.getEntries());
results = qResult.getHits();
responseFacetFields = LanguageAwareFacets.merge(qResult.getFacetFields(), facetSettings, q, index);
} catch (SolrException se) {
// AppExceptionHandler logs both at a lower level; a Solr failure should be visible in the logs
log.warn("SolrException ({}): {}", se.code(), se.getMessage());
if (se.code() >= HttpStatus.INTERNAL_SERVER_ERROR.value()) {
throw new CustomResponseException("Search is temporarily unavailable", HttpStatus.SERVICE_UNAVAILABLE, se);
}
throw new BadRequestException("Search failed due to wrong parameters");
}
return new QueryResultsDto(entries, results, responseFacetFields);
} catch (JSONException e) {
throw new InternalServerErrorException("Error during Solr search", e);
}
}
public String generateSyndication(HttpServletRequest request, List<Entry> entries, String feedType, String language,
int limit, String urlTemplate, String feedTitle) {
SyndFeed feed = Syndication.createFeedFromEntries(repositoryManager.getPrincipalManager(),
syndicationProperties.template(urlTemplate), entries, language, limit);
feed.setTitle(Syndication.sanitizeFeedTitle(feedTitle));
feed.setLink(buildRequestUri(request));
feed.setFeedType(feedType);
return Syndication.convertSyndFeedToXml(feed);
}
public String generateJson(int offset, int limit, QueryResultsDto queryResults, String rdfFormat) {
Instant startTime = Instant.now();
JSONArray children = new JSONArray();
if (queryResults.entries() != null) {
for (Entry e : queryResults.entries()) {
if (e != null) {
JSONObject childJSON = new JSONObject();
childJSON.put("entryId", e.getId());
childJSON.put("contextId", e.getContext().getEntry().getId());
GraphType btChild = e.getGraphType();
EntryType locChild = e.getEntryType();
if (btChild == GraphType.Context || btChild == GraphType.SystemContext) {
childJSON.put("alias", repositoryManager.getContextManager().getName(e.getResourceURI()));
} else if (btChild == GraphType.User && locChild == EntryType.Local) {
User u = (User) e.getResource();
childJSON.put("name", u.getName());
try {
if (u.isDisabled()) {
childJSON.put("disabled", true);
}
} catch (AuthorizationException ae) {
log.debug("Not allowed to read disabled status of " + e.getEntryURI());
}
} else if (btChild == GraphType.Group && locChild == EntryType.Local) {
Resource groupResource = e.getResource();
if (groupResource != null) {
childJSON.put("name", ((Group) groupResource).getName());
}
}
JSONArray rights = resourceSerializationService.serializeRights(e);
if (!rights.isEmpty()) {
// unlike list serialization, search children only carry a "rights" key when
// at least one right exists
childJSON.put("rights", rights);
}
resourceSerializationService.appendMetadataInfoAndRelations(e, childJSON, rdfFormat, true);
children.put(childJSON);
}
}
}
JSONObject result = new JSONObject();
JSONObject resource = new JSONObject();
resource.put("children", children);
result.put("resource", resource);
result.put("results", queryResults.resultsCount());
result.put("limit", limit);
result.put("offset", offset);
result.put("facetFields", getFacetFieldsArr(queryResults));
log.debug("Graph fetching and serialization took {} ms ", Duration.between(startTime, Instant.now()).toMillis());
return result.toString(2);
}
/**
* Serialises the client-facing facet fields. A bucket carries {@code name} and {@code count}; the
* {@code facet.missing} bucket has a {@code null} name, which {@code JSONObject.put} drops, so it carries
* {@code count} only.
*/
private static @NotNull JSONArray getFacetFieldsArr(QueryResultsDto queryResults) {
JSONArray facetFieldsArr = new JSONArray();
for (FacetValuesDto facet : queryResults.responseFacetFields()) {
JSONObject ffObj = new JSONObject();
ffObj.put("name", facet.name());
ffObj.put("valueCount", facet.values().size());
JSONArray ffValArr = new JSONArray();
for (FacetValueDto value : facet.values()) {
JSONObject valueObj = new JSONObject();
valueObj.put("name", value.name());
valueObj.put("count", value.count());
ffValArr.put(valueObj);
}
ffObj.put("values", ffValArr);
facetFieldsArr.put(ffObj);
}
return facetFieldsArr;
}
/**
* Builds the request URI using the configured base URL from {@code entrystore.baseurl.folder}.
* This ensures the URI always uses the canonical public base URL regardless of how the request
* arrived (e.g., via reverse proxy with or without X-Forwarded-* headers).
*
* @return Request URI with scheme, host, and port from the configured base URL.
*/
// Package-private for direct unit testing of the redactor-integration contract.
String buildRequestUri(HttpServletRequest request) {
var repositoryURL = repositoryManager.getRepositoryURL();
if (repositoryURL == null) {
throw new InternalServerErrorException(
"Repository base URL is not configured; check 'entrystore.baseurl.folder'");
}
String baseUrl = repositoryURL.toExternalForm();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
// Redact known sensitive parameters (confirm, token, ticket, RelayState, …) before the
// query string lands in a public Atom/RSS self-link. No current search endpoint accepts
// any of these names; this is defensive against a future addition.
String query = HttpQueryRedactor.redact(request.getQueryString());
return baseUrl + request.getServletPath() + (query != null ? "?" + query : "");
}
}