org.geoserver.opensearch.rest.ProductsController.java Source code

Java tutorial

Introduction

Here is the source code for org.geoserver.opensearch.rest.ProductsController.java

Source

/* (c) 2017 Open Source Geospatial Foundation - all rights reserved
 * This code is licensed under the GPL 2.0 license, available at the root
 * application directory.
 */
package org.geoserver.opensearch.rest;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.io.IOUtils;
import org.geoserver.opensearch.eo.DefaultOpenSearchEoService;
import org.geoserver.opensearch.eo.OpenSearchAccessProvider;
import org.geoserver.opensearch.eo.store.OpenSearchAccess;
import org.geoserver.opensearch.rest.CollectionsController.IOConsumer;
import org.geoserver.ows.URLMangler.URLType;
import org.geoserver.ows.util.ResponseUtils;
import org.geoserver.rest.ResourceNotFoundException;
import org.geoserver.rest.RestBaseController;
import org.geoserver.rest.RestException;
import org.geoserver.rest.util.MediaTypeExtensions;
import org.geotools.data.DataUtilities;
import org.geotools.data.FeatureSource;
import org.geotools.data.FeatureStore;
import org.geotools.data.Query;
import org.geotools.data.collection.ListFeatureCollection;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureSource;
import org.geotools.data.simple.SimpleFeatureStore;
import org.geotools.feature.FeatureCollection;
import org.geotools.feature.NameImpl;
import org.opengis.feature.Feature;
import org.opengis.feature.Property;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.FeatureType;
import org.opengis.feature.type.Name;
import org.opengis.filter.And;
import org.opengis.filter.Filter;
import org.opengis.filter.PropertyIsEqualTo;
import org.opengis.filter.sort.SortBy;
import org.opengis.filter.sort.SortOrder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * Controller for the OpenSearch products
 *
 * @author Andrea Aime - GeoSolutions
 */
@RestController
@ControllerAdvice
@RequestMapping(path = RestBaseController.ROOT_PATH + "/oseo/collections/{collection}/products")
public class ProductsController extends AbstractOpenSearchController {

    /**
     * List of parts making up a zipfile for a collection
     */
    enum ProductPart implements ZipPart {
        Product("product.json"), Description("description.html"), Metadata("metadata.xml"), Thumbnail(
                "thumbnail\\.(png|jpeg|jpg)"), OwsLinks("owsLinks.json"), Granules("granules.json");

        Pattern pattern;

        ProductPart(String pattern) {
            this.pattern = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE);
        }

