EditFeatureController.java

/*
 * Copyright (C) 2023 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */
package org.tailormap.api.controller;

import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getEditableAttributes;
import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getNonHiddenAttributeNames;
import static org.tailormap.api.persistence.helper.TMFeatureTypeHelper.getNonHiddenAttributes;

import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import java.io.IOException;
import java.io.Serializable;
import java.lang.invoke.MethodHandles;
import java.util.List;
import java.util.Map;
import org.geotools.api.data.FeatureStore;
import org.geotools.api.data.SimpleFeatureSource;
import org.geotools.api.data.SimpleFeatureStore;
import org.geotools.api.data.Transaction;
import org.geotools.api.feature.simple.SimpleFeature;
import org.geotools.api.feature.type.AttributeDescriptor;
import org.geotools.api.filter.Filter;
import org.geotools.api.filter.FilterFactory;
import org.geotools.api.filter.identity.FeatureId;
import org.geotools.api.referencing.FactoryException;
import org.geotools.api.referencing.operation.MathTransform;
import org.geotools.data.DataUtilities;
import org.geotools.data.DefaultTransaction;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.util.factory.GeoTools;
import org.geotools.util.factory.Hints;
import org.locationtech.jts.geom.Geometry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.tailormap.api.annotation.AppRestController;
import org.tailormap.api.geotools.TransformationUtil;
import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
import org.tailormap.api.geotools.processing.GeometryProcessor;
import org.tailormap.api.persistence.Application;
import org.tailormap.api.persistence.GeoService;
import org.tailormap.api.persistence.TMFeatureType;
import org.tailormap.api.persistence.helper.TMAttributeTypeHelper;
import org.tailormap.api.persistence.json.AppLayerSettings;
import org.tailormap.api.persistence.json.AppTreeLayerNode;
import org.tailormap.api.persistence.json.GeoServiceLayer;
import org.tailormap.api.util.Constants;
import org.tailormap.api.util.EditUtil;
import org.tailormap.api.viewer.model.AttachmentMetadata;
import org.tailormap.api.viewer.model.Feature;

