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