UnboundID Server SDK

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
 *
 *
 *      Copyright 2013 UnboundID Corp.
 */
package com.unboundid.directory.sdk.examples;

import com.unboundid.directory.sdk.broker.api.StoreAdapter;
import com.unboundid.directory.sdk.broker.config.StoreAdapterConfig;
import com.unboundid.directory.sdk.broker.types.IdentityBrokerContext;
import com.unboundid.directory.sdk.broker.types.StoreCreateRequest;
import com.unboundid.directory.sdk.broker.types.StoreDeleteRequest;
import com.unboundid.directory.sdk.broker.types.StoreSearchRequest;
import com.unboundid.directory.sdk.broker.types.StoreUpdateRequest;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.scim.data.BaseResource;
import com.unboundid.scim.data.Meta;
import com.unboundid.scim.marshal.json.JsonMarshaller;
import com.unboundid.scim.marshal.json.JsonUnmarshaller;
import com.unboundid.scim.schema.CoreSchema;
import com.unboundid.scim.schema.ResourceDescriptor;
import com.unboundid.scim.sdk.AttributePath;
import com.unboundid.scim.sdk.Diff;
import com.unboundid.scim.sdk.PageParameters;
import com.unboundid.scim.sdk.ResourceNotFoundException;
import com.unboundid.scim.sdk.Resources;
import com.unboundid.scim.sdk.SCIMConstants;
import com.unboundid.scim.sdk.SCIMException;
import com.unboundid.scim.sdk.SCIMFilter;
import com.unboundid.scim.sdk.SCIMFilterType;
import com.unboundid.scim.sdk.SCIMObject;
import com.unboundid.scim.sdk.SCIMQueryAttributes;
import com.unboundid.scim.sdk.ServerErrorException;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.Validator;
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.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedList;
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 users 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 Identity Broker instances.
 * <p>
 * This implementation supports all required SCIM features, but does not
 * support certain optional features such as sorting or resource versioning.
 */
public class ExampleStoreAdapter extends StoreAdapter
{
  // Handle to the ServerContext object.
  private IdentityBrokerContext serverContext;

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

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

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

  /**
   * {@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 users 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 Identity Broker instances."
    };
  }

  /**
   * {@inheritDoc}
   */
  @Override
  public void defineConfigArguments(final ArgumentParser parser)
          throws ArgumentException
  {
    String description = "The path to the JSON file containing the users.";
    List<File> defaultValues = new ArrayList<File>(1);
    defaultValues.add(new File("resource/user-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 com.unboundid.directory.sdk.broker.types.IdentityBrokerContext} 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 IdentityBrokerContext serverContext,
                                     final StoreAdapterConfig config,
                                     final ArgumentParser parser)
      throws LDAPException
  {
    this.serverContext = serverContext;

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

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

    Resources<BaseResource> resourceList =
            new Resources<BaseResource>(Collections.<BaseResource>emptyList());

    //Preload the resources from the file if it already exists
    if (jsonFile.length() > 0)
    {
      JsonUnmarshaller unmarshaller = new JsonUnmarshaller();
      BufferedInputStream inputStream = null;
      try
      {
        inputStream = new BufferedInputStream(new FileInputStream(jsonFile));
        resourceList = unmarshaller.unmarshalResources(inputStream,
                getNativeSchema(), BaseResource.BASE_RESOURCE_FACTORY);
      }
      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);
      }
    }

    // Populate the resources map
    for (BaseResource r : resourceList)
    {
      resources.put(r.getId(), r);
    }
  }

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

  /**
   * Gets a ResourceDescriptor that describes the schema for the objects that
   * will be returned by this StoreAdapter. The StoreAdapter will always be
   * initialized before this method is called.
   *
   * @return a SCIM ResourceDescriptor object. This may not be {@code null}.
   */
  @Override
  public ResourceDescriptor getNativeSchema()
  {
    return CoreSchema.USER_DESCRIPTOR;
  }

