SearchController.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 io.swagger.v3.oas.annotations.Parameter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.Entry;
import org.entrystore.repository.util.SolrSearchIndex;
import org.entrystore.rest.springboot.model.api.FacetSettingsRequestParams;
import org.entrystore.rest.springboot.model.dto.QueryResultsDto;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.service.SearchRateLimiter;
import org.entrystore.rest.springboot.service.SearchService;
import org.entrystore.rest.springboot.service.SolrSearchInputValidator;
import org.entrystore.rest.springboot.util.Syndication;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Slf4j
@RestController
@RequestMapping("/search")
@Validated
@RequiredArgsConstructor
public class SearchController {

	private final SearchService searchService;
	private final SolrSearchInputValidator solrSearchInputValidator;
	private final SearchRateLimiter searchRateLimiter;

	@Operation(summary = "Searches the repository and returns entries")
	@GetMapping(
			params = "type=sparql"
	)
	public ResponseEntity<String> findEntriesSparql(
			HttpServletRequest request,
			@RequestParam @Size(min = 3, message = "'query' param length must be minimum 3") String query,
			@RequestParam(required = false) String syndication,    // feed type = rss_* or atom_*
			@RequestParam(name = "urltemplate", required = false) String urlTemplate,
			@RequestParam(name = "feedtitle", defaultValue = "Syndication feed of search") String feedTitle,
			@RequestParam(defaultValue = MediaType.APPLICATION_JSON_VALUE) String rdfFormat,
			@RequestParam(defaultValue = "en") String lang,
			@RequestParam(defaultValue = "" + SearchService.DEFAULT_LIMIT) int limit
	) {

		searchRateLimiter.acquirePermit(request.getRemoteAddr());

		limit = searchService.clampLimit(limit);

		List<Entry> foundEntries = searchService.findEntriesSparql(query);

		// In Restlet's SPARQL search logic, the "offset" param is accepted from the user but not used
		return buildSearchResponse(request, new QueryResultsDto(foundEntries), syndication, lang, 0, limit,
				urlTemplate, feedTitle, rdfFormat);
	}

	@Operation(
			summary = "Searches the repository and returns entries",
			description = "Solr search. Facet buckets carry a name and a count; facetLang narrows the buckets of a "
					+ "literal facet field to one language.",
			parameters = {
					@Parameter(name = "facetLang", description = "BCP 47 language tag, e.g. sv or en-GB. Keeps only the "
							+ "buckets of literal facet fields (metadata.predicate.literal_s.<hash> or the "
							+ "metadata.predicate.literal.<hash> shorthand) whose label occurs in that language (prefix "
							+ "match, so en also matches en-GB) or untagged. The selection happens in Solr before "
							+ "facetLimit is applied. A bucket's count is the label's count in all languages, taken "
							+ "before access filtering; it is not the number of entries in the requested language. "
							+ "Candidates are gathered with a bounded overrequest ranked by their count in the "
							+ "requested language, while the final order uses the total count, so a label that is rare "
							+ "in that language but frequent overall can be missing from the top N entirely. Labels "
							+ "longer than 256 characters are not selectable this way and are therefore absent "
							+ "whenever facetLang is set. Requires facetFields.")
			}
	)
	@GetMapping(
			params = "type=solr"
	)
	public ResponseEntity<String> findEntriesSolr(
			HttpServletRequest request,
			@RequestParam @Size(min = 3, message = "'query' param length must be minimum 3") String query,
			@RequestParam(required = false) String syndication,    // feed type = rss_* or atom_*
			@RequestParam(name = "urltemplate", required = false) String urlTemplate,
			@RequestParam(name = "feedtitle", defaultValue = "Syndication feed of search") String feedTitle,
			@RequestParam(defaultValue = MediaType.APPLICATION_JSON_VALUE) String rdfFormat,
			@RequestParam(defaultValue = "en") String lang,
			@RequestParam(defaultValue = "0") int offset,
			@RequestParam(defaultValue = "" + SearchService.DEFAULT_LIMIT) int limit,
			@RequestParam(required = false) String sort,
			@RequestParam(required = false) String filterQuery,
			FacetSettingsRequestParams facetRequest
	) {

		searchRateLimiter.acquirePermit(request.getRemoteAddr());

		solrSearchInputValidator.validateQuery(query);
		solrSearchInputValidator.validateSort(sort);
		solrSearchInputValidator.validateFacetSettings(facetRequest);

		if (StringUtils.isNotEmpty(syndication) && offset > 0) {
			throw new BadRequestException("Query parameter 'offset' not supported with syndication");
		}
		if (offset < 0) {
			offset = 0;
		}

		limit = searchService.clampLimit(limit);
		List<String> filterQueries = solrSearchInputValidator.parseFilterQueries(filterQuery);
		SolrSearchIndex.FacetSettings facetSettings = searchService.toFacetSettings(facetRequest);

		QueryResultsDto queryResults = searchService.findEntriesSolr(query, sort, offset, limit, filterQueries, facetSettings);

		return buildSearchResponse(request, queryResults, syndication, lang, offset, limit, urlTemplate, feedTitle, rdfFormat);
	}

	private ResponseEntity<String> buildSearchResponse(HttpServletRequest request, QueryResultsDto queryResults,
													   String syndication, String lang, int offset, int limit,
													   String urlTemplate, String feedTitle, String rdfFormat) {
		String responseBody;
		MediaType responseMediaType;
		if (StringUtils.isNotEmpty(syndication)) {
			responseBody = searchService.generateSyndication(request, queryResults.entries(), syndication, lang, limit, urlTemplate, feedTitle);
			responseMediaType = Syndication.convertFeedTypeToMediaType(syndication);
		} else {
			responseBody = searchService.generateJson(offset, limit, queryResults, rdfFormat);
			responseMediaType = MediaType.APPLICATION_JSON;
		}

		return ResponseEntity.ok().contentType(responseMediaType).body(responseBody);
	}

}