xref: /OpenGrok/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/SuggesterController.java (revision c6f0939b1c668e9f8e1e276424439c3106b3a029)
1 /*
2  * CDDL HEADER START
3  *
4  * The contents of this file are subject to the terms of the
5  * Common Development and Distribution License (the "License").
6  * You may not use this file except in compliance with the License.
7  *
8  * See LICENSE.txt included in this distribution for the specific
9  * language governing permissions and limitations under the License.
10  *
11  * When distributing Covered Code, include this CDDL HEADER in each
12  * file and include the License file at LICENSE.txt.
13  * If applicable, add the following below this CDDL HEADER, with the
14  * fields enclosed by brackets "[]" replaced with your own identifying
15  * information: Portions Copyright [yyyy] [name of copyright owner]
16  *
17  * CDDL HEADER END
18  */
19 
20 /*
21  * Copyright (c) 2018, 2021, Oracle and/or its affiliates. All rights reserved.
22  * Portions Copyright (c) 2020, Chris Fraire <cfraire@me.com>.
23  */
24 package org.opengrok.web.api.v1.controller;
25 
26 import jakarta.inject.Inject;
27 import jakarta.validation.Valid;
28 import jakarta.validation.constraints.Min;
29 import jakarta.validation.constraints.NotBlank;
30 import jakarta.ws.rs.BeanParam;
31 import jakarta.ws.rs.Consumes;
32 import jakarta.ws.rs.DefaultValue;
33 import jakarta.ws.rs.GET;
34 import jakarta.ws.rs.POST;
35 import jakarta.ws.rs.PUT;
36 import jakarta.ws.rs.Path;
37 import jakarta.ws.rs.PathParam;
38 import jakarta.ws.rs.Produces;
39 import jakarta.ws.rs.QueryParam;
40 import jakarta.ws.rs.WebApplicationException;
41 import jakarta.ws.rs.core.MediaType;
42 import jakarta.ws.rs.core.Response;
43 import org.apache.lucene.index.Term;
44 import org.apache.lucene.queryparser.classic.ParseException;
45 import org.apache.lucene.search.Query;
46 import org.apache.lucene.util.BytesRef;
47 import org.opengrok.indexer.web.Laundromat;
48 import org.opengrok.suggest.LookupResultItem;
49 import org.opengrok.suggest.Suggester.Suggestions;
50 import org.opengrok.suggest.SuggesterUtils;
51 import org.opengrok.indexer.configuration.RuntimeEnvironment;
52 import org.opengrok.indexer.configuration.SuggesterConfig;
53 import org.opengrok.indexer.logger.LoggerFactory;
54 import org.opengrok.indexer.search.QueryBuilder;
55 import org.opengrok.indexer.web.Util;
56 import org.opengrok.web.api.v1.filter.CorsEnable;
57 import org.opengrok.web.api.v1.filter.IncomingFilter;
58 import org.opengrok.web.api.v1.suggester.model.SuggesterData;
59 import org.opengrok.web.api.v1.suggester.model.SuggesterQueryData;
60 import org.opengrok.web.api.v1.suggester.parser.SuggesterQueryDataParser;
61 import org.opengrok.web.api.v1.suggester.provider.filter.Authorized;
62 import org.opengrok.web.api.v1.suggester.provider.filter.Suggester;
63 import org.opengrok.web.api.v1.suggester.provider.service.SuggesterService;
64 
65 import java.net.MalformedURLException;
66 import java.net.URL;
67 import java.time.Duration;
68 import java.time.Instant;
69 import java.util.AbstractMap.SimpleEntry;
70 import java.util.List;
71 import java.util.Map;
72 import java.util.Map.Entry;
73 import java.util.concurrent.CompletableFuture;
74 import java.util.logging.Level;
75 import java.util.logging.Logger;
76 import java.util.stream.Collectors;
77 
78 /**
79  * Endpoint for suggester related REST queries.
80  */
81 @Path(SuggesterController.PATH)
82 @Suggester
83 public final class SuggesterController {
84 
85     public static final String PATH = "suggest";
86 
87     private static final int POPULARITY_DEFAULT_PAGE_SIZE = 100;
88 
89     private static final Logger logger = LoggerFactory.getLogger(SuggesterController.class);
90 
91     private final RuntimeEnvironment env = RuntimeEnvironment.getInstance();
92 
93     @Inject
94     private SuggesterService suggester;
95 
96     /**
97      * Returns suggestions based on the search criteria specified in {@code data}.
98      * @param data suggester form data
99      * @return list of suggestions and other related information
100      * @throws ParseException if the Lucene query created from {@code data} could not be parsed
101      */
102     @GET
103     @Authorized
104     @CorsEnable
105     @Produces(MediaType.APPLICATION_JSON)
getSuggestions(@alid @eanParam final SuggesterQueryData data)106     public Result getSuggestions(@Valid @BeanParam final SuggesterQueryData data) throws ParseException {
107         Instant start = Instant.now();
108 
109         SuggesterData suggesterData = SuggesterQueryDataParser.parse(data);
110         if (suggesterData.getSuggesterQuery() == null) {
111             throw new ParseException("Could not determine suggester query");
112         }
113 
114         SuggesterConfig config = env.getSuggesterConfig();
115 
116         modifyDataBasedOnConfiguration(suggesterData, config);
117 
118         if (!satisfiesConfiguration(suggesterData, config)) {
119             logger.log(Level.FINER, "Suggester request with data {0} does not satisfy configuration settings", data);
120             throw new WebApplicationException(Response.Status.NOT_FOUND);
121         }
122 
123         Suggestions suggestions = suggester.getSuggestions(
124                 suggesterData.getProjects(), suggesterData.getSuggesterQuery(), suggesterData.getQuery());
125 
126         Instant end = Instant.now();
127 
128         long timeInMs = Duration.between(start, end).toMillis();
129 
130         return new Result(suggestions.getItems(), suggesterData.getIdentifier(),
131                 suggesterData.getSuggesterQueryFieldText(), timeInMs, suggestions.isPartialResult());
132     }
133 
modifyDataBasedOnConfiguration(final SuggesterData data, final SuggesterConfig config)134     private void modifyDataBasedOnConfiguration(final SuggesterData data, final SuggesterConfig config) {
135         if (config.getAllowedProjects() != null) {
136             data.getProjects().removeIf(project -> !config.getAllowedProjects().contains(project));
137         }
138     }
139 
satisfiesConfiguration(final SuggesterData data, final SuggesterConfig config)140     private boolean satisfiesConfiguration(final SuggesterData data, final SuggesterConfig config) {
141         if (config.getMinChars() > data.getSuggesterQuery().length()) {
142             return false;
143         }
144 
145         if (config.getMaxProjects() < data.getProjects().size()) {
146             return false;
147         }
148 
149         if (config.getAllowedFields() != null && !config.getAllowedFields().contains(data.getSuggesterQuery().getField())) {
150             return false;
151         }
152 
153         return config.isAllowComplexQueries() || !SuggesterUtils.isComplexQuery(data.getQuery(), data.getSuggesterQuery());
154     }
155 
156     /**
157      * Returns the suggester configuration {@link SuggesterConfig}.
158      * Because of the {@link IncomingFilter}, the
159      * {@link org.opengrok.web.api.v1.controller.ConfigurationController} cannot be accessed from the
160      * web page by the remote user. To resolve the problem, this method exposes this functionality.
161      * @return suggester configuration
162      */
163     @GET
164     @Path("/config")
165     @CorsEnable
166     @Produces(MediaType.APPLICATION_JSON)
getConfig()167     public SuggesterConfig getConfig() {
168         return env.getSuggesterConfig();
169     }
170 
171     @PUT
172     @Path("/rebuild")
rebuild()173     public void rebuild() {
174         CompletableFuture.runAsync(() -> suggester.rebuild());
175     }
176 
177     @PUT
178     @Path("/rebuild/{project}")
rebuild(@athParam"project") final String project)179     public void rebuild(@PathParam("project") final String project) {
180         CompletableFuture.runAsync(() -> suggester.rebuild(project));
181     }
182 
183     /**
184      * Initializes the search data used by suggester to perform most popular completion. The passed {@code urls} are
185      * decomposed into single terms which search counts are then increased by 1.
186      * @param urls list of URLs in JSON format, e.g.
187      * {@code ["http://demo.opengrok.org/search?project=opengrok&full=test"]}
188      */
189     @POST
190     @Path("/init/queries")
191     @Consumes(MediaType.APPLICATION_JSON)
addSearchCountsQueries(final List<String> urls)192     public void addSearchCountsQueries(final List<String> urls) {
193         for (String urlStr : urls) {
194             try {
195                 URL url = new URL(urlStr);
196                 Map<String, List<String>> params = Util.getQueryParams(url);
197 
198                 List<String> projects = params.get("project");
199 
200                 for (String field : QueryBuilder.getSearchFields()) {
201 
202                     List<String> fieldQueryText = params.get(field);
203                     if (fieldQueryText == null || fieldQueryText.isEmpty()) {
204                         continue;
205                     }
206                     if (fieldQueryText.size() > 2) {
207                         logger.log(Level.WARNING, "Bad format, ignoring {0}", urlStr);
208                         continue;
209                     }
210                     String value = fieldQueryText.get(0);
211 
212                     Query q = null;
213                     try {
214                         q = getQuery(field, value);
215                     } catch (ParseException e) {
216                         logger.log(Level.FINE, "Bad request", e);
217                     }
218 
219                     if (q != null) {
220                         suggester.onSearch(projects, q);
221                     }
222                 }
223             } catch (MalformedURLException e) {
224                 logger.log(Level.WARNING, "Could not add search counts for " + urlStr, e);
225             }
226         }
227     }
228 
getQuery(final String field, final String value)229     private Query getQuery(final String field, final String value) throws ParseException {
230         QueryBuilder builder = new QueryBuilder();
231 
232         switch (field) {
233             case QueryBuilder.FULL:
234                 builder.setFreetext(value);
235                 break;
236             case QueryBuilder.DEFS:
237                 builder.setDefs(value);
238                 break;
239             case QueryBuilder.REFS:
240                 builder.setRefs(value);
241                 break;
242             case QueryBuilder.PATH:
243                 builder.setPath(value);
244                 break;
245             case QueryBuilder.HIST:
246                 builder.setHist(value);
247                 break;
248             case QueryBuilder.TYPE:
249                 builder.setType(value);
250                 break;
251             default:
252                 return null;
253         }
254 
255         return builder.build();
256     }
257 
258     /**
259      * Initializes the search data used by suggester to perform most popular completion.
260      * @param termIncrements data by which to initialize the search data
261      */
262     @POST
263     @Path("/init/raw")
264     @Consumes(MediaType.APPLICATION_JSON)
addSearchCountsRaw(@alid final List<TermIncrementData> termIncrements)265     public void addSearchCountsRaw(@Valid final List<TermIncrementData> termIncrements) {
266         for (TermIncrementData termIncrement : termIncrements) {
267             suggester.increaseSearchCount(termIncrement.project,
268                     new Term(termIncrement.field, termIncrement.token), termIncrement.increment);
269         }
270     }
271 
272     /**
273      * Returns the searched terms sorted according to their popularity.
274      * @param project project for which to return the data
275      * @param field field for which to return the data
276      * @param page which page of data to retrieve
277      * @param pageSize number of results to return
278      * @param all return all pages
279      * @return list of terms with their popularity
280      */
281     @GET
282     @Path("/popularity/{project}")
283     @Produces(MediaType.APPLICATION_JSON)
getPopularityDataPaged( @athParam"project") String project, @QueryParam("field") @DefaultValue(QueryBuilder.FULL) String field, @QueryParam("page") @DefaultValue("" + 0) final int page, @QueryParam("pageSize") @DefaultValue("" + POPULARITY_DEFAULT_PAGE_SIZE) final int pageSize, @QueryParam("all") final boolean all )284     public List<Entry<String, Integer>> getPopularityDataPaged(
285             @PathParam("project") String project,
286             @QueryParam("field") @DefaultValue(QueryBuilder.FULL) String field,
287             @QueryParam("page") @DefaultValue("" + 0) final int page,
288             @QueryParam("pageSize") @DefaultValue("" + POPULARITY_DEFAULT_PAGE_SIZE) final int pageSize,
289             @QueryParam("all") final boolean all
290     ) {
291         if (!QueryBuilder.isSearchField(field)) {
292             throw new WebApplicationException("field is invalid", Response.Status.BAD_REQUEST);
293         }
294         // Avoid classification as a taint bug.
295         project = Laundromat.launderInput(project);
296         field = Laundromat.launderInput(field);
297 
298         List<Entry<BytesRef, Integer>> data;
299         if (all) {
300             data = suggester.getPopularityData(project, field, 0, Integer.MAX_VALUE);
301         } else {
302             data = suggester.getPopularityData(project, field, page, pageSize);
303         }
304         return data.stream()
305                 .map(e -> new SimpleEntry<>(e.getKey().utf8ToString(), e.getValue()))
306                 .collect(Collectors.toList());
307     }
308 
309     private static class Result {
310 
311         private long time;
312 
313         private List<LookupResultItem> suggestions;
314 
315         private String identifier;
316 
317         private String queryText;
318 
319         private boolean partialResult;
320 
Result( final List<LookupResultItem> suggestions, final String identifier, final String queryText, final long time, final boolean partialResult )321         Result(
322                 final List<LookupResultItem> suggestions,
323                 final String identifier,
324                 final String queryText,
325                 final long time,
326                 final boolean partialResult
327         ) {
328             this.suggestions = suggestions;
329             this.identifier = identifier;
330             this.queryText = queryText;
331             this.time = time;
332             this.partialResult = partialResult;
333         }
334 
getTime()335         public long getTime() {
336             return time;
337         }
338 
setTime(long time)339         public void setTime(long time) {
340             this.time = time;
341         }
342 
getSuggestions()343         public List<LookupResultItem> getSuggestions() {
344             return suggestions;
345         }
346 
setSuggestions(List<LookupResultItem> suggestions)347         public void setSuggestions(List<LookupResultItem> suggestions) {
348             this.suggestions = suggestions;
349         }
350 
getIdentifier()351         public String getIdentifier() {
352             return identifier;
353         }
354 
setIdentifier(String identifier)355         public void setIdentifier(String identifier) {
356             this.identifier = identifier;
357         }
358 
getQueryText()359         public String getQueryText() {
360             return queryText;
361         }
362 
setQueryText(String queryText)363         public void setQueryText(String queryText) {
364             this.queryText = queryText;
365         }
366 
isPartialResult()367         public boolean isPartialResult() {
368             return partialResult;
369         }
370 
setPartialResult(boolean partialResult)371         public void setPartialResult(boolean partialResult) {
372             this.partialResult = partialResult;
373         }
374     }
375 
376     private static class TermIncrementData {
377 
378         private String project;
379 
380         @NotBlank(message = "Field cannot be blank")
381         private String field;
382 
383         @NotBlank(message = "Token cannot be blank")
384         private String token;
385 
386         @Min(message = "Increment must be positive", value = 0)
387         private int increment;
388 
getProject()389         public String getProject() {
390             return project;
391         }
392 
setProject(String project)393         public void setProject(String project) {
394             this.project = project;
395         }
396 
getField()397         public String getField() {
398             return field;
399         }
400 
setField(String field)401         public void setField(String field) {
402             this.field = field;
403         }
404 
getToken()405         public String getToken() {
406             return token;
407         }
408 
setToken(String token)409         public void setToken(String token) {
410             this.token = token;
411         }
412 
getIncrement()413         public int getIncrement() {
414             return increment;
415         }
416 
setIncrement(int increment)417         public void setIncrement(int increment) {
418             this.increment = increment;
419         }
420     }
421 
422 }
423