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

Java tutorial

Introduction

Here is the source code for io.klerch.alexa.state.handler.AWSIotStateHandler.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.iot.AWSIot;
import com.amazonaws.services.iot.AWSIotClient;
import com.amazonaws.services.iot.model.*;
import com.amazonaws.services.iotdata.AWSIotData;
import com.amazonaws.services.iotdata.AWSIotDataClient;
import com.amazonaws.services.iotdata.model.GetThingShadowRequest;
import com.amazonaws.services.iotdata.model.GetThingShadowResult;
import com.amazonaws.services.iotdata.model.UpdateThingShadowRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.klerch.alexa.state.model.AlexaScope;
import io.klerch.alexa.state.model.AlexaStateModel;
import io.klerch.alexa.state.model.AlexaStateObject;
import io.klerch.alexa.state.utils.AlexaStateException;
import io.klerch.alexa.state.utils.EncryptUtils;
import org.apache.log4j.Logger;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.security.NoSuchAlgorithmException;
import java.util.*;

import static java.lang.String.format;

/**
 * As this handler works in the user and application scope it persists all models to a thing shadow in AWS IoT.
 * A saved state goes into the "desired" JSON portion of a shadow state whereas state is read only from
 * the "reported" portion of that shadow. That said this handler differs a bit from the other ones as you
 * cannot expect to read state like you wrote it to the store. It needs the thing to fulfill the desired
 * state and reports it back to the shadow. That's how you cannot only trigger physical action by saving state
 * over this handler but also get informed about current thing state.
 */
public class AWSIotStateHandler extends AlexaSessionStateHandler {
    private final Logger log = Logger.getLogger(AWSIotStateHandler.class);

    private final AWSIot awsClient;
    private final AWSIotData awsDataClient;
    private static final String thingAttributeName = "name";
    private static final String thingAttributeUser = "amzn-user-id";
    private static final String thingAttributeApp = "amzn-app-id";
    private List<String> thingsExisting = new ArrayList<>();

    public AWSIotStateHandler(final Session session) {
        this(session, new AWSIotClient(), new AWSIotDataClient());
    }

    public AWSIotStateHandler(final Session session, final AWSIot awsClient, final AWSIotData awsDataClient) {
        super(session);
        this.awsClient = awsClient;
        this.awsDataClient = awsDataClient;
    }

    /**
     * Returns the AWS connection client used by this handler to manage resources
     * in AWS IoT.
     *
     * @return AWS connection client for AWS IoT
     */
    public AWSIot getAwsClient() {
        return this.awsClient;
    }

    /**
     * Returns the AWS connection client used by this handler to store model states in
     * thing shadows of AWS IoT.
     *
     * @return AWS data connection client for AWS IoT
     */
    AWSIotData getAwsDataClient() {
        return this.awsDataClient;
    }

    /**
     * {@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()) {
                publishState(model, AlexaScope.USER);
            }
            if (model.hasApplicationScopedField()) {
                publishState(model, AlexaScope.APPLICATION);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void writeValues(final Collection<AlexaStateObject> stateObjects) throws AlexaStateException {
        // write to session
        super.writeValues(stateObjects);

        for (final AlexaStateObject stateObject : stateObjects) {

            if (stateObject.getScope().isIn(AlexaScope.USER, AlexaScope.APPLICATION)) {
                // only publish USER or APPLICATION scoped state objects
                publishState(stateObject);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public <TModel extends AlexaStateModel> Optional<TModel> readModel(final Class<TModel> modelClass)
            throws AlexaStateException {
        return this.readModel(modelClass, null);
    }

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

        for (final AlexaStateModel model : models) {
            if (model.hasSessionScopedField() || model.hasUserScopedField()) {
                removeModelFromShadow(model, AlexaScope.USER);
            }
            if (model.hasApplicationScopedField()) {
                removeModelFromShadow(model, AlexaScope.APPLICATION);
            }
            log.debug(format("Removed state from AWS IoT shadow for '%1$s'.", model));
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void removeValues(final Collection<String> ids) throws AlexaStateException {
        super.removeValues(ids);

        for (final String id : ids) {
            removeNodeFromShadow(id, AlexaScope.USER);
            removeNodeFromShadow(id, AlexaScope.APPLICATION);
            log.debug(String.format("Removed value from AWS IoT shadow for '%1$s'.", id));
        }
    }

    /**
     * {@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 thing shadow 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() && fromThingShadowToModel(model, AlexaScope.USER)) {
            modelChanged = true;
        }
        // and if there are app-scoped fields ...
        if (model.hasApplicationScopedField() && fromThingShadowToModel(model, AlexaScope.APPLICATION)) {
            modelChanged = true;
        }
        // so if model changed from within something out of the shadow 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 IOT 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 Optional<AlexaStateObject> readValue(final String id, final AlexaScope scope)
            throws AlexaStateException {
        if (AlexaScope.SESSION.includes(scope)) {
            return super.readValue(id, scope);
        }
        return getNodeFromThingShadow(id, scope).map(value -> new AlexaStateObject(id, value, scope));
    }

    /**
     * Returns name of the thing whose shadow is updated by this handler. It depends on
     * the scope of the fields persisted in AWS IoT as APPLICATION-scoped fields go to a different
     * thing shadow than USER-scoped fields.
     *
     * @param scope The scope this thing is dedicated to
     * @return Name of the thing for this scope
     * @throws AlexaStateException Any error regarding thing name generation
     */
    public String getThingName(final AlexaScope scope) throws AlexaStateException {
        return AlexaScope.APPLICATION.includes(scope) ? getAppScopedThingName() : getUserScopedThingName();
    }

