UnboundID Server SDK

Ping Identity
UnboundID Server SDK Documentation

ExampleStoreAdapter.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
 *
 *
 *      Portions Copyright 2013-2024 Ping Identity Corporation
 */
package com.unboundid.directory.sdk.examples;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.unboundid.directory.sdk.broker.api.StoreAdapter;
import com.unboundid.directory.sdk.broker.config.StoreAdapterConfig;
import com.unboundid.directory.sdk.broker.types.BrokerContext;
import com.unboundid.directory.sdk.broker.types.StoreCreateRequest;
import com.unboundid.directory.sdk.broker.types.StoreDeleteRequest;
import com.unboundid.directory.sdk.broker.types.StoreSearchResultListener;
import com.unboundid.directory.sdk.broker.types.StoreRetrieveRequest;
import com.unboundid.directory.sdk.broker.types.StoreSearchRequest;
import com.unboundid.directory.sdk.broker.types.StoreUpdateRequest;
import com.unboundid.directory.sdk.common.types.LogSeverity;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.scim2.common.GenericScimResource;
import com.unboundid.scim2.common.exceptions.ResourceNotFoundException;
import com.unboundid.scim2.common.exceptions.ScimException;
import com.unboundid.scim2.common.exceptions.ServerErrorException;
import com.unboundid.scim2.common.filters.Filter;
import com.unboundid.scim2.common.messages.PatchRequest;
import com.unboundid.scim2.common.utils.FilterEvaluator;
import com.unboundid.scim2.common.utils.JsonUtils;
import com.unboundid.scim2.common.utils.ScimJsonFactory;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.args.ArgumentException;
import com.unboundid.util.args.ArgumentParser;
import com.unboundid.util.args.FileArgument;

import java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;


/**
 * This example provides an implementation of a flat-file StoreAdapter
 * which stores entries in JSON format. Note that this is a simplistic
 * implementation for the sake of example; production StoreAdapter
 * implementations must store their data in persistent storage that is
 * accessible to all PingAuthorize Server instances.
 */
public class ExampleStoreAdapter extends StoreAdapter
{
  // Handle to the ServerContext object.
  private BrokerContext serverContext;

  // The name of the argument that will be used to specify the path to the JSON
  // file containing the entries.
  private static final String ARG_NAME_JSON_FILE_PATH = "json-file-path";

  // Handle to the JSON file containing the entries.
  private File jsonFile;

  // An in-memory map of SCIM resources based on their ID attribute.
  private final ConcurrentHashMap<String, GenericScimResource> resources =
          new ConcurrentHashMap<String, GenericScimResource>();



  /**
   * {@inheritDoc}
   */
  @Override
  public String getExtensionName()
  {
    return "Example Store Adapter";
  }



  /**
   * {@inheritDoc}
   */
  @Override
  public String[] getExtensionDescription()
  {
    return new String[]
    {
      "This example provides an implementation of a flat-file StoreAdapter " +
      "which stores entries in JSON format. Note that this is a simplistic " +
      "implementation for the sake of example; production StoreAdapter " +
      "implementations must store their data in persistent storage that is " +
      "accessible to all PingAuthorize Server instances."
    };
  }



  /**
   * {@inheritDoc}
   */
  @Override
  public void defineConfigArguments(final ArgumentParser parser)
          throws ArgumentException
  {
    final String description =
        "The path to the JSON file containing the entries.";
    final List<File> defaultValues = new ArrayList<File>(1);
    defaultValues.add(new File("resource/entry-database.json"));

    final FileArgument jsonFileArg = new FileArgument(null,
            ARG_NAME_JSON_FILE_PATH, true, 1, "{file-path}", description,
            false, true, true, false, defaultValues);
    parser.addArgument(jsonFileArg);
  }



