UnboundID Server SDK

Ping Identity
UnboundID Server SDK Documentation

ExampleNotificationManager.java

/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at
 * docs/licenses/cddl.txt
 * or http://www.opensource.org/licenses/cddl1.php.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at
 * docs/licenses/cddl.txt.  If applicable,
 * add the following below this CDDL HEADER, with the fields enclosed
 * by brackets "[]" replaced with your own identifying information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2014-2021 Ping Identity Corporation
 */
package com.unboundid.directory.sdk.examples;

import com.unboundid.asn1.ASN1OctetString;
import com.unboundid.directory.sdk.common.operation.AddRequest;
import com.unboundid.directory.sdk.common.operation.DeleteRequest;
import com.unboundid.directory.sdk.common.operation.ModifyDNRequest;
import com.unboundid.directory.sdk.common.operation.ModifyRequest;
import com.unboundid.directory.sdk.common.schema.AttributeType;
import com.unboundid.directory.sdk.common.types.LogSeverity;
import com.unboundid.directory.sdk.common.types.OperationContext;
import com.unboundid.directory.sdk.ds.api.NotificationManager;
import com.unboundid.directory.sdk.ds.config.NotificationManagerConfig;
import com.unboundid.directory.sdk.ds.types.DirectoryServerContext;
import com.unboundid.directory.sdk.ds.types.Notification;
import com.unboundid.directory.sdk.ds.types.NotificationChange;
import com.unboundid.directory.sdk.ds.types.NotificationDeliveryResult;
import com.unboundid.directory.sdk.ds.types.NotificationProperties;
import com.unboundid.directory.sdk.proxy.types.Location;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.ChangeType;
import com.unboundid.ldap.sdk.CompareResult;
import com.unboundid.ldap.sdk.DN;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPConnectionOptions;
import com.unboundid.ldap.sdk.LDAPConnectionPool;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPURL;
import com.unboundid.ldap.sdk.Modification;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.ldap.sdk.unboundidds.UnboundIDChangeLogEntry;
import com.unboundid.ldap.sdk.unboundidds.extensions.
    NotificationDestinationDetails;
import com.unboundid.ldap.sdk.unboundidds.extensions.
    NotificationSubscriptionDetails;
import com.unboundid.util.ObjectPair;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.args.ArgumentParser;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;

import static java.util.Arrays.asList;



/**
 * This class provides an example of a notification manager that can synchronize
 * changes out to another directory server.
 * <BR><BR>
 * Each notification destination identifies a target directory server.
 * The connection information for the target directory server is specified
 * by notification destination details. Each destination
 * detail value is expected to be a string of the form NAME=VALUE. The
 * following information can be specified.
 * <UL>
 *   <LI>
 *     ldapurl=<i>LDAPURL</i><BR>
 *     Only the scheme, host and port of the LDAP URL are allowed.
 *   </LI>
 *   <LI>
 *     binddn=<i>DN</i><BR>
 *     The LDAP simple bind DN
 *   </LI>
 *   <LI>
 *     password=<i>password</i><BR>
 *     The LDAP simple bind password
 *   </LI>
 *   <LI>
 *     location=<i>location</i><BR>
 *     The location of the target server. If this matches the source server
 *     location then the isPreferredForDestination method will return
 *     true for this destination.
 *   </LI>
 * </UL>
 *
 * Each notification subscription specifies one or more LDAP URLs that identify
 * the base DN and scope of source entries to be synchronized. Each subscription
 * detail value should be an LDAP URL, where only the base DN, scope and
 * attributes of the LDAP URL are allowed. For example,
 * <i>ldap:///ou=people,dc=example,dc=com?uid,mail?sub</i>.
 * Attributes may be specified in the subscription LDAP URL to cause
 * additional key attributes to be written to the LDAP changelog and included
 * in the notification. This is for testing purposes only because this example
 * does not use these key attribute values when the changes are synchronized
 * to the target server.
 */