  /**
   * Fetches the entries that match the specified criteria.
   *
   * @param request the search request
   * @return a collection of SCIM resources that are in the native schema. This
   *         may be empty, but it may not be {@code null}.
   * @throws SCIMException if there is a problem fulfilling the search request
   */
  @Override
  public Resources<? extends BaseResource> search(
          final StoreSearchRequest request) throws SCIMException
  {
    final SCIMFilter filter = request.getSCIMFilter();

    final AttributePath idAttrPath = new AttributePath(
            SCIMConstants.SCHEMA_URI_CORE, "id", null);

    if(filter != null && filter.getFilterType().equals(SCIMFilterType.EQUALITY)
        && filter.getFilterAttribute().toString().equals(idAttrPath.toString()))
    {
      //This is a search by SCIM ID
      BaseResource r = resources.get(filter.getFilterValue());
      if (r != null)
      {
        removeSensitiveAttributes(r.getScimObject());
        return new Resources<BaseResource>(Collections.singletonList(r));
      }
      else
      {
        return new Resources<BaseResource>(
                Collections.<BaseResource>emptyList());
      }
    }
    else
    {
      PageParameters pageParams = request.getPageParameters();
      List<BaseResource> resultList = new LinkedList<BaseResource>();

      int idx = 0;
      for (BaseResource r : resources.values())
      {
        if (filter == null || r.getScimObject().matchesFilter(filter))
        {
          idx++;
          if (pageParams != null && pageParams.getStartIndex() > idx)
          {
            continue;
          }

          if (pageParams != null && idx > pageParams.getCount())
          {
            break;
          }

          resultList.add(createParedResource(r.getScimObject(),
            request.getSCIMQueryAttributes()));
        }
      }
      return new Resources<BaseResource>(resultList);
    }
  }