        public boolean matches(String name) {
            return pattern.matcher(name).matches();
        }
    }

    static final Name PRODUCT_ID = new NameImpl(OpenSearchAccess.ProductClass.EOP_GENERIC.getNamespace(),
            "identifier");

    static final Name PARENT_ID = new NameImpl(OpenSearchAccess.EO_NAMESPACE, "parentIdentifier");

    static final List<String> PRODUCT_HREFS = Arrays.asList("ogcLinksHref", "metadataHref", "descriptionHref",
            "thumbnailHref", "granulesHref");

    public ProductsController(OpenSearchAccessProvider accessProvider, OseoJSONConverter jsonConverter) {
        super(accessProvider, jsonConverter);
    }

    @GetMapping(produces = { MediaType.APPLICATION_JSON_VALUE })
    @ResponseBody
    public ProductReferences getProducts(HttpServletRequest request,
            @PathVariable(name = "collection", required = true) String collection,
            @RequestParam(name = "offset", required = false) Integer offset,
            @RequestParam(name = "limit", required = false) Integer limit) throws IOException {
        // query products for their identifiers
        Query query = new Query();
        PropertyIsEqualTo parentIdFilter = FF.equal(FF.property(PARENT_ID), FF.literal(collection), true);
        query.setFilter(parentIdFilter);
        setupQueryPaging(query, offset, limit);
        query.setSortBy(new SortBy[] { FF.sort(PRODUCT_ID.getLocalPart(), SortOrder.ASCENDING) });
        query.setProperties(Collections.singletonList(FF.property(PRODUCT_ID)));
        OpenSearchAccess access = accessProvider.getOpenSearchAccess();
        FeatureSource<FeatureType, Feature> fs = access.getProductSource();
        FeatureCollection<FeatureType, Feature> features = fs.getFeatures(query);

        // map to java beans for JSON encoding
        String baseURL = ResponseUtils.baseURL(request);
        List<ProductReference> list = new ArrayList<>();
        features.accepts(f -> {
            String id = (String) f.getProperty("identifier").getValue();
            String productHref = ResponseUtils.buildURL(baseURL,
                    "/rest/oseo/collections/" + collection + "/products/" + id, null, URLType.RESOURCE);
            String oseoHref = ResponseUtils.buildURL(baseURL, "/oseo/search", Collections.singletonMap("uid", id),
                    URLType.RESOURCE);
            ProductReference pr = new ProductReference(id, productHref, oseoHref);
            list.add(pr);
        }, null);

        if (list.isEmpty()) {
            // suspicious, did the collection actually exist?
            queryCollection(collection, q -> {
            });
        }

        return new ProductReferences(list);
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<String> postProductJson(
            @PathVariable(name = "collection", required = true) String collection, HttpServletRequest request,
            @RequestBody(required = true) SimpleFeature feature) throws IOException, URISyntaxException {
        String productId = (String) feature.getAttribute("eop:identifier");
        Feature productFeature = simpleToComplex(feature, getProductSchema(), PRODUCT_HREFS);

        // insert the new feature
        runTransactionOnProductStore(fs -> fs.addFeatures(singleton(productFeature)));

        return returnCreatedProductReference(collection, request, productId);
    }

    @PostMapping(consumes = MediaTypeExtensions.APPLICATION_ZIP_VALUE)
    public ResponseEntity<String> postProductZip(
            @PathVariable(name = "collection", required = true) String collection, HttpServletRequest request,
            InputStream body) throws IOException, URISyntaxException {

        Map<ProductPart, byte[]> parts = parsePartsFromZip(body, ProductPart.values());

        // process the product part
        final byte[] productPayload = parts.get(ProductPart.Product);
        if (productPayload == null) {
            throw new RestException("product.json file is missing from the zip", HttpStatus.BAD_REQUEST);
        }
        SimpleFeature jsonFeature = parseGeoJSONFeature("product.json", productPayload);
        String productId = (String) jsonFeature.getAttribute("eop:identifier");
        String parentId = (String) jsonFeature.getAttribute("eop:parentIdentifier");
        queryCollection(parentId, q -> {
        });

        Feature productFeature = simpleToComplex(jsonFeature, getProductSchema(), PRODUCT_HREFS);

        // grab the other parts
        byte[] description = parts.get(ProductPart.Description);
        byte[] metadata = parts.get(ProductPart.Metadata);
        byte[] thumbnail = parts.get(ProductPart.Thumbnail);
        byte[] rawLinks = parts.get(ProductPart.OwsLinks);
        SimpleFeatureCollection linksCollection;
        if (rawLinks != null) {
            OgcLinks links = parseJSON(OgcLinks.class, rawLinks);
            linksCollection = beansToLinksCollection(links);
        } else {
            linksCollection = null;
        }
        byte[] rawGranules = parts.get(ProductPart.Granules);
        SimpleFeatureCollection granulesCollection;
        if (rawGranules != null) {
            granulesCollection = parseGeoJSONFeatureCollection("granules.json", rawGranules);
        } else {
            granulesCollection = null;
        }

        // insert the new feature and accessory bits
        runTransactionOnProductStore(fs -> {
            fs.addFeatures(singleton(productFeature));

            final String nsURI = fs.getSchema().getName().getNamespaceURI();
            Filter filter = getProductFilter(collection, productId);

            if (description != null) {
                String descriptionString = new String(description);
                fs.modifyFeatures(new NameImpl(nsURI, OpenSearchAccess.DESCRIPTION), descriptionString, filter);
            }

            if (metadata != null) {
                String descriptionString = new String(metadata);
                fs.modifyFeatures(OpenSearchAccess.METADATA_PROPERTY_NAME, descriptionString, filter);
            }

            if (linksCollection != null) {
                fs.modifyFeatures(OpenSearchAccess.OGC_LINKS_PROPERTY_NAME, linksCollection, filter);
            }

            if (thumbnail != null) {
                fs.modifyFeatures(OpenSearchAccess.QUICKLOOK_PROPERTY_NAME, thumbnail, filter);
            }

            if (granulesCollection != null) {
                fs.modifyFeatures(new NameImpl(nsURI, OpenSearchAccess.GRANULES), granulesCollection, filter);
            }

        });

        // if got here, all is fine
        return returnCreatedProductReference(collection, request, productId);
    }

    private ResponseEntity<String> returnCreatedProductReference(String collection, HttpServletRequest request,
            String productId) throws URISyntaxException {
        String baseURL = ResponseUtils.baseURL(request);
        String newCollectionLocation = ResponseUtils.buildURL(baseURL,
                "/rest/oseo/collections/" + collection + "/products/" + productId, null, URLType.RESOURCE);
        HttpHeaders headers = new HttpHeaders();
        headers.setLocation(new URI(newCollectionLocation));
        return new ResponseEntity<>(productId, headers, HttpStatus.CREATED);
    }

    /*
     * Note, the .+ regular expression allows the product id to contain dots instead of having them interpreted as format extension
     */
    @GetMapping(path = "{product:.+}", produces = { MediaType.APPLICATION_JSON_VALUE })
    @ResponseBody
    public SimpleFeature getProduct(HttpServletRequest request,
            @PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        Feature feature = queryProduct(collection, product, q -> {
        });

        // map to the output schema for GeoJSON encoding
        SimpleFeatureType targetSchema = mapFeatureTypeToSimple(feature.getType(), ftb -> {
            ftb.add("ogcLinksHref", String.class);
            ftb.add("metadataHref", String.class);
            ftb.add("descriptionHref", String.class);
            ftb.add("thumbnailHref", String.class);
            ftb.add("granulesHref", String.class);
        });
        return mapFeatureToSimple(feature, targetSchema, fb -> {
            String baseURL = ResponseUtils.baseURL(request);
            String pathBase = "/rest/oseo/collections/" + collection + "/products/" + product + "/";
            String ogcLinks = ResponseUtils.buildURL(baseURL, pathBase + "ogcLinks", null, URLType.RESOURCE);
            String metadata = ResponseUtils.buildURL(baseURL, pathBase + "metadata", null, URLType.RESOURCE);
            String description = ResponseUtils.buildURL(baseURL, pathBase + "description", null, URLType.RESOURCE);
            String thumb = ResponseUtils.buildURL(baseURL, pathBase + "thumbnail", null, URLType.RESOURCE);
            String granules = ResponseUtils.buildURL(baseURL, pathBase + "granules", null, URLType.RESOURCE);

            fb.set("ogcLinksHref", ogcLinks);
            fb.set("metadataHref", metadata);
            fb.set("descriptionHref", description);
            fb.set("thumbnailHref", thumb);
            fb.set("granulesHref", granules);
        });
    }

    @PutMapping(path = "{product:.+}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public void putProductJson(HttpServletRequest request,
            @PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product,
            @RequestBody(required = true) SimpleFeature feature) throws IOException, URISyntaxException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // prepare the update, need to convert each field into a Name/Value couple
        Feature productFeature = simpleToComplex(feature, getProductSchema(), PRODUCT_HREFS);
        List<Name> names = new ArrayList<>();
        List<Object> values = new ArrayList<>();
        for (Property p : productFeature.getProperties()) {
            // skip over the large/complex attributes that are being modified via separate calls
            final Name propertyName = p.getName();
            if (OpenSearchAccess.METADATA_PROPERTY_NAME.equals(propertyName)
                    || OpenSearchAccess.OGC_LINKS_PROPERTY_NAME.equals(propertyName)
                    || OpenSearchAccess.DESCRIPTION.equals(propertyName.getLocalPart())) {
                continue;
            }
            names.add(propertyName);
            values.add(p.getValue());
        }
        Name[] attributeNames = (Name[]) names.toArray(new Name[names.size()]);
        Object[] attributeValues = (Object[]) values.toArray();
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(fs -> fs.modifyFeatures(attributeNames, attributeValues, filter));
    }

    @DeleteMapping(path = "{product:.+}")
    public void deleteProduct(@PathVariable(required = true, name = "collection") String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // TODO: handle removing the publishing side without removing the metadata
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(fs -> fs.removeFeatures(filter));
    }

    /*
     * Note, the .+ regular expression allows the product id to contain dots instead of having them interpreted as format extension
     */
    @GetMapping(path = "{product:.+}/ogcLinks", produces = { MediaType.APPLICATION_JSON_VALUE })
    @ResponseBody
    public OgcLinks getProductOgcLinks(HttpServletRequest request,
            @PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // query one collection and grab its OGC links
        Feature feature = queryProduct(collection, product, q -> {
            q.setProperties(Collections.singletonList(FF.property(OpenSearchAccess.OGC_LINKS_PROPERTY_NAME)));
        });

        OgcLinks links = buildOgcLinksFromFeature(feature, true);
        return links;
    }

    @PutMapping(path = "{product:.+}/ogcLinks", consumes = MediaType.APPLICATION_JSON_VALUE)
    public void putProductLinks(HttpServletRequest request,
            @PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, @RequestBody OgcLinks links)
            throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        ListFeatureCollection linksCollection = beansToLinksCollection(links);

        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(
                fs -> fs.modifyFeatures(OpenSearchAccess.OGC_LINKS_PROPERTY_NAME, linksCollection, filter));
    }

    @DeleteMapping(path = "{product:.+}/ogcLinks")
    public void deleteProductLinks(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // prepare the update
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(
                fs -> fs.modifyFeatures(OpenSearchAccess.OGC_LINKS_PROPERTY_NAME, null, filter));
    }

    /*
     * Note, the .+ regular expression allows the product id to contain dots instead of having them interpreted as format extension
     */
    @GetMapping(path = "{product:.+}/metadata", produces = { MediaType.APPLICATION_JSON_VALUE })
    public void getProductMetadata(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletResponse response)
            throws IOException {
        // query one product and grab its metadata
        Feature feature = queryProduct(collection, product, q -> {
            q.setProperties(Collections.singletonList(FF.property(OpenSearchAccess.METADATA_PROPERTY_NAME)));
        });

        // grab the metadata
        Property metadataProperty = feature.getProperty(OpenSearchAccess.METADATA_PROPERTY_NAME);
        if (metadataProperty != null && metadataProperty.getValue() instanceof String) {
            String value = (String) metadataProperty.getValue();
            response.setContentType("text/xml");
            StreamUtils.copy(value, Charset.forName("UTF-8"), response.getOutputStream());
        } else {
            throwProductNotFound(collection, product, "Metadata for product");
        }
    }

    @PutMapping(path = "{product:.+}/metadata", consumes = MediaType.TEXT_XML_VALUE)
    public void putCollectionMetadata(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletRequest request)
            throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // TODO: validate it's actual O&M metadata
        String metadata = IOUtils.toString(request.getReader());
        checkWellFormedXML(metadata);

        // prepare the update
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(
                fs -> fs.modifyFeatures(OpenSearchAccess.METADATA_PROPERTY_NAME, metadata, filter));
    }

    @DeleteMapping(path = "{product:.+}/metadata")
    public void deleteCollectionMetadata(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // prepare the update
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(
                fs -> fs.modifyFeatures(OpenSearchAccess.METADATA_PROPERTY_NAME, null, filter));
    }

    private void throwProductNotFound(String collection, String product, String item) {
        throw new ResourceNotFoundException(
                item + " '" + product + "' in collection '" + collection + "' could not be found");
    }

    /*
     * Note, the .+ regular expression allows the product id to contain dots instead of having them interpreted as format extension
     */
    @GetMapping(path = "{product:.+}/description", produces = { MediaType.TEXT_HTML_VALUE })
    public void getProductDescription(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletResponse response)
            throws IOException {
        // query one collection and grab its OGC links
        Feature feature = queryProduct(collection, product, q -> {
            q.setPropertyNames(new String[] { "htmlDescription" });
        });

        // grab the description
        Property descriptionProperty = feature.getProperty("htmlDescription");
        if (descriptionProperty != null && descriptionProperty.getValue() instanceof String) {
            String value = (String) descriptionProperty.getValue();
            response.setContentType("text/html");
            StreamUtils.copy(value, Charset.forName("UTF-8"), response.getOutputStream());
        } else {
            throwProductNotFound(collection, product, "Product");
        }
    }

    @PutMapping(path = "{product:.+}/description", consumes = MediaType.TEXT_HTML_VALUE)
    public void putProductDescription(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletRequest request)
            throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        String description = IOUtils.toString(request.getReader());

        updateDescription(collection, product, description);
    }

    @DeleteMapping(path = "{product:.+}/description")
    public void deleteProductDescription(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // set it to null
        updateDescription(collection, product, null);
    }

    private void updateDescription(String collection, String product, String description) throws IOException {
        // prepare the update
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(fs -> {
            // set the description to the specified value
            final FeatureType schema = fs.getSchema();
            final String nsURI = schema.getName().getNamespaceURI();
            fs.modifyFeatures(new NameImpl(nsURI, OpenSearchAccess.DESCRIPTION), description, filter);
        });
    }

    /*
     * Note, the .+ regular expression allows the product id to contain dots instead of having them interpreted as format extension
     */
    @GetMapping(path = "{product:.+}/thumbnail", produces = { MediaType.ALL_VALUE })
    public void getProductThumbnail(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletResponse response)
            throws IOException {
        // query one collection and grab its OGC links
        Feature feature = queryProduct(collection, product, q -> {
            q.setProperties(Collections.singletonList(FF.property(OpenSearchAccess.QUICKLOOK_PROPERTY_NAME)));
        });

        // grab the thumbnail
        Property thumbnailProperty = feature.getProperty(OpenSearchAccess.QUICKLOOK_PROPERTY_NAME);
        if (thumbnailProperty != null && thumbnailProperty.getValue() instanceof byte[]) {
            byte[] value = (byte[]) thumbnailProperty.getValue();
            response.setContentType(DefaultOpenSearchEoService.guessImageMimeType(value));
            StreamUtils.copy(value, response.getOutputStream());
        } else {
            throwProductNotFound(collection, product, "Thumbnail for product");
        }
    }

    @PutMapping(path = "{product:.+}/thumbnail", consumes = { MediaType.IMAGE_JPEG_VALUE,
            MediaType.IMAGE_PNG_VALUE })
    public void putProductThumbnail(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product, HttpServletRequest request)
            throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        byte[] thumbnail = IOUtils.toByteArray(request.getInputStream());
        updateThumbnail(collection, product, thumbnail);
    }

    @DeleteMapping(path = "{product:.+}/thumbnail")
    public void deleteProductThumbnail(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        // check the product exists
        queryProduct(collection, product, q -> {
        });

        // set it to null
        updateThumbnail(collection, product, null);
    }

    @GetMapping(path = "{product:.+}/granules", produces = { MediaType.APPLICATION_JSON_VALUE })
    @ResponseBody
    public SimpleFeatureCollection getProductGranules(
            @PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        SimpleFeatureSource granules = getOpenSearchAccess().getGranules(collection, product);
        return granules.getFeatures();
    }

    @PutMapping(path = "{product:.+}/granules", produces = { MediaType.APPLICATION_JSON_VALUE })
    public void putProductGranules(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product,
            @RequestBody SimpleFeatureCollection granules) throws IOException {
        SimpleFeatureStore store = (SimpleFeatureStore) getOpenSearchAccess().getGranules(collection, product);
        runTransactionOnStore(store, s -> {
            s.removeFeatures(Filter.INCLUDE);
            s.addFeatures(granules);
        });
    }

    @DeleteMapping(path = "{product:.+}/granules")
    public void putProductGranules(@PathVariable(name = "collection", required = true) String collection,
            @PathVariable(name = "product", required = true) String product) throws IOException {
        SimpleFeatureStore store = (SimpleFeatureStore) getOpenSearchAccess().getGranules(collection, product);
        runTransactionOnStore(store, s -> {
            s.removeFeatures(Filter.INCLUDE);
        });
    }

    private void updateThumbnail(String collection, String product, byte[] thumbnail) throws IOException {
        // prepare the update
        Filter filter = getProductFilter(collection, product);
        runTransactionOnProductStore(fs -> {
            // set the description to the specified value
            fs.modifyFeatures(OpenSearchAccess.QUICKLOOK_PROPERTY_NAME, thumbnail, filter);
        });
    }

    private Feature queryProduct(String collection, String product, Consumer<Query> queryDecorator)
            throws IOException {
        // query products
        Query query = new Query();
        Filter filter = getProductFilter(collection, product);
        query.setFilter(filter);
        queryDecorator.accept(query);
        OpenSearchAccess access = accessProvider.getOpenSearchAccess();
        FeatureSource<FeatureType, Feature> fs = access.getProductSource();
        FeatureCollection<FeatureType, Feature> features = fs.getFeatures(query);
        Feature feature = DataUtilities.first(features);

        if (feature == null) {
            throwProductNotFound(collection, product, "Product");
        }

        return feature;
    }

    private And getProductFilter(String collection, String product) {
        PropertyIsEqualTo parentIdFilter = FF.equal(FF.property(PARENT_ID), FF.literal(collection), true);
        PropertyIsEqualTo idFilter = FF.equal(FF.property(PRODUCT_ID), FF.literal(product), true);
        return FF.and(parentIdFilter, idFilter);
    }

    private void runTransactionOnProductStore(IOConsumer<FeatureStore> featureStoreConsumer) throws IOException {
        FeatureStore store = (FeatureStore) getOpenSearchAccess().getProductSource();
        super.runTransactionOnStore(store, featureStoreConsumer);
    }

    FeatureType getProductSchema() throws IOException {
        final OpenSearchAccess access = accessProvider.getOpenSearchAccess();
        final FeatureSource<FeatureType, Feature> productSource = access.getProductSource();
        final FeatureType schema = productSource.getSchema();
        return schema;
    }

}