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