    /**
     * The thing will be created in AWS IoT if not existing for this application (when scope
     * APPLICATION is given) or for this user in this application (when scope USER is given)
     *
     * @param scope The scope this thing is dedicated to
     * @throws AlexaStateException Any error regarding thing creation or existence check
     */
    public void createThingIfNotExisting(final AlexaScope scope) throws AlexaStateException {
        final String thingName = getThingName(scope);
        if (!doesThingExist(thingName)) {
            createThing(thingName, scope);
        }
    }

    /**
     * Returns the name of the thing which is used to store model state scoped
     * as USER
     *
     * @return Thing name for user-wide model state
     * @throws AlexaStateException some exceptions may occure when encrypting the user-id
     */
    String getUserScopedThingName() throws AlexaStateException {
        // user-ids in Alexa are too long for thing names in AWS IOT.
        // use the SHA1-hash of the user-id
        final String userHash;
        try {
            userHash = EncryptUtils.encryptSha1(session.getUser().getUserId());
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
            final String error = "Could not encrypt user-id for generating the IOT thing-name";
            log.error(error, e);
            throw AlexaStateException.create(error).withHandler(this).withCause(e).build();
        }
        return getAppScopedThingName() + "-" + userHash;
    }

    /**
     * Returns the name of the thing which is used to store model state scoped
     * as APPLICATION
     *
     * @return Thing name for application-wide model state
     */
    String getAppScopedThingName() {
        // thing names do not allow dots in it
        return session.getApplication().getApplicationId().replace(".", "-");
    }

    /**
     * Returns if the thing dedicated to the scope given is existing in AWS IoT.
     *
     * @param scope The scope this thing is dedicated to
     * @return True, if the thing dedicated to the scope given is existing in AWS IoT.
     * @throws AlexaStateException Any error regarding thing creation or existence check
     */
    public boolean doesThingExist(final AlexaScope scope) throws AlexaStateException {
        final String thingName = getThingName(scope);
        return doesThingExist(thingName);
    }

    private void removeModelFromShadow(final AlexaStateModel model, final AlexaScope scope)
            throws AlexaStateException {
        removeNodeFromShadow(model.getAttributeKey(), scope);
    }

    private void removeNodeFromShadow(final String nodeName, final AlexaScope scope) throws AlexaStateException {
        final String thingName = getThingName(scope);
        final String thingState = getState(scope);
        try {
            final ObjectMapper mapper = new ObjectMapper();
            final JsonNode root = mapper.readTree(thingState);
            if (!root.isMissingNode()) {
                final JsonNode desired = root.path("state").path("desired");
                if (!desired.isMissingNode() && desired instanceof ObjectNode) {
                    ((ObjectNode) desired).remove(nodeName);
                    final String json = "{\"state\":{\"desired\":" + mapper.writeValueAsString(desired) + "}}";
                    publishState(thingName, json);
                }
            }
        } catch (final IOException e) {
            final String error = format("Could not extract model state of '%1$s' from thing shadow '%2$s'",
                    nodeName, thingName);
            log.error(error, e);
            throw AlexaStateException.create(error).withCause(e).build();
        }
    }

    private Optional<String> getNodeFromThingShadow(final String nodeName, final AlexaScope scope)
            throws AlexaStateException {
        // read from item with scoped model
        final String thingName = getThingName(scope);
        final String thingState = getState(scope);
        try {
            final ObjectMapper mapper = new ObjectMapper();
            final JsonNode node = mapper.readTree(thingState).path("state").path("reported").path(nodeName);
            return !node.isMissingNode() ? Optional.of(mapper.writeValueAsString(node)) : Optional.empty();
        } catch (IOException e) {
            final String error = format("Could not extract model state of '%1$s' from thing shadow '%2$s'",
                    nodeName, thingName);
            log.error(error, e);
            throw AlexaStateException.create(error).withCause(e).build();
        }
    }