  /**
   * Create the specified entry in the JSON file.
   *
   * @param request the create request
   * @return the resource that was just created, scoped according to the
   *         SCIMQueryAttributes contained in the request
   * @throws SCIMException if there is a problem creating the resource
   */
  @Override
  public BaseResource create(final StoreCreateRequest request)
          throws SCIMException
  {
    //All write operations are synchronized to prevent concurrent modifications
    //to the JSON file.
    synchronized(this)
    {
      FileOutputStream outputStream = null;
      try
      {
        //Populate the SCIM 'id' and 'meta' attributes
        BaseResource resourceToCreate = request.getResource();
        resourceToCreate.setId(UUID.randomUUID().toString());
        URI location = URI.create(jsonFile.toURI() + "/" +
                                  resourceToCreate.getId());
        Meta meta = new Meta(new Date(), null, location, null);
        resourceToCreate.setMeta(meta);

        List<BaseResource> resourceList =
                new LinkedList<BaseResource>(resources.values());
        resourceList.add(resourceToCreate);
        resources.put(resourceToCreate.getId(), resourceToCreate);

        //Write the new set of resources to the JSON file
        jsonFile.delete();
        outputStream = new FileOutputStream(jsonFile);
        writeResources(outputStream, resourceList);

        return createParedResource(resourceToCreate.getScimObject(),
          request.getSCIMQueryAttributes());
      }
      catch (Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
      finally
      {
        safeCloseStream(outputStream);
      }
    }
  }

  /**
   * Update the specified entry in the JSON file.
   *
   * @param request the update request
   * @return the updated resource, scoped according to the SCIMQueryAttributes
   *         contained in the request
   * @throws SCIMException if there is a problem modifying the resource
   */
  @Override
  public BaseResource update(final StoreUpdateRequest request)
          throws SCIMException
  {
    checkIfIdExists(request.getId());

    //All write operations are synchronized to prevent concurrent modifications
    //to the JSON file.
    synchronized(this)
    {
      FileOutputStream outputStream = null;
      try
      {
        BaseResource updatedResource = null;
        List<BaseResource> resourceList = new LinkedList<BaseResource>();
        for (BaseResource r : resources.values())
        {
          if (r.getId().equals(request.getId()))
          {
            Diff<BaseResource> diff = Diff.fromPartialResource(
                    request.getPartialResource(), false);

            //Apply the PATCH modifications to the resource
            updatedResource = diff.apply(r, BaseResource.BASE_RESOURCE_FACTORY);

            //Update the 'meta.lastModified' attribute
            Meta meta = r.getMeta();
            meta.setLastModified(new Date());
            r.setMeta(meta);
          }

          //Finally, add the resource to the total list of resources
          resourceList.add(r);
        }

        Validator.ensureNotNull(updatedResource);
        resources.put(request.getId(), updatedResource);

        //Write the updated set of resources to the JSON file
        jsonFile.delete();
        outputStream = new FileOutputStream(jsonFile);
        writeResources(outputStream, resourceList);

        return createParedResource(updatedResource.getScimObject(),
          request.getSCIMQueryAttributes());
      }
      catch(Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
      finally
      {
        safeCloseStream(outputStream);
      }
    }
  }

  /**
   * Delete the specified entry from the JSON file.
   *
   * @param request the delete request
   * @throws SCIMException if there is a problem deleting the resource
   */
  @Override
  public void delete(final StoreDeleteRequest request) throws SCIMException
  {
    checkIfIdExists(request.getId());

    //All write operations are synchronized to prevent concurrent modifications
    //to the JSON file.
    synchronized(this)
    {
      FileOutputStream outputStream = null;
      try
      {
        List<BaseResource> resourceList = new LinkedList<BaseResource>();
        resources.remove(request.getId());
        resourceList.addAll(resources.values());

        //Write the updated set of resources to the JSON file
        jsonFile.delete();
        outputStream = new FileOutputStream(jsonFile);
        writeResources(outputStream, resourceList);
      }
      catch(Exception e)
      {
        serverContext.debugCaught(e);
        throw new ServerErrorException(StaticUtils.getExceptionMessage(e));
      }
      finally
      {
        safeCloseStream(outputStream);
      }
    }
  }

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

  /**
   * Checks to see if a resource exists.
   *
   * @param id to check
   * @throws ResourceNotFoundException if a resource was not found
   */
  private void checkIfIdExists(final String id) throws ResourceNotFoundException
  {
    if (!resources.containsKey(id))
    {
      throw new ResourceNotFoundException(String.format(
        "No resource found with ID '%s'", id));
    }
  }

  /**
   * Writes the current resources to an output stream.
   *
   * @param outputStream where the resources are written
   * @param resourceList resources to write
   * @throws SCIMException if a problem occurs
   */
  private void writeResources(final OutputStream outputStream,
                              final List<BaseResource> resourceList)
    throws SCIMException
  {
    //Write a set of resources to an output stream
    Resources<BaseResource> outputList =
      new Resources<BaseResource>(resourceList);
    JsonMarshaller marshaller = new JsonMarshaller();
    marshaller.marshal(outputList, outputStream);
  }

  /**
   * Creates a new resource from a scim object with specific attributes.
   *
   * @param scimObject to create a resource from
   * @param attributes to include in the resource
   * @return BaseResource created from the scim object
   */
  private BaseResource createParedResource(final SCIMObject scimObject,
                                           final SCIMQueryAttributes attributes)
  {
    //Pare the returned resource down to the attributes that were requested
    SCIMObject paredObject = attributes.pareObject(scimObject);
    removeSensitiveAttributes(paredObject);
    return new BaseResource(getNativeSchema(), paredObject);
  }

  /**
   * Removes any sensitive attributes from a scim object.
   *
   * @param scimObject to process
   */
  private void removeSensitiveAttributes(final SCIMObject scimObject)
  {
    scimObject.removeAttribute(SCIMConstants.SCHEMA_URI_CORE, "password");
  }
}