io.klerch.alexa.state.model.AlexaStateModel Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of alexa-skills-kit-states-java Show documentation
Show all versions of alexa-skills-kit-states-java Show documentation
This SDK is an extension to the Alexa Skills SDK for Java. It provides a framework for managing state of POJO models in a variety of persistence stores in an Alexa skill.
/**
* 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.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import io.klerch.alexa.state.handler.AlexaStateHandler;
import io.klerch.alexa.state.model.serializer.AlexaAppStateSerializer;
import io.klerch.alexa.state.model.serializer.AlexaSessionStateSerializer;
import io.klerch.alexa.state.model.serializer.AlexaStateSerializer;
import io.klerch.alexa.state.model.serializer.AlexaUserStateSerializer;
import io.klerch.alexa.state.utils.AlexaStateException;
import io.klerch.alexa.state.utils.ConversionUtils;
import io.klerch.alexa.state.utils.ReflectionUtils;
import org.apache.commons.lang3.Validate;
import org.apache.log4j.Logger;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* This abstract class turns your POJO model into a model compatible to the AlexaStateHandler.
*/
public abstract class AlexaStateModel {
@AlexaStateIgnore
private final Logger log = Logger.getLogger(AlexaStateModel.class);
@AlexaStateIgnore
private String __internalId;
@AlexaStateIgnore
private AlexaStateHandler __handler;
@AlexaStateIgnore
private final String validIdPattern = "[a-zA-Z0-9_\\-]+";
@AlexaStateIgnore
private Boolean hasSessionScopedFields;
@AlexaStateIgnore
private Boolean hasApplicationScopedFields;
@AlexaStateIgnore
private Boolean hasUserScopedFields;
@AlexaStateIgnore
private static final String AttributeKeySeparator = ":";
/**
* Returns the key used to save the model in the session attributes. This method doesn't take an id
* thus will return the key for the singleton object of the model.
* @param modelClass The type of an AlexaStateModel.
* @param The model type derived from AlexaStateModel.
* @return key used to save the model in the session attributes
*/
public static String getAttributeKey(final Class modelClass) {
return getAttributeKey(modelClass, null);
}
/**
* Returns the key used to save the model in the session attributes. This method takes an id
* thus will return the key for a specific instance of the model as many of them can exist in your session.
* @param modelClass The type of an AlexaStateModel.
* @param id the key for a specific instance of the model
* @param The model type derived from AlexaStateModel.
* @return key used to save the model in the session attributes
*/
public static String getAttributeKey(final Class modelClass, final String id) {
return modelClass.getTypeName() + (id != null && !id.isEmpty() ? AttributeKeySeparator + id : "");
}
/**
* Returns the key used to save the model in the session attributes. This method obtains an id from this model
* thus will return the key for a specific instance of the model as many of them can exist in your session. If this
* model does not provide an id it will return the key for the singleton object of the model.
* @return key used to save the model in the session attributes
*/
public String getAttributeKey() {
return getAttributeKey(this.getClass(), __internalId);
}
/**
* Sets an id for this model instance. The id should be an unique identifier within a model-type within an AlexaScope.
* This is how you can persist multiple instances per model per user or per application or per session. An id has
* some regulations to its allowed characters (a-zA-Z0-9_-.)
* @param id an identifier for this model instance.
*/
public void setId(final String id) {
if (id != null && !id.isEmpty()) {
Validate.matchesPattern(id, validIdPattern, "Chosen model Id contains illegal characters. Ensure your Id matches the following pattern: " + validIdPattern);
this.__internalId = id;
}
}
/**
* Gets id for this model instance. The id is an unique identifier within a model-type within an AlexaScope.
* This is how you can a specific instance for a model in either scope of user or application or session.
* @return identifier which can also be null for singleton model instances
*/
public String getId() {
return this.__internalId;
}
/**
* Sets the AlexaStateHandler which takes care of this model when it {@link #saveState()}, {@link #removeState()}.
* A state handler usually is dedicated to a persistence store which stores the AlexaStateSave-tagged fields of this model
* @param handler a state handler implementation
*/
public void setHandler(final AlexaStateHandler handler) {
this.__handler = handler;
}
/**
* Sets the AlexaStateHandler which takes care of this model when it {@link #saveState()}, {@link #removeState()}.
* A state handler usually is dedicated to a persistence store which stores the AlexaStateSave-tagged fields of this model
* @param handler a state handler implementation
* @return abstract representation of your model
*/
public AlexaStateModel withHandler(final AlexaStateHandler handler) {
setHandler(handler); return this; }
/**
* Gets the AlexaStateHandler which takes care of this model when it {@link #saveState()}, {@link #removeState()}.
* A state handler usually is dedicated to a persistence store which stores the AlexaStateSave-tagged fields of this model
* @return the state handler
*/
public AlexaStateHandler getHandler() {
return this.__handler;
}
/**
* Asks the state handler associated with this model to save all AlexaStateSave-tagged fields in the persistence store.
* This method will raise an exception if no handler is set for this model.
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
*/
public void saveState() throws AlexaStateException {
Validate.notNull(this.__handler, "Save state is not allowed for this model as it needs an AlexaSessionHandler. Assign a handler to this object or use AlexaStateModelFactory.");
this.__handler.writeModel(this);
}
/**
* Asks the state handler associated with this model to remove the model from the persistence store. It means you won't be
* able to access the model with its id anymore. There is no impact on the runtime instance. Its values remain.
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
*/
public void removeState() throws AlexaStateException {
Validate.notNull(this.__handler, "Remove state is not allowed for this model as it needs an AlexaSessionHandler. Assign a handler to this object or use AlexaStateModelFactory.");
this.__handler.removeModel(this);
}
/**
* Creates a new AlexaStateModel
* @param modelClass type of the model.
* @param type of the model.
* @return builder to use for completing the object creation
*/
static AlexaModelBuilder create(final Class modelClass) {
return new AlexaModelBuilder(modelClass);
}
/**
* Generic getter for all the fields in this model. A getter method following the naming convention
* get[Fieldname] is called. Otherwise the field is read out directly.
* @param field The field whose value you desire.
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
* @return Value of the given field.
*/
public Object get(final Field field) throws AlexaStateException {
final String fieldName = field.getName();
// prefer getting value from getter over direct read from field
try {
// look for a getter for this field
final Method getter = ReflectionUtils.getGetter(this, fieldName);
// if there is a getter go for it otherwise read value from field directly
field.setAccessible(true);
return getter != null ? getter.invoke(this) : field.get(this);
}
catch (IllegalAccessException | InvocationTargetException e) {
final String error = String.format("Could not access field '%1$s' of model '%2$s' for reading. Ensure there's a public getter for this field.", fieldName, this);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withModel(this).build();
}
}
/**
* Generic setter for all the fields in this model. A setter method following the naming convention
* set[Fieldname] is used to write the given value. Otherwise the field is written directly. If there's a problem
* with accessing the field this method returns false
* @param field The field whose value you want to set.
* @param value New value for the given field
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
*/
public void set(final Field field, final Object value) throws AlexaStateException {
final String fieldName = field.getName();
// prefer setting value with setter over direct value assignment to field
try {
// look for a setter for this field
final Method setter = ReflectionUtils.getSetter(this, fieldName);
if (setter != null) {
// invoke setter
setter.invoke(this, value);
}
else {
// or direct value assignment
field.setAccessible(true);
field.set(this, value);
}
}
catch (IllegalAccessException | InvocationTargetException e) {
final String error = String.format("Could not access field '%1$s' of model '%2$s' for writing. Ensure there's a public setter for this field.", fieldName, this);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withModel(this).build();
}
}
/**
* Expects a json-string which contains keys with values. Any key which is equal a fieldname of this model
* will result in its value being written to the field of this model. Those fields needs to have the AlexaStateSave-annotation
* otherwise they will not be considered even though there name match with a key in the given json.
* @param json A json with key-value-pairs where the keys likely equal some of the AlexaStateSave-tagged fields in this model.
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
* @return True, if json-keys matched with AlexaStateSave-tagged fields.
*/
public boolean fromJSON(final String json) throws AlexaStateException {
// by default take over everything from json to fields that map in this model
// session-scope covers it all
return fromJSON(json, AlexaScope.SESSION);
}
/**
* Expects a json-string which contains keys with values. Any key which is equal a fieldname of this model
* will result in its value being written to the field of this model. Those fields need to have the AlexaStateSave-annotation
* with the given scope otherwise they will not be considered even though there name match with a key in the given json.
* @param json A json with key-value-pairs where the keys likely equal some of the AlexaStateSave-tagged with given scope fields in this model.
* @param scope The scope a AlexaStateSave-annotated field must have to be considered for value assignment
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
* @return True, if json-keys matched with AlexaStateSave-tagged fields with given scope.
*/
public boolean fromJSON(final String json, final AlexaScope scope) throws AlexaStateException {
Boolean modelChanged = false;
try {
final Object model = new ObjectMapper().readValue(json, this.getClass());
for (final Field field : getSaveStateFields(scope)) {
this.set(field, field.get(model));
modelChanged = true;
}
} catch (final IOException | IllegalAccessException e) {
final String error = String.format("Error while deserializing model of '%1$s' as Json.", this);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withModel(this).build();
}
return modelChanged;
}
/**
* Returns a json with key-value-pairs - one for each AlexaStateSave-annotated field in this model configured to be valid
* in the given scope
* @param scope The scope a AlexaStateSave-annotated field must have or be part of to be considered in the returned json
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
* @return A json-string with key-value-pairs - one for each AlexaStateSave-annotated field in this model configured to be valid
*/
public String toJSON(final AlexaScope scope) throws AlexaStateException {
// for each scope there is a custom json serializer so initialize the one which corresponds to the given scope
final AlexaStateSerializer serializer = AlexaScope.APPLICATION.equals(scope) ?
new AlexaAppStateSerializer() : AlexaScope.USER.equals(scope) ?
new AlexaUserStateSerializer() : new AlexaSessionStateSerializer();
// associate a mapper with the serializer
final ObjectMapper mapper = new ObjectMapper();
final SimpleModule module = new SimpleModule();
module.addSerializer(this.getClass(), serializer);
mapper.registerModule(module);
try {
// serialize model which only contains those fields tagged with the given scope
return mapper.writeValueAsString(this);
} catch (JsonProcessingException e) {
final String error = String.format("Error while serializing model of '%1$s' as Json.", this);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withModel(this).build();
}
}
/**
* Returns a map with key-value-pairs - one for each AlexaStateSave-annotated field in this model configured to be valid
* in the given scope
* @param scope The scope a AlexaStateSave-annotated field must have or be part of to be considered in the returned map
* @throws AlexaStateException Wraps all inner exceptions and gives you context related to handler and model
* @return A map with key-value-pairs - one for each AlexaStateSave-annotated field in this model configured to be valid
*/
public Map toMap(final AlexaScope scope) throws AlexaStateException {
// for each scope there is a custom json serializer so initialize the one which corresponds to the given scope
return ConversionUtils.mapJson(toJSON(scope));
}
/**
* It returns if any AlexaStateSave field is in the model scoped in SESSION
* @return True, if there are any AlexaStateSave fields in the model scoped in SESSION
*/
public Boolean hasSessionScopedField() {
hasSessionScopedFields = (hasSessionScopedFields != null) ? hasSessionScopedFields :
hasFieldInScope(AlexaScope.SESSION);
return hasSessionScopedFields;
}
/**
* It returns if any AlexaStateSave field is in the model scoped in USER
* @return True, if there are any AlexaStateSave fields in the model scoped in USER
*/
public Boolean hasUserScopedField() {
hasUserScopedFields = (hasUserScopedFields != null) ? hasUserScopedFields :
hasFieldInScope(AlexaScope.USER);
return hasUserScopedFields;
}
/**
* It returns if any AlexaStateSave field is in the model scoped in APPLICATION
* @return True, if there are any AlexaStateSave fields in the model scoped in APPLICATION
*/
public Boolean hasApplicationScopedField() {
hasApplicationScopedFields = (hasApplicationScopedFields != null) ? hasApplicationScopedFields :
hasFieldInScope(AlexaScope.APPLICATION);
return hasApplicationScopedFields;
}
/**
* Gives you all the fields of this model which are annotated with AlexaStateSave
* @return list of all the fields of this model which are annotated with AlexaStateSave
*/
public List getSaveStateFields() {
return Arrays.stream(this.getClass().getDeclaredFields()).filter(this::isStateSave).collect(Collectors.toList());
}
/**
* Gives you all the fields of this model which are annotated with AlexaStateSave and whose scope is set to a scope
* which at least in included in the given scope.
* @param scope Defines the scope which is used to filter all the AlexaStateSave-annotated fields
* @return list of all the fields of this model which are annotated with AlexaStateSave and whose scope is set to a scope
* which at least in included in the given scope.
*/
public List getSaveStateFields(final AlexaScope scope) {
return Arrays.stream(this.getClass().getDeclaredFields()).filter(field -> isStateSave(field, scope)).collect(Collectors.toList());
}
/**
* Checks, if the given field is tagged with AlexaStateSave
* @param field the field you want to check for the AlexaStateSave-annotation
* @return True, if the given field has the AlexaStateSave-annotation
*/
private boolean isStateSave(final Field field) {
// either field itself is annotated as statesave or whole class is
// however, StateIgnore prevends field of being statesave
return !field.isAnnotationPresent(AlexaStateIgnore.class) &&
(field.isAnnotationPresent(AlexaStateSave.class) ||
this.getClass().isAnnotationPresent(AlexaStateSave.class));
}
/**
* Checks, if the given field is tagged with AlexaStateSave and whose scope is set to a scope which at least
* is included in the given scope.
* @param field the field you want to check for the AlexaStateSave-annotation
* @param scope the scope which at least must be included in the scope of the field
* @return True, if the field has the AlexaStateSave-annotation and given scope (or an included scope)
*/
private boolean isStateSave(final Field field, final AlexaScope scope) {
// either field itself is tagged as state-save in given scope or whole class is statesave in the given scope
// however, StateIgnore in given scope prevents field of being statesave
return ((!field.isAnnotationPresent(AlexaStateIgnore.class) || !scope.isIn(field.getAnnotation(AlexaStateIgnore.class).Scope())) &&
((field.isAnnotationPresent(AlexaStateSave.class) && scope.includes(field.getAnnotation(AlexaStateSave.class).Scope()) ||
(this.getClass().isAnnotationPresent(AlexaStateSave.class) && scope.includes(this.getClass().getAnnotation(AlexaStateSave.class).Scope())))));
}
private boolean hasFieldInScope(final AlexaScope scope) {
return !getSaveStateFields(scope).isEmpty();
}
static final class AlexaModelBuilder {
private final Logger log = Logger.getLogger(AlexaModelBuilder.class);
private String __internalId;
private AlexaStateHandler __handler;
private Class> modelClass;
AlexaModelBuilder(Class modelClass) {
this.modelClass = modelClass;
}
/**
* Sets an id for this model instance. The id should be an unique identifier within a model-type within an AlexaScope.
* This is how you can persist multiple instances per model per user or per application or per session. An id has
* some regulations to its allowed characters (a-zA-Z0-9_-.)
* @param id an identifier for this model instance.
* @return builder
*/
public AlexaModelBuilder withId(final String id) {
this.__internalId = id;
return this;
}
/**
* Sets the AlexaStateHandler which takes care of this model when it {@link #saveState()}, {@link #removeState()}.
* A state handler usually is dedicated to a persistence store which stores the AlexaStateSave-tagged fields of this model
* @param handler a state handler implementation
* @return builder
*/
public AlexaModelBuilder withHandler(final AlexaStateHandler handler) {
this.__handler = handler;
return this;
}
/**
* Builds the model. Be sure your model class has a parameterless constructor otherwise this
* instanciation will fail. In that case this method returns null.
* @param type of the model. Must be of type AlexaStateModel
* @return model of desired type
*/
public TModel build() {
Validate.notNull(this.__handler, "Model needs a handler for its initialization.");
try {
final TModel model = (TModel)modelClass.newInstance();
model.setId(__internalId);
model.setHandler(__handler);
return model;
} catch (InstantiationException | IllegalAccessException e) {
log.error(String.format("Could not create model of '%1$s'.", this.modelClass.getTypeName()), e);
return null;
}
}
}
@Override
public String toString() {
return this.getAttributeKey();
}
}