Add additional help to quick search and an advanced search documentation page (#1757)

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2021-08-09 12:07:28 +02:00
committed by GitHub
parent f2249cea73
commit ddd2fc1055
43 changed files with 1494 additions and 94 deletions

View File

@@ -143,6 +143,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("importLog", resourceLinks.repository().importLog("IMPORT_LOG_ID").replace("IMPORT_LOG_ID", "{logId}")));
builder.array(searchLinks());
builder.single(link("searchableTypes", resourceLinks.search().searchableTypes()));
} else {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}

View File

@@ -93,5 +93,7 @@ public class MapperModule extends AbstractModule {
bind(RepositoryImportDtoToRepositoryImportParametersMapper.class).to(Mappers.getMapperClass(RepositoryImportDtoToRepositoryImportParametersMapper.class));
bind(QueryResultMapper.class).to(Mappers.getMapperClass(QueryResultMapper.class));
bind(SearchableTypeMapper.class).to(Mappers.getMapperClass(SearchableTypeMapper.class));
}
}

View File

@@ -1128,6 +1128,7 @@ class ResourceLinks {
public String query(String type) {
return searchLinkBuilder.method("query").parameters(type).href();
}
public String searchableTypes() { return searchLinkBuilder.method("searchableTypes").parameters().href(); }
}
public InitialAdminAccountLinks initialAdminAccount() {

View File

@@ -42,7 +42,9 @@ import javax.ws.rs.BeanParam;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import java.util.Collection;
import java.util.Collections;
import java.util.stream.Collectors;
@Path(SearchResource.PATH)
@OpenAPIDefinition(tags = {
@@ -53,12 +55,14 @@ public class SearchResource {
static final String PATH = "v2/search";
private final SearchEngine engine;
private final QueryResultMapper mapper;
private final QueryResultMapper queryResultMapper;
private final SearchableTypeMapper searchableTypeMapper;
@Inject
public SearchResource(SearchEngine engine, QueryResultMapper mapper) {
public SearchResource(SearchEngine engine, QueryResultMapper mapper, SearchableTypeMapper searchableTypeMapper) {
this.engine = engine;
this.mapper = mapper;
this.queryResultMapper = mapper;
this.searchableTypeMapper = searchableTypeMapper;
}
@GET
@@ -110,6 +114,34 @@ public class SearchResource {
return search(params);
}
@GET
@Path("searchableTypes")
@Produces(VndMediaType.SEARCHABLE_TYPE_COLLECTION)
@Operation(
summary = "Searchable types",
description = "Returns a collection of all searchable types.",
tags = "Search",
operationId = "searchable_types"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.SEARCHABLE_TYPE_COLLECTION
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Collection<SearchableTypeDto> searchableTypes() {
return engine.getSearchableTypes().stream().map(searchableTypeMapper::map).collect(Collectors.toList());
}
private QueryResultDto search(SearchParameters params) {
QueryResult result = engine.forType(params.getType())
.search()
@@ -117,7 +149,7 @@ public class SearchResource {
.limit(params.getPageSize())
.execute(params.getQuery());
return mapper.map(params, result);
return queryResultMapper.map(params, result);
}
private QueryResultDto count(SearchParameters params) {
@@ -125,7 +157,7 @@ public class SearchResource {
.search()
.count(params.getQuery());
return mapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList()));
return queryResultMapper.map(params, new QueryResult(result.getTotalHits(), result.getType(), Collections.emptyList()));
}
}

View File

@@ -0,0 +1,32 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import lombok.Data;
@Data
public class SearchableFieldDto {
private String name;
private String type;
}

View File

@@ -0,0 +1,40 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Collection;
@Getter
@Setter
@NoArgsConstructor
@SuppressWarnings("java:S2160") // we need no equals for dtos
public class SearchableTypeDto extends HalRepresentation {
private String name;
private Collection<SearchableFieldDto> fields;
}

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import sonia.scm.search.SearchableField;
import sonia.scm.search.SearchableType;
import sonia.scm.search.TypeCheck;
@Mapper
public abstract class SearchableTypeMapper {
@Mapping(target = "attributes", ignore = true)
public abstract SearchableTypeDto map(SearchableType searchableType);
public abstract SearchableFieldDto map(SearchableField searchableField);
public String map(Class<?> type) {
if (TypeCheck.isString(type)) {
return "string";
} else if (TypeCheck.isInstant(type) || TypeCheck.isNumber(type)) {
return "number";
} else if (TypeCheck.isBoolean(type)) {
return "boolean";
} else {
return "unknown";
}
}
}

View File

@@ -66,14 +66,14 @@ public class AnalyzerFactory {
Analyzer defaultAnalyzer = create(options);
Map<String, Analyzer> analyzerMap = new HashMap<>();
for (SearchableField field : type.getFields()) {
for (LuceneSearchableField field : type.getFields()) {
addFieldAnalyzer(analyzerMap, field);
}
return new PerFieldAnalyzerWrapper(defaultAnalyzer, analyzerMap);
}
private void addFieldAnalyzer(Map<String, Analyzer> analyzerMap, SearchableField field) {
private void addFieldAnalyzer(Map<String, Analyzer> analyzerMap, LuceneSearchableField field) {
if (field.getAnalyzer() != Indexed.Analyzer.DEFAULT) {
analyzerMap.put(field.getName(), new NonNaturalLanguageAnalyzer());
}

View File

@@ -32,7 +32,7 @@ import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
import java.lang.reflect.Field;
@Getter
class SearchableField {
class LuceneSearchableField implements SearchableField {
private final String name;
private final Class<?> type;
@@ -43,7 +43,7 @@ class SearchableField {
private final PointsConfig pointsConfig;
private final Indexed.Analyzer analyzer;
SearchableField(Field field, Indexed indexed) {
LuceneSearchableField(Field field, Indexed indexed) {
this.name = name(field, indexed);
this.type = field.getType();
this.valueExtractor = ValueExtractors.create(name, type);
@@ -65,4 +65,5 @@ class SearchableField {
}
return field.getName();
}
}

View File

@@ -28,6 +28,7 @@ import com.google.common.base.Strings;
import lombok.Value;
import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -42,13 +43,13 @@ public class LuceneSearchableType implements SearchableType {
Class<?> type;
String name;
String permission;
List<SearchableField> fields;
List<LuceneSearchableField> fields;
String[] fieldNames;
Map<String, Float> boosts;
Map<String, PointsConfig> pointsConfig;
TypeConverter typeConverter;
LuceneSearchableType(Class<?> type, IndexedType annotation, List<SearchableField> fields) {
public LuceneSearchableType(Class<?> type, IndexedType annotation, List<LuceneSearchableField> fields) {
this.type = type;
this.name = name(type, annotation);
this.permission = Strings.emptyToNull(annotation.permission());
@@ -72,16 +73,16 @@ public class LuceneSearchableType implements SearchableType {
return nameFromAnnotation;
}
private String[] fieldNames(List<SearchableField> fields) {
private String[] fieldNames(List<LuceneSearchableField> fields) {
return fields.stream()
.filter(SearchableField::isDefaultQuery)
.map(SearchableField::getName)
.filter(LuceneSearchableField::isDefaultQuery)
.map(LuceneSearchableField::getName)
.toArray(String[]::new);
}
private Map<String, Float> boosts(List<SearchableField> fields) {
private Map<String, Float> boosts(List<LuceneSearchableField> fields) {
Map<String, Float> map = new HashMap<>();
for (SearchableField field : fields) {
for (LuceneSearchableField field : fields) {
if (field.isDefaultQuery() && field.getBoost() != DEFAULT_BOOST) {
map.put(field.getName(), field.getBoost());
}
@@ -89,9 +90,9 @@ public class LuceneSearchableType implements SearchableType {
return Collections.unmodifiableMap(map);
}
private Map<String, PointsConfig> pointsConfig(List<SearchableField> fields) {
private Map<String, PointsConfig> pointsConfig(List<LuceneSearchableField> fields) {
Map<String, PointsConfig> map = new HashMap<>();
for (SearchableField field : fields) {
for (LuceneSearchableField field : fields) {
PointsConfig config = field.getPointsConfig();
if (config != null) {
map.put(field.getName(), config);
@@ -99,4 +100,8 @@ public class LuceneSearchableType implements SearchableType {
}
return Collections.unmodifiableMap(map);
}
public Collection<LuceneSearchableField> getFields() {
return Collections.unmodifiableCollection(fields);
}
}

View File

@@ -77,13 +77,13 @@ public class QueryResultFactory {
private Hit createHit(ScoreDoc scoreDoc) throws IOException, InvalidTokenOffsetsException {
Document document = searcher.doc(scoreDoc.doc);
Map<String, Hit.Field> fields = new HashMap<>();
for (SearchableField field : searchableType.getFields()) {
for (LuceneSearchableField field : searchableType.getFields()) {
field(document, field).ifPresent(f -> fields.put(field.getName(), f));
}
return new Hit(document.get(FieldNames.ID), document.get(FieldNames.REPOSITORY), scoreDoc.score, fields);
}
private Optional<Hit.Field> field(Document document, SearchableField field) throws IOException, InvalidTokenOffsetsException {
private Optional<Hit.Field> field(Document document, LuceneSearchableField field) throws IOException, InvalidTokenOffsetsException {
Object value = field.value(document);
if (value != null) {
if (field.isHighlighted()) {
@@ -97,7 +97,7 @@ public class QueryResultFactory {
return empty();
}
private String[] createFragments(SearchableField field, String value) throws InvalidTokenOffsetsException, IOException {
private String[] createFragments(LuceneSearchableField field, String value) throws InvalidTokenOffsetsException, IOException {
return highlighter.getBestFragments(analyzer, field.getName(), value, 5);
}

View File

@@ -34,7 +34,7 @@ final class SearchableTypes {
}
static LuceneSearchableType create(Class<?> type) {
List<SearchableField> fields = new ArrayList<>();
List<LuceneSearchableField> fields = new ArrayList<>();
IndexedType annotation = type.getAnnotation(IndexedType.class);
if (annotation == null) {
throw new IllegalArgumentException(
@@ -45,7 +45,7 @@ final class SearchableTypes {
return new LuceneSearchableType(type, annotation, fields);
}
private static void collectFields(Class<?> type, List<SearchableField> fields) {
private static void collectFields(Class<?> type, List<LuceneSearchableField> fields) {
Class<?> parent = type.getSuperclass();
if (parent != null) {
collectFields(parent, fields);
@@ -53,7 +53,7 @@ final class SearchableTypes {
for (Field field : type.getDeclaredFields()) {
Indexed indexed = field.getAnnotation(Indexed.class);
if (indexed != null) {
fields.add(new SearchableField(field, indexed));
fields.add(new LuceneSearchableField(field, indexed));
}
}
}

View File

@@ -26,7 +26,7 @@ package sonia.scm.search;
import java.time.Instant;
final class TypeCheck {
public final class TypeCheck {
private TypeCheck() {
}
@@ -50,4 +50,6 @@ final class TypeCheck {
public static boolean isString(Class<?> type) {
return type == String.class;
}
public static boolean isNumber(Class<?> type) { return isLong(type) || isInteger(type); }
}