001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.http.api; 019 020import static javax.ws.rs.core.MediaType.APPLICATION_JSON; 021import static javax.ws.rs.core.Response.ok; 022import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET; 023import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET; 024import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX; 025import static org.slf4j.LoggerFactory.getLogger; 026 027import java.net.URL; 028import java.util.ArrayList; 029import java.util.Arrays; 030import java.util.List; 031 032import javax.ws.rs.BadRequestException; 033import javax.ws.rs.DefaultValue; 034import javax.ws.rs.GET; 035import javax.ws.rs.Path; 036import javax.ws.rs.Produces; 037import javax.ws.rs.QueryParam; 038import javax.ws.rs.core.Response; 039 040import io.micrometer.core.annotation.Timed; 041import org.apache.commons.lang3.StringUtils; 042import org.fcrepo.http.commons.api.rdf.HttpIdentifierConverter; 043import org.fcrepo.search.api.Condition; 044import org.fcrepo.search.api.InvalidConditionExpressionException; 045import org.fcrepo.search.api.InvalidQueryException; 046import org.fcrepo.search.api.SearchIndex; 047import org.fcrepo.search.api.SearchParameters; 048import org.fcrepo.search.api.SearchResult; 049import org.slf4j.Logger; 050import org.springframework.beans.factory.annotation.Autowired; 051import org.springframework.beans.factory.annotation.Qualifier; 052import org.springframework.context.annotation.Scope; 053 054/** 055 * @author dbernstein 056 * @since 05/06/20 057 */ 058@Timed 059@Scope("request") 060@Path("/fcr:search") 061public class FedoraSearch extends FedoraBaseResource { 062 063 private static final Logger LOGGER = getLogger(FedoraSearch.class); 064 065 @Autowired 066 @Qualifier("searchIndex") 067 private SearchIndex searchIndex; 068 069 /** 070 * Default JAX-RS entry point 071 */ 072 public FedoraSearch() { 073 super(); 074 } 075 076 /** 077 * Perform simple search on the repository 078 * 079 * @param conditions The conditions constraining the query 080 * @param fields The fields to return in results 081 * @param maxResults The max number of results to return 082 * @param offset The zero-based offset of the first result to be returned 083 * @param order The order: ie "asc" or "desc" 084 * @param orderBy The field by which to order the results 085 * @param includeTotalResultCount A flag for including total result count (false by default) 086 * @return A response object with the search results 087 */ 088 @GET 089 @Produces({APPLICATION_JSON + ";qs=1.0", 090 TEXT_PLAIN_WITH_CHARSET, 091 TEXT_HTML_WITH_CHARSET}) 092 public Response doSearch(@QueryParam(value = "condition") final List<String> conditions, 093 @QueryParam(value = "fields") final String fields, 094 @DefaultValue("100") @QueryParam("max_results") final int maxResults, 095 @DefaultValue("0") @QueryParam("offset") final int offset, 096 @DefaultValue("asc") @QueryParam("order") final String order, 097 @QueryParam("order_by") final String orderBy, 098 @DefaultValue("false") @QueryParam("include_total_result_count") 099 final boolean includeTotalResultCount) { 100 101 LOGGER.info("GET on search with conditions: {}, and fields: {}", conditions, fields); 102 try { 103 final var conditionList = new ArrayList<Condition>(); 104 for (final String condition : conditions) { 105 final var parsedCondition = parse(condition, identifierConverter()); 106 conditionList.add(parsedCondition); 107 } 108 109 List<Condition.Field> parsedFields = null; 110 if (StringUtils.isBlank(fields) || fields.equals("*")) { 111 parsedFields = Arrays.asList(Condition.Field.values()); 112 } else { 113 parsedFields = new ArrayList<>(); 114 for (final String field : fields.split(",")) { 115 try { 116 parsedFields.add(Condition.Field.fromString(field)); 117 } catch (final Exception e) { 118 throw new InvalidQueryException("The field \"" + field + "\" is not a valid output field."); 119 } 120 } 121 } 122 123 final Condition.Field orderByField; 124 try { 125 if (!StringUtils.isBlank(orderBy)) { 126 orderByField = Condition.Field.fromString(orderBy); 127 } else { 128 orderByField = null; 129 } 130 } catch (final Exception e) { 131 throw new InvalidQueryException("The order_by field must contain a valid value such as " + 132 StringUtils.join(Condition.Field.values(), ",")); 133 } 134 135 if (!(order.equalsIgnoreCase("asc") || order.equalsIgnoreCase("desc"))) { 136 throw new InvalidQueryException("The order field is invalid: valid values are \"asc\" and \"desc\""); 137 } 138 139 final var params = new SearchParameters(parsedFields, conditionList, maxResults, offset, orderByField, 140 order, includeTotalResultCount); 141 final Response.ResponseBuilder builder = ok(); 142 final var result = this.searchIndex.doSearch(params); 143 final var translatedResults = translateResults(result); 144 145 builder.entity(translatedResults); 146 return builder.build(); 147 } catch (final InvalidConditionExpressionException | InvalidQueryException ex) { 148 throw new BadRequestException(ex.getMessage(), ex); 149 } 150 } 151 152 private SearchResult translateResults(final SearchResult result) { 153 result.getItems().forEach(item -> { 154 final var key = Condition.Field.FEDORA_ID.toString(); 155 final var fedoraId = item.get(key); 156 if (fedoraId != null) { 157 item.put(key, identifierConverter().toExternalId(fedoraId.toString())); 158 } 159 }); 160 return result; 161 } 162 163 /** 164 * Parses the url decoded value of a single parameter passed by the 165 * http layer into a {@link Condition}. 166 * 167 * @param expression The url decoded value of the condition parameter. 168 * @return the parsed {@link Condition} object. 169 */ 170 protected static Condition parse(final String expression, final HttpIdentifierConverter converter) 171 throws InvalidConditionExpressionException { 172 final Condition condition = Condition.fromExpression(expression); 173 if (condition.getField().equals(Condition.Field.FEDORA_ID)) { 174 //convert the object value to an internal identifier stem where appropriate 175 final var object = condition.getObject(); 176 final var field = condition.getField(); 177 final var operator = condition.getOperator(); 178 if (!object.startsWith(FEDORA_ID_PREFIX) && isExternalUrl(object)) { 179 return Condition.fromEnums(field, operator, converter.toInternalId(object)); 180 } else if (object.startsWith("/")) { 181 return Condition.fromEnums(field, operator, FEDORA_ID_PREFIX + object); 182 } else if (!object.startsWith(FEDORA_ID_PREFIX) && !object.equals("*")) { 183 return Condition.fromEnums(field, operator, FEDORA_ID_PREFIX + "/" + object); 184 } 185 } 186 187 return condition; 188 } 189 190 private static boolean isExternalUrl(final String str) { 191 try { 192 new URL(str); 193 return true; 194 } catch (final Exception ex) { 195 return false; 196 } 197 } 198 199} 200