io.klerch.alexa.state.handler.AWSS3StateHandler.java Source code

Java tutorial

Introduction

Here is the source code for io.klerch.alexa.state.handler.AWSS3StateHandler.java

Source

/**
 * Made by Kay Lerch (https://twitter.com/KayLerch)
 * <p>
 * Attached license applies.
 * This library is licensed under GNU GENERAL PUBLIC LICENSE Version 3 as of 29 June 2007
 */
package io.klerch.alexa.state.handler;

import com.amazon.speech.speechlet.Session;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import com.amazonaws.services.s3.model.DeleteObjectsRequest;
import com.amazonaws.services.s3.model.S3Object;
import io.klerch.alexa.state.model.AlexaStateObject;
import io.klerch.alexa.state.utils.AlexaStateException;
import io.klerch.alexa.state.model.AlexaScope;
import io.klerch.alexa.state.model.AlexaStateModel;
import org.apache.commons.lang3.Validate;
import org.apache.log4j.Logger;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
import java.util.stream.Collectors;

/**
 * As this handler works in the user and application scope it persists all models to an S3 bucket.
 * This handler reads and writes state for AlexaStateModels and considers all its fields annotated with AlexaSaveState-tags.
 * This handler derives from the AlexaSessionStateHandler thus it reads and writes state out of S3 files also to your Alexa
 * session. For each individual scope (which is described by the Alexa User Id there will be a directory in your bucket which
 * then contains files - one for each instance of a saved model. Be aware that S3 does not support
 * bulk uploads thus writeModels and writeValues upload files one by one without batch processing.
 */
public class AWSS3StateHandler extends AlexaSessionStateHandler {
    private final Logger log = Logger.getLogger(AWSS3StateHandler.class);

    private final AmazonS3 awsClient;
    private final String bucketName;
    private static final String folderNameApp = "__application";
    private static final String fileExtension = "json";

    /**
     * Takes the Alexa session. An AWS client for accessing the S3 bucket will make use
     * of all the defaults in your runtime environment in regards to AWS region and credentials. The
     * credentials of this client need permission for getting and putting objects to this bucket.
     *
     * @param session    The Alexa session of your current skill invocation.
     * @param bucketName The bucket where all saved states will go into.
     */
    public AWSS3StateHandler(final Session session, final String bucketName) {
        this(session, new AmazonS3Client(), bucketName);
    }

    /**
     * Takes the Alexa session and an AWS client set up for the AWS region the given bucket is in. The
     * credentials of this client need permission for getting and putting objects to this bucket.
     *
     * @param session    The Alexa session of your current skill invocation.
     * @param awsClient  An AWS client capable of getting and putting objects to the given bucket.
     * @param bucketName The bucket where all saved states will go into.
     */
    public AWSS3StateHandler(final Session session, final AmazonS3 awsClient, final String bucketName) {
        super(session);
        this.awsClient = awsClient;
        this.bucketName = bucketName;
    }

    /**
     * Returns the AWS connection client used to write to and read from files in S3 bucket.
     *
     * @return AWS connection client to S3
     */
    public AmazonS3 getAwsClient() {
        return this.awsClient;
    }