    private boolean fromThingShadowToModel(final AlexaStateModel model, final AlexaScope scope)
            throws AlexaStateException {
        final Optional<String> state = getNodeFromThingShadow(model.getAttributeKey(), scope);
        return state.isPresent() && model.fromJSON(state.get(), scope);
    }

    private String getState(final AlexaScope scope) throws AlexaStateException {
        final String thingName = getThingName(scope);

        createThingIfNotExisting(scope);

        final GetThingShadowRequest awsRequest = new GetThingShadowRequest().withThingName(thingName);
        try {
            final GetThingShadowResult response = awsDataClient.getThingShadow(awsRequest);
            final ByteBuffer buffer = response.getPayload();

            try {
                return (buffer != null && buffer.hasArray()) ? new String(buffer.array(), "UTF-8") : "{}";
            } catch (UnsupportedEncodingException e) {
                final String error = format("Could not handle received contents of thing-shadow '%1$s'", thingName);
                log.error(error, e);
                throw AlexaStateException.create(error).withCause(e).withHandler(this).build();
            }
        }
        // if a thing does not have a shadow this is a usual exception
        catch (com.amazonaws.services.iotdata.model.ResourceNotFoundException e) {
            log.info(e);
            // we are fine with a thing having no shadow what just means there's nothing to read out for the model
            // return an empty JSON to indicate nothing is in the thing shadow
            return "{}";
        }
    }

    private void publishState(final AlexaStateModel model, final AlexaScope scope) throws AlexaStateException {
        final String thingName = getThingName(scope);
        createThingIfNotExisting(scope);
        final String payload = "{\"state\":{\"desired\":{\"" + model.getAttributeKey() + "\":" + model.toJSON(scope)
                + "}}}";
        publishState(thingName, payload);
        log.debug(format("State '%1$s' is published to shadow of '%2$s' in AWS IoT.", payload, thingName));
    }

    private void publishState(final AlexaStateObject stateObject) throws AlexaStateException {
        final String thingName = getThingName(stateObject.getScope());
        createThingIfNotExisting(stateObject.getScope());
        // wrap non-primitive values in quotes (json string-value)
        final Object state = stateObject.getValue().getClass().isPrimitive() ? stateObject.getValue()
                : "\"" + stateObject.getValue() + "\"";
        final String payload = "{\"state\":{\"desired\":{\"" + stateObject.getId() + "\":" + state + "}}}";
        publishState(thingName, payload);
        log.debug(format("State '%1$s' is published to shadow of '%2$s' in AWS IoT.", payload, thingName));
    }

    private void publishState(final String thingName, final String json) throws AlexaStateException {
        final ByteBuffer buffer;
        try {
            buffer = ByteBuffer.wrap(json.getBytes("UTF-8"));
        } catch (UnsupportedEncodingException e) {
            final String error = format("Could not prepare JSON for model state publication to thing shadow '%1$s'",
                    thingName);
            log.error(error, e);
            throw AlexaStateException.create(error).withCause(e).withHandler(this).build();
        }
        final UpdateThingShadowRequest iotRequest = new UpdateThingShadowRequest().withThingName(thingName)
                .withPayload(buffer);
        awsDataClient.updateThingShadow(iotRequest);
    }

    private void createThing(final String thingName, final AlexaScope scope) {
        // only create thing if not already existing
        final AttributePayload attrPayload = new AttributePayload();
        // add thing name as attribute as well. this is how the handler queries for the thing from now on
        attrPayload.addAttributesEntry(thingAttributeName, thingName);
        // if scope is user an attribute saves the plain user id as it is encrypted in the thing name
        if (AlexaScope.USER.includes(scope)) {
            attrPayload.addAttributesEntry(thingAttributeUser, session.getUser().getUserId());
        }
        // another thing attributes holds the Alexa application-id
        attrPayload.addAttributesEntry(thingAttributeApp, session.getApplication().getApplicationId());
        // now create the thing
        final CreateThingRequest request = new CreateThingRequest().withThingName(thingName)
                .withAttributePayload(attrPayload);
        awsClient.createThing(request);
        log.info(format("Thing '%1$s' is created in AWS IoT.", thingName));
    }

    private boolean doesThingExist(final String thingName) {
        // if already checked existence than return immediately
        if (thingsExisting.contains(thingName)) {
            return true;
        }
        // query by an attribute having the name of the thing
        // unfortunately you can only query for things with their attributes, not directly with their names
        final ListThingsRequest request = new ListThingsRequest().withAttributeName(thingAttributeName)
                .withAttributeValue(thingName).withMaxResults(1);
        final ListThingsResult result = awsClient.listThings(request);
        if (result != null && result.getThings() != null && result.getThings().isEmpty()) {
            thingsExisting.add(thingName);
            return true;
        }
        return false;
    }
}