diff --git a/.kokoro/tests/run_tests.sh b/.kokoro/tests/run_tests.sh
index c1b9336427d..24f8a6d1335 100755
--- a/.kokoro/tests/run_tests.sh
+++ b/.kokoro/tests/run_tests.sh
@@ -65,12 +65,14 @@ if [[ "$SCRIPT_DEBUG" != "true" ]]; then
source "${KOKORO_GFILE_DIR}/aws-secrets.sh"
# shellcheck source=src/storage-hmac-credentials.sh
source "${KOKORO_GFILE_DIR}/storage-hmac-credentials.sh"
- # shellcheck source=src/dlp_secrets.sh
+ # shellcheck source=src/dlp_secrets.txt
source "${KOKORO_GFILE_DIR}/dlp_secrets.txt"
- # shellcheck source=src/bigtable_secrets.sh
+ # shellcheck source=src/bigtable_secrets.txt
source "${KOKORO_GFILE_DIR}/bigtable_secrets.txt"
- # shellcheck source=src/automl_secrets.sh
+ # shellcheck source=src/automl_secrets.txt
source "${KOKORO_GFILE_DIR}/automl_secrets.txt"
+ # shellcheck source=src/functions_secrets.txt
+ source "${KOKORO_GFILE_DIR}/functions_secrets.txt"
# Activate service account
gcloud auth activate-service-account \
--key-file="$GOOGLE_APPLICATION_CREDENTIALS" \
diff --git a/functions/snippets/.gcloudignore b/functions/snippets/.gcloudignore
deleted file mode 100644
index 0ac51e7dd4e..00000000000
--- a/functions/snippets/.gcloudignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Don't run tests when deploying a function using gcloud
-# Comment out (with a leading '#') or remove the line below
-# if you want to run tests upon deployment with 'gcloud'
-src/test/
-
-# If you want your code to be automatically tested and
-# deployed to Cloud Functions, we recommend using
-# a continuous integration pipeline to enable this.
\ No newline at end of file
diff --git a/functions/snippets/pom.xml b/functions/snippets/pom.xml
index d9097416e54..42f8895c54c 100644
--- a/functions/snippets/pom.xml
+++ b/functions/snippets/pom.xml
@@ -26,20 +26,32 @@
-
+
com.google.code.gson
gson
2.8.6
-
+
com.google.cloud
google-cloud-logging
1.100.0
+
+
+ com.google.apis
+ google-api-services-kgsearch
+ v1-rev253-1.25.0
+
+
+ com.github.seratch
+ jslack
+ 3.4.1
+
+
junit
@@ -102,10 +114,36 @@
-
+
+
+
+
+
+
+
+
+ env.NEW_BUILD
+
+
+
+ true
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0-M3
+
+ ${skipTests}
+ sponge_log
+ false
+
+
+
org.apache.maven.plugins
maven-compiler-plugin
diff --git a/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java b/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java
new file mode 100644
index 00000000000..e4991da499c
--- /dev/null
+++ b/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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.example.functions;
+
+import com.github.seratch.jslack.app_backend.SlackSignature;
+import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
+import com.google.api.client.json.jackson2.JacksonFactory;
+import com.google.api.services.kgsearch.v1.Kgsearch;
+import com.google.cloud.functions.HttpFunction;
+import com.google.cloud.functions.HttpRequest;
+import com.google.cloud.functions.HttpResponse;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+public class SlackSlashCommand implements HttpFunction {
+
+ private Kgsearch kgClient;
+ private static final String API_KEY = System.getenv("KG_API_KEY");
+ private static final String SLACK_SECRET = System.getenv("SLACK_SECRET");
+ private static final Logger LOGGER = Logger.getLogger(SlackSlashCommand.class.getName());
+ private SlackSignature.Verifier verifier;
+ private Gson gson = new Gson();
+
+ public SlackSlashCommand() throws IOException, GeneralSecurityException {
+ kgClient = new Kgsearch.Builder(
+ GoogleNetHttpTransport.newTrustedTransport(), new JacksonFactory(), null).build();
+
+ verifier = new SlackSignature.Verifier(new SlackSignature.Generator(SLACK_SECRET));
+ }
+
+ boolean isValidSlackWebhook(HttpRequest request, String requestBody) throws IOException {
+
+ // Check for headers
+ HashMap> headers = new HashMap(request.getHeaders());
+ if (!headers.containsKey("X-Slack-Request-Timestamp")
+ || !headers.containsKey("X-Slack-Signature")) {
+ return false;
+ }
+ return verifier.isValid(
+ headers.get("X-Slack-Request-Timestamp").get(0),
+ requestBody,
+ headers.get("X-Slack-Signature").get(0),
+ 1L);
+ }
+
+ void addPropertyIfPresent(
+ JsonObject target, String targetName, JsonObject source, String sourceName) {
+ if (source.has(sourceName)) {
+ target.addProperty(targetName, source.get(sourceName).getAsString());
+ }
+ }
+
+ String formatSlackMessage(JsonObject kgResponse, String query) {
+ JsonObject attachmentJson = new JsonObject();
+ JsonArray attachments = new JsonArray();
+
+ JsonObject responseJson = new JsonObject();
+ responseJson.addProperty("response_type", "in_channel");
+ responseJson.addProperty("text", String.format("Query: %s", query));
+
+ JsonArray entityList = kgResponse.getAsJsonArray("itemListElement");
+
+ // Extract the first entity from the result list, if any
+ if (entityList.size() == 0) {
+ attachmentJson.addProperty("text","No results match your query...");
+
+ attachments.add(attachmentJson);
+ responseJson.add("attachments", attachmentJson);
+
+ return gson.toJson(responseJson);
+ }
+
+ JsonObject entity = entityList.get(0).getAsJsonObject().getAsJsonObject("result");
+
+ // Construct Knowledge Graph response attachment
+ String title = entity.get("name").getAsString();
+ if (entity.has("description")) {
+ title = String.format("%s: %s", title, entity.get("description").getAsString());
+ }
+ attachmentJson.addProperty("title", title);
+
+ if (entity.has("detailedDescription")) {
+ JsonObject detailedDescJson = entity.getAsJsonObject("detailedDescription");
+ addPropertyIfPresent(attachmentJson, "title_link", detailedDescJson, "url");
+ addPropertyIfPresent(attachmentJson, "text", detailedDescJson, "articleBody");
+ }
+
+ if (entity.has("image")) {
+ JsonObject imageJson = entity.getAsJsonObject("image");
+ addPropertyIfPresent(attachmentJson, "image_url", imageJson, "contentUrl");
+ }
+
+ // Construct top level response
+ attachments.add(attachmentJson);
+ responseJson.add("attachments", attachmentJson);
+
+ return gson.toJson(responseJson);
+ }
+
+ JsonObject searchKnowledgeGraph(String query) throws IOException {
+ Kgsearch.Entities.Search kgRequest = kgClient.entities().search();
+ kgRequest.setQuery(query);
+ kgRequest.setKey(API_KEY);
+
+ return gson.fromJson(kgRequest.execute().toString(), JsonObject.class);
+ }
+
+ @Override
+ public void service(HttpRequest request, HttpResponse response) throws IOException {
+
+ // Validate request
+ if (request.getMethod() != "POST") {
+ response.setStatusCode(HttpURLConnection.HTTP_BAD_METHOD);
+ return;
+ }
+
+ // reader can only be read once per request, so we preserve its contents
+ String bodyString = request.getReader().lines().collect(Collectors.joining());
+ JsonObject body = (new Gson()).fromJson(bodyString, JsonObject.class);
+
+ if (body == null || !body.has("text")) {
+ response.setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST);
+ return;
+ }
+
+ if (!isValidSlackWebhook(request, bodyString)) {
+ response.setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED);
+ return;
+ }
+
+ String query = body.get("text").getAsString();
+
+ // Call knowledge graph API
+ JsonObject kgResponse = searchKnowledgeGraph(query);
+
+ // Format response to Slack
+ BufferedWriter writer = response.getWriter();
+ writer.write(formatSlackMessage(kgResponse, query));
+ }
+}
diff --git a/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java b/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java
new file mode 100644
index 00000000000..9b4eaad6fb0
--- /dev/null
+++ b/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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.example.functions;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.powermock.api.mockito.PowerMockito.mock;
+import static org.powermock.api.mockito.PowerMockito.when;
+
+import com.github.seratch.jslack.app_backend.SlackSignature;
+import com.google.api.client.googleapis.json.GoogleJsonResponseException;
+import com.google.cloud.functions.HttpRequest;
+import com.google.cloud.functions.HttpResponse;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.net.HttpURLConnection;
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.powermock.reflect.Whitebox;
+
+public class SlackSlashCommandTest {
+
+ private BufferedWriter writerOut;
+ private StringWriter responseOut;
+
+ @Mock private HttpRequest request;
+ @Mock private HttpResponse response;
+
+ @Mock private SlackSignature.Verifier alwaysValidVerifier;
+
+ @Before
+ public void beforeTest() throws IOException {
+ request = mock(HttpRequest.class);
+ when(request.getReader()).thenReturn(new BufferedReader(new StringReader("")));
+
+ response = mock(HttpResponse.class);
+
+ responseOut = new StringWriter();
+
+ writerOut = new BufferedWriter(responseOut);
+ when(response.getWriter()).thenReturn(writerOut);
+
+ alwaysValidVerifier = mock(SlackSignature.Verifier.class);
+ when(alwaysValidVerifier.isValid(
+ ArgumentMatchers.any(),
+ ArgumentMatchers.any(),
+ ArgumentMatchers.any(),
+ ArgumentMatchers.anyLong())
+ ).thenReturn(true);
+
+ // Construct valid header list
+ HashMap> validHeaders = new HashMap>();
+ String validSlackSignature = System.getenv("SLACK_TEST_SIGNATURE");
+ String timestamp = "0"; // start of Unix epoch
+
+ validHeaders.put("X-Slack-Signature", Arrays.asList(validSlackSignature));
+ validHeaders.put("X-Slack-Request-Timestamp", Arrays.asList(timestamp));
+
+ when(request.getHeaders()).thenReturn(validHeaders);
+
+ // Reset knowledge graph API key
+ Whitebox.setInternalState(SlackSlashCommand.class, "API_KEY", System.getenv("KG_API_KEY"));
+ }
+
+ @Test
+ public void onlyAcceptsPostRequestsTest() throws IOException, GeneralSecurityException {
+ when(request.getMethod()).thenReturn("GET");
+ new SlackSlashCommand().service(request, response);
+
+ writerOut.flush();
+ verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_METHOD);
+ }
+
+ @Test
+ public void requiresSlackAuthHeadersTest() throws IOException, GeneralSecurityException {
+ StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n");
+
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
+
+ new SlackSlashCommand().service(request, response);
+
+ // Do NOT look for HTTP_BAD_REQUEST here (that means the request WAS authorized)!
+ verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED);
+ }
+
+ @Test
+ public void recognizesValidSlackTokenTest() throws IOException, GeneralSecurityException {
+ StringReader requestReadable = new StringReader("{}");
+
+ when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
+ when(request.getMethod()).thenReturn("POST");
+
+ new SlackSlashCommand().service(request, response);
+
+ verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST);
+ }
+
+ @Test(expected = GoogleJsonResponseException.class)
+ public void handlesSearchErrorTest() throws IOException, GeneralSecurityException {
+ StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n");
+
+ when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
+ when(request.getMethod()).thenReturn("POST");
+
+ SlackSlashCommand functionInstance = new SlackSlashCommand();
+ Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);
+ Whitebox.setInternalState(SlackSlashCommand.class, "API_KEY", "gibberish");
+
+ // Should throw a GoogleJsonResponseException (due to invalid API key)
+ functionInstance.service(request, response);
+ }
+
+ @Test
+ public void handlesEmptyKgResultsTest() throws IOException, GeneralSecurityException {
+ StringReader requestReadable = new StringReader("{ \"text\": \"asdfjkl13579\" }\n");
+
+ when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
+ when(request.getMethod()).thenReturn("POST");
+
+ SlackSlashCommand functionInstance = new SlackSlashCommand();
+ Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);
+
+
+ functionInstance.service(request, response);
+
+ writerOut.flush();
+ assertThat(responseOut.toString()).contains("No results match your query...");
+ }
+
+ @Test
+ public void handlesPopulatedKgResultsTest() throws IOException, GeneralSecurityException {
+ StringReader requestReadable = new StringReader("{ \"text\": \"lion\" }\n");
+
+ when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
+ when(request.getMethod()).thenReturn("POST");
+
+ SlackSlashCommand functionInstance = new SlackSlashCommand();
+ Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);
+
+
+ functionInstance.service(request, response);
+
+ writerOut.flush();
+ assertThat(responseOut.toString()).contains("https://en.wikipedia.org/wiki/Lion");
+ }
+}
diff --git a/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java b/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java
index ecdf7af7a7f..c13a6aa3b9e 100644
--- a/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java
+++ b/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java
@@ -20,24 +20,6 @@
import static org.powermock.api.mockito.PowerMockito.mock;
import static org.powermock.api.mockito.PowerMockito.when;
-import com.example.functions.CorsEnabled;
-import com.example.functions.EnvVars;
-import com.example.functions.ExecutionCount;
-import com.example.functions.FileSystem;
-import com.example.functions.FirebaseAuth;
-import com.example.functions.HelloBackground;
-import com.example.functions.HelloGcs;
-import com.example.functions.HelloHttp;
-import com.example.functions.HelloPubSub;
-import com.example.functions.HelloWorld;
-import com.example.functions.LazyFields;
-import com.example.functions.LogHelloWorld;
-import com.example.functions.ParseContentType;
-import com.example.functions.PubSubMessage;
-import com.example.functions.RetrieveLogs;
-import com.example.functions.Scopes;
-import com.example.functions.SendHttpRequest;
-import com.example.functions.StackdriverLogging;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.common.testing.TestLogHandler;