diff --git a/service/src/main/java/gov/nasa/pds/api/registry/controllers/ProductsController.java b/service/src/main/java/gov/nasa/pds/api/registry/controllers/ProductsController.java index 8980e1ce..0c31e496 100644 --- a/service/src/main/java/gov/nasa/pds/api/registry/controllers/ProductsController.java +++ b/service/src/main/java/gov/nasa/pds/api/registry/controllers/ProductsController.java @@ -2,9 +2,16 @@ import java.lang.reflect.InvocationTargetException; import java.io.IOException; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.HashMap; +import java.util.Objects; + +import gov.nasa.pds.api.registry.model.exceptions.*; +import gov.nasa.pds.api.registry.model.identifiers.PdsLid; +import gov.nasa.pds.api.registry.model.identifiers.PdsLidVid; +import gov.nasa.pds.api.registry.model.identifiers.PdsProductClasses; import jakarta.servlet.http.HttpServletRequest; import com.fasterxml.jackson.databind.ObjectMapper; import org.opensearch.client.opensearch.OpenSearchClient; @@ -22,10 +29,6 @@ import gov.nasa.pds.api.base.ProductsApi; import gov.nasa.pds.api.registry.ConnectionContext; import gov.nasa.pds.api.registry.model.ErrorMessageFactory; -import gov.nasa.pds.api.registry.model.exceptions.AcceptFormatNotSupportedException; -import gov.nasa.pds.api.registry.model.exceptions.SortSearchAfterMismatchException; -import gov.nasa.pds.api.registry.model.exceptions.NotFoundException; -import gov.nasa.pds.api.registry.model.exceptions.UnhandledException; import gov.nasa.pds.api.registry.model.api_responses.PdsProductBusinessObject; import gov.nasa.pds.api.registry.model.api_responses.ProductBusinessLogic; import gov.nasa.pds.api.registry.model.api_responses.ProductBusinessLogicImpl; @@ -331,6 +334,270 @@ private RawMultipleProductResponse getAllLidVid(PdsProductIdentifier identifier, } + private PdsProductClasses resolveProductClass(PdsProductIdentifier identifier) + throws OpenSearchException, IOException, NotFoundException{ + SearchRequest searchRequest = new RegistrySearchRequestBuilder(this.connectionContext) + .matchLid(identifier) + .fieldsFromStrings(List.of(PdsProductClasses.getPropertyName())) + .onlyLatest() + .build(); + + SearchResponse searchResponse = this.openSearchClient.search(searchRequest, HashMap.class); + + if (searchResponse.hits().total().value() == 0) { + throw new NotFoundException("No product found with identifier " + identifier.toString()); + } + + String productClassStr = searchResponse.hits().hits().get(0).source().get(PdsProductClasses.getPropertyName()).toString(); + return PdsProductClasses.valueOf(productClassStr); + } + + + private PdsLidVid resolveLatestLidvid(PdsProductIdentifier identifier) + throws OpenSearchException, IOException, NotFoundException { + + SearchRequest searchRequest = new RegistrySearchRequestBuilder(this.connectionContext) + .matchLid(identifier.getLid()) + .fieldsFromStrings(List.of()) + .onlyLatest() + .build(); + + SearchResponse searchResponse = this.openSearchClient.search(searchRequest, HashMap.class); + + if (searchResponse.hits().total().value() == 0) { + throw new NotFoundException("No lidvids found with lid " + identifier.getLid().toString()); + } + + // TODO: Determine how to handle multiple hits due to sweepers lag + + return PdsLidVid.fromString(searchResponse.hits().hits().get(0).id()); + } + + + private List resolveExtantLidvids(PdsLid lid) + throws OpenSearchException, IOException, NotFoundException{ + + String lidvidKey = "_id"; + + SearchRequest searchRequest = new RegistrySearchRequestBuilder(this.connectionContext) + .matchLid(lid) + .fieldsFromStrings(List.of(lidvidKey)) + .build(); + + SearchResponse searchResponse = this.openSearchClient.search(searchRequest, HashMap.class); + + if (searchResponse.hits().total().value() == 0) { + throw new NotFoundException("No lidvids found with lid " + lid.toString()); + } + + return searchResponse.hits().hits().stream().map(hit -> hit.source().get(lidvidKey).toString()).map(PdsLidVid::fromString).toList(); + } + + /** + * Resolve a PdsProductIdentifier to a PdsLidVid according to the common rules of the API. + * The rules are currently trivial, but may incorporate additional behaviour later + * @param identifier a LID or LIDVID + * @return a LIDVID + */ + private PdsLidVid resolveIdentifierToLidvid(PdsProductIdentifier identifier) throws NotFoundException, IOException { + return identifier.isLidvid() ? (PdsLidVid) identifier : resolveLatestLidvid(identifier); + } + + @Override + public ResponseEntity productMembers( + String identifier, List fields, Integer limit, List sort, List searchAfter) + throws NotFoundException, UnhandledException, SortSearchAfterMismatchException, BadRequestException, + AcceptFormatNotSupportedException{ + + try{ + PdsProductIdentifier pdsIdentifier = PdsProductIdentifier.fromString(identifier); + PdsProductClasses productClass = resolveProductClass(pdsIdentifier); + PdsLidVid lidvid = resolveIdentifierToLidvid(pdsIdentifier); + + RegistrySearchRequestBuilder searchRequestBuilder = new RegistrySearchRequestBuilder(this.connectionContext); + + if (productClass.isBundle()) { + searchRequestBuilder.matchMembersOfBundle(lidvid); + searchRequestBuilder.onlyCollections(); + } else if (productClass.isCollection()) { + searchRequestBuilder.matchMembersOfCollection(lidvid); + searchRequestBuilder.onlyBasicProducts(); + } else { + throw new BadRequestException("productMembers endpoint is only valid for products with Product_Class '" + + PdsProductClasses.Product_Bundle + "' or '" + PdsProductClasses.Product_Collection + + "' (got '" + productClass + "')"); + } + + SearchRequest searchRequest = searchRequestBuilder + .fieldsFromStrings(fields) + .paginate(limit, sort, searchAfter) + .onlyLatest() + .build(); + + SearchResponse searchResponse = + this.openSearchClient.search(searchRequest, HashMap.class); + + RawMultipleProductResponse products = new RawMultipleProductResponse(searchResponse); + + return formatMultipleProducts(products, fields); + + } catch (IOException | OpenSearchException e) { + throw new UnhandledException(e); + } + } + + @Override + public ResponseEntity productMembersMembers( + String identifier, List fields, Integer limit, List sort, List searchAfter) + throws NotFoundException, UnhandledException, SortSearchAfterMismatchException, BadRequestException, + AcceptFormatNotSupportedException{ + try{ + PdsProductIdentifier pdsIdentifier = PdsProductIdentifier.fromString(identifier); + PdsProductClasses productClass = resolveProductClass(pdsIdentifier); + PdsLidVid lidvid = resolveIdentifierToLidvid(pdsIdentifier); + + RegistrySearchRequestBuilder searchRequestBuilder = new RegistrySearchRequestBuilder(this.connectionContext); + + if (productClass.isBundle()) { + searchRequestBuilder.matchMembersOfBundle(lidvid); + searchRequestBuilder.onlyBasicProducts(); + } else { + throw new BadRequestException("productMembers endpoint is only valid for products with Product_Class '" + + PdsProductClasses.Product_Bundle + "' (got '" + productClass + "')"); + } + + SearchRequest searchRequest = searchRequestBuilder + .fieldsFromStrings(fields) + .paginate(limit, sort, searchAfter) + .onlyLatest() + .build(); + + SearchResponse searchResponse = + this.openSearchClient.search(searchRequest, HashMap.class); + + RawMultipleProductResponse products = new RawMultipleProductResponse(searchResponse); + + return formatMultipleProducts(products, fields); + + } catch (IOException | OpenSearchException e) { + throw new UnhandledException(e); + } + } + + /** + * Given a PdsProductIdentifier and the name of a document field which is expected to contain an array of LIDVID + * strings, return the chained contents of that field from all documents matching the identifier (multiple docs are + * possible if the identifier is a LID). + * @param identifier the LID/LIDVID for which to retrieve documents + * @param fieldName the name of the document _source property/field from which to extract results + * @return a deduplicated list of the aggregated property/field contents, converted to PdsProductLidvids + */ + private List resolveLidVidsFromProductField(PdsProductIdentifier identifier, String fieldName) + throws OpenSearchException, IOException, NotFoundException, UnhandledException { + + RegistrySearchRequestBuilder searchRequestBuilder = new RegistrySearchRequestBuilder(this.connectionContext); + + if (identifier.isLid()) { + searchRequestBuilder.matchLid(identifier); + } else if (identifier.isLidvid()) { + searchRequestBuilder.matchLidvid(identifier); + } else { + throw new UnhandledException("PdsProductIdentifier identifier is neither LID nor LIDVID. This should never occur"); + } + + SearchRequest searchRequest = searchRequestBuilder + .matchLid(identifier) + .fieldsFromStrings(List.of(fieldName)) + .build(); + + SearchResponse searchResponse = this.openSearchClient.search(searchRequest, HashMap.class); + + if (searchResponse.hits().total().value() == 0) { + throw new NotFoundException("No product found with identifier " + identifier); + } + + return searchResponse.hits().hits().stream().map(hit -> (List) hit.source().get(fieldName)).filter(Objects::nonNull).flatMap(Collection::stream).map(PdsLidVid::fromString).toList(); + } + + + @Override + public ResponseEntity productMemberOf( + String identifier, List fields, Integer limit, List sort, List searchAfter) + throws NotFoundException, UnhandledException, SortSearchAfterMismatchException, BadRequestException, + AcceptFormatNotSupportedException{ + + try{ + PdsProductIdentifier pdsIdentifier = PdsProductIdentifier.fromString(identifier); + PdsProductClasses productClass = resolveProductClass(pdsIdentifier); + PdsLidVid lidvid = resolveIdentifierToLidvid(pdsIdentifier); + + List parentIds; + if (productClass.isCollection()) { + parentIds = resolveLidVidsFromProductField(lidvid, "ops:Provenance/ops:parent_bundle_identifier"); + } else if (productClass.isBasicProduct()) { + parentIds = resolveLidVidsFromProductField(lidvid, "ops:Provenance/ops:parent_collection_identifier"); + } else { + throw new BadRequestException("productMembersOf endpoint is not valid for products with Product_Class '" + + PdsProductClasses.Product_Bundle + "' (got '" + productClass + "')"); + } + + SearchRequest searchRequest = new RegistrySearchRequestBuilder(this.connectionContext) + .matchFieldAnyOfIdentifiers("_id", parentIds) + .fieldsFromStrings(fields) + .paginate(limit, sort, searchAfter) + .onlyLatest() + .build(); + + SearchResponse searchResponse = + this.openSearchClient.search(searchRequest, HashMap.class); + + RawMultipleProductResponse products = new RawMultipleProductResponse(searchResponse); + + return formatMultipleProducts(products, fields); + + } catch (IOException | OpenSearchException e) { + throw new UnhandledException(e); + } + } + + @Override + public ResponseEntity productMemberOfOf( + String identifier, List fields, Integer limit, List sort, List searchAfter) + throws NotFoundException, UnhandledException, SortSearchAfterMismatchException, BadRequestException, + AcceptFormatNotSupportedException{ + + try{ + PdsProductIdentifier pdsIdentifier = PdsProductIdentifier.fromString(identifier); + PdsProductClasses productClass = resolveProductClass(pdsIdentifier); + PdsLidVid lidvid = resolveIdentifierToLidvid(pdsIdentifier); + + List parentIds; + if (productClass.isBasicProduct()) { + parentIds = resolveLidVidsFromProductField(lidvid, "ops:Provenance/ops:parent_bundle_identifier"); + } else { +// TODO: replace with enumeration of acceptable values later + throw new BadRequestException("productMembersOf endpoint is not valid for products with Product_Class '" + + PdsProductClasses.Product_Bundle + "' or '" + PdsProductClasses.Product_Collection + "' (got '" + productClass + "')"); + } + + SearchRequest searchRequest = new RegistrySearchRequestBuilder(this.connectionContext) + .matchFieldAnyOfIdentifiers("_id", parentIds) + .fieldsFromStrings(fields) + .paginate(limit, sort, searchAfter) + .onlyLatest() + .build(); + + SearchResponse searchResponse = + this.openSearchClient.search(searchRequest, HashMap.class); + + RawMultipleProductResponse products = new RawMultipleProductResponse(searchResponse); + + return formatMultipleProducts(products, fields); + + } catch (IOException | OpenSearchException e) { + throw new UnhandledException(e); + } + } } diff --git a/service/src/main/java/gov/nasa/pds/api/registry/controllers/RegistryApiResponseEntityExceptionHandler.java b/service/src/main/java/gov/nasa/pds/api/registry/controllers/RegistryApiResponseEntityExceptionHandler.java index 4caf77f4..2de03b14 100644 --- a/service/src/main/java/gov/nasa/pds/api/registry/controllers/RegistryApiResponseEntityExceptionHandler.java +++ b/service/src/main/java/gov/nasa/pds/api/registry/controllers/RegistryApiResponseEntityExceptionHandler.java @@ -2,6 +2,8 @@ import java.util.Set; + +import gov.nasa.pds.api.registry.model.exceptions.*; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -9,13 +11,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -import gov.nasa.pds.api.registry.model.exceptions.AcceptFormatNotSupportedException; -import gov.nasa.pds.api.registry.model.exceptions.SortSearchAfterMismatchException; -import gov.nasa.pds.api.registry.model.exceptions.NotFoundException; -import gov.nasa.pds.api.registry.model.exceptions.RegistryApiException; -import gov.nasa.pds.api.registry.model.exceptions.UnhandledException; -import gov.nasa.pds.api.registry.model.exceptions.UnparsableQParamException; - @ControllerAdvice @@ -49,6 +44,12 @@ protected ResponseEntity notFound(NotFoundException ex, WebRequest reque } + @ExceptionHandler(value = {BadRequestException.class}) + protected ResponseEntity badRequest(BadRequestException ex, WebRequest request) { + return genericExceptionHandler(ex, request, "", HttpStatus.BAD_REQUEST); + + } + @ExceptionHandler(value = {UnhandledException.class}) protected ResponseEntity unhandled(UnhandledException ex, WebRequest request) { return genericExceptionHandler(ex, request, "", HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/service/src/main/java/gov/nasa/pds/api/registry/model/exceptions/BadRequestException.java b/service/src/main/java/gov/nasa/pds/api/registry/model/exceptions/BadRequestException.java new file mode 100644 index 00000000..55345724 --- /dev/null +++ b/service/src/main/java/gov/nasa/pds/api/registry/model/exceptions/BadRequestException.java @@ -0,0 +1,17 @@ +package gov.nasa.pds.api.registry.model.exceptions; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Serial; + +public class BadRequestException extends RegistryApiException { + private static final Logger log = LoggerFactory.getLogger(BadRequestException.class); + @Serial + private static final long serialVersionUID = 2026697251322082840L; + + public BadRequestException(String msg) { + super("BadRequestException: " + msg); + } + +} diff --git a/service/src/main/java/gov/nasa/pds/api/registry/model/identifiers/PdsProductClasses.java b/service/src/main/java/gov/nasa/pds/api/registry/model/identifiers/PdsProductClasses.java new file mode 100644 index 00000000..b33fed06 --- /dev/null +++ b/service/src/main/java/gov/nasa/pds/api/registry/model/identifiers/PdsProductClasses.java @@ -0,0 +1,72 @@ +package gov.nasa.pds.api.registry.model.identifiers; + +/** + * An enumeration of the valid product_class values from the PDS4 Data Dictionary + * ... + */ +public enum PdsProductClasses { + Product_AIP("Product_AIP"), + Product_Ancillary("Product_Ancillary"), + Product_Attribute_Definition("Product_Attribute_Definition"), + Product_Browse("Product_Browse"), + Product_Bundle("Product_Bundle"), + Product_Class_Definition("Product_Class_Definition"), + Product_Collection("Product_Collection"), + Product_Context("Product_Context"), + Product_DIP("Product_DIP"), + Product_DIP_Deep_Archive("Product_DIP_Deep_Archive"), + Product_Data_Set_PDS3("Product_Data_Set_PDS3"), + Product_Document("Product_Document"), + Product_External("Product_External"), + Product_File_Repository("Product_File_Repository"), + Product_File_Text("Product_File_Text"), + Product_Instrument_Host_PDS3("Product_Instrument_Host_PDS3"), + Product_Instrument_PDS3("Product_Instrument_PDS3"), + Product_Metadata_Supplemental("Product_Metadata_Supplemental"), + Product_Mission_PDS3("Product_Mission_PDS3"), + Product_Native("Product_Native"), + Product_Observational("Product_Observational"), + Product_Proxy_PDS3("Product_Proxy_PDS3"), + Product_SIP("Product_SIP"), + Product_SIP_Deep_Archive("Product_SIP_Deep_Archive"), + Product_SPICE_Kernel("Product_SPICE_Kernel"), + Product_Service("Product_Service"), + Product_Software("Product_Software"), + Product_Subscription_PDS3("Product_Subscription_PDS3"), + Product_Target_PDS3("Product_Target_PDS3"), + Product_Thumbnail("Product_Thumbnail"), + Product_Update("Product_Update"), + Product_Volume_PDS3("Product_Volume_PDS3"), + Product_Volume_Set_PDS3("Product_Volume_Set_PDS3"), + Product_XML_Schema("Product_XML_Schema"), + Product_Zipped("Product_Zipped"); + + private final String value; + + PdsProductClasses(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** + * @return The database property/field these string values appear in. Provided for convenience. + */ + public static String getPropertyName() { + return "product_class"; + } + + public Boolean isBundle() { + return this == Product_Bundle; + } + + public Boolean isCollection() { + return this == Product_Collection; + } + + public Boolean isBasicProduct() { + return !(isBundle() || isCollection()); + } +} diff --git a/service/src/main/java/gov/nasa/pds/api/registry/search/RegistrySearchRequestBuilder.java b/service/src/main/java/gov/nasa/pds/api/registry/search/RegistrySearchRequestBuilder.java index 812eb227..c7c5cca6 100644 --- a/service/src/main/java/gov/nasa/pds/api/registry/search/RegistrySearchRequestBuilder.java +++ b/service/src/main/java/gov/nasa/pds/api/registry/search/RegistrySearchRequestBuilder.java @@ -4,6 +4,8 @@ import java.util.Arrays; import java.util.List; +import gov.nasa.pds.api.registry.model.identifiers.PdsLidVid; +import gov.nasa.pds.api.registry.model.identifiers.PdsProductClasses; import org.antlr.v4.runtime.BailErrorStrategy; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CodePointCharStream; @@ -134,12 +136,45 @@ public RegistrySearchRequestBuilder matchField(String fieldName, PdsProductIdent return this.matchField(fieldName, identifier.toString()); } + + /** + * Add a constraint that a given field name must match at least one of the given field values + * @param fieldName the name of the field in OpenSearch format + * @param values the values, one of which must be present in the given field + */ + public RegistrySearchRequestBuilder matchFieldAnyOf(String fieldName, List values) { + List fieldValues = values.stream().map(value -> new FieldValue.Builder().stringValue(value).build()).toList(); + TermsQueryField termsQueryField = new TermsQueryField.Builder().value(fieldValues).build(); + TermsQuery query = new TermsQuery.Builder().field(fieldName).terms(termsQueryField).build(); + + this.queryBuilder.must(query.toQuery()); + + return this; + } + + /** + * Add a constraint that a given field name must match at least one of the given field values + * @param fieldName the name of the field in OpenSearch format + * @param identifiers the PDS identifiers, one of whose string representation must be present in the given field + */ + public RegistrySearchRequestBuilder matchFieldAnyOfIdentifiers(String fieldName, List identifiers) { + return this.matchFieldAnyOf(fieldName, identifiers.stream().map(PdsProductIdentifier::toString).toList()); + } + public RegistrySearchRequestBuilder matchLidvid(PdsProductIdentifier identifier) { return this.matchField("_id", identifier); } public RegistrySearchRequestBuilder matchLid(PdsProductIdentifier identifier) { - return this.matchField("lid", identifier); + return this.matchField("lid", identifier.getLid()); + } + + public RegistrySearchRequestBuilder matchMembersOfBundle(PdsLidVid identifier) { + return this.matchField("ops:Provenance/ops:parent_bundle_identifier", identifier); + } + + public RegistrySearchRequestBuilder matchMembersOfCollection(PdsLidVid identifier) { + return this.matchField("ops:Provenance/ops:parent_collection_identifier", identifier); } public RegistrySearchRequestBuilder paginate(Integer pageSize, List sortFieldNames, @@ -281,6 +316,11 @@ public RegistrySearchRequestBuilder addKeywordsParam(List keywords) { return this; } + /** + * Limit results to the latest version of each LID in the result-set. + * N.B. this does *not* mean the latest version which satisfies other constraints, so application of this constraint + * can result in no hits being returned despite valid results existing. + */ public RegistrySearchRequestBuilder onlyLatest() { ExistsQuery supersededByExists = new ExistsQuery.Builder() @@ -292,5 +332,34 @@ public RegistrySearchRequestBuilder onlyLatest() { return this; } + /** + * Limit results to bundle products + */ + public RegistrySearchRequestBuilder onlyBundles() { + return this.matchField(PdsProductClasses.getPropertyName(), PdsProductClasses.Product_Bundle.toString()); + } + + + /** + * Limit results to collection products + */public RegistrySearchRequestBuilder onlyCollections() { + return this.matchField(PdsProductClasses.getPropertyName(), PdsProductClasses.Product_Collection.toString()); + } + + + /** + * Limit results to basic (non-aggregate) products, i.e. exclude bundles/collections + */ + public RegistrySearchRequestBuilder onlyBasicProducts() { + List excludeValues = Arrays.stream(PdsProductClasses.values()) + .filter(cls -> !cls.isBasicProduct()) + .map(value -> new FieldValue.Builder().stringValue(value.toString()).build()).toList(); + TermsQueryField termsQueryField = new TermsQueryField.Builder().value(excludeValues).build(); + TermsQuery query = new TermsQuery.Builder().field(PdsProductClasses.getPropertyName()).terms(termsQueryField).build(); + + this.queryBuilder.mustNot(query.toQuery()); + return this; + } + }