  /**
   * Initializes this store adapter. Any initialization should be performed
   * here. This method should generally store the
   * {@link BrokerContext} in
   * a class member so that it can be used elsewhere in the implementation.
   *
   * @param  serverContext  A handle to the server context for the server in
   *                        which this extension is running. Extensions should
   *                        typically store this in a class member.
   * @param  config         The general configuration for this object.
   * @param  parser         The argument parser which has been initialized from
   *                        the configuration for this store adapter.
   * @throws LDAPException  If a problem occurs while initializing this store
   *                        adapter.
   */
  @Override
  public void initializeStoreAdapter(final BrokerContext serverContext,
                                     final StoreAdapterConfig config,
                                     final ArgumentParser parser)
      throws LDAPException
  {
    this.serverContext = serverContext;

    final FileArgument jsonFileArg =
            (FileArgument) parser.getNamedArgument(ARG_NAME_JSON_FILE_PATH);
    jsonFile = jsonFileArg.getValue();

    if (!jsonFile.isAbsolute())
    {
      jsonFile = new File(serverContext.getServerRoot(), jsonFile.getPath());
    }

    // Preload the entries from the file if it already exists.
    if (jsonFile.length() > 0)
    {
      BufferedInputStream inputStream = null;
      try
      {
        inputStream = new BufferedInputStream(new FileInputStream(jsonFile));
        final MappingIterator<GenericScimResource> nodes =
            JsonUtils.getObjectReader().forType(GenericScimResource.class).
                readValues(inputStream);

        // Populate the resources map.
        for (GenericScimResource r : nodes.readAll())
        {
          resources.put(r.getId(), r);
        }
      }
      catch(Exception e)
      {
        serverContext.debugCaught(e);
        throw new LDAPException(ResultCode.LOCAL_ERROR, e);
      }
      finally
      {
        safeCloseStream(inputStream);
      }
    }
    else  // Otherwise, create the file.
    {
      try
      {
        jsonFile.createNewFile();
      }
      catch (IOException ioe)
      {
        serverContext.debugCaught(ioe);
        throw new LDAPException(ResultCode.LOCAL_ERROR, ioe);
      }
    }
  }



  /**
   * Determines whether this Store Adapter is currently available and
   * in-service. This method may return {@code false} in the case that the
   * JSON file is not accessible; during this time the SCIMResourceType will
   * return an appropriate 503 response code to clients.
   *
   * @return {@code true} if this Store Adapter is initialized and connected;
   *         {@code false} otherwise.
   */
  @Override
  public boolean isAvailable()
  {
    return jsonFile != null && jsonFile.canWrite();
  }



  /**
   * Fetches the entries that match the specified criteria.
   *
   * @param request  The search request.
   * @throws ScimException if there is a problem fulfilling the search request.
   */
  @Override
  public void search(final StoreSearchRequest request)
      throws ScimException
  {
    final Filter filter = Filter.fromString(request.getSCIMFilter());
    final StoreSearchResultListener listener =
        request.getStoreSearchResultListener();

    int resultCount = 0;
    for (GenericScimResource r : resources.values())
    {
      if (filter == null ||
          FilterEvaluator.evaluate(filter, r.getObjectNode()))
      {
        listener.searchResultReturned(r.toString());
        resultCount++;
        if (request.getSizeLimit() > 0 && resultCount >= request.getSizeLimit())
        {
          break;
        }
      }
    }

    if (serverContext.isTraceMessageLoggable(LogSeverity.INFO))
    {
      serverContext.logTraceMessage(
          LogSeverity.INFO,
          String.format("Search returned %d results", resultCount));
    }
  }



  /**
   * Fetches the specified entry.
   *
   * @param request  The retrieve request.
   * @return  The retrieved entry.
   * @throws ScimException if there is a problem fulfilling the request.
   */
  @Override
  public String retrieve(final StoreRetrieveRequest request)
      throws ScimException
  {
    final Filter filter = Filter.fromString(request.getSCIMFilter());

    final GenericScimResource r = retrieve(filter);

    if (serverContext.isTraceMessageLoggable(LogSeverity.INFO))
    {
      serverContext.logTraceMessage(
          LogSeverity.INFO,
          String.format("Retrieved resource ID '%s'", r.getId()));
    }

    return r.toString();
  }