public class ExampleNotificationManager
    extends NotificationManager
{
  private static final String DEST_DETAIL_LDAPURL  = "ldapurl";
  private static final String DEST_DETAIL_BINDDN   = "binddn";
  private static final String DEST_DETAIL_PASSWORD = "password";
  private static final String DEST_DETAIL_LOCATION = "location";

  private static final Set<ResultCode> UNLIMITED_RETRY_RESULT_CODES =
      new LinkedHashSet<ResultCode>(asList(
          ResultCode.BUSY,
          ResultCode.UNAVAILABLE,
          ResultCode.SERVER_DOWN,
          ResultCode.CONNECT_ERROR));

  private static final Set<ResultCode> LIMITED_RETRY_RESULT_CODES =
      new LinkedHashSet<ResultCode>(asList(
          ResultCode.OPERATIONS_ERROR,
          ResultCode.PROTOCOL_ERROR,
          ResultCode.TIME_LIMIT_EXCEEDED,
          ResultCode.ADMIN_LIMIT_EXCEEDED,
          ResultCode.CONSTRAINT_VIOLATION,
          ResultCode.ALIAS_DEREFERENCING_PROBLEM,
          ResultCode.UNWILLING_TO_PERFORM,
          ResultCode.LOOP_DETECT,
          ResultCode.CANCELED,
          ResultCode.OTHER,
          ResultCode.LOCAL_ERROR,
          ResultCode.ENCODING_ERROR,
          ResultCode.DECODING_ERROR,
          ResultCode.TIMEOUT,
          ResultCode.USER_CANCELED,
          ResultCode.NO_MEMORY,
          ResultCode.CLIENT_LOOP,
          ResultCode.REFERRAL_LIMIT_EXCEEDED,
          ResultCode.INTERACTIVE_TRANSACTION_ABORTED));

  private static final int MAX_LIMITED_RETRY_ATTEMPTS = 3;

  /**
   * The human-readable name for this extension.
   */
  private static final String EXTENSION_NAME = "Example Notification Manager";

  /**
   * The handle to the server context for the server in which this extension
   * is running.
   */
  private DirectoryServerContext serverContext;

  /**
   * A map from destination ID to a target LDAP server.
   */
  private volatile Map<String,TargetServer>
      targetServers = new ConcurrentHashMap<String, TargetServer>();

  /**
   * A map from destination and subscription ID pair to a list of LDAPURLs for
   * the subscription.
   */
  private volatile Map<ObjectPair<String,String>,List<LDAPURL>>
      subscriptionURLs =
      new ConcurrentHashMap<ObjectPair<String, String>, List<LDAPURL>>();



  /**
   * This class represents a target LDAP server.
   */
  class TargetServer
  {
    private final LDAPURL ldapURL;
    private final String bindDN;
    private final String password;
    private final String locationName;
    private AtomicReference<LDAPConnectionPool> connectionPoolRef =
        new AtomicReference<LDAPConnectionPool>(null);


    /**
     * Create a new instance of a target server.
     *
     * @param ldapURL       The LDAP URL of the target server.
     * @param bindDN        The bind DN to use to bind to the target server.
     * @param password      The password to use to bind to the target server.
     * @param locationName  The location of the target server, or
     *                      {@code null} if the location is not defined.
     */
    TargetServer(final LDAPURL ldapURL, final String bindDN,
                 final String password, final String locationName)
    {
      this.ldapURL = ldapURL;
      this.bindDN = bindDN;
      this.password = password;
      this.locationName = locationName;
    }



    /**
     * Retrieve the LDAP URL of the target server.
     *
     * @return  The LDAP URL of the target server.
     */
    public LDAPURL getLdapURL()
    {
      return ldapURL;
    }



    /**
     * Retrieve the bind DN to use to bind to the target server.
     *
     * @return  The bind DN to use to bind to the target server.
     */
    public String getBindDN()
    {
      return bindDN;
    }



    /**
     * Retrieve the password to use to bind to the target server.
     *
     * @return  The password to use to bind to the target server.
     */
    public String getPassword()
    {
      return password;
    }



    /**
     * Retrieve the name of the location of the target server, or
     * {@code null} if the location is not defined.
     *
     * @return  The name of the location of the target server, or
     *          {@code null} if the location is not defined.
     */
    public String getLocationName()
    {
      return locationName;
    }



    /**
     * Retrieve an LDAP connection pool for connections to the target server.
     *
     * @return  An  LDAP connection pool for connections to the target server.
     *
     * @throws LDAPException  If a connection could not be established.
     */
    public LDAPConnectionPool getConnectionPool()
        throws LDAPException
    {
      LDAPConnectionPool p = connectionPoolRef.get();

      if ((p != null) && p.isClosed())
      {
        connectionPoolRef.compareAndSet(p, null);
        p = null;
      }

      if (p == null)
      {
        final LDAPConnectionOptions connectionOptions =
            new LDAPConnectionOptions();
        LDAPConnection connection =
            new LDAPConnection(connectionOptions,
                               getLdapURL().getHost(),
                               getLdapURL().getPort(),
                               getBindDN(),
                               getPassword());
        p = new LDAPConnectionPool(connection, 16);

        if (! connectionPoolRef.compareAndSet(null, p))
        {
          p.close();
          p = connectionPoolRef.get();
        }
      }

      return p;
    }



    /**
     * Performs cleanup when this target server is finished with.
     */
    public void finalizeTargetServer()
    {
      final LDAPConnectionPool p = connectionPoolRef.get();
      if (p != null)
      {
        p.close();
      }
    }
  }



  /**
   * Retrieves a human-readable name for this extension.
   *
   * @return  A human-readable name for this extension.
   */
  @Override
  public String getExtensionName()
  {
    return EXTENSION_NAME;
  }



  /**
   * Initializes this notification manager.
   *
   * @param serverContext A handle to the server context for the server in which
   *                      this extension is running.
   * @param config        The general configuration for this notification
   *                      manager.
   * @param parser        The argument parser which has been initialized from
   *                      the configuration for this notification manager.
   *
   * @throws LDAPException  If a problem occurs while initializing this
   *                        notification manager.
   */
  @Override
  public void initializeNotificationManager(
      final DirectoryServerContext serverContext,
      final NotificationManagerConfig config,
      final ArgumentParser parser) throws LDAPException
  {
    this.serverContext = serverContext;
  }



  /**
   * Performs any cleanup which may be necessary when this notification manager
   * is to be taken out of service.
   */
  @Override
  public void finalizeNotificationManager()
  {
    for (TargetServer targetServer : targetServers.values())
    {
      targetServer.finalizeTargetServer();
    }

    targetServers = null;
    subscriptionURLs = null;
  }



  /**
   * Retrieves a human-readable description for this extension.  Each element
   * of the array that is returned will be considered a separate paragraph in
   * generated documentation.
   *
   * @return  A human-readable description for this extension, or {@code null}
   *          or an empty array if no description should be available.
   */
  @Override()
  public String[] getExtensionDescription()
  {
    return new String[]
    {
        "This notification manager serves an example that may be used " +
        "to demonstrate the process for creating a third-party " +
        "notification manager.  For each change matching a subscription " +
        "it will synchronize this change to another directory server."
    };
  }



  /**
   * Parse the provided list of destination details.
   *
   * @param destinationDetails  The notification destination details.
   *
   * @return  A target LDAP server.
   */
  private TargetServer parseDestinationDetails(
      final List<ASN1OctetString> destinationDetails
  )
  {
    LDAPURL ldapURL = null;
    String bindDN = null;
    String password = null;
    String location = null;

    for (ASN1OctetString s : destinationDetails)
    {
      final String detailName;
      final String detailValue;
      try
      {
        final String components[] = s.stringValue().split(":", 2);
        detailName = components[0].trim();
        detailValue = components[1];
      }
      catch (Exception e)
      {
        throw new RuntimeException(
            String.format("Destination detail '%s' is not of the form " +
                          "NAME=VALUE", s));
      }

      if (detailName.startsWith(DEST_DETAIL_LDAPURL))
      {
        if (ldapURL != null)
        {
          throw new RuntimeException("LDAP URL must only be specified once");
        }

        try
        {
          ldapURL = new LDAPURL(detailValue);
        }
        catch (LDAPException e)
        {
          throw new RuntimeException(
              String.format("'%s' cannot be parsed as an LDAP URL",
                            detailValue));
        }

        if (!ldapURL.getScheme().equals("ldap"))
        {
          throw new RuntimeException(
              "The '" + ldapURL.getScheme() + "' scheme is not supported " +
              "in a destination LDAP URL");
        }

        if (ldapURL.baseDNProvided() || ldapURL.scopeProvided() ||
            ldapURL.attributesProvided() || ldapURL.filterProvided())
        {
          throw new RuntimeException(
              "Only the scheme, host and port should be provided in a " +
              "destination LDAP URL");
        }
      }
      else if (detailName.startsWith(DEST_DETAIL_BINDDN))
      {
        if (bindDN != null)
        {
          throw new RuntimeException("Bind DN must only be specified once");
        }
        bindDN = detailValue;
      }
      else if (detailName.startsWith(DEST_DETAIL_PASSWORD))
      {
        if (password != null)
        {
          throw new RuntimeException("Bind password must only be " +
                                     "specified once");
        }
        password = detailValue;
      }
      else if (detailName.startsWith(DEST_DETAIL_LOCATION))
      {
        if (location != null)
        {
          throw new RuntimeException("Location must only be specified once");
        }
        location = detailValue;
      }
      else
      {
        throw new RuntimeException(
            String.format("Unrecognized destination detail '%s'", s));
      }
    }

    if (ldapURL == null)
    {
      throw new RuntimeException(
          String.format("Destination details must specify an LDAP URL"));
    }

    return new TargetServer(ldapURL, bindDN, password, location);
  }



  /**
   * Initializes or re-initializes the notification destinations for this
   * notification manager. This will be called once after the notification
   * manager has been initialized and any time subsequently.
   *
   * @param destinations  The list of defined notification destinations and
   *                      their associated subscriptions.
   *
   * @throws  LDAPException  If a problem occurs while initializing the
   *                         notification destinations for this notification
   *                         manager.
   */
  @Override
  public synchronized void initializeNotificationDestinations(
      final List<NotificationDestinationDetails> destinations)
      throws LDAPException
  {
    final Map<String, TargetServer> newTargetServers =
        new ConcurrentHashMap<String, TargetServer>();
    final Map<ObjectPair<String,String>,List<LDAPURL>> newSubscriptionURLs =
        new ConcurrentHashMap<ObjectPair<String, String>, List<LDAPURL>>();

    for (NotificationDestinationDetails destination : destinations)
    {
      final TargetServer targetServer =
          parseDestinationDetails(destination.getDetails());
      newTargetServers.put(destination.getID(), targetServer);

      for (NotificationSubscriptionDetails subscription :
          destination.getSubscriptions())
      {
        final List<LDAPURL> ldapurls =
            new ArrayList<LDAPURL>(subscription.getDetails().size());
        for (ASN1OctetString s : subscription.getDetails())
        {
          ldapurls.add(new LDAPURL(s.stringValue()));
        }

        final ObjectPair<String, String> subscriptionKey =
            new ObjectPair<String, String>(destination.getID(),
                                           subscription.getID());
        newSubscriptionURLs.put(subscriptionKey, ldapurls);
      }
    }

    targetServers     = newTargetServers;
    subscriptionURLs  = newSubscriptionURLs;
  }



  /**
   * Determine whether the provided destination details are acceptable. If this
   * method returns true then it is expected that a call to {@code
   * setNotificationDestination} with the same details will not fail due to
   * invalid details.
   *
   * @param destinationID       The notification destination ID.
   * @param destinationDetails  The notification destination details.
   * @param unacceptableReasons A list that may be used to hold the reasons that
   *                            the provided details are unacceptable.
   *
   * @return {@code true} if the provided details are acceptable.
   */
  @Override
  public boolean areDestinationDetailsAcceptable(
      final String destinationID,
      final List<ASN1OctetString> destinationDetails,
      final List<String> unacceptableReasons)
  {
    boolean areAcceptable = true;
    try
    {
      final TargetServer targetServer =
          parseDestinationDetails(destinationDetails);

      try
      {
        final LDAPConnectionPool pool = targetServer.getConnectionPool();

        // Make sure we do not target the server in which this extension is
        // running.
        final CompareResult compareResult =
            pool.compare("", "startupUUID",
                         serverContext.getStartupUUID().toString());
        if (compareResult.compareMatched())
        {
          throw new RuntimeException(
              "The destination directory server is the same server as the " +
              "one in which this extension is running");
        }
      }
      catch (LDAPException le)
      {
        // The target server is currently unavailable but we'll allow that.
      }
      finally
      {
        targetServer.finalizeTargetServer();
      }
    }
    catch (Exception e)
    {
      areAcceptable = false;
      unacceptableReasons.add(e.getMessage());
    }

    return areAcceptable;
  }



  /**
   * Create or update a notification destination.
   *
   * @param destinationID       The notification destination ID.
   * @param destinationDetails  The notification destination details.
   *
   * @throws  LDAPException  If a problem occurs while creating or updating the
   *                         notification destination.
   */
  @Override
  public synchronized void setNotificationDestination(
      final String destinationID,
      final List<ASN1OctetString> destinationDetails)
      throws LDAPException
  {
    final TargetServer targetServer =
        parseDestinationDetails(destinationDetails);

    final TargetServer previous =
        targetServers.put(destinationID, targetServer);

    if (previous != null)
    {
      previous.finalizeTargetServer();
    }

    if (serverContext.debugEnabled())
    {
      if (previous != null)
      {
        serverContext.debugVerbose("Updated destination " + destinationID);
      }
      else
      {
        serverContext.debugVerbose("Added destination " + destinationID);
      }
    }
  }



  /**
   * Determine whether the provided destination details are acceptable. If this
   * method returns true then it is expected that a call to
   * setNotificationSubscription with the same details will not fail due to
   * invalid details.
   *
   * @param destinationID       The notification destination ID.
   * @param subscriptionID      The notification subscription ID.
   * @param subscriptionDetails The notification subscription details.
   * @param unacceptableReasons A list that may be used to hold the reasons that
   *                            the provided details are unacceptable.
   *
   * @return {@code true} if the provided details are acceptable.
   */
  @Override
  public boolean areSubscriptionDetailsAcceptable(
      final String destinationID,
      final String subscriptionID,
      final List<ASN1OctetString> subscriptionDetails,
      final List<String> unacceptableReasons)
  {
    boolean areAcceptable = true;
    for (ASN1OctetString s : subscriptionDetails)
    {
      LDAPURL ldapURL;
      try
      {
        ldapURL = new LDAPURL(s.stringValue());
      }
      catch (LDAPException e)
      {
        areAcceptable = false;
        unacceptableReasons.add(
            String.format("Subscription detail '%s' cannot be parsed as " +
                          "an LDAP URL", s.stringValue()));
        continue;
      }

      if (ldapURL.hostProvided() || ldapURL.portProvided() ||
          ldapURL.filterProvided())
      {
        areAcceptable = false;
        unacceptableReasons.add(
            "Only base DN, attributes and scope should be provided in a " +
            "subscription LDAP URL");
      }
    }

    return areAcceptable;
  }



  /**
   * Create or update a notification subscription.
   *
   * @param destinationID        The notification destination ID.
   * @param subscriptionID       The notification subscription ID.
   * @param subscriptionDetails  The notification destination details.
   *
   * @throws  LDAPException  If a problem occurs while creating or updating the
   *                         notification subscription.
   */
  @Override
  public synchronized void setNotificationSubscription(
      final String destinationID,
      final String subscriptionID,
      final List<ASN1OctetString> subscriptionDetails)
      throws LDAPException
  {
    final List<LDAPURL> ldapurls =
        new ArrayList<LDAPURL>(subscriptionDetails.size());
    for (ASN1OctetString s : subscriptionDetails)
    {
      ldapurls.add(new LDAPURL(s.stringValue()));
    }

    final ObjectPair<String, String> subscriptionKey =
        new ObjectPair<String, String>(destinationID, subscriptionID);
    final List<LDAPURL> previous =
        subscriptionURLs.put(subscriptionKey, ldapurls);

    if (serverContext.debugEnabled())
    {
      if (previous != null)
      {
        serverContext.debugVerbose(
            "Updated subscription " + subscriptionID +
            " on destination " + destinationID +
            " with LDAP URLs " + String.valueOf(ldapurls));
      }
      else
      {
        serverContext.debugVerbose(
            "Added subscription " + subscriptionID +
            " to destination " + destinationID +
            " with LDAP URLs " + String.valueOf(ldapurls));
      }
    }
  }



  /**
   * Delete a notification destination including any associated
   * subscriptions.
   *
   * @param destinationID  The notification destination ID.
   */
  @Override
  public synchronized void deleteNotificationDestination(
      final String destinationID)
  {
    // Remove all subscriptions associated with the destination.
    final Iterator<ObjectPair<String,String>> iter =
        subscriptionURLs.keySet().iterator();
    while (iter.hasNext())
    {
      final ObjectPair<String,String> subscriptionKey = iter.next();
      if (subscriptionKey.getFirst().equals(destinationID))
      {
        iter.remove();
      }
    }

    final TargetServer targetServer = targetServers.remove(destinationID);
    if (targetServer != null)
    {
      targetServer.finalizeTargetServer();
    }

    if (serverContext.debugEnabled())
    {
      serverContext.debugVerbose("Deleted destination " + destinationID);
    }
  }



  /**
   * Delete a notification subscription.
   *
   * @param destinationID   The notification destination ID.
   * @param subscriptionID  The notification subscription ID.
   */
  @Override
  public synchronized void deleteNotificationSubscription(
      final String destinationID,
      final String subscriptionID)
  {
    final ObjectPair<String,String> subscriptionKey =
        new ObjectPair<String, String>(destinationID, subscriptionID);
    subscriptionURLs.remove(subscriptionKey);

    if (serverContext.debugEnabled())
    {
      serverContext.debugVerbose(
          "Deleted subscription " + subscriptionID +
          " from destination " + destinationID);
    }
  }



  /**
   * Determine whether the server running this extension is preferred for the
   * given notification destination. This method returns {@code true} if
   * the source server's location is the same as that of the destination, and
   * {@code false} if the locations are different, or either of the source or
   * target server's locations are not defined.
   *
   * @param destinationID  The notification destination ID.
   *
   * @return  {@code true} if this server is preferred for the given
   *          notification destination.
   */
  @Override
  public boolean isPreferredForDestination(final String destinationID)
  {
    final TargetServer targetServer = targetServers.get(destinationID);
    final Location location = serverContext.getLocation();

    return (targetServer != null &&
            targetServer.getLocationName() != null &&
            location != null &&
            targetServer.getLocationName().equals(location.getName()));
  }



  /**
   * Determine if any notifications are required for the provided add
   * request and return notification properties for each notification
   * destination that requires a notification.
   *
   * @param addRequest  The add request that is being processed.
   *
   * @param addedEntry  The entry that was added.
   *
   * @param opContext   The operation context for the current operation.
   *
   * @return   A list of notification properties with an element for each
   *           notification destination that requires a notification. An
   *           empty or {@code null} list indicates that the operation does not
   *           require any notifications.
   */
  @Override
  public List<NotificationProperties> getAddNotificationProperties(
      final AddRequest addRequest,
      final com.unboundid.directory.sdk.common.types.Entry addedEntry,
      final OperationContext opContext)
  {
    // Return immediately if there are no subscriptions to check.
    if (subscriptionURLs.isEmpty())
    {
      return null;
    }

    // Parse the updated entry's DN.
    final DN entryDN;
    try
    {
      entryDN = new DN(addRequest.getEntry().getDN());
    }
    catch (LDAPException e)
    {
      // Should never happen because the DN should always be valid.
      return null;
    }

    return getNotificationProperties(entryDN);
  }



  /**
   * Determine if any notifications are required for the provided delete
   * request and return notification properties for each notification
   * destination that requires a notification.
   *
   * @param deleteRequest  The delete request that is being processed.
   *
   * @param deletedEntry   The entry against which the delete operation was
   *                       processed, if available, and {@code null} otherwise.
   *
   * @param opContext      The operation context for the current operation.
   *
   * @return   A list of notification properties with an element for each
   *           notification destination that requires a notification. An
   *           empty or {@code null} list indicates that the operation does not
   *           require any notifications.
   */
  @Override
  public List<NotificationProperties> getDeleteNotificationProperties(
      final DeleteRequest deleteRequest,
      final com.unboundid.directory.sdk.common.types.Entry deletedEntry,
      final OperationContext opContext)
  {
    // Return immediately if there are no subscriptions to check.
    if (subscriptionURLs.isEmpty())
    {
      return null;
    }

    // Parse the updated entry's DN.
    final DN entryDN;
    try
    {
      entryDN = new DN(deleteRequest.getDN());
    }
    catch (LDAPException e)
    {
      // Should never happen because the DN should always be valid.
      return null;
    }

    return getNotificationProperties(entryDN);
  }



  /**
   * Determine if any notifications are required for the provided modify
   * request and return notification properties for each notification
   * destination that requires a notification.
   *
   * @param modifyRequest  The modify request that is being processed.
   *
   * @param oldEntry       The entry as it appeared before the modify operation,
   *                       or {@code null} if it is not available.
   *
   * @param newEntry       The entry as it appeared after the modify operation,
   *                       or {@code null} if it is not available.
   *
   * @param opContext      The operation context for the current operation.
   *
   * @return   A list of notification properties with an element for each
   *           notification destination that requires a notification. An
   *           empty or {@code null} list indicates that the operation does not
   *           require any notifications.
   */
  @Override
  public List<NotificationProperties> getModifyNotificationProperties(
      final ModifyRequest modifyRequest,
      final com.unboundid.directory.sdk.common.types.Entry oldEntry,
      final com.unboundid.directory.sdk.common.types.Entry newEntry,
      final OperationContext opContext)
  {
    // Return immediately if there are no subscriptions to check.
    if (subscriptionURLs.isEmpty())
    {
      return null;
    }

    // Parse the updated entry's DN.
    final DN entryDN;
    try
    {
      entryDN = new DN(modifyRequest.getDN());
    }
    catch (LDAPException e)
    {
      // Should never happen because the DN should always be valid.
      return null;
    }

    return getNotificationProperties(entryDN);
  }



  /**
   * Determine if any notifications are required for the provided modify DN
   * request and return notification properties for each notification
   * destination that requires a notification.
   *
   * @param modifyDNRequest  The modify DN request that is being processed.
   *
   * @param oldEntry         The entry as it appeared before the modify DN
   *                         operation, or {@code null} if it is not available.
   *
   * @param newEntry         The entry as it appeared after the modify DN
   *                         operation,  or {@code null} if it is not available.
   *
   * @param opContext        The operation context for the current operation.
   *
   * @return   A list of notification properties with an element for each
   *           notification destination that requires a notification. An
   *           empty or {@code null} list indicates that the operation does not
   *           require any notifications.
   */
  @Override
  public List<NotificationProperties>
  getModifyDNNotificationProperties(
      final ModifyDNRequest modifyDNRequest,
      final com.unboundid.directory.sdk.common.types.Entry oldEntry,
      final com.unboundid.directory.sdk.common.types.Entry newEntry,
      final OperationContext opContext)
  {
    // Return immediately if there are no subscriptions to check.
    if (subscriptionURLs.isEmpty())
    {
      return null;
    }

    // Parse the updated entry's DN (before the update).
    final DN entryDN;
    try
    {
      entryDN = new DN(modifyDNRequest.getDN());
    }
    catch (LDAPException e)
    {
      // Should never happen because the DN should always be valid.
      return null;
    }

    return getNotificationProperties(entryDN);
  }



  /**
   * Attempt delivery of a notification. In this example, we attempt to apply
   * the changes in the notification to another LDAP server.
   *
   * @param notification  The notification to be delivered.
   *
   * @return  A delivery result indicating whether delivery was successful,
   *          and whether delivery should be retried if this attempt was
   *          unsuccessful.
   */
  @Override
  public NotificationDeliveryResult attemptDelivery(
      final Notification notification)
  {
    final TargetServer targetServer =
        targetServers.get(notification.getDestinationID());
    if (targetServer == null)
    {
      serverContext.logMessage(LogSeverity.SEVERE_ERROR,
           "Delivery attempt failed, no destination with ID " +
           notification.getDestinationID());
      return NotificationDeliveryResult.FAILURE;
    }

    try
    {
      final LDAPConnectionPool pool = targetServer.getConnectionPool();

      for (NotificationChange c : notification.getNotificationChanges())
      {
        final UnboundIDChangeLogEntry changeLogEntry = c.getChangeLogEntry();

        if (changeLogEntry.getChangeType().equals(ChangeType.DELETE))
        {
          pool.delete(changeLogEntry.getTargetDN());
        }
        else if (changeLogEntry.getChangeType().equals(ChangeType.ADD))
        {
          final Entry partialEntryAfterChange =
              changeLogEntry.constructPartialEntryAfterChange(false).
                  duplicate();

          // Remove attributes that are NO-USER-MODIFICATION.
          final List<Attribute> toRemove = new ArrayList<Attribute>();
          for (Attribute a : partialEntryAfterChange.getAttributes())
          {
            final AttributeType attributeType =
                serverContext.getSchema().getAttributeType(a.getName(), false);
            if (attributeType != null && attributeType.isNoUserModification())
            {
              toRemove.add(a);
            }
          }
          for (final Attribute a : toRemove)
          {
            partialEntryAfterChange.removeAttribute(a.getName());
          }

          pool.add(partialEntryAfterChange);
        }
        else if (changeLogEntry.getChangeType().equals(ChangeType.MODIFY_DN))
        {
          pool.modifyDN(changeLogEntry.getTargetDN(),
                        changeLogEntry.getNewRDN(),
                        changeLogEntry.deleteOldRDN(),
                        changeLogEntry.getNewSuperior());
        }
        else if (changeLogEntry.getChangeType().equals(ChangeType.MODIFY))
        {
          // Remove modifications to attributes that are NO-USER-MODIFICATION.
          final List<Modification> modifications =
              new ArrayList<Modification>(changeLogEntry.getModifications());
          final Iterator<Modification> iterator = modifications.iterator();
          while (iterator.hasNext())
          {
            final Modification m = iterator.next();
            final AttributeType attributeType =
                serverContext.getSchema().getAttributeType(
                    m.getAttributeName(), false);
            if (attributeType != null && attributeType.isNoUserModification())
            {
              iterator.remove();
            }
          }

          if (modifications.isEmpty())
          {
            serverContext.logMessage(LogSeverity.SEVERE_WARNING,
                 "Skipping MODIFY for notification changelog entry " +
                 "containing only modifications to NO-USER-MODIFICATION " +
                 "attributes:  " + changeLogEntry);
          }
          else
          {
            pool.modify(changeLogEntry.getTargetDN(), modifications);
          }
        }
      }
    }
    catch (LDAPException e)
    {
      serverContext.logMessage(LogSeverity.SEVERE_ERROR,
           "Delivery attempt failed:  " + StaticUtils.getExceptionMessage(e));

      if (UNLIMITED_RETRY_RESULT_CODES.contains(e.getResultCode()))
      {
        return NotificationDeliveryResult.RETRY;
      }
      else if (LIMITED_RETRY_RESULT_CODES.contains(e.getResultCode()))
      {
        if (notification.getNumPreviousDeliveryAttempts() + 1 <
            MAX_LIMITED_RETRY_ATTEMPTS)
        {
          return NotificationDeliveryResult.RETRY;
        }
        else
        {
          return NotificationDeliveryResult.FAILURE;
        }
      }
      else
      {
        return NotificationDeliveryResult.FAILURE;
      }
    }

    return NotificationDeliveryResult.SUCCESS;
  }



  /**
   * Determine if any notifications are required for the provided entry DN
   * and return notification properties for each notification destination
   * that requires a notification.
   *
   * @param entryDN  The entry DN of the entry that is the target of an update.
   *
   * @return   A list of notification properties with an element for each
   *           notification destination that requires a notification. An
   *           empty or {@code null} list indicates that the operation does not
   *           require any notifications.
   */
  private List<NotificationProperties> getNotificationProperties(
      final DN entryDN)
  {
    if (serverContext.debugEnabled())
    {
      serverContext.debugVerbose(
          "Checking " + subscriptionURLs.size() +
          " subscription(s) in " + targetServers.size() +
          " destination(s) for a match on entry " + entryDN.toString());
    }

    // Check all the subscriptions for a match.
    final List<NotificationProperties> propertiesList =
        new ArrayList<NotificationProperties>();
    for (Map.Entry<ObjectPair<String,String>,List<LDAPURL>> mapEntry :
        subscriptionURLs.entrySet())
    {
      final ObjectPair<String,String> subscriptionKey = mapEntry.getKey();
      final List<LDAPURL> ldapurls = mapEntry.getValue();
      for (LDAPURL ldapurl : ldapurls)
      {
        // Check if the modified entry is within the base DN and scope of
        // the LDAP URL.
        boolean inScope = false;
        try
        {
          inScope = entryDN.matchesBaseAndScope(ldapurl.getBaseDN(),
                                                ldapurl.getScope());
        }
        catch (LDAPException e)
        {
          // This can only happen if the scope is not supported.
        }

        if (!inScope)
        {
          if (serverContext.debugEnabled())
          {
            serverContext.debugVerbose(
                "Entry '" + entryDN.toString() +
                "' did not match subscription LDAPURL " + ldapurl.toString());
          }
          continue;
        }

        if (serverContext.debugEnabled())
        {
          serverContext.debugVerbose(
              "Entry '" + entryDN.toString() +
              "' matched subscription LDAPURL " + ldapurl.toString());
        }

        final HashMap<String, String> propertiesMap =
            new HashMap<String, String>();
        propertiesMap.put("subscription", subscriptionKey.getSecond());

        final HashSet<AttributeType> keyAttributes =
            new HashSet<AttributeType>(ldapurl.getAttributes().length);
        for (String keyAttr : ldapurl.getAttributes())
        {
          final AttributeType attributeType =
              serverContext.getSchema().getAttributeType(keyAttr, false);
          if (attributeType != null)
          {
            keyAttributes.add(attributeType);
          }
        }
        propertiesList.add(new NotificationProperties(
            subscriptionKey.getFirst(), propertiesMap, keyAttributes));
      }
    }

    return propertiesList;
  }
}