You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by ma...@apache.org on 2019/03/07 18:01:50 UTC

[nifi] branch master updated: NIFI-6078: Create PostSlack processor

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 <tu...@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 <ma...@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 (kevina@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 &lt;Text&gt; property,
+                while &lt;Upload FlowFile&gt; 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 &lt;Text&gt; 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 &lt;Upload FlowFile&gt; 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 &lt;Text&gt; 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 &lt;Text&gt; property and set &lt;Upload FlowFile&gt; to 'Yes'.
+                The content of the FlowFile will be uploaded with the message and it will be displayed
+                below the text. You should specify &lt;File Name&gt; and &lt;File Mime Type&gt; properties too
+                (otherwise some fallback values will be set), and optionally &lt;File Title&gt;.
+                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 &lt;Text&gt; 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();
+    }
+}