  /**
   * Create the specified entry in the JSON file.
   *
   * @param request  The create request.
   * @return The entry that was just created;
   * @throws ScimException  if there is a problem creating the entry.
   */
  @Override
  public String create(final StoreCreateRequest request)
          throws ScimException
  {
    // All write operations are synchronized to prevent concurrent modifications
    // to the JSON file.
    synchronized(this)
    {
      try
      {
        final GenericScimResource resourceToCreate =
            new GenericScimResource(toObjectNode(request.getObjectToCreate()));

        // Populate the ID attribute.
        resourceToCreate.setId(UUID.randomUUID().toString());

        resources.put(resourceToCreate.getId(), resourceToCreate);

        // Write the new set of resources to the JSON file.
        writeResources();

        if (serverContext.isTraceMessageLoggable(LogSeverity.INFO))
        {
          serverContext.logTraceMessage(
              LogSeverity.INFO,
              String.format("Created resource ID '%s'",
                            resourceToCreate.getId()));
        }

        return resourceToCreate.toString();
      }
      catch (Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
    }
  }



  /**
   * Update the specified entry in the JSON file.
   *
   * @param request The update request.
   * @return The updated entry.
   * @throws ScimException if there is a problem modifying the entry.
   */
  @Override
  public String update(final StoreUpdateRequest request)
      throws ScimException
  {
    final Filter filter = Filter.fromString(request.getSCIMFilter());
    final GenericScimResource resourceToUpdate = retrieve(filter);

    // All write operations are synchronized to prevent concurrent modifications
    // to the JSON file.
    synchronized(this)
    {
      try
      {
        final ObjectReader reader =
            JsonUtils.getObjectReader().forType(PatchRequest.class);
        final PatchRequest patchOp =
            reader.readValue(request.getPatchRequest());
        patchOp.apply(resourceToUpdate);

        // Write the updated set of resources to the JSON file.
        writeResources();

        if (serverContext.isTraceMessageLoggable(LogSeverity.INFO))
        {
          serverContext.logTraceMessage(
              LogSeverity.INFO,
              String.format("Updated resource ID '%s'",
                            resourceToUpdate.getId()));
        }

        return resourceToUpdate.toString();
      }
      catch(Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
    }
  }



  /**
   * Delete the specified entry from the JSON file.
   *
   * @param request  The delete request.
   * @throws ScimException  if there is a problem deleting the entry.
   */
  @Override
  public void delete(final StoreDeleteRequest request) throws ScimException
  {
    final Filter filter = Filter.fromString(request.getSCIMFilter());
    final GenericScimResource resourceToDelete =
        retrieve(filter);

    // All write operations are synchronized to prevent concurrent modifications
    // to the JSON file.
    synchronized(this)
    {
      try
      {
        resources.remove(resourceToDelete.getId());

        // Write the updated set of resources to the JSON file.
        writeResources();

        if (serverContext.isTraceMessageLoggable(LogSeverity.INFO))
        {
          serverContext.logTraceMessage(
              LogSeverity.INFO,
              String.format("Deleted resource ID '%s'",
                            resourceToDelete.getId()));
        }

      }
      catch(Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
    }
  }



  /**
   * Closes a stream quietly.
   *
   * @param stream to close
   */
  private void safeCloseStream(final Closeable stream)
  {
    if (stream != null)
    {
      try
      {
        stream.close();
      }
      catch (final IOException ioe)
      {
        // Ignore.
      }
    }
  }



  /**
   * Retrieves a resource identified by a SCIM filter.
   *
   * @param filter  The SCIM filter identifying a resource to retrieve.
   * @return  The requested resource.
   * @throws ScimException  If the resource was not found.
   */
  private GenericScimResource retrieve(final Filter filter)
      throws ScimException
  {
    for (GenericScimResource r : resources.values())
    {
      if (FilterEvaluator.evaluate(filter, r.getObjectNode()))
      {
        return r;
      }
    }

    throw new ResourceNotFoundException(String.format(
      "No resource found matching filter '%s'", filter.toString()));
  }



  /**
   * Writes the current resources to the JSON file.
   * @throws IOException  if the resources could not be written.
   */
  private void writeResources()
    throws IOException
  {
    final ObjectMapper mapper = new ObjectMapper(new ScimJsonFactory());
    mapper.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);

    final File file = this.jsonFile;
    final File tempFile = new File(file.getAbsolutePath() + ".new");
    final File oldFile  = new File(file.getAbsolutePath() + ".old");

    final OutputStream outputStream = new FileOutputStream(tempFile);
    try
    {
      final ObjectWriter writer = mapper.writer();
      for (GenericScimResource r : resources.values())
      {
        writer.writeValue(outputStream, r);
      }
    }
    finally
    {
      safeCloseStream(outputStream);
    }

    if (oldFile.exists())
    {
      oldFile.delete();
    }

    if (file.exists())
    {
      file.renameTo(oldFile);
    }

    tempFile.renameTo(file);
  }



  /**
   * Parse a JSON object in string form into an ObjectNode.
   *
   * @param jsonString  The string containing a JSON object.
   * @return  The parsed ObjectNode.
   * @throws IOException  if the object could not be parsed.
   */
  private ObjectNode toObjectNode(final String jsonString)
      throws IOException
  {
    return (ObjectNode)JsonUtils.getObjectReader().readTree(jsonString);
  }
}