From fa8f81f1865f7f5c9d413e554dc440bd294b4831 Mon Sep 17 00:00:00 2001 From: Jonathan Breedlove Date: Thu, 18 Apr 2019 11:19:34 -0700 Subject: [PATCH] Add support for templated responses (#195) --- ask-sdk-core/pom.xml | 8 + .../src/com/amazon/ask/CustomSkill.java | 16 +- .../ask/builder/CustomSkillConfiguration.java | 3 + .../com/amazon/ask/builder/SkillBuilder.java | 12 +- .../ask/builder/SkillConfiguration.java | 46 ++++- .../request/handler/HandlerInput.java | 34 +++- .../loader/impl/LocalTemplateFileLoader.java | 50 +++++ .../loader/impl/LocaleTemplateEnumerator.java | 136 +++++++++++++ .../amazon/ask/builder/SkillBuilderTest.java | 20 +- .../ask/builder/SkillConfigurationTest.java | 12 +- .../request/handler/HandlerInputTest.java | 54 ++++- .../ask/response/template/FileLoaderTest.java | 131 +++++++++++++ .../template/LocaleEnumeratorTest.java | 73 +++++++ .../response/template/test_template/en/US.txt | 1 + ask-sdk-freemarker-renderer/pom.xml | 117 +++++++++++ .../impl/FreeMarkerTemplateRenderer.java | 138 +++++++++++++ .../template/FreeMarkerRendererTest.java | 87 +++++++++ .../ask/response/template/TestResponse.java | 37 ++++ .../template/TemplateFactoryException.java | 31 +++ .../template/TemplateLoaderException.java | 29 +++ .../template/TemplateRendererException.java | 29 +++ .../template/TemplateContentData.java | 87 +++++++++ .../response/template/TemplateFactory.java | 36 ++++ .../template/impl/BaseTemplateFactory.java | 131 +++++++++++++ .../template/loader/TemplateCache.java | 39 ++++ .../template/loader/TemplateEnumerator.java | 23 +++ .../template/loader/TemplateLoader.java | 36 ++++ .../impl/AbstractLocalTemplateFileLoader.java | 160 +++++++++++++++ .../AccessOrderedTemplateContentData.java | 63 ++++++ .../impl/ConcurrentLRUTemplateCache.java | 184 ++++++++++++++++++ .../template/renderer/TemplateRenderer.java | 36 ++++ .../util/impl/JacksonJsonUnmarshaller.java | 6 +- .../template/BaseTemplateFactoryTest.java | 143 ++++++++++++++ .../ConcurrentLRUTemplateCacheTest.java | 95 +++++++++ docs/en/Response-Building.rst | 137 +++++++++++++ 35 files changed, 2218 insertions(+), 22 deletions(-) create mode 100644 ask-sdk-core/src/com/amazon/ask/response/template/loader/impl/LocalTemplateFileLoader.java create mode 100644 ask-sdk-core/src/com/amazon/ask/response/template/loader/impl/LocaleTemplateEnumerator.java create mode 100644 ask-sdk-core/tst/com/amazon/ask/response/template/FileLoaderTest.java create mode 100644 ask-sdk-core/tst/com/amazon/ask/response/template/LocaleEnumeratorTest.java create mode 100644 ask-sdk-core/tst/com/amazon/ask/response/template/test_template/en/US.txt create mode 100644 ask-sdk-freemarker-renderer/pom.xml create mode 100644 ask-sdk-freemarker-renderer/src/com/amazon/ask/response/template/renderer/impl/FreeMarkerTemplateRenderer.java create mode 100644 ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/FreeMarkerRendererTest.java create mode 100644 ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/TestResponse.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateFactoryException.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateLoaderException.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateRendererException.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateContentData.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateFactory.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/impl/BaseTemplateFactory.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateCache.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateEnumerator.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateLoader.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AbstractLocalTemplateFileLoader.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AccessOrderedTemplateContentData.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/ConcurrentLRUTemplateCache.java create mode 100644 ask-sdk-runtime/src/com/amazon/ask/response/template/renderer/TemplateRenderer.java create mode 100644 ask-sdk-runtime/tst/com/amazon/ask/response/template/BaseTemplateFactoryTest.java create mode 100644 ask-sdk-runtime/tst/com/amazon/ask/response/template/ConcurrentLRUTemplateCacheTest.java diff --git a/ask-sdk-core/pom.xml b/ask-sdk-core/pom.xml index 0b43a4ee..537da804 100644 --- a/ask-sdk-core/pom.xml +++ b/ask-sdk-core/pom.xml @@ -60,6 +60,14 @@ src tst + + + tst + + **/*.java + + + diff --git a/ask-sdk-core/src/com/amazon/ask/CustomSkill.java b/ask-sdk-core/src/com/amazon/ask/CustomSkill.java index 9ca939ef..fbba4df7 100644 --- a/ask-sdk-core/src/com/amazon/ask/CustomSkill.java +++ b/ask-sdk-core/src/com/amazon/ask/CustomSkill.java @@ -13,16 +13,24 @@ import com.amazon.ask.builder.CustomSkillConfiguration; import com.amazon.ask.dispatcher.request.handler.HandlerInput; import com.amazon.ask.exception.AskSdkException; +import com.amazon.ask.impl.AbstractSkill; import com.amazon.ask.model.RequestEnvelope; import com.amazon.ask.model.Response; import com.amazon.ask.model.ResponseEnvelope; import com.amazon.ask.model.services.ApiClient; import com.amazon.ask.model.services.ApiConfiguration; import com.amazon.ask.model.services.DefaultApiConfiguration; +import com.amazon.ask.model.services.Serializer; import com.amazon.ask.model.services.ServiceClientFactory; +import com.amazon.ask.request.dispatcher.GenericRequestDispatcher; +import com.amazon.ask.request.dispatcher.impl.BaseRequestDispatcher; +import com.amazon.ask.response.template.TemplateFactory; +import com.amazon.ask.util.JacksonSerializer; import com.amazon.ask.util.SdkConstants; import com.amazon.ask.util.UserAgentUtils; import com.fasterxml.jackson.databind.JsonNode; +import com.amazon.ask.util.impl.JacksonJsonMarshaller; +import com.amazon.ask.util.impl.JacksonJsonUnmarshaller; import java.util.Optional; @@ -34,6 +42,7 @@ public class CustomSkill extends AbstractSkill templateFactory; public CustomSkill(CustomSkillConfiguration configuration) { super(JacksonJsonUnmarshaller.withTypeBinding(RequestEnvelope.class, "request"), @@ -50,6 +59,7 @@ public CustomSkill(CustomSkillConfiguration configuration) { this.serializer = new JacksonSerializer(); this.customUserAgent = configuration.getCustomUserAgent(); this.skillId = configuration.getSkillId(); + this.templateFactory = configuration.getTemplateFactory(); } public ResponseEnvelope invoke(RequestEnvelope requestEnvelope) { @@ -75,7 +85,7 @@ protected ResponseEnvelope invoke(UnmarshalledRequest unmarshal throw new AskSdkException("AlexaSkill ID verification failed."); } - ServiceClientFactory factory = apiClient != null ? ServiceClientFactory.builder() + ServiceClientFactory serviceClientFactory = apiClient != null ? ServiceClientFactory.builder() .withDefaultApiConfiguration(getApiConfiguration(requestEnvelope)) .build() : null; @@ -83,8 +93,9 @@ protected ResponseEnvelope invoke(UnmarshalledRequest unmarshal .withRequestEnvelope(requestEnvelope) .withPersistenceAdapter(persistenceAdapter) .withContext(context) - .withServiceClientFactory(factory) .withRequestEnvelopeJson(requestEnvelopeJson) + .withServiceClientFactory(serviceClientFactory) + .withTemplateFactory(templateFactory) .build(); Optional response = requestDispatcher.dispatch(handlerInput); @@ -108,5 +119,4 @@ protected ApiConfiguration getApiConfiguration(RequestEnvelope requestEnvelope) .build(); } - } diff --git a/ask-sdk-core/src/com/amazon/ask/builder/CustomSkillConfiguration.java b/ask-sdk-core/src/com/amazon/ask/builder/CustomSkillConfiguration.java index f4986e9a..6021255e 100644 --- a/ask-sdk-core/src/com/amazon/ask/builder/CustomSkillConfiguration.java +++ b/ask-sdk-core/src/com/amazon/ask/builder/CustomSkillConfiguration.java @@ -17,6 +17,7 @@ import com.amazon.ask.dispatcher.request.handler.HandlerInput; import com.amazon.ask.model.Response; import com.amazon.ask.model.services.ApiClient; +import com.amazon.ask.response.template.TemplateFactory; import java.util.Optional; @@ -30,4 +31,6 @@ public interface CustomSkillConfiguration extends GenericSkillConfiguration getTemplateFactory(); + } diff --git a/ask-sdk-core/src/com/amazon/ask/builder/SkillBuilder.java b/ask-sdk-core/src/com/amazon/ask/builder/SkillBuilder.java index 91cf6e70..d38af28f 100644 --- a/ask-sdk-core/src/com/amazon/ask/builder/SkillBuilder.java +++ b/ask-sdk-core/src/com/amazon/ask/builder/SkillBuilder.java @@ -14,8 +14,8 @@ package com.amazon.ask.builder; import com.amazon.ask.Skill; -import com.amazon.ask.builder.impl.AbstractSkillBuilder; import com.amazon.ask.attributes.persistence.PersistenceAdapter; +import com.amazon.ask.builder.impl.AbstractSkillBuilder; import com.amazon.ask.dispatcher.request.handler.HandlerInput; import com.amazon.ask.dispatcher.request.handler.RequestHandler; import com.amazon.ask.model.Response; @@ -23,6 +23,7 @@ import com.amazon.ask.module.SdkModule; import com.amazon.ask.module.SdkModuleContext; import com.amazon.ask.request.handler.adapter.impl.BaseHandlerAdapter; +import com.amazon.ask.response.template.TemplateFactory; import java.util.ArrayList; import java.util.List; @@ -34,6 +35,7 @@ public class SkillBuilder> extends AbstractSkillBuilde protected PersistenceAdapter persistenceAdapter; protected ApiClient apiClient; protected String skillId; + protected TemplateFactory templateFactory; public SkillBuilder() { this.sdkModules = new ArrayList<>(); @@ -59,6 +61,11 @@ public T withSkillId(String skillId) { return getThis(); } + public T withTemplateFactory(TemplateFactory templateFactory) { + this.templateFactory = templateFactory; + return getThis(); + } + @SuppressWarnings("unchecked") private T getThis() { return (T) this; @@ -75,7 +82,8 @@ protected SkillConfiguration.Builder getConfigBuilder() { skillConfigBuilder.withPersistenceAdapter(persistenceAdapter) .withApiClient(apiClient) - .withSkillId(skillId); + .withSkillId(skillId) + .withTemplateFactory(templateFactory); SdkModuleContext sdkModuleContext = new SdkModuleContext(skillConfigBuilder); for (SdkModule sdkModule : sdkModules) { diff --git a/ask-sdk-core/src/com/amazon/ask/builder/SkillConfiguration.java b/ask-sdk-core/src/com/amazon/ask/builder/SkillConfiguration.java index 1f5e50a7..dd131ceb 100644 --- a/ask-sdk-core/src/com/amazon/ask/builder/SkillConfiguration.java +++ b/ask-sdk-core/src/com/amazon/ask/builder/SkillConfiguration.java @@ -23,7 +23,11 @@ import com.amazon.ask.request.interceptor.GenericRequestInterceptor; import com.amazon.ask.request.interceptor.GenericResponseInterceptor; import com.amazon.ask.request.mapper.GenericRequestMapper; +import com.amazon.ask.response.template.TemplateFactory; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.response.template.renderer.TemplateRenderer; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -36,14 +40,18 @@ public class SkillConfiguration extends AbstractSkillConfiguration templateFactory; protected SkillConfiguration(List>> requestMappers, List>> handlerAdapters, GenericExceptionMapper> exceptionMapper, - PersistenceAdapter persistenceAdapter, ApiClient apiClient, - String customUserAgent, String skillId) { + PersistenceAdapter persistenceAdapter, + ApiClient apiClient, + String customUserAgent, + String skillId, + TemplateFactory templateFactory) { this(requestMappers, handlerAdapters, null, null, exceptionMapper, - persistenceAdapter, apiClient, customUserAgent, skillId); + persistenceAdapter, apiClient, customUserAgent, skillId, templateFactory); } protected SkillConfiguration(List>> requestMappers, @@ -51,13 +59,17 @@ protected SkillConfiguration(List> requestInterceptors, List>> responseInterceptors, GenericExceptionMapper> exceptionMapper, - PersistenceAdapter persistenceAdapter, ApiClient apiClient, - String customUserAgent, String skillId) { + PersistenceAdapter persistenceAdapter, + ApiClient apiClient, + String customUserAgent, + String skillId, + TemplateFactory templateFactory) { super(requestMappers, handlerAdapters, requestInterceptors, responseInterceptors, exceptionMapper); this.customUserAgent = customUserAgent; this.skillId = skillId; this.persistenceAdapter = persistenceAdapter; this.apiClient = apiClient; + this.templateFactory = templateFactory; } public static Builder builder() { @@ -80,11 +92,19 @@ public ApiClient getApiClient() { return apiClient; } + public TemplateFactory getTemplateFactory() { + return templateFactory; + } + public static final class Builder extends AbstractSkillConfiguration.Builder, Builder> { private PersistenceAdapter persistenceAdapter; private ApiClient apiClient; private String customUserAgent; private String skillId; + private TemplateFactory templateFactory; + + public Builder() { + } public Builder withPersistenceAdapter(PersistenceAdapter persistenceAdapter) { this.persistenceAdapter = persistenceAdapter; @@ -127,11 +147,19 @@ public String getSkillId() { return skillId; } - public SkillConfiguration build() { - return new SkillConfiguration(requestMappers, handlerAdapters, requestInterceptors, responseInterceptors, - exceptionMapper, persistenceAdapter, apiClient, customUserAgent, skillId); + public Builder withTemplateFactory(TemplateFactory templateFactory) { + this.templateFactory = templateFactory; + return this; } + public TemplateFactory getTemplateFactory() { + return templateFactory; + } + public SkillConfiguration build() { + return new SkillConfiguration(requestMappers, handlerAdapters, requestInterceptors, responseInterceptors, + exceptionMapper, persistenceAdapter, apiClient, customUserAgent, skillId, templateFactory); + } } -} \ No newline at end of file + +} diff --git a/ask-sdk-core/src/com/amazon/ask/dispatcher/request/handler/HandlerInput.java b/ask-sdk-core/src/com/amazon/ask/dispatcher/request/handler/HandlerInput.java index 0c675b07..4e9a074b 100644 --- a/ask-sdk-core/src/com/amazon/ask/dispatcher/request/handler/HandlerInput.java +++ b/ask-sdk-core/src/com/amazon/ask/dispatcher/request/handler/HandlerInput.java @@ -13,6 +13,8 @@ package com.amazon.ask.dispatcher.request.handler; +import com.amazon.ask.exception.template.TemplateFactoryException; +import com.amazon.ask.model.Response; import com.amazon.ask.request.exception.handler.impl.AbstractHandlerInput; import com.amazon.ask.attributes.AttributesManager; import com.amazon.ask.attributes.persistence.PersistenceAdapter; @@ -22,7 +24,10 @@ import com.amazon.ask.response.ResponseBuilder; import com.amazon.ask.util.ValidationUtils; import com.fasterxml.jackson.databind.JsonNode; +import com.amazon.ask.response.template.TemplateFactory; +import java.util.Map; +import java.util.Optional; import java.util.function.Predicate; /** @@ -40,9 +45,11 @@ public class HandlerInput extends AbstractHandlerInput { protected final ServiceClientFactory serviceClientFactory; protected final ResponseBuilder responseBuilder; protected final JsonNode requestEnvelopeJson; + protected final TemplateFactory templateFactory; protected HandlerInput(RequestEnvelope requestEnvelope, PersistenceAdapter persistenceAdapter, - Object context, ServiceClientFactory serviceClientFactory, JsonNode requestEnvelopeJson) { + Object context, ServiceClientFactory serviceClientFactory, + JsonNode requestEnvelopeJson, TemplateFactory templateFactory) { super(ValidationUtils.assertNotNull(requestEnvelope, "request envelope").getRequest(), context); this.requestEnvelope = requestEnvelope; this.serviceClientFactory = serviceClientFactory; @@ -52,12 +59,29 @@ protected HandlerInput(RequestEnvelope requestEnvelope, PersistenceAdapter persi .build(); this.responseBuilder = new ResponseBuilder(); this.requestEnvelopeJson = requestEnvelopeJson; + this.templateFactory = templateFactory; } public static Builder builder() { return new Builder(); } + /** + * Generate {@link Response} using skill response template and injecting data. + * Response template contains response components including but not limited to + * {@link com.amazon.ask.model.ui.OutputSpeech}, {@link com.amazon.ask.model.ui.Card}, {@link com.amazon.ask.model.Directive} and {@link com.amazon.ask.model.canfulfill.CanFulfillIntent} + * and placeholders for injecting data. + * Injecting data provides component values to be injected into template. + * + * @param responseTemplateName name of response template + * @param dataMap a map that contains injecting data + * @return skill response + * @throws TemplateFactoryException if fail to load or render template using provided {@link com.amazon.ask.response.template.loader.TemplateLoader} or {@link com.amazon.ask.response.template.renderer.TemplateRenderer} + */ + public Optional generateTemplateResponse(String responseTemplateName, Map dataMap) throws TemplateFactoryException { + return Optional.of(templateFactory.processTemplate(responseTemplateName, dataMap, this)); + } + /** * Returns the {@link RequestEnvelope} of the incoming request. * @@ -120,6 +144,7 @@ public static final class Builder extends AbstractHandlerInput.Builder { + + protected LocalTemplateFileLoader(String directoryPath, String fileExtension, + ClassLoader classLoader, TemplateCache templateCache, + BiFunction> templateEnumeratorSupplier) { + super(directoryPath, fileExtension, classLoader, templateCache, templateEnumeratorSupplier); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends AbstractLocalTemplateFileLoader.Builder { + + public LocalTemplateFileLoader build() { + return new LocalTemplateFileLoader(directoryPath, fileExtension, + classLoader, templateCache, + templateEnumeratorSupplier == null + ? (BiFunction>) (s, handlerInput) -> LocaleTemplateEnumerator.builder().withTemplateName(s).withHandlerInput(handlerInput).build() + : templateEnumeratorSupplier); + } + } + +} diff --git a/ask-sdk-core/src/com/amazon/ask/response/template/loader/impl/LocaleTemplateEnumerator.java b/ask-sdk-core/src/com/amazon/ask/response/template/loader/impl/LocaleTemplateEnumerator.java new file mode 100644 index 00000000..d634b14d --- /dev/null +++ b/ask-sdk-core/src/com/amazon/ask/response/template/loader/impl/LocaleTemplateEnumerator.java @@ -0,0 +1,136 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader.impl; + +import com.amazon.ask.dispatcher.request.handler.HandlerInput; +import com.amazon.ask.response.template.loader.TemplateEnumerator; +import com.amazon.ask.util.ValidationUtils; +import org.slf4j.Logger; + +import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * Enumerate possible template name, given a locale "en-US" from the request, and a response template name "template", + * the following combinations will be tried: + *
    + *
  • template/en/US
  • + *
  • template/en_US
  • + *
  • template/en
  • + *
  • template_en_US
  • + *
  • template_en
  • + *
  • template
  • + *
+ */ +public final class LocaleTemplateEnumerator implements TemplateEnumerator { + + private static final Logger LOGGER = getLogger(LocaleTemplateEnumerator.class); + private static final String FILE_SEPARATOR = File.separator; + private static final String UNDERSCORE = "_"; + private static final int NULL_LOCALE_ENUMERATION_SIZE = 1; + private static final int NON_NULL_LOCALE_ENUMERATION_SIZE = 6; + + protected String templateName; + protected HandlerInput handlerInput; + private int enumerationSize; + private Matcher matcher; + private int cursor; + + protected LocaleTemplateEnumerator(String templateName, HandlerInput handlerInput) { + this.templateName = ValidationUtils.assertNotNull(templateName, "templateName"); + this.handlerInput = ValidationUtils.assertNotNull(handlerInput, "handlerInput"); + this.enumerationSize = getEnumerationSize(handlerInput); + this.cursor = 0; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Check whether next combination of template name is available. + * + * @return true if next combination of template name is available + */ + @Override + public boolean hasNext() { + return cursor < enumerationSize; + } + + /** + * Generate the next combination of template name and return. + * + * @return the next combination of template name + */ + @Override + public String next() { + if (hasNext()) { + if (enumerationSize == NULL_LOCALE_ENUMERATION_SIZE) { + cursor++; + return templateName; + } + final String language = matcher.group(1); + final String country = matcher.group(2); + switch (cursor) { + case 0: cursor++; return templateName + FILE_SEPARATOR + language + FILE_SEPARATOR + country; + case 1: cursor++; return templateName + FILE_SEPARATOR + language + UNDERSCORE + country; + case 2: cursor++; return templateName + FILE_SEPARATOR + language; + case 3: cursor++; return templateName + UNDERSCORE + language + UNDERSCORE + country; + case 4: cursor++; return templateName + UNDERSCORE + language; + case 5: cursor++; return templateName; + } + } + String message = "No next available template name combination."; + LOGGER.error(message); + throw new IllegalStateException(message); + } + + private int getEnumerationSize(HandlerInput handlerInput) { + String locale = handlerInput.getRequestEnvelope().getRequest().getLocale(); + if (locale == null || locale.isEmpty()) { + return NULL_LOCALE_ENUMERATION_SIZE; + } + Pattern localeParser = Pattern.compile("^([a-z]{2})\\-([A-Z]{2})$"); + matcher = localeParser.matcher(locale); + if (matcher.matches()) { + return NON_NULL_LOCALE_ENUMERATION_SIZE; + } + String message = String.format("Invalid locale: %s", locale); + LOGGER.error(message); + throw new IllegalArgumentException(message); + } + + public static final class Builder { + private String templateName; + private HandlerInput handlerInput; + + public Builder withTemplateName(String templateName) { + this.templateName = templateName; + return this; + } + + public Builder withHandlerInput(HandlerInput handlerInput) { + this.handlerInput = handlerInput; + return this; + } + + public LocaleTemplateEnumerator build() { + return new LocaleTemplateEnumerator(templateName, handlerInput); + } + } + +} diff --git a/ask-sdk-core/tst/com/amazon/ask/builder/SkillBuilderTest.java b/ask-sdk-core/tst/com/amazon/ask/builder/SkillBuilderTest.java index ac4693b1..d6fe366b 100644 --- a/ask-sdk-core/tst/com/amazon/ask/builder/SkillBuilderTest.java +++ b/ask-sdk-core/tst/com/amazon/ask/builder/SkillBuilderTest.java @@ -20,7 +20,6 @@ import com.amazon.ask.dispatcher.exception.ExceptionHandler; import com.amazon.ask.dispatcher.request.handler.HandlerInput; import com.amazon.ask.dispatcher.request.handler.RequestHandler; -import com.amazon.ask.dispatcher.request.handler.impl.DefaultHandlerAdapter; import com.amazon.ask.dispatcher.request.interceptor.RequestInterceptor; import com.amazon.ask.dispatcher.request.interceptor.ResponseInterceptor; import com.amazon.ask.model.Intent; @@ -30,6 +29,9 @@ import com.amazon.ask.model.services.ApiClient; import com.amazon.ask.module.SdkModule; import com.amazon.ask.module.SdkModuleContext; +import com.amazon.ask.response.template.TemplateFactory; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.response.template.renderer.TemplateRenderer; import org.junit.Before; import org.junit.Test; @@ -48,12 +50,14 @@ public class SkillBuilderTest { private CustomSkillBuilder builder; private RequestHandler mockRequestHandler; private ExceptionHandler mockExceptionHandler; + private TemplateFactory mockTemplateFactory; @Before public void setup() { builder = new CustomSkillBuilder(); mockRequestHandler = mock(RequestHandler.class); mockExceptionHandler = mock(ExceptionHandler.class); + mockTemplateFactory = mock(TemplateFactory.class); } @Test @@ -148,6 +152,20 @@ public void sdk_module_executed() { verify(mockModule).setupModule(any(SdkModuleContext.class)); } + @Test + public void template_factory_used() { + builder.withTemplateFactory(mockTemplateFactory); + SkillConfiguration config = builder.getConfigBuilder().build(); + assertEquals(config.getTemplateFactory(), mockTemplateFactory); + } + + @Test + public void template_factory_null() { + builder.withTemplateFactory(null); + SkillConfiguration config = builder.getConfigBuilder().build(); + assertNull(config.getTemplateFactory()); + } + private HandlerInput getInputForIntent(String intentName) { return HandlerInput.builder() .withRequestEnvelope(getRequestEnvelopeForIntent(intentName)) diff --git a/ask-sdk-core/tst/com/amazon/ask/builder/SkillConfigurationTest.java b/ask-sdk-core/tst/com/amazon/ask/builder/SkillConfigurationTest.java index 14a225fe..ef43eac2 100644 --- a/ask-sdk-core/tst/com/amazon/ask/builder/SkillConfigurationTest.java +++ b/ask-sdk-core/tst/com/amazon/ask/builder/SkillConfigurationTest.java @@ -25,6 +25,9 @@ import com.amazon.ask.attributes.persistence.PersistenceAdapter; import com.amazon.ask.dispatcher.exception.ExceptionMapper; import com.amazon.ask.dispatcher.request.mapper.RequestMapper; +import com.amazon.ask.response.template.TemplateFactory; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.response.template.renderer.TemplateRenderer; import org.junit.Before; import org.junit.Test; @@ -93,10 +96,17 @@ public void get_api_client() { } @Test - public void get_skillId() { + public void get_skill_id() { String skillId = "fooSkillId"; SkillConfiguration config = SkillConfiguration.builder().withSkillId(skillId).build(); assertEquals(skillId, config.getSkillId()); } + @Test + public void get_template_loaders() { + TemplateFactory templateFactory = mock(TemplateFactory.class); + SkillConfiguration config = SkillConfiguration.builder().withTemplateFactory(templateFactory).build(); + assertEquals(config.getTemplateFactory(), templateFactory); + } + } diff --git a/ask-sdk-core/tst/com/amazon/ask/dispatcher/request/handler/HandlerInputTest.java b/ask-sdk-core/tst/com/amazon/ask/dispatcher/request/handler/HandlerInputTest.java index d0af5dc1..7e477054 100644 --- a/ask-sdk-core/tst/com/amazon/ask/dispatcher/request/handler/HandlerInputTest.java +++ b/ask-sdk-core/tst/com/amazon/ask/dispatcher/request/handler/HandlerInputTest.java @@ -13,29 +13,77 @@ package com.amazon.ask.dispatcher.request.handler; +import com.amazon.ask.exception.template.TemplateFactoryException; import com.amazon.ask.model.Intent; import com.amazon.ask.model.IntentRequest; import com.amazon.ask.model.Request; import com.amazon.ask.model.RequestEnvelope; +import com.amazon.ask.model.Response; +import com.amazon.ask.response.template.impl.BaseTemplateFactory; +import org.junit.Before; import org.junit.Test; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; import java.util.function.Predicate; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMap; +import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class HandlerInputTest { + private static final String templateName = "fooTemplateName"; + private static final Map dataMap = new HashMap<>(); + + private RequestEnvelope requestEnvelope; + private BaseTemplateFactory mockTemplateFactory; + + @Before + public void setup() { + Request request = IntentRequest.builder().withIntent(Intent.builder().withName("fooIntent").build()).build(); + requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + mockTemplateFactory = mock(BaseTemplateFactory.class); + } + @Test public void matches_calls_given_predicate() { Predicate mockPredicate = mock(Predicate.class); - Request request = IntentRequest.builder().withIntent(Intent.builder().withName("fooIntent").build()).build(); - RequestEnvelope envelope = RequestEnvelope.builder().withRequest(request).build(); HandlerInput input = HandlerInput.builder() - .withRequestEnvelope(envelope) + .withRequestEnvelope(requestEnvelope) .build(); input.matches(mockPredicate); verify(mockPredicate).test(input); } + @Test + public void generate_template_response_success() { + Response response = Response.builder().build(); + when(mockTemplateFactory.processTemplate(anyString(), anyMap(), any(HandlerInput.class))).thenReturn((response)); + HandlerInput handlerInput = HandlerInput.builder() + .withRequestEnvelope(requestEnvelope) + .withTemplateFactory(mockTemplateFactory) + .build(); + Optional outputResponse = handlerInput.generateTemplateResponse(templateName, dataMap); + assertTrue(outputResponse.isPresent()); + assertEquals(outputResponse.get(), response); + verify(mockTemplateFactory).processTemplate(templateName, dataMap, handlerInput); + } + + @Test (expected = TemplateFactoryException.class) + public void generate_template_response_with_factory_exception() { + when(mockTemplateFactory.processTemplate(anyString(), anyMap(), any(HandlerInput.class))).thenThrow(mock(TemplateFactoryException.class)); + HandlerInput handlerInput = HandlerInput.builder() + .withRequestEnvelope(requestEnvelope) + .withTemplateFactory(mockTemplateFactory) + .build(); + handlerInput.generateTemplateResponse(templateName, dataMap); + } + } diff --git a/ask-sdk-core/tst/com/amazon/ask/response/template/FileLoaderTest.java b/ask-sdk-core/tst/com/amazon/ask/response/template/FileLoaderTest.java new file mode 100644 index 00000000..67be8967 --- /dev/null +++ b/ask-sdk-core/tst/com/amazon/ask/response/template/FileLoaderTest.java @@ -0,0 +1,131 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.dispatcher.request.handler.HandlerInput; +import com.amazon.ask.model.LaunchRequest; +import com.amazon.ask.model.Request; +import com.amazon.ask.model.RequestEnvelope; +import com.amazon.ask.response.template.loader.TemplateCache; +import com.amazon.ask.response.template.loader.impl.LocalTemplateFileLoader; +import org.junit.Before; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FileLoaderTest { + + private static final String TEST_TEMPLATE = "test_template"; + private static final String INVALID_TEMPLATE = "invalid_template"; + private static final String BASE_PATH = "com/amazon/ask/response/template/"; + private static final String EXTENSION = "txt"; + + private TemplateCache mockCache; + private LocalTemplateFileLoader fileLoader; + private HandlerInput handlerInput; + + @Before + public void setup() { + mockCache = mock(TemplateCache.class); + Request request = LaunchRequest.builder().withLocale("en-US").build(); + RequestEnvelope requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + handlerInput = HandlerInput.builder().withRequestEnvelope(requestEnvelope).build(); + } + + @Test + public void load_success_assert_template_content_data() { + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath(BASE_PATH) + .withFileExtension(EXTENSION) + .build(); + Optional template = fileLoader.load(TEST_TEMPLATE, handlerInput); + assertTrue(template != null && template.isPresent()); + TemplateContentData templateContentData = template.get(); + assertNotNull(templateContentData); + assertTrue(templateContentData.getIdentifier().contains(TEST_TEMPLATE)); + assertEquals(new String(templateContentData.getTemplateContent()), "empty"); + assertEquals(templateContentData.getTemplateBaseDir(), BASE_PATH); + } + + @Test + public void load_success_verify_no_cache() { + when(mockCache.get(anyString())).thenReturn(null); + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath(BASE_PATH) + .withFileExtension(EXTENSION) + .withTemplateCache(mockCache) + .build(); + Optional template = fileLoader.load(TEST_TEMPLATE, handlerInput); + assertTrue(template != null && template.isPresent()); + assertNotNull(template.get()); + verify(mockCache).put(anyString(), any(TemplateContentData.class)); + } + + @Test + public void load_success_verify_cache_hit() { + TemplateContentData mockTemplateContentData = mock(TemplateContentData.class); + when(mockCache.get(anyString())).thenReturn(mockTemplateContentData); + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath(BASE_PATH) + .withFileExtension(EXTENSION) + .withTemplateCache(mockCache) + .build(); + fileLoader.load(TEST_TEMPLATE, handlerInput); + fileLoader.load(TEST_TEMPLATE, handlerInput); + verify(mockCache, never()).put(anyString(), any(TemplateContentData.class)); + } + + @Test + public void load_invalid_template() { + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath(BASE_PATH) + .withFileExtension(EXTENSION) + .build(); + Optional template = fileLoader.load(INVALID_TEMPLATE, handlerInput); + assertEquals(template, Optional.empty()); + } + + @Test + public void load_invalid_base_directory() { + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath("invalid_base_dir") + .withFileExtension(EXTENSION) + .build(); + Optional template = fileLoader.load(TEST_TEMPLATE, handlerInput); + assertEquals(template, Optional.empty()); + } + + @Test (expected = IllegalArgumentException.class) + public void load_invalid_locale() { + Request request = LaunchRequest.builder().withLocale("en").build(); + RequestEnvelope requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + handlerInput = HandlerInput.builder().withRequestEnvelope(requestEnvelope).build(); + fileLoader = LocalTemplateFileLoader.builder() + .withDirectoryPath(BASE_PATH) + .withFileExtension(EXTENSION) + .build(); + fileLoader.load(TEST_TEMPLATE, handlerInput); + } + +} diff --git a/ask-sdk-core/tst/com/amazon/ask/response/template/LocaleEnumeratorTest.java b/ask-sdk-core/tst/com/amazon/ask/response/template/LocaleEnumeratorTest.java new file mode 100644 index 00000000..e94f46bd --- /dev/null +++ b/ask-sdk-core/tst/com/amazon/ask/response/template/LocaleEnumeratorTest.java @@ -0,0 +1,73 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.dispatcher.request.handler.HandlerInput; +import com.amazon.ask.model.LaunchRequest; +import com.amazon.ask.model.Request; +import com.amazon.ask.model.RequestEnvelope; +import com.amazon.ask.response.template.loader.impl.LocaleTemplateEnumerator; +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LocaleEnumeratorTest { + + private static final String LOCALE = "en-US"; + private static final String INVALID_LOCALE = "en"; + private static final String TEMPLATE_NAME = "template"; + private static final String TEMPLATE_CANDIDATE = TEMPLATE_NAME + File.separator + "en" + File.separator + "US"; + + private Request request; + private RequestEnvelope requestEnvelope; + private HandlerInput mockHandlerInput; + private LocaleTemplateEnumerator localeTemplateEnumerator; + + @Test + public void empty_locale_enumeration() { + request = LaunchRequest.builder().build(); + requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + mockHandlerInput = mock(HandlerInput.class); + when(mockHandlerInput.getRequestEnvelope()).thenReturn(requestEnvelope); + localeTemplateEnumerator = LocaleTemplateEnumerator.builder().withTemplateName(TEMPLATE_NAME).withHandlerInput(mockHandlerInput).build(); + assertTrue(localeTemplateEnumerator.hasNext()); + assertEquals(localeTemplateEnumerator.next(), TEMPLATE_NAME); + } + + @Test + public void valid_locale_enumeration() { + request = LaunchRequest.builder().withLocale(LOCALE).build(); + requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + mockHandlerInput = mock(HandlerInput.class); + when(mockHandlerInput.getRequestEnvelope()).thenReturn(requestEnvelope); + localeTemplateEnumerator = LocaleTemplateEnumerator.builder().withTemplateName(TEMPLATE_NAME).withHandlerInput(mockHandlerInput).build(); + assertTrue(localeTemplateEnumerator.hasNext()); + assertEquals(localeTemplateEnumerator.next(), TEMPLATE_CANDIDATE); + } + + @Test (expected = IllegalArgumentException.class) + public void invalid_locale_enumeration() { + request = LaunchRequest.builder().withLocale(INVALID_LOCALE).build(); + requestEnvelope = RequestEnvelope.builder().withRequest(request).build(); + mockHandlerInput = mock(HandlerInput.class); + when(mockHandlerInput.getRequestEnvelope()).thenReturn(requestEnvelope); + localeTemplateEnumerator = LocaleTemplateEnumerator.builder().withTemplateName(TEMPLATE_NAME).withHandlerInput(mockHandlerInput).build(); + } + +} diff --git a/ask-sdk-core/tst/com/amazon/ask/response/template/test_template/en/US.txt b/ask-sdk-core/tst/com/amazon/ask/response/template/test_template/en/US.txt new file mode 100644 index 00000000..7b4d68d7 --- /dev/null +++ b/ask-sdk-core/tst/com/amazon/ask/response/template/test_template/en/US.txt @@ -0,0 +1 @@ +empty \ No newline at end of file diff --git a/ask-sdk-freemarker-renderer/pom.xml b/ask-sdk-freemarker-renderer/pom.xml new file mode 100644 index 00000000..facdaaee --- /dev/null +++ b/ask-sdk-freemarker-renderer/pom.xml @@ -0,0 +1,117 @@ + + 4.0.0 + + com.amazon.alexa + ask-sdk-pom + 2.16.0 + + com.amazon.alexa + ask-sdk-freemarker + jar + 2.16.0 + ASK SDK for Java FreeMarker Template Renderer + Template Renderer for ASK SDK Java using the Apache Freemarker template engine. + http://developer.amazon.com/ask + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Alexa Skills Kit + ask-sdk-java@amazon.com + Alexa + http://developer.amazon.com/ask + + + + scm:git:https://github.com/amzn/alexa-skills-kit-java.git + scm:git:https://github.com/amzn/alexa-skills-kit-java.git + https://github.com/amzn/alexa-skills-kit-java.git + + + + + com.amazon.alexa + ask-sdk-runtime + 2.16.0 + + + org.freemarker + freemarker + 2.3.28 + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.8.2 + + + junit + junit + 4.12 + test + + + org.powermock + powermock-api-mockito + 1.7.3 + test + + + + + src + tst + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + 1.8 + 1.8 + true + + + + + + + + publishing + + + + + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + sonatype-nexus-staging + https://oss.sonatype.org + + + + + + + + diff --git a/ask-sdk-freemarker-renderer/src/com/amazon/ask/response/template/renderer/impl/FreeMarkerTemplateRenderer.java b/ask-sdk-freemarker-renderer/src/com/amazon/ask/response/template/renderer/impl/FreeMarkerTemplateRenderer.java new file mode 100644 index 00000000..a2dcadef --- /dev/null +++ b/ask-sdk-freemarker-renderer/src/com/amazon/ask/response/template/renderer/impl/FreeMarkerTemplateRenderer.java @@ -0,0 +1,138 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.renderer.impl; + +import com.amazon.ask.exception.AskSdkException; +import com.amazon.ask.exception.template.TemplateRendererException; +import com.amazon.ask.response.template.TemplateContentData; +import com.amazon.ask.response.template.renderer.TemplateRenderer; +import com.amazon.ask.util.JsonUnmarshaller; +import com.amazon.ask.util.ValidationUtils; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import org.slf4j.Logger; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * {@link TemplateRenderer} implementation to render a FreeMarker template, and deserialize to skill response output. + */ +public class FreeMarkerTemplateRenderer implements TemplateRenderer { + + private static final Logger LOGGER = getLogger(FreeMarkerTemplateRenderer.class); + private static final Charset STANDARD_CHARSET = StandardCharsets.UTF_8; + + protected final Configuration configuration; + protected final JsonUnmarshaller unmarshaller; + + protected FreeMarkerTemplateRenderer(Configuration configuration, JsonUnmarshaller unmarshaller) { + this.configuration = configuration == null ? buildConfig() : configuration; + this.unmarshaller = ValidationUtils.assertNotNull(unmarshaller, "unmarshaller"); + } + + private Configuration buildConfig() { + Configuration configuration = new Configuration(Configuration.VERSION_2_3_24); + configuration.setDefaultEncoding(STANDARD_CHARSET.toString()); + configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + configuration.setLogTemplateExceptions(true); + return configuration; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Render {@link TemplateContentData} by processing data and deserialize to skill response output. + * + * @param templateContentData {@link TemplateContentData} that contains template content + * @param dataMap map that contains injecting data + * @return skill response output. + * @throws TemplateRendererException if fail to render template or deserialize to skill response output. + */ + @Override + public Output render(TemplateContentData templateContentData, + Map dataMap) throws TemplateRendererException { + byte[] contentBytes = templateContentData.getTemplateContent(); + try (Reader reader = new InputStreamReader(new ByteArrayInputStream(contentBytes), STANDARD_CHARSET); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + Writer writer = new OutputStreamWriter(byteArrayOutputStream, STANDARD_CHARSET)) { + updateLoadingConfig(templateContentData); + Template template = new Template(templateContentData.getIdentifier(), reader, configuration); + template.process(dataMap, writer); + return unmarshaller.unmarshall(byteArrayOutputStream.toByteArray()).get().getUnmarshalledRequest(); + } catch (IOException e) { + String exceptionMsg = "Unable to process invalid template"; + throw throwException(exceptionMsg, e, templateContentData, dataMap); + } catch (TemplateException e) { + String exceptionMsg = "Unable to process template"; + throw throwException(exceptionMsg, e, templateContentData, dataMap); + } catch (AskSdkException e) { + String exceptionMsg = "Unable to unmarshall template"; + throw throwException(exceptionMsg, e, templateContentData, dataMap); + } + } + + private TemplateRendererException throwException(String exceptionMessage, Exception e, + TemplateContentData templateContentData, + Map dataMap) { + String exceptionMsg = exceptionMessage + " with identifier: %s using data model map: %s with error: %s "; + if (LOGGER.isTraceEnabled()) { + String traceMsg = String.format(exceptionMsg + "with template content data: %s.", + templateContentData.getIdentifier(), dataMap, e.getMessage(), templateContentData); + LOGGER.trace(traceMsg); + } + return new TemplateRendererException(String.format(exceptionMessage, + templateContentData.getIdentifier(), dataMap, e.getMessage())); + } + + private void updateLoadingConfig(TemplateContentData templateContentData) { + String baseDir = templateContentData.getTemplateBaseDir(); + ClassLoader classLoader = this.getClass().getClassLoader(); + configuration.setClassLoaderForTemplateLoading(classLoader, baseDir); + } + + public static final class Builder { + private Configuration configuration; + private JsonUnmarshaller unmarshaller; + + public Builder withConfiguration(Configuration configuration) { + this.configuration = configuration; + return this; + } + + public Builder withUnmarshaller(JsonUnmarshaller unmarshaller) { + this.unmarshaller = unmarshaller; + return this; + } + + public FreeMarkerTemplateRenderer build() { + return new FreeMarkerTemplateRenderer(configuration, unmarshaller); + } + } + +} diff --git a/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/FreeMarkerRendererTest.java b/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/FreeMarkerRendererTest.java new file mode 100644 index 00000000..102140e9 --- /dev/null +++ b/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/FreeMarkerRendererTest.java @@ -0,0 +1,87 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.exception.template.TemplateRendererException; +import com.amazon.ask.response.template.renderer.impl.FreeMarkerTemplateRenderer; +import com.amazon.ask.util.impl.JacksonJsonUnmarshaller; +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FreeMarkerRendererTest { + + private static final String TEMPLATE = + "{\n" + + " \"type\": \"PlainText\",\n" + + " \"text\": \"${outputSpeechText}\"\n" + + "}"; + + private static final String EMPTY_TEMPLATE_JSON = "{ }"; + + private TemplateContentData mockTemplateContentData; + private JacksonJsonUnmarshaller unmarshaller; + private FreeMarkerTemplateRenderer freeMarkerTemplateRenderer; + + @Before + public void setup() { + mockTemplateContentData = mock(TemplateContentData.class); + when(mockTemplateContentData.getTemplateBaseDir()).thenReturn("base_dir"); + unmarshaller = JacksonJsonUnmarshaller.withTypeBinding(TestResponse.class); + freeMarkerTemplateRenderer = FreeMarkerTemplateRenderer.builder().withUnmarshaller(unmarshaller).build(); + } + + @Test + public void render_success() { + Map dataMap = new HashMap<>(); + dataMap.put("outputSpeechText", "Hello"); + byte[] templateBytes = TEMPLATE.getBytes(); + when(mockTemplateContentData.getTemplateContent()).thenReturn(templateBytes); + TestResponse response = (TestResponse) freeMarkerTemplateRenderer.render(mockTemplateContentData, dataMap); + assertNotNull(response); + assertEquals(response.getType(), "PlainText"); + assertEquals(response.getText(), "Hello"); + } + + @Test + public void render_empty_template_json() { + when(mockTemplateContentData.getTemplateContent()).thenReturn(EMPTY_TEMPLATE_JSON.getBytes()); + TestResponse response = (TestResponse) freeMarkerTemplateRenderer.render(mockTemplateContentData, new HashMap<>()); + assertNotNull(response); + assertNull(response.getType()); + } + + @Test (expected = TemplateRendererException.class) + public void render_process_exception() { + byte[] templateBytes = TEMPLATE.getBytes(); + when(mockTemplateContentData.getTemplateContent()).thenReturn(templateBytes); + freeMarkerTemplateRenderer.render(mockTemplateContentData, new HashMap<>()); + } + + @Test (expected = TemplateRendererException.class) + public void render_convert_exception() { + byte[] bytes = "invalid".getBytes(); + when(mockTemplateContentData.getTemplateContent()).thenReturn(bytes); + freeMarkerTemplateRenderer.render(mockTemplateContentData, null); + } + +} diff --git a/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/TestResponse.java b/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/TestResponse.java new file mode 100644 index 00000000..c9ee18fd --- /dev/null +++ b/ask-sdk-freemarker-renderer/tst/com/amazon/ask/response/template/TestResponse.java @@ -0,0 +1,37 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +public class TestResponse { + + private String type; + private String text; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateFactoryException.java b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateFactoryException.java new file mode 100644 index 00000000..151d829b --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateFactoryException.java @@ -0,0 +1,31 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.exception.template; + +import com.amazon.ask.exception.AskSdkException; + +/** + * Exception thrown when an exception is encountered while {@link com.amazon.ask.response.template.TemplateFactory} is processing template. + */ +public class TemplateFactoryException extends AskSdkException { + + public TemplateFactoryException(String message) { + super(message); + } + + public TemplateFactoryException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateLoaderException.java b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateLoaderException.java new file mode 100644 index 00000000..fa494166 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateLoaderException.java @@ -0,0 +1,29 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.exception.template; + +/** + * Exception thrown when an exception is encountered while {@link com.amazon.ask.response.template.loader.TemplateLoader} is loading template. + */ +public class TemplateLoaderException extends TemplateFactoryException { + + public TemplateLoaderException(String message) { + super(message); + } + + public TemplateLoaderException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateRendererException.java b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateRendererException.java new file mode 100644 index 00000000..9b0d95bc --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/exception/template/TemplateRendererException.java @@ -0,0 +1,29 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.exception.template; + +/** + * Exception thrown when an exception is encountered while {@link com.amazon.ask.response.template.renderer.TemplateRenderer} is rendering template. + */ +public class TemplateRendererException extends TemplateFactoryException { + + public TemplateRendererException(String message) { + super(message); + } + + public TemplateRendererException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateContentData.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateContentData.java new file mode 100644 index 00000000..032193a8 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateContentData.java @@ -0,0 +1,87 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.util.ValidationUtils; + +/** + * Abstraction of template content using byte array with unique identifier, base directory information will be used in resolving template import. + */ +public class TemplateContentData { + + protected final String identifier; + protected final byte[] templateContent; + protected final String templateBaseDir; + + public TemplateContentData(String identifier, byte[] templateContent, String templateBaseDir) { + this.identifier = ValidationUtils.assertNotNull(identifier, "identifier"); + this.templateContent = ValidationUtils.assertNotNull(templateContent, "templateContent"); + this.templateBaseDir = ValidationUtils.assertNotNull(templateBaseDir, "templateBaseDir"); + } + + public static Builder builder() { + return new Builder(); + } + + public String getIdentifier() { + return identifier; + } + + public byte[] getTemplateContent() { + return templateContent; + } + + public String getTemplateBaseDir() { + return templateBaseDir; + } + + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TemplateContentData {\n"); + sb.append(" templateContent: ").append(this.toIndentedString(new String(this.templateContent))).append("\n"); + sb.append(" templateBaseDir: ").append(this.toIndentedString(this.templateBaseDir)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + private String toIndentedString(Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } + + public static final class Builder { + private String identifier; + private byte[] templateContent; + private String templateBaseDir; + + public Builder withIdentifier(String identifier) { + this.identifier = identifier; + return this; + } + + public Builder withTemplateContent(byte[] templateContent) { + this.templateContent = templateContent; + return this; + } + + public Builder withTemplateBaseDir(String templateBaseDir) { + this.templateBaseDir = templateBaseDir; + return this; + } + + public TemplateContentData build() { + return new TemplateContentData(identifier, templateContent, templateBaseDir); + } + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateFactory.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateFactory.java new file mode 100644 index 00000000..cd1b0267 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/TemplateFactory.java @@ -0,0 +1,36 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.exception.template.TemplateFactoryException; + +import java.util.Map; + +/** + * Template Factory interface to process template and data to generate skill response. + */ +public interface TemplateFactory { + + /** + * Process response template and data to generate skill response. + * + * @param responseTemplateName template name + * @param dataMap map that contains template injecting data + * @param input skill request input + * @return skill response output + * @throws TemplateFactoryException if fail to process template + */ + Output processTemplate(String responseTemplateName, Map dataMap, Input input) throws TemplateFactoryException; + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/impl/BaseTemplateFactory.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/impl/BaseTemplateFactory.java new file mode 100644 index 00000000..49c821c9 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/impl/BaseTemplateFactory.java @@ -0,0 +1,131 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.impl; + +import com.amazon.ask.exception.template.TemplateFactoryException; +import com.amazon.ask.response.template.TemplateContentData; +import com.amazon.ask.response.template.TemplateFactory; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.response.template.renderer.TemplateRenderer; +import org.slf4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * {@link TemplateFactory} implementation to chain {@link TemplateLoader} and {@link TemplateRenderer}. + * It is responsible to pass in template name, data map and Input to get response output for skill request. + */ +public class BaseTemplateFactory implements TemplateFactory { + + private static final Logger LOGGER = getLogger(BaseTemplateFactory.class); + + protected final List> templateLoaders; + protected final TemplateRenderer templateRenderer; + + protected BaseTemplateFactory(List> templateLoaders, TemplateRenderer templateRenderer) { + this.templateLoaders = templateLoaders; + this.templateRenderer = templateRenderer; + } + + public static > Builder forTypes( + Class input, Class output) { + return new Builder<>(); + } + + public static Builder builder() { + return new Builder<>(); + } + + /** + * Process template and data using provided {@link TemplateLoader} and {@link TemplateRenderer} to generate skill response output. + * + * @param responseTemplateName name of response template + * @param dataMap map contains injecting data + * @param input skill input + * @return Output skill response output if loading and rendering successfully + * @throws TemplateFactoryException if fail to load or render template + */ + @Override + public Output processTemplate(String responseTemplateName, Map dataMap, Input input) throws TemplateFactoryException { + if (templateLoaders == null || templateLoaders.isEmpty() || templateRenderer == null) { + String message = "Template Loader list is null or empty, or Template Renderer is null."; + LOGGER.error(message); + throw new TemplateFactoryException(message); + } + TemplateContentData templateContentData = loadTemplate(responseTemplateName, input); + Output response = renderResponse(templateContentData, dataMap); + return response; + } + + private TemplateContentData loadTemplate(String responseTemplateName, Input input) throws TemplateFactoryException { + Optional templateContentData; + for (TemplateLoader templateLoader : templateLoaders) { + try { + templateContentData = templateLoader.load(responseTemplateName, input); + if (templateContentData != null && templateContentData.isPresent()) { + return templateContentData.get(); + } + } catch (TemplateFactoryException e) { + LOGGER.error(String.format("Fail to load template: %s using %s with error: %s.", responseTemplateName, templateLoader, e.getMessage())); + throw e; + } + } + String message = String.format("Unable to load template: %s using provided Loader(s).", responseTemplateName); + LOGGER.error(message); + throw new TemplateFactoryException(message); + } + + private Output renderResponse(TemplateContentData templateContentData, Map dataMap) throws TemplateFactoryException { + try { + return templateRenderer.render(templateContentData, dataMap); + } catch (TemplateFactoryException e) { + LOGGER.error(String.format("Fail to render template: %s using %s with error: %s.", templateContentData, templateRenderer, e.getMessage())); + throw e; + } + } + + public static class Builder> { + protected List> templateLoaders; + protected TemplateRenderer templateRenderer; + + protected Builder() { + this.templateLoaders = new ArrayList<>(); + } + + public Self addTemplateLoader(TemplateLoader templateLoader) { + this.templateLoaders.add(templateLoader); + return (Self)this; + } + + public Self addTemplateLoaders(List> templateLoaders) { + this.templateLoaders.addAll(templateLoaders); + return (Self)this; + } + + public Self withTemplateRenderer(TemplateRenderer templateRenderer) { + this.templateRenderer = templateRenderer; + return (Self)this; + } + + public BaseTemplateFactory build() { + return new BaseTemplateFactory<>(templateLoaders, templateRenderer); + } + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateCache.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateCache.java new file mode 100644 index 00000000..a230d02c --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateCache.java @@ -0,0 +1,39 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader; + +import com.amazon.ask.response.template.TemplateContentData; + +/** + * Cache interface for template caching. + */ +public interface TemplateCache { + + /** + * Retrieve {@link TemplateContentData} from cache. + * + * @param identifier template identifier + * @return return {@link TemplateContentData} if cache hits + */ + TemplateContentData get(String identifier); + + /** + * Insert {@link TemplateContentData} into cache, assign identifier to entry. + * + * @param identifier template identifier + * @param templateContentData {@link TemplateContentData} + */ + void put(String identifier, TemplateContentData templateContentData); + +} \ No newline at end of file diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateEnumerator.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateEnumerator.java new file mode 100644 index 00000000..aa1bb8b4 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateEnumerator.java @@ -0,0 +1,23 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader; + +import java.util.Iterator; + +/** + * Enumerator interface to enumerate template name based on specific property. + */ +public interface TemplateEnumerator extends Iterator { + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateLoader.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateLoader.java new file mode 100644 index 00000000..9d643a4d --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/TemplateLoader.java @@ -0,0 +1,36 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader; + +import com.amazon.ask.exception.template.TemplateLoaderException; +import com.amazon.ask.response.template.TemplateContentData; + +import java.util.Optional; + +/** + * Loader interface for template loading from data store. + */ +public interface TemplateLoader { + + /** + * Given template name, load template content from data store. + * + * @param responseTemplateName template name + * @param input skill input + * @return {@link TemplateContentData} contains template content + * @throws TemplateLoaderException if fail to load template + */ + Optional load(String responseTemplateName, Input input) throws TemplateLoaderException; + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AbstractLocalTemplateFileLoader.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AbstractLocalTemplateFileLoader.java new file mode 100644 index 00000000..1ec0e755 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AbstractLocalTemplateFileLoader.java @@ -0,0 +1,160 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader.impl; + +import com.amazon.ask.exception.template.TemplateLoaderException; +import com.amazon.ask.response.template.TemplateContentData; +import com.amazon.ask.response.template.loader.TemplateCache; +import com.amazon.ask.response.template.loader.TemplateEnumerator; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.util.ValidationUtils; +import org.slf4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Optional; +import java.util.function.BiFunction; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * {@link TemplateLoader} abstract implementation to load template file from local file system, build and return {@link TemplateContentData}. + */ +public abstract class AbstractLocalTemplateFileLoader implements TemplateLoader { + + private static final Logger LOGGER = getLogger(AbstractLocalTemplateFileLoader.class); + private static final String FILE_EXTENSION_DELIMITER = "."; + + protected final String directoryPath; + protected final String fileExtension; + protected final ClassLoader classLoader; + protected final TemplateCache templateCache; + protected final BiFunction> templateEnumeratorSupplier; + + protected AbstractLocalTemplateFileLoader(String directoryPath, String fileExtension, + ClassLoader classLoader, TemplateCache templateCache, + BiFunction> templateEnumeratorSupplier) { + this.directoryPath = ValidationUtils.assertNotNull(directoryPath, "directoryPath"); + this.fileExtension = ValidationUtils.assertNotNull(fileExtension, "fileExtension"); + this.classLoader = ValidationUtils.assertNotNull(classLoader, "classLoader"); + this.templateCache = templateCache == null ? ConcurrentLRUTemplateCache.builder().build() : templateCache; + this.templateEnumeratorSupplier = ValidationUtils.assertNotNull(templateEnumeratorSupplier, "templateEnumeratorSupplier"); + } + + /** + * {@inheritDoc} + * + * Load template file content from local file system, using {@link TemplateEnumerator} and {@link TemplateCache}. + */ + @Override + public Optional load(String responseTemplateName, Input input) throws TemplateLoaderException { + TemplateEnumerator templateEnumerator = templateEnumeratorSupplier.apply(responseTemplateName, input); + while (templateEnumerator.hasNext()) { + String templateName = (String) templateEnumerator.next(); + String templatePath = buildCompletePath(templateName); + try { + URI templateUri = getResourceURI(templatePath); + if (templateUri != null) { + String templateIdentifier = templateUri.toString(); + TemplateContentData templateContentData = templateCache.get(templateIdentifier); + if (templateContentData == null) { + try (InputStream inputStream = getTemplateAsStream(templatePath)) { + byte[] templateByteArray = new byte[inputStream.available()]; + inputStream.read(templateByteArray); + templateContentData = TemplateContentData.builder() + .withIdentifier(templateIdentifier) + .withTemplateContent(templateByteArray) + .withTemplateBaseDir(directoryPath) + .build(); + } catch (IOException e) { + String message = String.format("Fail to read template file: %s with error: %s", templatePath, e.getMessage()); + LOGGER.error(message); + throw new TemplateLoaderException(message); + } + templateCache.put(templateIdentifier, templateContentData); + } + return Optional.of(templateContentData); + } + } catch (URISyntaxException e) { + String message = String.format("Cannot get valid URI for template file path: %s with error: %s", templatePath, e.getMessage()); + LOGGER.error(message); + throw new TemplateLoaderException(message); + } + } + String message = String.format("Cannot find template file: %s given directory path: %s and file extension: %s, returning empty.", + responseTemplateName, directoryPath, fileExtension); + LOGGER.warn(message); + return Optional.empty(); + } + + private String buildCompletePath(String candidate) { + char directoryPathLastChar = directoryPath.charAt(directoryPath.length() - 1); + if(String.valueOf(directoryPathLastChar).equals(File.separator)) { + return this.directoryPath + candidate + FILE_EXTENSION_DELIMITER + fileExtension; + } + return this.directoryPath + File.separator + candidate + FILE_EXTENSION_DELIMITER + fileExtension; + } + + private InputStream getTemplateAsStream(String templatePath) { + return this.classLoader.getResourceAsStream(templatePath); + } + + private URI getResourceURI(String templatePath) throws URISyntaxException { + URL url = this.classLoader.getResource(templatePath); + return url == null ? null : url.toURI(); + } + + public abstract static class Builder> { + protected String directoryPath; + protected String fileExtension; + protected ClassLoader classLoader; + protected TemplateCache templateCache; + protected BiFunction> templateEnumeratorSupplier; + + protected Builder() { + this.classLoader = this.getClass().getClassLoader(); + } + + public Self withDirectoryPath(String directoryPath) { + this.directoryPath = directoryPath; + return (Self) this; + } + public Self withFileExtension(String fileExtension) { + this.fileExtension = fileExtension; + return (Self)this; + } + + public Self withClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + return (Self)this; + } + + public Self withTemplateCache(TemplateCache templateCache) { + this.templateCache = templateCache; + return (Self)this; + } + + public Self withTemplateEnumeratorSupplier(BiFunction> templateEnumeratorSupplier) { + this.templateEnumeratorSupplier = templateEnumeratorSupplier; + return (Self)this; + } + + public abstract AbstractLocalTemplateFileLoader build(); + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AccessOrderedTemplateContentData.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AccessOrderedTemplateContentData.java new file mode 100644 index 00000000..5984b944 --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/AccessOrderedTemplateContentData.java @@ -0,0 +1,63 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader.impl; + +import com.amazon.ask.response.template.TemplateContentData; +import com.amazon.ask.util.ValidationUtils; + +/** + * Time based wrapper of {@link TemplateContentData} for {@link ConcurrentLRUTemplateCache} to manage. + */ +public class AccessOrderedTemplateContentData { + + protected final TemplateContentData templateContentData; + + private long accessTimestamp; + + public AccessOrderedTemplateContentData(TemplateContentData templateContentData) { + this.templateContentData = ValidationUtils.assertNotNull(templateContentData, "templateContentData"); + this.accessTimestamp = System.currentTimeMillis(); + } + + public static Builder builder() { + return new Builder(); + } + + public long getAccessTimestamp() { + return this.accessTimestamp; + } + + public TemplateContentData getTemplateContentData() { + updateTimestamp(); + return this.templateContentData; + } + + private void updateTimestamp() { + this.accessTimestamp = System.currentTimeMillis(); + } + + public static final class Builder { + private TemplateContentData templateContentData; + + public Builder withTemplateContentData(TemplateContentData templateContentData) { + this.templateContentData = templateContentData; + return this; + } + + public AccessOrderedTemplateContentData build() { + return new AccessOrderedTemplateContentData(templateContentData); + } + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/ConcurrentLRUTemplateCache.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/ConcurrentLRUTemplateCache.java new file mode 100644 index 00000000..20021f1a --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/loader/impl/ConcurrentLRUTemplateCache.java @@ -0,0 +1,184 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.loader.impl; + +import com.amazon.ask.response.template.TemplateContentData; +import com.amazon.ask.response.template.loader.TemplateCache; +import org.slf4j.Logger; + +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * {@link TemplateCache} implementation to cache {@link TemplateContentData} using LRU replacement policy based on access order. + * If no capacity specified, use default value of 5 MB. + * If no time to live threshold specified, use default value of 1 day. + */ +public class ConcurrentLRUTemplateCache implements TemplateCache { + + private static final long DEFAULT_CAPACITY = 1000 * 1000 * 5; + private static final long DEFAULT_TIME_TO_LIVE_THRESHOLD = 1000 * 60 * 60 * 24; + private static final int INITIAL_QUEUE_CAPACITY = 11; + + private static final Logger LOGGER = getLogger(ConcurrentLRUTemplateCache.class); + + protected final long capacity; + protected final long timeToLiveThreshold; + protected final Map templateDataMap; + protected final Queue templateOrderQueue; + + private AtomicInteger capacityCounter; + private Map locksMap; + + protected ConcurrentLRUTemplateCache(long capacity, long timeToLiveThreshold) { + this.capacity = capacity; + this.timeToLiveThreshold = timeToLiveThreshold; + this.templateDataMap = new ConcurrentHashMap<>(); + this.templateOrderQueue = new PriorityBlockingQueue<>(INITIAL_QUEUE_CAPACITY, + (AccessOrderedTemplateContentData data1, AccessOrderedTemplateContentData data2) -> + (int) (data1.getAccessTimestamp() - data2.getAccessTimestamp())); + this.capacityCounter = new AtomicInteger(0); + this.locksMap = new ConcurrentHashMap<>(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Get cache size. + * + * @return cache size + */ + public int size() { + int cacheSize = templateDataMap.size(); + return cacheSize; + } + + /** + * Check whether cache is empty. + * + * @return true if cache is empty + */ + public boolean isEmpty() { + return templateDataMap.isEmpty(); + } + + /** + * Get cache current capacity. + * @return cache current capacity + */ + public int getCurrentCapacity() { + return this.capacityCounter.get(); + } + + /** + * {@inheritDoc} + * + * If template size is larger than total cache capacity, no caching. + * If there's not enough capacity for new entry, remove eldest ones until have capacity to insert. + */ + @Override + synchronized public void put(String identifier, TemplateContentData templateContentData) { + if (!locksMap.containsKey(identifier)) { + locksMap.put(identifier, new Object()); + } + Object lock = locksMap.get(identifier); + synchronized (lock) { + int size = templateContentData.getTemplateContent().length; + if (size > capacity) { + LOGGER.warn(String.format("No caching for template with size: %s larger than total capacity: %s.", size, capacity)); + return; + } + if (templateDataMap.containsKey(identifier)) { + LOGGER.info(String.format("Try to put the same template with identifier: %s into cache, removing duplicate entry in queue.", identifier)); + templateOrderQueue.remove(templateDataMap.get(identifier)); + } + while (size + capacityCounter.get() > capacity) { + AccessOrderedTemplateContentData eldest = templateOrderQueue.poll(); + TemplateContentData eldestTemplate = eldest.getTemplateContentData(); + templateDataMap.remove(eldestTemplate.getIdentifier()); + deductAndGet(eldestTemplate.getTemplateContent().length); + } + AccessOrderedTemplateContentData data = AccessOrderedTemplateContentData.builder() + .withTemplateContentData(templateContentData) + .build(); + templateOrderQueue.offer(data); + templateDataMap.put(identifier, data); + capacityCounter.addAndGet(size); + } + } + + /** + * {@inheritDoc} + * + * @return {@link TemplateContentData} if exists and it is fresh, otherwise return null + */ + @Override + public TemplateContentData get(String identifier) { + Object lock = locksMap.get(identifier); + if (lock != null) { + synchronized (lock) { + AccessOrderedTemplateContentData data = templateDataMap.get(identifier); + if ((data != null) && templateOrderQueue.contains(data)) { + templateOrderQueue.remove(data); + if (isFresh(data)) { + TemplateContentData templateContentData = data.getTemplateContentData(); + templateOrderQueue.offer(data); + return templateContentData; + } + templateDataMap.remove(identifier); + deductAndGet(data.getTemplateContentData().getTemplateContent().length); + LOGGER.warn(String.format("Template: %s is out of date, removing.", identifier)); + } + } + } + return null; + } + + private boolean isFresh(AccessOrderedTemplateContentData data) { + long current = System.currentTimeMillis(); + long dataTimestamp = data.getAccessTimestamp(); + return (current - dataTimestamp) < timeToLiveThreshold; + } + + private int deductAndGet(int delta) { + return capacityCounter.addAndGet(Math.negateExact(delta)); + } + + public static class Builder { + private long capacity = DEFAULT_CAPACITY; + private long timeToLiveThreshold = DEFAULT_TIME_TO_LIVE_THRESHOLD; + + public Builder withCapacity(long capacity) { + this.capacity = capacity; + return this; + } + + public Builder withLiveTimeThreshold(long liveTimeThreshold) { + this.timeToLiveThreshold = liveTimeThreshold; + return this; + } + + public ConcurrentLRUTemplateCache build() { + return new ConcurrentLRUTemplateCache(this.capacity, this.timeToLiveThreshold); + } + } + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/response/template/renderer/TemplateRenderer.java b/ask-sdk-runtime/src/com/amazon/ask/response/template/renderer/TemplateRenderer.java new file mode 100644 index 00000000..85a0a2ae --- /dev/null +++ b/ask-sdk-runtime/src/com/amazon/ask/response/template/renderer/TemplateRenderer.java @@ -0,0 +1,36 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template.renderer; + +import com.amazon.ask.exception.template.TemplateRendererException; +import com.amazon.ask.response.template.TemplateContentData; + +import java.util.Map; + +/** + * Renderer interface for template rendering and response conversion. + */ +public interface TemplateRenderer { + + /** + * Render template and data, and convert to skill response output. + * + * @param templateContentData {@link TemplateContentData} that contains template content + * @param dataMap map that contains injecting data to template + * @return response to skill + * @throws TemplateRendererException if fail to render and convert template + */ + Output render(TemplateContentData templateContentData, Map dataMap) throws TemplateRendererException; + +} diff --git a/ask-sdk-runtime/src/com/amazon/ask/util/impl/JacksonJsonUnmarshaller.java b/ask-sdk-runtime/src/com/amazon/ask/util/impl/JacksonJsonUnmarshaller.java index 740f3828..fff3ebc3 100644 --- a/ask-sdk-runtime/src/com/amazon/ask/util/impl/JacksonJsonUnmarshaller.java +++ b/ask-sdk-runtime/src/com/amazon/ask/util/impl/JacksonJsonUnmarshaller.java @@ -40,11 +40,15 @@ public static JacksonJsonUnmarshaller withTypeBinding(Class(outputType, requiredField); } + public static JacksonJsonUnmarshaller withTypeBinding(Class outputType) { + return new JacksonJsonUnmarshaller<>(outputType, null); + } + @Override public Optional> unmarshall(byte[] in) { try { JsonNode json = MAPPER.readTree(in); - if (!json.has(requiredField)) { + if (requiredField != null && !json.has(requiredField)) { return Optional.empty(); } UnmarshalledRequest unmarshalledRequest = new BaseUnmarshalledRequest<>(MAPPER.treeToValue(json, outputType), json); diff --git a/ask-sdk-runtime/tst/com/amazon/ask/response/template/BaseTemplateFactoryTest.java b/ask-sdk-runtime/tst/com/amazon/ask/response/template/BaseTemplateFactoryTest.java new file mode 100644 index 00000000..0da2145d --- /dev/null +++ b/ask-sdk-runtime/tst/com/amazon/ask/response/template/BaseTemplateFactoryTest.java @@ -0,0 +1,143 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.exception.template.TemplateFactoryException; +import com.amazon.ask.exception.template.TemplateLoaderException; +import com.amazon.ask.exception.template.TemplateRendererException; +import com.amazon.ask.response.template.impl.BaseTemplateFactory; +import com.amazon.ask.response.template.loader.TemplateLoader; +import com.amazon.ask.response.template.renderer.TemplateRenderer; +import com.amazon.ask.sdk.TestHandlerInput; +import com.amazon.ask.sdk.TestHandlerOutput; +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMap; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BaseTemplateFactoryTest { + + private static final String templateIdentifier = "identifier"; + private static final String responseTemplateName = "responseTemplateName"; + private static final Map dataMap = new HashMap<>(); + + private TestHandlerInput mockInput; + private TestHandlerOutput mockOutput; + private TemplateLoader mockLoader; + private TemplateRenderer mockRenderer; + private TemplateContentData mockTemplateContentData; + + @Before + public void setup() { + mockInput = mock(TestHandlerInput.class); + mockOutput = mock(TestHandlerOutput.class); + mockLoader = mock(TemplateLoader.class); + mockRenderer = mock(TemplateRenderer.class); + mockTemplateContentData = mock(TemplateContentData.class); + } + + @Test + public void process_template_success() { + when(mockLoader.load(anyString(), any())).thenReturn(Optional.of(mockTemplateContentData)); + when(mockRenderer.render(any(TemplateContentData.class), anyMap())).thenReturn(mockOutput); + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList(mockLoader)) + .withTemplateRenderer(mockRenderer) + .build(); + TestHandlerOutput output = (TestHandlerOutput) templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + assertNotNull(output); + assertEquals(output, mockOutput); + verify(mockLoader).load(responseTemplateName, mockInput); + verify(mockRenderer).render(mockTemplateContentData, dataMap); + } + + @Test (expected = TemplateFactoryException.class) + public void process_template_with_null_loaders() { + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .withTemplateRenderer(mockRenderer) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockRenderer, never()).render(any(), anyMap()); + } + + @Test (expected = TemplateFactoryException.class) + public void process_template_with_empty_loaders() { + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList()) + .withTemplateRenderer(mockRenderer) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockRenderer, never()).render(any(), anyMap()); + } + + @Test (expected = TemplateFactoryException.class) + public void process_template_with_no_matching_loaders() { + when(mockLoader.load(anyString(), any())).thenReturn(Optional.empty()); + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList(mockLoader)) + .withTemplateRenderer(mockRenderer) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockLoader).load(responseTemplateName, mockInput); + verify(mockRenderer, never()).render(any(), anyMap()); + } + + @Test (expected = TemplateLoaderException.class) + public void process_template_with_loader_exception() { + when(mockLoader.load(anyString(), any())).thenThrow(mock(TemplateLoaderException.class)); + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList(mockLoader)) + .withTemplateRenderer(mockRenderer) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockLoader).load(responseTemplateName, mockInput); + verify(mockRenderer, never()).render(any(), anyMap()); + } + + @Test (expected = TemplateFactoryException.class) + public void process_template_with_null_renderers() { + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList(mockLoader)) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockLoader, never()).load(anyString(),any()); + } + + @Test (expected = TemplateRendererException.class) + public void process_template_with_renderer_exception() { + when(mockLoader.load(anyString(), any())).thenReturn(Optional.of(mockTemplateContentData)); + when(mockRenderer.render(any(TemplateContentData.class), anyMap())).thenThrow(mock(TemplateRendererException.class)); + TemplateFactory templateFactory = BaseTemplateFactory.forTypes(TestHandlerInput.class, TestHandlerOutput.class) + .addTemplateLoaders(Arrays.asList(mockLoader)) + .withTemplateRenderer(mockRenderer) + .build(); + templateFactory.processTemplate(responseTemplateName, dataMap, mockInput); + verify(mockLoader).load(responseTemplateName, mockInput); + verify(mockRenderer).render(mockTemplateContentData, dataMap); + } + +} diff --git a/ask-sdk-runtime/tst/com/amazon/ask/response/template/ConcurrentLRUTemplateCacheTest.java b/ask-sdk-runtime/tst/com/amazon/ask/response/template/ConcurrentLRUTemplateCacheTest.java new file mode 100644 index 00000000..e7517ef0 --- /dev/null +++ b/ask-sdk-runtime/tst/com/amazon/ask/response/template/ConcurrentLRUTemplateCacheTest.java @@ -0,0 +1,95 @@ +/* + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file + except in compliance with the License. A copy of the License is located at + + http://aws.amazon.com/apache2.0/ + + or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for + the specific language governing permissions and limitations under the License. + */ + +package com.amazon.ask.response.template; + +import com.amazon.ask.response.template.loader.impl.ConcurrentLRUTemplateCache; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ConcurrentLRUTemplateCacheTest { + + private static final String IDENTIFIER = "identifier"; + private static final int DEFAULT_CAPACITY = 1000 * 1000 * 5; + private static final String BASE_DIR = "basedir"; + + private ConcurrentLRUTemplateCache cache; + private TemplateContentData smallTemplate; + + @Before + public void setup() { + cache = ConcurrentLRUTemplateCache.builder().withCapacity(DEFAULT_CAPACITY).build(); + smallTemplate = TemplateContentData.builder() + .withIdentifier(IDENTIFIER) + .withTemplateContent(new byte[10]) + .withTemplateBaseDir(BASE_DIR) + .build(); + } + + @Test + public void put_template_within_capacity() { + assertTrue(cache.isEmpty()); + cache.put(IDENTIFIER, smallTemplate); + assertEquals(cache.size(), 1); + } + + @Test + public void put_template_verify_remove_elder_when_out_of_capacity() { + assertTrue(cache.isEmpty()); + cache.put(IDENTIFIER, smallTemplate); + assertEquals(cache.size(), 1); + String identifier2 = "identifier2"; + TemplateContentData largeTemplate = TemplateContentData.builder() + .withIdentifier(identifier2) + .withTemplateContent(new byte[DEFAULT_CAPACITY]) + .withTemplateBaseDir(BASE_DIR) + .build(); + cache.put(identifier2, largeTemplate); + assertEquals(cache.size(), 1); + assertNull(cache.get(IDENTIFIER)); + assertNotNull(cache.get(identifier2)); + } + + @Test + public void get_fresh_template() { + cache.put(IDENTIFIER, smallTemplate); + assertEquals(cache.get(IDENTIFIER), smallTemplate); + } + + @Test + public void get_stale_template_return_null() throws InterruptedException { + ConcurrentLRUTemplateCache cache = ConcurrentLRUTemplateCache.builder() + .withLiveTimeThreshold(0) + .build(); + cache.put(IDENTIFIER, smallTemplate); + assertNull(cache.get(IDENTIFIER)); + } + + @Test + public void get_stale_template_verify_capacity_counter() { + ConcurrentLRUTemplateCache cache = ConcurrentLRUTemplateCache.builder() + .withLiveTimeThreshold(0) + .build(); + cache.put(IDENTIFIER, smallTemplate); + assertNotEquals(cache.getCurrentCapacity(), 0); + assertNull(cache.get(IDENTIFIER)); + assertEquals(cache.getCurrentCapacity(), 0); + } + +} diff --git a/docs/en/Response-Building.rst b/docs/en/Response-Building.rst index 929512ed..458c175e 100644 --- a/docs/en/Response-Building.rst +++ b/docs/en/Response-Building.rst @@ -31,6 +31,8 @@ Available helper methods Once you add the desired response elements, you can generate a ``Response`` by calling the ``build()`` method. +Example usage of Response Builder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The following example shows how to construct a response using ``ResponseBuilder`` helper methods. @@ -44,3 +46,138 @@ The following example shows how to construct a response using .withReprompt("bar") .build(); } + +Construct a Response with a Template +------------------------------------- +The SDK allows you to use templates to construct skill responses. A *template* is similar to a *view* in the model–view–controller (MVC) pattern, which is commonly used to build dynamic web pages. Using a template to construct a response provides the following benefits: + +- It can help you generate a ``Response`` by separating the presentation logic from the request handling logic. +- It can help make it easier to generate a ``Response`` with a complex and nested structure, for example when using `Alexa Presentation Language `__. +- It can help to reduce duplicate code by reusing common templates across skills. + +You can include multiple response components in a single template, for example ``outputSpeech``, ``card``, ``shouldEndSession``, ``directives``, and more. These components can contain static data and placeholders for dynamic data, and will be built into a full skill response. + +To generate a ``Response`` using a template, you need to configure the ``Template Factory`` when building the skill. + +Template Factory +~~~~~~~~~~~~~~~~ +The template factory interface processes a response template by injecting dynamic data to generate the skill response. It is the entry point that you should call when building a skill response inside of a ``RequestHandler``. You can implement your own unique template factory, or leverage the ``BaseTemplateFactory`` provided in the SDK, which consists of a list of ``Template Loader`` and ``Template Renderer`` objects. + +Template Loader +~~~~~~~~~~~~~~~ + +The template loader interface loads template content from data storage. The SDK provides the ``LocalTemplateFileLoader`` to load a template file from the local file system. You can implement your own loader to load a template from a different data storage location. + +To use ``LocalTemplateFileLoader``, you must provide the directory path and the file extension for the template files. If you host your skill on AWS Lambda, you must include the template files as resources in the skill project JAR, and you must configure the resource directory in your Maven POM. + +**Template directory structure** + +The following screenshot of an example skill shows the template directory structure containing FreeMarker template files: + +.. image:: images/template_directory_structure.png + +**Maven resource configuration** +:: + + + + src/resources + + + +**Template Enumerator** + +You can generate different responses for different locales. To use different template files for different locales, the SDK provides the ``TemplateEnumerator`` interface and a ``LocaleTemplateEnumerator`` implementation to enumerate possible template locations and names based on the `locale `__ property in the skill request. For example, ``en-US`` is the locale property for a template file located at ``template/en/US`` or named ``template_en_US``. + +As shown in the preceding screenshot, you can name the template file ``example_response``, or put it in a locale-specific folder inside the ``base_response_template`` folder. The ``LocaleTemplateEnumerator`` first tries to find the most specific template file (for example, ``base_response_template/de/DE``), then falls back to a less specific one (``base_response_template/de``), then finally a global one (``base_response_template``). + +You can implement your own template enumerator to enumerate templates according to your preference. + +**Template Cache** + +To facilitate the template loading process, the SDK provides the ``TemplateCache`` interface, and provides a ``ConcurrentLRUTemplateCache`` to cache loaded templates for future use. The ``ConcurrentLRUTemplateCache`` supports concurrent caching, has a capacity of 5 MB, and a time-to-live (TTL) of 24 hours by default. You can modify these values according to your needs, or implement your own template cache. + +Template Renderer +~~~~~~~~~~~~~~~~~ + +The template renderer interface renders a full template including dynamic data, and converts it into a skill ``Response``. The SDK provides the ``FreeMarkerTemplateRenderer`` implementation to render a `FreeMarker template `__. This allows you to leverage FreeMarker features including `macro `__, `import `__, and more. You can also implement your own template renderer to support other template engines. + +To use the ``FreeMarkerTemplateRenderer``, you must add a dependency on ``ask-sdk-freemarker`` in your Maven project: + +:: + + + com.amazon.alexa + ask-sdk-freemarker + ${version} + + +Example Usage of the Template Factory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following example shows how to configure the ``BaseTemplateFactory`` using the default ``LocalTemplateFileLoader`` and ``ConcurrentLRUTemplateCache`` to construct a skill response with a FreeMarker template. + +**Configure Template Factory with Template Loader and Template Renderer to Skill Builder** + +.. code:: java + + private static Skill getSkill() { + + // Configure LocalTemplateFileLoader + TemplateLoader loader = LocalTemplateFileLoader.builder() + .withDirectoryPath("com/amazon/ask/example/") + .withFileExtension("ftl") + .build(); + + // Configure FreeMarkerTemplateRenderer + JacksonJsonUnmarshaller jacksonJsonUnmarshaller = JacksonJsonUnmarshaller + .withTypeBinding(Response.class); + TemplateRenderer renderer = FreeMarkerTemplateRenderer.builder() + .withUnmarshaller(jacksonJsonUnmarshaller) + .build(); + + // Configure BaseTemplateFactory + TemplateFactory templateFactory = BaseTemplateFactory.builder() + .withTemplateRenderer(renderer) + .addTemplateLoader(loader) + .build(); + + // Build Skill + return Skills.standard() + .withTemplateFactory(templateFactory) + .addRequestHandlers( + new LaunchRequestHandler(), + ... ... + new SessionEndedRequestHandler()) + .build(); + } + +**Generate a response using a template in the Request Handler** + +.. code:: java + + @Override + public Optional handle(HandlerInput input) { + String speechText = "Hi, welcome to templating response."; + + // Provide dynamic data to template + Map datamap = new HashMap<>(); + datamap.put("outputSpeechText", speechText); + + return input.generateTemplateResponse("base_response_template", datamap); + } + +Using the preceding example code, the following example of a FreeMarker template should have a full resource path of ``/com/amazon/ask/example/base_response_template/en/US.ftl``, with the directory path ``/com/amazon/ask/example/`` and file extension ``ftl`` passed into the ``LocalTemplateFileLoader``, and the locale property of ``en-US`` from the ``Request`` passed into the ``LocaleTemplateEnumerator``. + +**Example FreeMarker template** + +The following example shows a FreeMarker template for the `OutputSpeech `__ component in a skill response. + +:: + + { + "outputSpeech": { + "type": "PlainText", + "text": "${outputSpeechText}" + } +