    /**
     * Returns the name of the S3 bucket which is used by this handler to store JSON files with
     * model states.
     *
     * @return Name of the S3 bucket
     */
    public String getBucketName() {
        return this.bucketName;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void writeModels(final Collection<AlexaStateModel> models) throws AlexaStateException {
        // write to session
        super.writeModels(models);

        for (final AlexaStateModel model : models) {
            if (model.hasUserScopedField()) {
                final String filePath = getUserScopedFilePath(model.getClass(), model.getId());
                // add json as new content of file
                final String fileContents = model.toJSON(AlexaScope.USER);
                // write all user-scoped attributes to file
                awsClient.putObject(bucketName, filePath, fileContents);
            }
            if (model.hasApplicationScopedField()) {
                // add primary keys as attributes
                final String filePath = getAppScopedFilePath(model.getClass(), model.getId());
                // add json as new content of file
                final String fileContents = model.toJSON(AlexaScope.APPLICATION);
                // write all app-scoped attributes to file
                awsClient.putObject(bucketName, filePath, fileContents);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void writeValues(final Collection<AlexaStateObject> stateObjects) throws AlexaStateException {
        // write to session
        super.writeValues(stateObjects);
        stateObjects.stream()
                // select only USER or APPLICATION scoped state objects
                .filter(stateObject -> stateObject.getScope().isIn(AlexaScope.USER, AlexaScope.APPLICATION))
                .forEach(stateObject -> {
                    final String id = stateObject.getId();
                    final String value = String.valueOf(stateObject.getValue());
                    final AlexaScope scope = stateObject.getScope();
                    final String filePath = AlexaScope.USER.includes(scope) ? getUserScopedFilePath(id)
                            : getAppScopedFilePath(id);
                    // write all app-scoped attributes to file
                    awsClient.putObject(bucketName, filePath, value);
                });
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void removeValues(final Collection<String> ids) throws AlexaStateException {
        super.removeValues(ids);
        final List<DeleteObjectsRequest.KeyVersion> keys = new ArrayList<>();
        ids.forEach(id -> keys.addAll(Arrays.asList(new DeleteObjectsRequest.KeyVersion(getUserScopedFilePath(id)),
                new DeleteObjectsRequest.KeyVersion(getAppScopedFilePath(id)))));
        final DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(bucketName).withKeys(keys);
        awsClient.deleteObjects(deleteObjectsRequest);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean exists(final String id, final AlexaScope scope) throws AlexaStateException {
        if (AlexaScope.SESSION.includes(scope)) {
            return super.exists(id, scope);
        } else {
            final String filePath = AlexaScope.USER.includes(scope) ? getUserScopedFilePath(id)
                    : getAppScopedFilePath(id);
            return awsClient.doesObjectExist(bucketName, filePath);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public <TModel extends AlexaStateModel> Optional<TModel> readModel(final Class<TModel> modelClass,
            final String id) throws AlexaStateException {
        // if there is nothing for this model in the session ...
        final Optional<TModel> modelSession = super.readModel(modelClass, id);
        // create new model with given id. for now we assume a model exists for this id. we find out by
        // reading file from the bucket in the following lines. only if this is true model will be written back to session
        final TModel model = modelSession.orElse(createModel(modelClass, id));
        // we need to remember if there will be something from S3 to be written to the model
        // in order to write those values back to the session at the end of this method
        Boolean modelChanged = false;
        // and if there are user-scoped fields ...
        if (model.hasUserScopedField() && fromS3FileContentsToModel(model, id, AlexaScope.USER)) {
            modelChanged = true;
        }
        // and if there are app-scoped fields ...
        if (model.hasApplicationScopedField() && fromS3FileContentsToModel(model, id, AlexaScope.APPLICATION)) {
            modelChanged = true;
        }
        // so if model changed from within something out of S3 we want this to be in the speechlet as well
        // this gives you access to user- and app-scoped attributes throughout a session without reading from S3 over and over again
        if (modelChanged) {
            super.writeModel(model);
            return Optional.of(model);
        } else {
            // if there was nothing received from S3 and there is nothing to return from session
            // then its not worth return the model. better indicate this model does not exist
            return modelSession.isPresent() ? Optional.of(model) : Optional.empty();
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Map<String, AlexaStateObject> readValues(final Map<String, AlexaScope> idsInScope)
            throws AlexaStateException {
        final Map<String, AlexaStateObject> stateObjectMap = new HashMap<>();
        // first read all the session-scoped items and put to result map
        stateObjectMap.putAll(super.readValues(idsInScope));

        idsInScope.forEach((id, scope) -> {
            if (scope.isIn(AlexaScope.USER, AlexaScope.APPLICATION)) {
                final String filePath = AlexaScope.USER.includes(scope) ? getUserScopedFilePath(id)
                        : getAppScopedFilePath(id);
                try {
                    // get S3 file
                    getS3FileContentsAsString(filePath)
                            // wrap its contents in state object
                            .map(fileContents -> new AlexaStateObject(id, fileContents, scope))
                            // add to result map
                            .ifPresent(stateObject -> stateObjectMap.putIfAbsent(id, stateObject));
                } catch (final AlexaStateException | AmazonS3Exception e) {
                    // we are fine with an exception likely caused by file (state) not exists
                    log.warn("Could not read from '" + filePath + "'.", e);
                }
            }
        });
        return stateObjectMap;
    }

    private boolean fromS3FileContentsToModel(final AlexaStateModel alexaStateModel, final String id,
            final AlexaScope scope) throws AlexaStateException {
        // read from item with scoped model
        final String filePath = AlexaScope.APPLICATION.includes(scope)
                ? getAppScopedFilePath(alexaStateModel.getClass(), id)
                : getUserScopedFilePath(alexaStateModel.getClass(), id);
        // extract values from json and assign it to model
        return awsClient.doesObjectExist(bucketName, filePath)
                && alexaStateModel.fromJSON(getS3FileContentsAsString(filePath).orElse("{}"), scope);
    }

    private Optional<String> getS3FileContentsAsString(final String filePath) throws AlexaStateException {
        final S3Object file = awsClient.getObject(bucketName, filePath);
        if (file == null) {
            return Optional.empty();
        }
        final BufferedReader reader = new BufferedReader(new InputStreamReader(file.getObjectContent()));
        final StringBuilder sb = new StringBuilder();
        String line;
        try {
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        } catch (IOException e) {
            final String error = String.format("Could not read from S3-file '%1$s' from Bucket '%2$s'.", filePath,
                    bucketName);
            log.error(error, e);
            throw AlexaStateException.create(error).withCause(e).withHandler(this).build();
        }
        final String fileContents = sb.toString();
        return fileContents.isEmpty() ? Optional.empty() : Optional.of(fileContents);
    }

    private <TModel extends AlexaStateModel> String getUserScopedFilePath(final Class<TModel> modelClass,
            final String id) {
        return session.getUser().getUserId() + "/" + TModel.getAttributeKey(modelClass, id) + "." + fileExtension;
    }

    private String getUserScopedFilePath(final String id) {
        return session.getUser().getUserId() + "/" + id + "." + fileExtension;
    }

    private <TModel extends AlexaStateModel> String getAppScopedFilePath(final Class<TModel> modelClass,
            final String id) {
        return folderNameApp + "/" + TModel.getAttributeKey(modelClass, id) + "." + fileExtension;
    }

    private String getAppScopedFilePath(final String id) {
        return folderNameApp + "/" + id + "." + fileExtension;
    }
}