This is an automated email from the ASF dual-hosted git repository. mattyb149 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/master by this push: new 79e05c9 NIFI-6078: Create PostSlack processor 79e05c9 is described below commit 79e05c9f583aa1e4d390f380e375253bd7be198e Author: Peter Turcsanyi <turcsa...@cloudera.com> AuthorDate: Thu Feb 21 00:27:43 2019 +0100 NIFI-6078: Create PostSlack processor Processor for sending messages on Slack and optionally upload and attach the FlowFile content (e.g. an image) to the message. NIFI-6078: Remove username/icon properties. NIFI-6078: Make Text property optional. NIFI-6078: Documentation changes. Signed-off-by: Matthew Burgess <mattyb...@apache.org> This closes #3339 --- .../src/main/resources/META-INF/NOTICE | 42 ++ .../nifi-slack-processors/pom.xml | 10 + .../apache/nifi/processors/slack/PostSlack.java | 474 +++++++++++++++++++++ .../services/org.apache.nifi.processor.Processor | 3 +- .../additionalDetails.html | 107 +++++ .../processors/slack/PostSlackCaptureServlet.java | 103 +++++ .../slack/PostSlackConfigValidationTest.java | 152 +++++++ .../processors/slack/PostSlackFileMessageTest.java | 312 ++++++++++++++ .../processors/slack/PostSlackTextMessageTest.java | 282 ++++++++++++ 9 files changed, 1484 insertions(+), 1 deletion(-) diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-nar/src/main/resources/META-INF/NOTICE b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-nar/src/main/resources/META-INF/NOTICE index 53ea274..f3e9cb8 100644 --- a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-nar/src/main/resources/META-INF/NOTICE +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-nar/src/main/resources/META-INF/NOTICE @@ -4,6 +4,48 @@ Copyright 2016-2019 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). +****************** +Apache Software License v2 +****************** + +The following binary components are provided under the Apache Software License v2 + + (ASLv2) Apache Commons Codec + The following NOTICE information applies: + Apache Commons Codec + Copyright 2002-2017 The Apache Software Foundation + + src/test/org/apache/commons/codec/language/DoubleMetaphoneTest.java + contains test data from http://aspell.net/test/orig/batch0.tab. + Copyright (C) 2002 Kevin Atkinson (kev...@gnu.org) + + =============================================================================== + + The content of package org.apache.commons.codec.language.bm has been translated + from the original php source code available at http://stevemorse.org/phoneticinfo.htm + with permission from the original authors. + Original source copyright: + Copyright (c) 2008 Alexander Beider & Stephen P. Morse. + + (ASLv2) Apache Commons Logging + The following NOTICE information applies: + Apache Commons Logging + Copyright 2003-2014 The Apache Software Foundation + + (ASLv2) Apache HttpComponents + The following NOTICE information applies: + Apache HttpClient + Copyright 1999-2019 The Apache Software Foundation + + Apache HttpCore + Copyright 2005-2019 The Apache Software Foundation + + Apache HttpMime + Copyright 1999-2019 The Apache Software Foundation + + This project contains annotations derived from JCIP-ANNOTATIONS + Copyright (c) 2005 Brian Goetz and Tim Peierls. See http://www.jcip.net + ************************ Common Development and Distribution License 1.1 ************************ diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/pom.xml b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/pom.xml index a6aa3b5..eb10033 100644 --- a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/pom.xml +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/pom.xml @@ -37,6 +37,16 @@ <version>1.0.4</version> </dependency> <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + <version>4.5.7</version> + </dependency> + <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpmime</artifactId> + <version>4.5.7</version> + </dependency> + <dependency> <groupId>org.apache.nifi</groupId> <artifactId>nifi-api</artifactId> </dependency> diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/java/org/apache/nifi/processors/slack/PostSlack.java b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/java/org/apache/nifi/processors/slack/PostSlack.java new file mode 100644 index 0000000..f581731 --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/java/org/apache/nifi/processors/slack/PostSlack.java @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.processors.slack; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.util.EntityUtils; +import org.apache.nifi.annotation.behavior.DynamicProperty; +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.WritesAttribute; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.annotation.lifecycle.OnScheduled; +import org.apache.nifi.annotation.lifecycle.OnStopped; +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.ValidationContext; +import org.apache.nifi.components.ValidationResult; +import org.apache.nifi.expression.AttributeExpression; +import org.apache.nifi.expression.ExpressionLanguageScope; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.flowfile.attributes.CoreAttributes; +import org.apache.nifi.processor.AbstractProcessor; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.processor.util.StandardValidators; + +import javax.json.Json; +import javax.json.JsonArrayBuilder; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.json.JsonString; +import javax.json.stream.JsonParsingException; +import java.io.IOException; +import java.io.StringReader; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Collectors; + +@Tags({"slack", "post", "notify", "upload", "message"}) +@CapabilityDescription("Sends a message on Slack. The FlowFile content (e.g. an image) can be uploaded and attached to the message.") +@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED) +@DynamicProperty(name = "<Arbitrary name>", value = "JSON snippet specifying a Slack message \"attachment\"", + description = "The property value will be converted to JSON and will be added to the array of attachments in the JSON payload being sent to Slack." + + " The property name will not be used by the processor.", + expressionLanguageScope = ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) +@WritesAttribute(attribute="slack.file.url", description = "The Slack URL of the uploaded file. It will be added if 'Upload FlowFile' has been set to 'Yes'.") +public class PostSlack extends AbstractProcessor { + + private static final String SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"; + + private static final String SLACK_FILE_UPLOAD_URL = "https://slack.com/api/files.upload"; + + public static final PropertyDescriptor POST_MESSAGE_URL = new PropertyDescriptor.Builder() + .name("post-message-url") + .displayName("Post Message URL") + .description("Slack Web API URL for posting text messages to channels." + + " It only needs to be changed if Slack changes its API URL.") + .required(true) + .defaultValue(SLACK_POST_MESSAGE_URL) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .addValidator(StandardValidators.URL_VALIDATOR) + .build(); + + public static final PropertyDescriptor FILE_UPLOAD_URL = new PropertyDescriptor.Builder() + .name("file-upload-url") + .displayName("File Upload URL") + .description("Slack Web API URL for uploading files to channels." + + " It only needs to be changed if Slack changes its API URL.") + .required(true) + .defaultValue(SLACK_FILE_UPLOAD_URL) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .addValidator(StandardValidators.URL_VALIDATOR) + .build(); + + public static final PropertyDescriptor ACCESS_TOKEN = new PropertyDescriptor.Builder() + .name("access-token") + .displayName("Access Token") + .description("OAuth Access Token used for authenticating/authorizing the Slack request sent by NiFi.") + .required(true) + .sensitive(true) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .build(); + + public static final PropertyDescriptor CHANNEL = new PropertyDescriptor.Builder() + .name("channel") + .displayName("Channel") + .description("Slack channel, private group, or IM channel to send the message to.") + .required(true) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .build(); + + public static final PropertyDescriptor TEXT = new PropertyDescriptor.Builder() + .name("text") + .displayName("Text") + .description("Text of the Slack message to send. Only required if no attachment has been specified and 'Upload File' has been set to 'No'.") + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .build(); + + public static final AllowableValue UPLOAD_FLOWFILE_YES = new AllowableValue( + "true", + "Yes", + "Upload and attach FlowFile content to the Slack message." + ); + + public static final AllowableValue UPLOAD_FLOWFILE_NO = new AllowableValue( + "false", + "No", + "Don't upload and attach FlowFile content to the Slack message." + ); + + public static final PropertyDescriptor UPLOAD_FLOWFILE = new PropertyDescriptor.Builder() + .name("upload-flowfile") + .displayName("Upload FlowFile") + .description("Whether or not to upload and attach the FlowFile content to the Slack message.") + .allowableValues(UPLOAD_FLOWFILE_YES, UPLOAD_FLOWFILE_NO) + .required(true) + .defaultValue("false") + .build(); + + public static final PropertyDescriptor FILE_TITLE = new PropertyDescriptor.Builder() + .name("file-title") + .displayName("File Title") + .description("Title of the file displayed in the Slack message." + + " The property value will only be used if 'Upload FlowFile' has been set to 'Yes'.") + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .build(); + + public static final PropertyDescriptor FILE_NAME = new PropertyDescriptor.Builder() + .name("file-name") + .displayName("File Name") + .description("Name of the file to be uploaded." + + " The property value will only be used if 'Upload FlowFile' has been set to 'Yes'." + + " If the property evaluated to null or empty string, then the file name will be set to 'file' in the Slack message.") + .defaultValue("${" + CoreAttributes.FILENAME.key() + "}") + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .build(); + + public static final PropertyDescriptor FILE_MIME_TYPE = new PropertyDescriptor.Builder() + .name("file-mime-type") + .displayName("File Mime Type") + .description("Mime type of the file to be uploaded." + + " The property value will only be used if 'Upload FlowFile' has been set to 'Yes'." + + " If the property evaluated to null or empty string, then the mime type will be set to 'application/octet-stream' in the Slack message.") + .defaultValue("${" + CoreAttributes.MIME_TYPE.key() + "}") + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .build(); + + public static final Relationship REL_SUCCESS = new Relationship.Builder() + .name("success") + .description("FlowFiles are routed to success after being successfully sent to Slack") + .build(); + + public static final Relationship REL_FAILURE = new Relationship.Builder() + .name("failure") + .description("FlowFiles are routed to failure if unable to be sent to Slack") + .build(); + + public static final List<PropertyDescriptor> properties = Collections.unmodifiableList( + Arrays.asList(POST_MESSAGE_URL, FILE_UPLOAD_URL, ACCESS_TOKEN, CHANNEL, TEXT, UPLOAD_FLOWFILE, FILE_TITLE, FILE_NAME, FILE_MIME_TYPE)); + + public static final Set<Relationship> relationships = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(REL_SUCCESS, REL_FAILURE))); + + private final SortedSet<PropertyDescriptor> attachmentProperties = Collections.synchronizedSortedSet(new TreeSet<>()); + + private volatile PoolingHttpClientConnectionManager connManager; + private volatile CloseableHttpClient client; + + private static final ContentType MIME_TYPE_PLAINTEXT_UTF8 = ContentType.create("text/plain", Charset.forName("UTF-8")); + + @Override + protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(String propertyDescriptorName) { + return new PropertyDescriptor.Builder() + .name(propertyDescriptorName) + .description("Slack Attachment JSON snippet that will be added to the message. The property value will only be used if 'Upload FlowFile' has been set to 'No'." + + " If the property evaluated to null or empty string, or contains invalid JSON, then it will not be added to the Slack message.") + .required(false) + .addValidator(StandardValidators.createAttributeExpressionLanguageValidator(AttributeExpression.ResultType.STRING, true)) + .addValidator(StandardValidators.ATTRIBUTE_KEY_PROPERTY_NAME_VALIDATOR) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .dynamic(true) + .build(); + } + + @Override + public List<PropertyDescriptor> getSupportedPropertyDescriptors() { + return properties; + } + + @Override + public Set<Relationship> getRelationships() { + return relationships; + } + + @OnScheduled + public void initDynamicProperties(ProcessContext context) { + attachmentProperties.clear(); + attachmentProperties.addAll( + context.getProperties().keySet() + .stream() + .filter(PropertyDescriptor::isDynamic) + .collect(Collectors.toList())); + } + + @OnScheduled + public void initHttpResources() { + connManager = new PoolingHttpClientConnectionManager(); + + client = HttpClientBuilder.create() + .setConnectionManager(connManager) + .build(); + } + + @OnStopped + public void closeHttpResources() { + try { + if (client != null) { + client.close(); + client = null; + } + if (connManager != null) { + connManager.close(); + connManager = null; + } + } catch (IOException e) { + getLogger().error("Could not properly close HTTP connections.", e); + } + } + + @Override + protected Collection<ValidationResult> customValidate(ValidationContext validationContext) { + List<ValidationResult> validationResults = new ArrayList<>(); + + boolean textSpecified = validationContext.getProperty(TEXT).isSet(); + boolean attachmentSpecified = validationContext.getProperties().keySet() + .stream() + .anyMatch(PropertyDescriptor::isDynamic); + boolean uploadFileYes = validationContext.getProperty(UPLOAD_FLOWFILE).asBoolean(); + + if (!textSpecified && !attachmentSpecified && !uploadFileYes) { + validationResults.add(new ValidationResult.Builder() + .subject(TEXT.getDisplayName()) + .valid(false) + .explanation("it is required if no attachment has been specified, nor 'Upload FlowFile' has been set to 'Yes'.") + .build()); + } + + return validationResults; + } + + @Override + public void onTrigger(ProcessContext context, ProcessSession session) throws ProcessException { + FlowFile flowFile = session.get(); + if (flowFile == null) { + return; + } + + CloseableHttpResponse response = null; + try { + String url; + String contentType; + HttpEntity requestBody; + + if (!context.getProperty(UPLOAD_FLOWFILE).asBoolean()) { + url = context.getProperty(POST_MESSAGE_URL).getValue(); + contentType = ContentType.APPLICATION_JSON.toString(); + requestBody = createTextMessageRequestBody(context, flowFile); + } else { + url = context.getProperty(FILE_UPLOAD_URL).getValue(); + contentType = null; // it will be set implicitly by HttpClient in case of multipart post request + requestBody = createFileMessageRequestBody(context, session, flowFile); + } + + HttpPost request = new HttpPost(url); + request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + context.getProperty(ACCESS_TOKEN).getValue()); + if (contentType != null) { + request.setHeader(HttpHeaders.CONTENT_TYPE, contentType); + } + request.setEntity(requestBody); + + response = client.execute(request); + + int statusCode = response.getStatusLine().getStatusCode(); + getLogger().debug("Status code: " + statusCode); + + if (!(statusCode >= 200 && statusCode < 300)) { + throw new PostSlackException("HTTP error code: " + statusCode); + } + + JsonObject responseJson; + try { + responseJson = Json.createReader(response.getEntity().getContent()).readObject(); + } catch (JsonParsingException e) { + throw new PostSlackException("Slack response JSON cannot be parsed.", e); + } + + getLogger().debug("Slack response: " + responseJson.toString()); + + try { + if (!responseJson.getBoolean("ok")) { + throw new PostSlackException("Slack error response: " + responseJson.getString("error")); + } + } catch (NullPointerException | ClassCastException e) { + throw new PostSlackException("Slack response JSON does not contain 'ok' key or it has invalid value.", e); + } + + JsonString warning = responseJson.getJsonString("warning"); + if (warning != null) { + getLogger().warn("Slack warning message: " + warning.getString()); + } + + if (context.getProperty(UPLOAD_FLOWFILE).asBoolean()) { + JsonObject file = responseJson.getJsonObject("file"); + if (file != null) { + JsonString fileUrl = file.getJsonString("url_private"); + if (fileUrl != null) { + session.putAttribute(flowFile, "slack.file.url", fileUrl.getString()); + } + } + } + + session.transfer(flowFile, REL_SUCCESS); + session.getProvenanceReporter().send(flowFile, url); + + } catch (IOException | PostSlackException e) { + getLogger().error("Failed to send message to Slack.", e); + flowFile = session.penalize(flowFile); + session.transfer(flowFile, REL_FAILURE); + context.yield(); + } finally { + if (response != null) { + try { + // consume the entire content of the response (entity) + // so that the manager can release the connection back to the pool + EntityUtils.consume(response.getEntity()); + response.close(); + } catch (IOException e) { + getLogger().error("Could not properly close HTTP response.", e); + } + } + } + } + + private HttpEntity createTextMessageRequestBody(ProcessContext context, FlowFile flowFile) throws PostSlackException, UnsupportedEncodingException { + JsonObjectBuilder jsonBuilder = Json.createObjectBuilder(); + + String channel = context.getProperty(CHANNEL).evaluateAttributeExpressions(flowFile).getValue(); + if (channel == null || channel.isEmpty()) { + throw new PostSlackException("The channel must be specified."); + } + jsonBuilder.add("channel", channel); + + String text = context.getProperty(TEXT).evaluateAttributeExpressions(flowFile).getValue(); + if (text != null && !text.isEmpty()){ + jsonBuilder.add("text", text); + } else { + if (attachmentProperties.isEmpty()) { + throw new PostSlackException("The text of the message must be specified if no attachment has been specified and 'Upload File' has been set to 'No'."); + } + } + + if (!attachmentProperties.isEmpty()) { + JsonArrayBuilder jsonArrayBuilder = Json.createArrayBuilder(); + for (PropertyDescriptor attachmentProperty : attachmentProperties) { + String propertyValue = context.getProperty(attachmentProperty).evaluateAttributeExpressions(flowFile).getValue(); + if (propertyValue != null && !propertyValue.isEmpty()) { + try { + jsonArrayBuilder.add(Json.createReader(new StringReader(propertyValue)).readObject()); + } catch (JsonParsingException e) { + getLogger().warn(attachmentProperty.getName() + " property contains no valid JSON, has been skipped."); + } + } else { + getLogger().warn(attachmentProperty.getName() + " property has no value, has been skipped."); + } + } + jsonBuilder.add("attachments", jsonArrayBuilder); + } + + return new StringEntity(jsonBuilder.build().toString(), Charset.forName("UTF-8")); + } + + private HttpEntity createFileMessageRequestBody(ProcessContext context, ProcessSession session, FlowFile flowFile) throws PostSlackException { + MultipartEntityBuilder multipartBuilder = MultipartEntityBuilder.create(); + + String channel = context.getProperty(CHANNEL).evaluateAttributeExpressions(flowFile).getValue(); + if (channel == null || channel.isEmpty()) { + throw new PostSlackException("The channel must be specified."); + } + multipartBuilder.addTextBody("channels", channel, MIME_TYPE_PLAINTEXT_UTF8); + + String text = context.getProperty(TEXT).evaluateAttributeExpressions(flowFile).getValue(); + if (text != null && !text.isEmpty()) { + multipartBuilder.addTextBody("initial_comment", text, MIME_TYPE_PLAINTEXT_UTF8); + } + + String title = context.getProperty(FILE_TITLE).evaluateAttributeExpressions(flowFile).getValue(); + if (title != null && !title.isEmpty()) { + multipartBuilder.addTextBody("title", title, MIME_TYPE_PLAINTEXT_UTF8); + } + + String fileName = context.getProperty(FILE_NAME).evaluateAttributeExpressions(flowFile).getValue(); + if (fileName == null || fileName.isEmpty()) { + fileName = "file"; + getLogger().warn("File name not specified, has been set to {}.", new Object[]{ fileName }); + } + multipartBuilder.addTextBody("filename", fileName, MIME_TYPE_PLAINTEXT_UTF8); + + ContentType mimeType; + String mimeTypeStr = context.getProperty(FILE_MIME_TYPE).evaluateAttributeExpressions(flowFile).getValue(); + if (mimeTypeStr == null || mimeTypeStr.isEmpty()) { + mimeType = ContentType.APPLICATION_OCTET_STREAM; + getLogger().warn("Mime type not specified, has been set to {}.", new Object[]{ mimeType.getMimeType() }); + } else { + mimeType = ContentType.getByMimeType(mimeTypeStr); + if (mimeType == null) { + mimeType = ContentType.APPLICATION_OCTET_STREAM; + getLogger().warn("Unknown mime type specified ({}), has been set to {}.", new Object[]{ mimeTypeStr, mimeType.getMimeType() }); + } + } + + multipartBuilder.addBinaryBody("file", session.read(flowFile), mimeType, fileName); + + return multipartBuilder.build(); + } + + private static class PostSlackException extends Exception { + PostSlackException(String message) { + super(message); + } + + PostSlackException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor index 9a861ee..47bc1d4 100644 --- a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor @@ -12,4 +12,5 @@ # 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. -org.apache.nifi.processors.slack.PutSlack \ No newline at end of file +org.apache.nifi.processors.slack.PutSlack +org.apache.nifi.processors.slack.PostSlack \ No newline at end of file diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/docs/org.apache.nifi.processors.slack.PostSlack/additionalDetails.html b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/docs/org.apache.nifi.processors.slack.PostSlack/additionalDetails.html new file mode 100644 index 0000000..ea85706 --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/main/resources/docs/org.apache.nifi.processors.slack.PostSlack/additionalDetails.html @@ -0,0 +1,107 @@ +<!DOCTYPE html> +<html lang="en"> + <!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You 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. + --> + <head> + <meta charset="utf-8" /> + <title>PostSlack</title> + <link rel="stylesheet" href="../../../../../css/component-usage.css" type="text/css" /> + <style> + dt {font-style: italic;} + </style> + </head> + + <body> + <!-- Processor Documentation ================================================== --> + <h2>Description:</h2> + <p> + The PostSlack processor sends messages on Slack, a team-oriented messaging service. + The message can be a simple text message, furthermore Slack attachments can also be specified + or the FlowFile content (e.g. an image) can be uploaded and attached to the message too. + </p> + <h3>Slack App setup</h3> + <p> + This processor uses Slack Web API methods to post messages to a specific channel. + Before using PostSlack, you need to create a Slack App, to add a Bot User to the app, + and to install the app in your Slack workspace. After the app installed, you can get + the Bot User's OAuth Access Token that will be needed to authenticate and authorize + your PostSlack processor to Slack. + </p> + <h3>Message types</h3> + <p> + You can send the following types of messages with PostSlack: + </p> + <dl> + <dt>Text-only message</dt> + <dd> + A simple text message. In this case you must specify the <Text> property, + while <Upload FlowFile> is 'No' and no attachments defined through dynamic properties. + File related properties will be ignored in this case. + </dd> + <dt>Text message with attachment</dt> + <dd> + Besides the <Text> property, one or more attachments are also defined through dynamic properties + (for more details see the <a href="#slack-attachments">Slack attachments</a> section below). + The <Upload FlowFile> property needs to be set to 'No'. File related properties will be ignored. + </dd> + <dt>Attachment-only message</dt> + <dd> + The same as the previous one, but the <Text> property is not specified + (so no text section will be displayed at the beginning of the message, only the attachment(s)). + </dd> + <dt>Text message with file upload</dt> + <dd> + You need to specify the <Text> property and set <Upload FlowFile> to 'Yes'. + The content of the FlowFile will be uploaded with the message and it will be displayed + below the text. You should specify <File Name> and <File Mime Type> properties too + (otherwise some fallback values will be set), and optionally <File Title>. + The dynamic properties will be ignored in this case. + </dd> + <dt>File upload message without text</dt> + <dd> + The same as the previous one, but the <Text> property is not specified + (so no text section will be displayed at the beginning of the message, only the uploaded file). + </dd> + </dl> + <h3 id="slack-attachments">Slack attachments</h3> + <p> + Slack content and link attachments can be added to the message through dynamic properties. + Please note that this kind of Slack message attachments does not involve the file + upload itself, but rather contain links to external resources (or internal resources already uploaded + to Slack). Please also note that this functionality does not work together with file upload + (so it can only be used when 'Upload FlowFile' has been set to 'No'). + </p> + <p> + The Dynamic Properties can be used to specify these Slack message attachments as JSON snippets. + Each property value will be converted to JSON and will be added to the array of attachments in the JSON + payload being sent to Slack. + </p> + <p> + Example JSON snippets to define Slack attachments: + </p> + <pre> + { + "text": "Text that appears within the attachment", + "image_url": "http://some-website.com/path/to/image.jpg" + } + </pre> + <pre> + { + "title": "Title of the attachment", + "image_url": "http://some-website.com/path/to/image.jpg" + } + </pre> + </body> +</html> diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackCaptureServlet.java b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackCaptureServlet.java new file mode 100644 index 0000000..629d78a --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackCaptureServlet.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.processors.slack; + +import org.apache.http.HttpStatus; +import org.apache.http.entity.ContentType; +import org.apache.nifi.stream.io.StreamUtils; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +public class PostSlackCaptureServlet extends HttpServlet { + + static final String REQUEST_PATH_SUCCESS_TEXT_MSG = "/success/text_msg"; + static final String REQUEST_PATH_SUCCESS_FILE_MSG = "/success/file_msg"; + static final String REQUEST_PATH_WARNING = "/warning"; + static final String REQUEST_PATH_ERROR = "/error"; + static final String REQUEST_PATH_EMPTY_JSON = "/empty-json"; + static final String REQUEST_PATH_INVALID_JSON = "/invalid-json"; + + private static final String RESPONSE_SUCCESS_TEXT_MSG = "{\"ok\": true}"; + private static final String RESPONSE_SUCCESS_FILE_MSG = "{\"ok\": true, \"file\": {\"url_private\": \"slack-file-url\"}}"; + private static final String RESPONSE_WARNING = "{\"ok\": true, \"warning\": \"slack-warning\"}"; + private static final String RESPONSE_ERROR = "{\"ok\": false, \"error\": \"slack-error\"}"; + private static final String RESPONSE_EMPTY_JSON = "{}"; + private static final String RESPONSE_INVALID_JSON = "{invalid-json}"; + + private static final Map<String, String> RESPONSE_MAP; + + static { + RESPONSE_MAP = new HashMap<>(); + + RESPONSE_MAP.put(REQUEST_PATH_SUCCESS_TEXT_MSG, RESPONSE_SUCCESS_TEXT_MSG); + RESPONSE_MAP.put(REQUEST_PATH_SUCCESS_FILE_MSG, RESPONSE_SUCCESS_FILE_MSG); + RESPONSE_MAP.put(REQUEST_PATH_WARNING, RESPONSE_WARNING); + RESPONSE_MAP.put(REQUEST_PATH_ERROR, RESPONSE_ERROR); + RESPONSE_MAP.put(REQUEST_PATH_EMPTY_JSON, RESPONSE_EMPTY_JSON); + RESPONSE_MAP.put(REQUEST_PATH_INVALID_JSON, RESPONSE_INVALID_JSON); + } + + private volatile boolean interacted; + private volatile Map<String, String> lastPostHeaders; + private volatile byte[] lastPostBody; + + public Map<String, String> getLastPostHeaders() { + return lastPostHeaders; + } + + public byte[] getLastPostBody() { + return lastPostBody; + } + + public boolean hasBeenInteracted() { + return interacted; + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws IOException { + interacted = true; + + Enumeration<String> headerNames = request.getHeaderNames(); + lastPostHeaders = new HashMap<>(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + lastPostHeaders.put(headerName, request.getHeader(headerName)); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + StreamUtils.copy(request.getInputStream(), baos); + lastPostBody = baos.toByteArray(); + + String responseJson = RESPONSE_MAP.get(request.getPathInfo()); + if (responseJson != null) { + response.setContentType(ContentType.APPLICATION_JSON.toString()); + PrintWriter out = response.getWriter(); + out.print(responseJson); + out.close(); + } else { + response.setStatus(HttpStatus.SC_BAD_REQUEST); + } + } +} diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackConfigValidationTest.java b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackConfigValidationTest.java new file mode 100644 index 0000000..6211b0d --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackConfigValidationTest.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.processors.slack; + +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.junit.Before; +import org.junit.Test; + +public class PostSlackConfigValidationTest { + + private TestRunner testRunner; + + @Before + public void setup() { + testRunner = TestRunners.newTestRunner(PostSlack.class); + } + + @Test + public void validationShouldPassIfTheConfigIsFine() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertValid(); + } + + @Test + public void validationShouldFailIfPostMessageUrlIsEmptyString() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, ""); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfPostMessageUrlIsNotValid() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, "not-url"); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfFileUploadUrlIsEmptyString() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, ""); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfFileUploadUrlIsNotValid() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, "not-url"); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfAccessTokenIsNotGiven() { + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfAccessTokenIsEmptyString() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, ""); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfChannelIsNotGiven() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfChannelIsEmptyString() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, ""); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldFailIfTextIsNotGivenAndNoAttachmentSpecifiedNorFileUploadChosen() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + + testRunner.assertNotValid(); + } + + @Test + public void validationShouldPassIfTextIsNotGivenButAttachmentSpecified() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty("attachment_01", "{\"my-attachment-key\": \"my-attachment-value\"}"); + + testRunner.assertValid(); + } + + @Test + public void validationShouldPassIfTextIsNotGivenButFileUploadChosen() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + + testRunner.assertValid(); + } + + @Test + public void validationShouldFailIfTextIsEmptyString() { + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, ""); + testRunner.setProperty("attachment_01", "{\"my-attachment-key\": \"my-attachment-value\"}"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + + testRunner.assertNotValid(); + } +} diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackFileMessageTest.java b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackFileMessageTest.java new file mode 100644 index 0000000..d4d7ea2 --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackFileMessageTest.java @@ -0,0 +1,312 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.processors.slack; + +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.flowfile.attributes.CoreAttributes; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.nifi.web.util.TestServer; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_SUCCESS_FILE_MSG; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class PostSlackFileMessageTest { + + private TestRunner testRunner; + + private TestServer server; + private PostSlackCaptureServlet servlet; + + @Before + public void setup() throws Exception { + testRunner = TestRunners.newTestRunner(PostSlack.class); + + servlet = new PostSlackCaptureServlet(); + + ServletContextHandler handler = new ServletContextHandler(); + handler.addServlet(new ServletHolder(servlet), "/*"); + + server = new TestServer(); + server.addHandler(handler); + server.startServer(); + } + + @Test + public void sendMessageWithBasicPropertiesSuccessfully() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + + Map<String, String> flowFileAttributes = new HashMap<>(); + flowFileAttributes.put(CoreAttributes.FILENAME.key(), "my-file-name"); + flowFileAttributes.put(CoreAttributes.MIME_TYPE.key(), "image/png"); + + // in order not to make the assertion logic (even more) complicated, the file content is tested with character data instead of binary data + testRunner.enqueue("my-data", flowFileAttributes); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + assertRequest("my-file-name", "image/png", null, null); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + @Test + public void sendMessageWithAllPropertiesSuccessfully() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + testRunner.setProperty(PostSlack.FILE_TITLE, "my-file-title"); + testRunner.setProperty(PostSlack.FILE_NAME, "my-file-name"); + testRunner.setProperty(PostSlack.FILE_MIME_TYPE, "image/png"); + + testRunner.enqueue("my-data"); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + assertRequest("my-file-name", "image/png", "my-text", "my-file-title"); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + @Test + public void processShouldFailWhenChannelIsEmpty() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl()); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "${dummy}"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + + testRunner.enqueue("my-data"); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + + assertFalse(servlet.hasBeenInteracted()); + } + + @Test + public void fileNameShouldHaveFallbackValueWhenEmpty() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + testRunner.setProperty(PostSlack.FILE_NAME, "${dummy}"); + testRunner.setProperty(PostSlack.FILE_MIME_TYPE, "image/png"); + + testRunner.enqueue("my-data"); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + // fallback value for file name is 'file' + assertRequest("file", "image/png", null, null); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + @Test + public void mimeTypeShouldHaveFallbackValueWhenEmpty() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + testRunner.setProperty(PostSlack.FILE_NAME, "my-file-name"); + testRunner.setProperty(PostSlack.FILE_MIME_TYPE, "${dummy}"); + + testRunner.enqueue("my-data"); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + // fallback value for mime type is 'application/octet-stream' + assertRequest("my-file-name", "application/octet-stream", null, null); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + @Test + public void mimeTypeShouldHaveFallbackValueWhenInvalid() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + testRunner.setProperty(PostSlack.FILE_NAME, "my-file-name"); + testRunner.setProperty(PostSlack.FILE_MIME_TYPE, "invalid"); + + testRunner.enqueue("my-data"); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + // fallback value for mime type is 'application/octet-stream' + assertRequest("my-file-name", "application/octet-stream", null, null); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + @Test + public void sendInternationalMessageSuccessfully() { + testRunner.setProperty(PostSlack.FILE_UPLOAD_URL, server.getUrl() + REQUEST_PATH_SUCCESS_FILE_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "Iñtërnâtiônàližætiøn"); + testRunner.setProperty(PostSlack.UPLOAD_FLOWFILE, PostSlack.UPLOAD_FLOWFILE_YES); + testRunner.setProperty(PostSlack.FILE_TITLE, "Iñtërnâtiônàližætiøn"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + Map<String, String> parts = parsePostBodyParts(parseMultipartBoundary(servlet.getLastPostHeaders().get("Content-Type"))); + assertEquals("Iñtërnâtiônàližætiøn", parts.get("initial_comment")); + assertEquals("Iñtërnâtiônàližætiøn", parts.get("title")); + + FlowFile flowFileOut = testRunner.getFlowFilesForRelationship(PutSlack.REL_SUCCESS).get(0); + assertEquals("slack-file-url", flowFileOut.getAttribute("slack.file.url")); + } + + private void assertRequest(String fileName, String mimeType, String text, String title) { + Map<String, String> requestHeaders = servlet.getLastPostHeaders(); + assertEquals("Bearer my-access-token", requestHeaders.get("Authorization")); + + String contentType = requestHeaders.get("Content-Type"); + assertTrue(contentType.startsWith("multipart/form-data")); + + String boundary = parseMultipartBoundary(contentType); + assertNotNull("Multipart boundary not found in Content-Type header: " + contentType, boundary); + + Map<String, String> parts = parsePostBodyParts(boundary); + + assertNotNull("'channels' parameter not found in the POST request body", parts.get("channels")); + assertEquals("'channels' parameter has wrong value", "my-channel", parts.get("channels")); + + if (text != null) { + assertNotNull("'initial_comment' parameter not found in the POST request body", parts.get("initial_comment")); + assertEquals("'initial_comment' parameter has wrong value", text, parts.get("initial_comment")); + } + + assertNotNull("'filename' parameter not found in the POST request body", parts.get("filename")); + assertEquals("'fileName' parameter has wrong value", fileName, parts.get("filename")); + + if (title != null) { + assertNotNull("'title' parameter not found in the POST request body", parts.get("title")); + assertEquals("'title' parameter has wrong value", title, parts.get("title")); + } + + assertNotNull("The file part not found in the POST request body", parts.get("file")); + + Map<String, String> fileParameters = parseFilePart(boundary); + assertEquals("File data is wrong in the POST request body", "my-data", fileParameters.get("data")); + assertEquals("'filename' attribute of the file part has wrong value", fileName, fileParameters.get("filename")); + assertEquals("Content-Type of the file part is wrong", mimeType, fileParameters.get("contentType")); + } + + private String parseMultipartBoundary(String contentType) { + String boundary = null; + + Pattern boundaryPattern = Pattern.compile("boundary=(.*?)$"); + Matcher boundaryMatcher = boundaryPattern.matcher(contentType); + + if (boundaryMatcher.find()) { + boundary = "--" + boundaryMatcher.group(1); + } + + return boundary; + } + + private Map<String, String> parsePostBodyParts(String boundary) { + Pattern partNamePattern = Pattern.compile("name=\"(.*?)\""); + Pattern partDataPattern = Pattern.compile("\r\n\r\n(.*?)\r\n$"); + + String[] postBodyParts = new String(servlet.getLastPostBody(), Charset.forName("UTF-8")).split(boundary); + + Map<String, String> parts = new HashMap<>(); + + for (String part: postBodyParts) { + Matcher partNameMatcher = partNamePattern.matcher(part); + Matcher partDataMatcher = partDataPattern.matcher(part); + + if (partNameMatcher.find() && partDataMatcher.find()) { + String partName = partNameMatcher.group(1); + String partData = partDataMatcher.group(1); + + parts.put(partName, partData); + } + } + + return parts; + } + + private Map<String, String> parseFilePart(String boundary) { + Pattern partNamePattern = Pattern.compile("name=\"file\""); + Pattern partDataPattern = Pattern.compile("\r\n\r\n(.*?)\r\n$"); + Pattern partFilenamePattern = Pattern.compile("filename=\"(.*?)\""); + Pattern partContentTypePattern = Pattern.compile("Content-Type: (.*?)\r\n"); + + String[] postBodyParts = new String(servlet.getLastPostBody(), Charset.forName("UTF-8")).split(boundary); + + Map<String, String> fileParameters = new HashMap<>(); + + for (String part: postBodyParts) { + Matcher partNameMatcher = partNamePattern.matcher(part); + + if (partNameMatcher.find()) { + Matcher partDataMatcher = partDataPattern.matcher(part); + if (partDataMatcher.find()) { + fileParameters.put("data", partDataMatcher.group(1)); + } + + Matcher partFilenameMatcher = partFilenamePattern.matcher(part); + if (partFilenameMatcher.find()) { + fileParameters.put("filename", partFilenameMatcher.group(1)); + } + + Matcher partContentTypeMatcher = partContentTypePattern.matcher(part); + if (partContentTypeMatcher.find()) { + fileParameters.put("contentType", partContentTypeMatcher.group(1)); + } + } + } + + return fileParameters; + } +} diff --git a/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackTextMessageTest.java b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackTextMessageTest.java new file mode 100644 index 0000000..809d23c --- /dev/null +++ b/nifi-nar-bundles/nifi-slack-bundle/nifi-slack-processors/src/test/java/org/apache/nifi/processors/slack/PostSlackTextMessageTest.java @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.nifi.processors.slack; + +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.nifi.web.util.TestServer; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.Before; +import org.junit.Test; + +import javax.json.Json; +import javax.json.JsonObject; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.Map; + +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_EMPTY_JSON; +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_ERROR; +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_INVALID_JSON; +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_SUCCESS_TEXT_MSG; +import static org.apache.nifi.processors.slack.PostSlackCaptureServlet.REQUEST_PATH_WARNING; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class PostSlackTextMessageTest { + + private TestRunner testRunner; + + private TestServer server; + private PostSlackCaptureServlet servlet; + + @Before + public void setup() throws Exception { + testRunner = TestRunners.newTestRunner(PostSlack.class); + + servlet = new PostSlackCaptureServlet(); + + ServletContextHandler handler = new ServletContextHandler(); + handler.addServlet(new ServletHolder(servlet), "/*"); + + server = new TestServer(); + server.addHandler(handler); + server.startServer(); + } + + @Test + public void sendTextOnlyMessageSuccessfully() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals("my-text", requestBodyJson.getString("text")); + } + + @Test + public void sendTextWithAttachmentMessageSuccessfully() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + testRunner.setProperty("attachment_01", "{\"my-attachment-key\": \"my-attachment-value\"}"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals("my-text", requestBodyJson.getString("text")); + assertEquals("[{\"my-attachment-key\":\"my-attachment-value\"}]", requestBodyJson.getJsonArray("attachments").toString()); + } + + @Test + public void sendAttachmentOnlyMessageSuccessfully() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty("attachment_01", "{\"my-attachment-key\": \"my-attachment-value\"}"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals("[{\"my-attachment-key\":\"my-attachment-value\"}]", requestBodyJson.getJsonArray("attachments").toString()); + } + + @Test + public void processShouldFailWhenChannelIsEmpty() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl()); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "${dummy}"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + + assertFalse(servlet.hasBeenInteracted()); + } + + @Test + public void processShouldFailWhenTextIsEmptyAndNoAttachmentSpecified() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl()); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "${dummy}"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + + assertFalse(servlet.hasBeenInteracted()); + } + + @Test + public void emptyAttachmentShouldBeSkipped() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty("attachment_01", "${dummy}"); + testRunner.setProperty("attachment_02", "{\"my-attachment-key\": \"my-attachment-value\"}"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals(1, requestBodyJson.getJsonArray("attachments").size()); + assertEquals("[{\"my-attachment-key\":\"my-attachment-value\"}]", requestBodyJson.getJsonArray("attachments").toString()); + } + + @Test + public void invalidAttachmentShouldBeSkipped() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty("attachment_01", "{invalid-json}"); + testRunner.setProperty("attachment_02", "{\"my-attachment-key\": \"my-attachment-value\"}"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals(1, requestBodyJson.getJsonArray("attachments").size()); + assertEquals("[{\"my-attachment-key\":\"my-attachment-value\"}]", requestBodyJson.getJsonArray("attachments").toString()); + } + + @Test + public void processShouldFailWhenHttpErrorCodeReturned() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl()); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + } + + @Test + public void processShouldFailWhenSlackReturnsError() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_ERROR); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + } + + @Test + public void processShouldNotFailWhenSlackReturnsWarning() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_WARNING); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + assertBasicRequest(getRequestBodyJson()); + } + + @Test + public void processShouldFailWhenSlackReturnsEmptyJson() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_EMPTY_JSON); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + } + + @Test + public void processShouldFailWhenSlackReturnsInvalidJson() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_INVALID_JSON); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "my-text"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_FAILURE); + } + + @Test + public void sendInternationalMessageSuccessfully() { + testRunner.setProperty(PostSlack.POST_MESSAGE_URL, server.getUrl() + REQUEST_PATH_SUCCESS_TEXT_MSG); + testRunner.setProperty(PostSlack.ACCESS_TOKEN, "my-access-token"); + testRunner.setProperty(PostSlack.CHANNEL, "my-channel"); + testRunner.setProperty(PostSlack.TEXT, "Iñtërnâtiônàližætiøn"); + + testRunner.enqueue(new byte[0]); + testRunner.run(1); + + testRunner.assertAllFlowFilesTransferred(PutSlack.REL_SUCCESS); + + JsonObject requestBodyJson = getRequestBodyJson(); + assertBasicRequest(requestBodyJson); + assertEquals("Iñtërnâtiônàližætiøn", requestBodyJson.getString("text")); + } + + private void assertBasicRequest(JsonObject requestBodyJson) { + Map<String, String> requestHeaders = servlet.getLastPostHeaders(); + assertEquals("Bearer my-access-token", requestHeaders.get("Authorization")); + assertEquals("application/json; charset=UTF-8", requestHeaders.get("Content-Type")); + + assertEquals("my-channel", requestBodyJson.getString("channel")); + } + + private JsonObject getRequestBodyJson() { + return Json.createReader( + new InputStreamReader( + new ByteArrayInputStream(servlet.getLastPostBody()), Charset.forName("UTF-8"))) + .readObject(); + } +}