diff --git a/libraries/datahandler/src/main/java/org/eclipse/sw360/datahandler/couchdb/lucene/NouveauLuceneAwareDatabaseConnector.java b/libraries/datahandler/src/main/java/org/eclipse/sw360/datahandler/couchdb/lucene/NouveauLuceneAwareDatabaseConnector.java index 5366ede9dd..162b15c48b 100644 --- a/libraries/datahandler/src/main/java/org/eclipse/sw360/datahandler/couchdb/lucene/NouveauLuceneAwareDatabaseConnector.java +++ b/libraries/datahandler/src/main/java/org/eclipse/sw360/datahandler/couchdb/lucene/NouveauLuceneAwareDatabaseConnector.java @@ -317,8 +317,14 @@ private static String sanitizeQueryInput(String input) { return "[" + dateToNouveauDouble(dates[0]) + RANGE_TO + dateToNouveauDouble(dates[1]) + "]"; } + /** + * Parse dates from String in (yyyy-MM-dd) format to Nouveau format (yyyyMMdd) which is used as a double in queries. + * @param date Date to convert + * @return Parsed date for Nouveau + * @throws ParseException If input date cannot be parsed + * @see #dateToNouveauFormat(Date) + */ public static @NotNull String dateToNouveauDouble(String date) throws ParseException { - SimpleDateFormat outputFormatter = new SimpleDateFormat("yyyyMMdd"); SimpleDateFormat inputFormatterDate = new SimpleDateFormat("yyyy-MM-dd"); Date parsedDate; try { @@ -328,6 +334,17 @@ private static String sanitizeQueryInput(String input) { } catch (Exception e) { throw new ParseException("Date format not recognized", 0); } - return outputFormatter.format(parsedDate.getTime()); + return dateToNouveauFormat(parsedDate); + } + + /** + * Convert a java.util.Date object to Nouveau format (yyyyMMdd) which is used as a double in queries. + * @param date Date to convert + * @return Parsed date for Nouveau + * @see #dateToNouveauDouble(String) + */ + public static @NotNull String dateToNouveauFormat(Date date) { + SimpleDateFormat outputFormatter = new SimpleDateFormat("yyyyMMdd"); + return outputFormatter.format(date.getTime()); } } diff --git a/rest/resource-server/src/docs/asciidoc/components.adoc b/rest/resource-server/src/docs/asciidoc/components.adoc index c7b8ec0473..a54ec75fd4 100644 --- a/rest/resource-server/src/docs/asciidoc/components.adoc +++ b/rest/resource-server/src/docs/asciidoc/components.adoc @@ -122,6 +122,26 @@ include::{snippets}/should_document_get_components_by_name/http-response.adoc[] include::{snippets}/should_document_get_components_by_name/links.adoc[] +[[resources-components-list-by-type-and-createdon]] +==== Filtering with more fields + +A `GET` request to fetch filtered list of service components. + +Note : send query parameter's value in encoded format. (Reference: `https://datatracker.ietf.org/doc/html/rfc3986`) + +===== Response structure +include::{snippets}/should_document_get_components_by_type_and_created_on/response-fields.adoc[] + +===== Example request +include::{snippets}/should_document_get_components_by_type_and_created_on/curl-request.adoc[] + +===== Example response +include::{snippets}/should_document_get_components_by_type_and_created_on/http-response.adoc[] + +===== Links +include::{snippets}/should_document_get_components_by_type_and_created_on/links.adoc[] + + [[resources-components-list-by-type]] ==== Listing by type diff --git a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java index 99567ebecc..b9e07f0892 100644 --- a/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java +++ b/rest/resource-server/src/main/java/org/eclipse/sw360/rest/resourceserver/component/ComponentController.java @@ -12,6 +12,7 @@ package org.eclipse.sw360.rest.resourceserver.component; +import com.google.common.collect.Sets; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -37,6 +38,7 @@ import org.eclipse.sw360.datahandler.thrift.attachments.AttachmentDTO; import org.eclipse.sw360.datahandler.thrift.components.Component; import org.eclipse.sw360.datahandler.thrift.components.ComponentDTO; +import org.eclipse.sw360.datahandler.thrift.components.ComponentType; import org.eclipse.sw360.datahandler.thrift.components.Release; import org.eclipse.sw360.datahandler.thrift.components.ReleaseLink; import org.eclipse.sw360.datahandler.thrift.projects.Project; @@ -71,7 +73,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -87,13 +88,11 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.stream.Collectors; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; @@ -141,13 +140,31 @@ public ResponseEntity>> getComponents( Pageable pageable, @Parameter(description = "Name of the component to filter") @RequestParam(value = "name", required = false) String name, - @Parameter(description = "Type of the component to filter") + @Parameter(description = "Categories of the component to filter, as a comma separated list.") + @RequestParam(value = "categories", required = false) String categories, + @Parameter(description = "Type of the component to filter", + schema = @Schema(implementation = ComponentType.class)) @RequestParam(value = "type", required = false) String componentType, + @Parameter(description = "Component languages to filter, as a comma separated list.") + @RequestParam(value = "languages", required = false) String languages, + @Parameter(description = "Software Platforms to filter, as a comma separated list.") + @RequestParam(value = "softwarePlatforms", required = false) String softwarePlatforms, + @Parameter(description = "Operating Systems to filter, as a comma separated list.") + @RequestParam(value = "operatingSystems", required = false) String operatingSystems, + @Parameter(description = "Vendors to filter, as a comma separated list.") + @RequestParam(value = "vendors", required = false) String vendors, + @Parameter(description = "Main Licenses to filter, as a comma separated list.") + @RequestParam(value = "mainLicenses", required = false) String mainLicenses, + @Parameter(description = "Created by user to filter (email).") + @RequestParam(value = "createdBy", required = false) String createdBy, + @Parameter(description = "Date component was created on (YYYY-MM-DD).", + schema = @Schema(type = "string", format = "date")) + @RequestParam(value = "createdOn", required = false) String createdOn, @Parameter(description = "Properties which should be present for each component in the result") @RequestParam(value = "fields", required = false) List fields, @Parameter(description = "Flag to get components with all details.") @RequestParam(value = "allDetails", required = false) boolean allDetails, - @Parameter(description = "lucenesearch parameter to filter the components.") + @Parameter(description = "Use lucenesearch to filter the components.") @RequestParam(value = "luceneSearch", required = false) boolean luceneSearch, HttpServletRequest request ) throws TException, URISyntaxException, PaginationParameterException, ResourceClassNotFoundException { @@ -158,12 +175,9 @@ public ResponseEntity>> getComponents( String queryString = request.getQueryString(); Map params = restControllerHelper.parseQueryString(queryString); - Map> filterMap = new HashMap<>(); if (luceneSearch) { - if (CommonUtils.isNotNullEmptyOrWhitespace(componentType)) { - Set values = CommonUtils.splitToSet(componentType); - filterMap.put(Component._Fields.COMPONENT_TYPE.getFieldName(), values); - } + Map> filterMap = getFilterMap(categories, componentType, languages, softwarePlatforms, + operatingSystems, vendors, mainLicenses, createdBy, createdOn); if (CommonUtils.isNotNullEmptyOrWhitespace(name)) { Set values = CommonUtils.splitToSet(name); values = values.stream().map(NouveauLuceneAwareDatabaseConnector::prepareWildcardQuery) @@ -177,19 +191,24 @@ public ResponseEntity>> getComponents( } else { allComponents.addAll(componentService.getComponentsForUser(sw360User)); } + Map> restrictions = getFilterMap(categories, componentType, languages, + softwarePlatforms, operatingSystems, vendors, mainLicenses, createdBy, createdOn); + if (!restrictions.isEmpty()) { + allComponents = new ArrayList<>(allComponents.stream() + .filter(filterComponentMap(restrictions)).toList()); + } } PaginationResult paginationResult = restControllerHelper.createPaginationResult(request, pageable, allComponents, SW360Constants.TYPE_COMPONENT); - CollectionModel resources = getFilteredComponentResources(componentType, fields, allDetails, luceneSearch, - sw360User, paginationResult); + CollectionModel resources = getFilteredComponentResources(fields, allDetails, sw360User, paginationResult); return new ResponseEntity<>(resources, HttpStatus.OK); } - private CollectionModel getFilteredComponentResources(String componentType, List fields, boolean allDetails, - boolean luceneSearch, User sw360User, PaginationResult paginationResult) - throws URISyntaxException { + private CollectionModel getFilteredComponentResources( + List fields, boolean allDetails, User sw360User, PaginationResult paginationResult + ) throws URISyntaxException { List> componentResources = new ArrayList<>(); Consumer consumer = c -> { EntityModel embeddedComponentResource = null; @@ -209,14 +228,7 @@ private CollectionModel getFilteredComponentResources(String componentType, List componentResources.add(embeddedComponentResource); }; - if (luceneSearch) { - paginationResult.getResources().stream().forEach(consumer); - } else { - paginationResult.getResources().stream() - .filter(component -> componentType == null - || (component.isSetComponentType() && componentType.equals(component.componentType.name()))) - .forEach(consumer); - } + paginationResult.getResources().forEach(consumer); CollectionModel resources; if (componentResources.isEmpty()) { @@ -1092,4 +1104,72 @@ public ResponseEntity splitComponents( return new ResponseEntity<>(requestStatus, HttpStatus.OK); } + + /** + * Create a map of filters with the field name in the key and expected value in the value (as set). + * @return Filter map from the user's request. + */ + private @NonNull Map> getFilterMap( + String categories, String componentType, String languages, String softwarePlatforms, + String operatingSystems, String vendors, String mainLicenses, String createdBy, String createdOn + ) { + Map> filterMap = new HashMap<>(); + if (CommonUtils.isNotNullEmptyOrWhitespace(categories)) { + filterMap.put(Component._Fields.CATEGORIES.getFieldName(), CommonUtils.splitToSet(categories)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(componentType)) { + filterMap.put(Component._Fields.COMPONENT_TYPE.getFieldName(), CommonUtils.splitToSet(componentType)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(languages)) { + filterMap.put(Component._Fields.LANGUAGES.getFieldName(), CommonUtils.splitToSet(languages)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(softwarePlatforms)) { + filterMap.put(Component._Fields.SOFTWARE_PLATFORMS.getFieldName(), CommonUtils.splitToSet(softwarePlatforms)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(operatingSystems)) { + filterMap.put(Component._Fields.OPERATING_SYSTEMS.getFieldName(), CommonUtils.splitToSet(operatingSystems)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(vendors)) { + filterMap.put(Component._Fields.VENDOR_NAMES.getFieldName(), CommonUtils.splitToSet(vendors)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(mainLicenses)) { + filterMap.put(Component._Fields.MAIN_LICENSE_IDS.getFieldName(), CommonUtils.splitToSet(mainLicenses)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(createdBy)) { + filterMap.put(Component._Fields.CREATED_BY.getFieldName(), CommonUtils.splitToSet(createdBy)); + } + if (CommonUtils.isNotNullEmptyOrWhitespace(createdOn)) { + filterMap.put(Component._Fields.CREATED_ON.getFieldName(), CommonUtils.splitToSet(createdOn)); + } + return filterMap; + } + + /** + * Create a filter predicate to remove all components which do not satisfy the restriction set. + * @param restrictions Restrictions set to filter components on + * @return Filter predicate for stream. + */ + private static @NonNull Predicate filterComponentMap(Map> restrictions) { + return component -> { + for (Map.Entry> restriction : restrictions.entrySet()) { + final Set filterSet = restriction.getValue(); + Component._Fields field = Component._Fields.findByName(restriction.getKey()); + Object fieldValue = component.getFieldValue(field); + if (fieldValue == null) { + return false; + } + if (field == Component._Fields.COMPONENT_TYPE && !filterSet.contains(component.componentType.name())) { + return false; + } else if ((field == Component._Fields.CREATED_BY || field == Component._Fields.CREATED_ON) + && !fieldValue.toString().equalsIgnoreCase(filterSet.iterator().next())) { + return false; + } else if (fieldValue instanceof Set) { + if (Sets.intersection(filterSet, (Set) fieldValue).isEmpty()) { + return false; + } + } + } + return true; + }; + } } diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/restdocs/ComponentSpecTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/restdocs/ComponentSpecTest.java index 4a7f7c5e96..1a6d439cb5 100644 --- a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/restdocs/ComponentSpecTest.java +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/restdocs/ComponentSpecTest.java @@ -911,6 +911,47 @@ public void should_document_get_components_by_name() throws Exception { ))); } + @Test + public void should_document_get_components_by_type_and_created_on() throws Exception { + mockMvc.perform(get("/api/components") + .header("Authorization", TestHelper.generateAuthHeader(testUserId, testUserPassword)) + .queryParam("componentType", angularComponent.getComponentType().toString()) + .queryParam("createdOn", angularComponent.getCreatedOn()) + .queryParam("categories", "javascript, sql") + .queryParam("luceneSearch", "false") + .queryParam("page", "0") + .queryParam("page_entries", "5") + .queryParam("sort", "name,desc") + .accept(MediaTypes.HAL_JSON)) + .andExpect(status().isOk()) + .andDo(this.documentationHandler.document( + queryParameters( + parameterWithName("componentType").description("Filter for type"), + parameterWithName("createdOn").description("Filter for component creation date"), + parameterWithName("categories").description("Filter for categories"), + parameterWithName("luceneSearch").description("Filter with exact match or lucene match."), + parameterWithName("page").description("Page of components"), + parameterWithName("page_entries").description("Amount of components per page"), + parameterWithName("sort").description("Defines order of the components") + ), + links( + linkWithRel("curies").description("Curies are used for online documentation"), + linkWithRel("first").description("Link to first page"), + linkWithRel("last").description("Link to last page") + ), + responseFields( + subsectionWithPath("_embedded.sw360:components.[]name").description("The name of the component"), + subsectionWithPath("_embedded.sw360:components.[]componentType").description("The component type, possible values are: " + Arrays.asList(ComponentType.values())), + subsectionWithPath("_embedded.sw360:components").description("An array of <>"), + subsectionWithPath("_links").description("<> to other resources"), + fieldWithPath("page").description("Additional paging information"), + fieldWithPath("page.size").description("Number of components per page"), + fieldWithPath("page.totalElements").description("Total number of all existing components"), + fieldWithPath("page.totalPages").description("Total number of pages"), + fieldWithPath("page.number").description("Number of the current page") + ))); + } + @Test public void should_document_update_component() throws Exception { ComponentDTO updateComponent = new ComponentDTO();