@AppRestController
@Validated
@RequestMapping(path = {"${tailormap-api.base-path}/{viewerKind}/{viewerName}/layer/{appLayerId}/edit/feature"})
public class EditFeatureController implements Constants {
  private static final Logger logger =
      LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
  private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());
  private final EditUtil editUtil;

  public EditFeatureController(FeatureSourceFactoryHelper featureSourceFactoryHelper, EditUtil editUtil) {
    this.featureSourceFactoryHelper = featureSourceFactoryHelper;
    this.editUtil = editUtil;
  }

  private static void checkFeatureHasOnlyValidAttributes(
      Feature feature, TMFeatureType tmFeatureType, AppLayerSettings appLayerSettings) {

    if (!getNonHiddenAttributeNames(tmFeatureType, appLayerSettings)
        .containsAll(feature.getAttributes().keySet())) {
      throw new ResponseStatusException(
          HttpStatus.BAD_REQUEST,
          "Feature cannot be edited, one or more requested attributes are not available on the feature type");
    }
    if (!getEditableAttributes(tmFeatureType, appLayerSettings)
        .containsAll(feature.getAttributes().keySet())) {
      throw new ResponseStatusException(
          HttpStatus.BAD_REQUEST,
          "Feature cannot be edited, one or more requested attributes are not editable on the feature type");
    }
  }

  @Transactional
  @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
  @Timed(value = "create_feature", description = "time spent to process create feature call")
  @Counted(value = "create_feature", description = "number of create feature calls")
  public ResponseEntity<Serializable> createFeature(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @RequestBody Feature completeFeature) {

    editUtil.checkEditAuthorisation();

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
    Map<String, Object> attributesMap = completeFeature.getAttributes();

    AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);

    checkFeatureHasOnlyValidAttributes(completeFeature, tmFeatureType, appLayerSettings);

    Feature newFeature;
    SimpleFeatureSource fs = null;

    try (Transaction transaction = new DefaultTransaction("create")) {
      fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);

      SimpleFeature simpleFeature;
      SimpleFeatureBuilder simpleFeatureBuilder = new SimpleFeatureBuilder(fs.getSchema());
      if (null != completeFeature.getFid() && !completeFeature.getFid().isEmpty()) {
        simpleFeature = simpleFeatureBuilder.buildFeature(completeFeature.getFid());
        simpleFeature.getUserData().put(Hints.USE_PROVIDED_FID, true);
      } else {
        simpleFeature = simpleFeatureBuilder.buildFeature(null);
      }

      handleGeometryAttributesInput(
          tmFeatureType, appLayerSettings, completeFeature, attributesMap, application, fs);
      for (Map.Entry<String, Object> entry : attributesMap.entrySet()) {
        simpleFeature.setAttribute(entry.getKey(), entry.getValue());
      }

      if (fs instanceof SimpleFeatureStore simpleFeatureStore) {
        simpleFeatureStore.setTransaction(transaction);
        List<FeatureId> newFids = simpleFeatureStore.addFeatures(DataUtilities.collection(simpleFeature));

        transaction.commit();
        // find the created feature to return
        newFeature = getFeature(fs, ff.id(newFids.getFirst()), application, tmFeatureType);
      } else {
        throw new ResponseStatusException(
            HttpStatus.BAD_REQUEST, "Feature cannot be added, datasource is not editable");
      }
    } catch (RuntimeException | IOException | FactoryException e) {
      // either opening datastore, modify or transaction failed
      logger.error("Error creating new feature {}", completeFeature, e);
      String message = e.getMessage();
      if (null != e.getCause() && null != e.getCause().getMessage()) {
        message = e.getCause().getMessage();
      }
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, e);
    } finally {
      if (fs != null) {
        fs.getDataStore().dispose();
      }
    }
    return new ResponseEntity<>(newFeature, HttpStatus.OK);
  }

  @Transactional
  @PatchMapping(
      consumes = MediaType.APPLICATION_JSON_VALUE,
      produces = MediaType.APPLICATION_JSON_VALUE,
      path = "/{fid}")
  @Timed(value = "update_feature", description = "time spent to process patch feature call")
  @Counted(value = "update_feature", description = "number of patch feature calls")
  public ResponseEntity<Serializable> patchFeature(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable String fid,
      @RequestBody Feature partialFeature) {

    editUtil.checkEditAuthorisation();

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);
    AppLayerSettings appLayerSettings = application.getAppLayerSettings(appTreeLayerNode);

    Map<String, Object> attributesMap = partialFeature.getAttributes();

    checkFeatureHasOnlyValidAttributes(partialFeature, tmFeatureType, appLayerSettings);

    Feature patchedFeature;
    SimpleFeatureSource fs = null;
    try (Transaction transaction = new DefaultTransaction("edit")) {
      fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
      // find feature to update
      final Filter filter = ff.id(ff.featureId(fid));
      if (!fs.getFeatures(filter).isEmpty() && fs instanceof SimpleFeatureStore simpleFeatureStore) {
        handleGeometryAttributesInput(
            tmFeatureType, appLayerSettings, partialFeature, attributesMap, application, fs);
        // NOTE geotools does not report back that the feature was updated, no error === success
        simpleFeatureStore.setTransaction(transaction);
        simpleFeatureStore.modifyFeatures(
            attributesMap.keySet().toArray(new String[] {}),
            attributesMap.values().toArray(),
            filter);
        transaction.commit();
        // find the updated feature to return
        patchedFeature = getFeature(fs, filter, application, tmFeatureType);
      } else {
        throw new ResponseStatusException(
            HttpStatus.BAD_REQUEST, "Feature cannot be edited, it does not exist or is not editable");
      }
    } catch (RuntimeException | IOException | FactoryException e) {
      // either opening datastore, modify or transaction failed
      logger.error("Error patching feature {}", partialFeature, e);
      String message = e.getMessage();
      if (null != e.getCause() && null != e.getCause().getMessage()) {
        message = e.getCause().getMessage();
      }
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, e);
    } finally {
      if (fs != null) {
        fs.getDataStore().dispose();
      }
    }
    return new ResponseEntity<>(patchedFeature, HttpStatus.OK);
  }

  @Transactional
  @DeleteMapping(path = "/{fid}")
  @Timed(value = "delete_feature", description = "time spent to process delete feature call")
  @Counted(value = "delete_feature", description = "number of delete feature calls")
  public ResponseEntity<Void> deleteFeature(
      @ModelAttribute AppTreeLayerNode appTreeLayerNode,
      @ModelAttribute GeoService service,
      @ModelAttribute GeoServiceLayer layer,
      @ModelAttribute Application application,
      @PathVariable String fid) {

    editUtil.checkEditAuthorisation();

    TMFeatureType tmFeatureType = editUtil.getEditableFeatureType(application, appTreeLayerNode, service, layer);

    SimpleFeatureSource fs = null;
    try (Transaction transaction = new DefaultTransaction("delete")) {
      fs = featureSourceFactoryHelper.openGeoToolsFeatureSource(tmFeatureType);
      Filter filter = ff.id(ff.featureId(fid));
      if (fs instanceof FeatureStore<?, ?> featureStore) {
        // NOTE geotools does not report back that the feature does not exist, nor the number of
        // deleted features, no error === success
        featureStore.setTransaction(transaction);
        featureStore.removeFeatures(filter);
        transaction.commit();
      } else {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Layer cannot be edited");
      }
    } catch (IOException e) {
      // either opening datastore or commit failed
      logger.error("Error deleting feature {}", fid, e);
      throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e);
    } finally {
      if (fs != null) {
        fs.getDataStore().dispose();
      }
    }

    return new ResponseEntity<>(HttpStatus.NO_CONTENT);
  }

  private static Feature getFeature(
      SimpleFeatureSource fs, Filter filter, Application application, TMFeatureType tmFeatureType)
      throws IOException, FactoryException {
    Feature modelFeature = null;
    try (SimpleFeatureIterator feats = fs.getFeatures(filter).features()) {
      if (feats.hasNext()) {
        SimpleFeature simpleFeature = feats.next();
        modelFeature = new Feature()
            .geometry(GeometryProcessor.processGeometry(
                simpleFeature.getDefaultGeometry(),
                false,
                true,
                TransformationUtil.getTransformationToApplication(application, fs)))
            .fid(simpleFeature.getID());
        for (AttributeDescriptor att : simpleFeature.getFeatureType().getAttributeDescriptors()) {
          Object value = simpleFeature.getAttribute(att.getName());
          if (value instanceof Geometry geometry) {
            geometry = GeometryProcessor.transformGeometry(
                geometry, TransformationUtil.getTransformationToApplication(application, fs));
            value = GeometryProcessor.geometryToWKT(geometry);
          }
          modelFeature.putAttributesItem(att.getLocalName(), value);
        }
        if (tmFeatureType.getSettings().getAttachmentAttributes() != null
            && !tmFeatureType
                .getSettings()
                .getAttachmentAttributes()
                .isEmpty()) {
          //  add attachments
          Object primaryKey = simpleFeature.getAttribute(tmFeatureType.getPrimaryKeyAttribute());
          Map<String, List<AttachmentMetadata>> attachmentsByFeatureId =
              AttachmentsHelper.listAttachmentsForFeaturesByFeatureId(tmFeatureType, List.of(primaryKey));
          List<AttachmentMetadata> attachments = attachmentsByFeatureId.get(simpleFeature.getID());
          if (attachments != null) {
            modelFeature.setAttachments(attachments);
          }
        }
      }
    }
    return modelFeature;
  }

  /** Handle geometry attributes, essentially this is a WKT to Geometry conversion. */
  private static void handleGeometryAttributesInput(
      TMFeatureType tmFeatureType,
      AppLayerSettings appLayerSettings,
      Feature modelFeature,
      Map<String, Object> attributesMap,
      Application application,
      SimpleFeatureSource fs)
      throws FactoryException {

    final MathTransform transform = TransformationUtil.getTransformationToDataSource(application, fs);
    getNonHiddenAttributes(tmFeatureType, appLayerSettings).stream()
        .filter(attr -> TMAttributeTypeHelper.isGeometry(attr.getType()))
        .filter(attr -> modelFeature.getAttributes().containsKey(attr.getName()))
        .forEach(attr -> {
          Geometry geometry = GeometryProcessor.wktToGeometry(
              (String) modelFeature.getAttributes().get(attr.getName()));
          if (transform != null && geometry != null) {
            geometry.setSRID(Integer.parseInt(application.getCrs().substring("EPSG:".length())));
            if (logger.isTraceEnabled()) {
              logger.trace(
                  "Transforming geometry {} from {} to {}",
                  geometry.toText(),
                  geometry.getSRID(),
                  fs.getSchema()
                      .getCoordinateReferenceSystem()
                      .getIdentifiers());
            }
            geometry = GeometryProcessor.transformGeometry(geometry, transform);
          }
          attributesMap.put(attr.getName(), geometry);
        });
  }
}