io.klerch.alexa.state.handler.AWSS3StateHandler Maven / Gradle / Ivy
Show all versions of alexa-skills-kit-states-java Show documentation
/**
* Made by Kay Lerch (https://twitter.com/KayLerch)
*
* 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 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 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 ids) throws AlexaStateException {
super.removeValues(ids);
final List 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 (scope.includes(AlexaScope.SESSION)) {
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 Optional readModel(final Class modelClass, final String id) throws AlexaStateException {
// if there is nothing for this model in the session ...
final Optional 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 readValues(final Map idsInScope) throws AlexaStateException {
final Map 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 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 String getUserScopedFilePath(final Class 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 String getAppScopedFilePath(final Class modelClass, final String id) {
return folderNameApp + "/" + TModel.getAttributeKey(modelClass, id) + "." + fileExtension;
}
private String getAppScopedFilePath(final String id) {
return folderNameApp + "/" + id + "." + fileExtension;
}
}