/*
* 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.spring;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.annotation.behavior.TriggerWhenEmpty;
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.PropertyDescriptor;
import org.apache.nifi.components.ValidationContext;
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.components.Validator;
import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.processor.AbstractProcessor;
import org.apache.nifi.processor.ProcessContext;
import org.apache.nifi.processor.ProcessSession;
import org.apache.nifi.processor.Processor;
import org.apache.nifi.processor.Relationship;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.io.InputStreamCallback;
import org.apache.nifi.processor.io.OutputStreamCallback;
import org.apache.nifi.processor.util.StandardValidators;
import org.apache.nifi.spring.SpringDataExchanger.SpringResponse;
import org.apache.nifi.stream.io.StreamUtils;
import org.apache.nifi.util.FormatUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.PollableChannel;
/**
* Implementation of {@link Processor} capable of sending and receiving data
* from application defined in Spring Application context. It does so via
* predefined in/out {@link MessageChannel}s (see spring-messaging module of
* Spring). Once such channels are defined user is free to implement the rest of
* the application any way they wish (e.g., custom code and/or using frameworks
* such as Spring Integration or Camel).
* <p>
* The requirement and expectations for channel types are:
* <ul>
* <li>Input channel must be of type {@link MessageChannel} and named "fromNiFi"
* (see {@link SpringNiFiConstants#FROM_NIFI})</li>
* <li>Output channel must be of type {@link PollableChannel} and named "toNiFi"
* (see {@link SpringNiFiConstants#TO_NIFI})</li>
* </ul>
* </p>
* Below is the example of sample configuration:
*
* <pre>
* <?xml version="1.0" encoding="UTF-8"?>
* <beans xmlns="http://www.springframework.org/schema/beans"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xmlns:int="http://www.springframework.org/schema/integration"
* xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
* http://www.springframework.org/schema/integration http://www.springframework.org/schema/integration/spring-integration-4.2.xsd">
*
* <int:channel id="fromNiFi"/>
*
* . . . . .
*
* <int:channel id="toNiFi">
* <int:queue/>
* </int:channel>
*
* </beans>
* </pre>
* <p>
* Defining {@link MessageChannel} is optional. That's why this processor
* supports 3 modes of interaction with Spring Application Context:
* <ul>
* <li>Headless – no channels are defined therefore nothing is sent to or
* received from such Application Contexts (i.e., some monitoring app).</li>
* <li>One way (NiFi -> Spring or Spring -> NiFi) - depends on existence
* of one of "fromNiFi" or "toNiFi" channel in the Spring Application Context.
* </li>
* <li>Bi-directional (NiFi -> Spring -> Nifi or Spring -> NiFi ->
* Spring) - depends on existence of both "fromNiFi" and "toNiFi" channels in
* the Spring Application Context</li>
* </ul>
*
* </p>
* <p>
* To create an instance of the ApplicationConetxt this processor requires user
* to provide configuration file path and the path to the resources that needs
* to be added to the classpath of ApplicationContext. This essentially allows
* user to package their Spring Application any way they want as long as
* everything it requires is available on the classpath.
* </p>
* <p>
* Data exchange between Spring and NiFi relies on simple mechanism which is
* exposed via {@link SpringDataExchanger}; {@link FlowFile}s's content is
* converted to primitive representation that can be easily wrapped in Spring
* {@link Message}. The requirement imposed by this Processor is to send/receive
* {@link Message} with <i>payload</i> of type <i>byte[]</i> and headers of type
* <i>Map<String, Object></i>. This is primarily for simplicity and type
* safety. Converters and Transformers could be used by either side to change
* representation of the content that is being exchanged between NiFi and
* Spring.
*/
@TriggerWhenEmpty
@Tags({ "Spring", "Message", "Get", "Put", "Integration" })
@CapabilityDescription("A Processor that supports sending and receiving data from application defined in "
+ "Spring Application Context via predefined in/out MessageChannels.")
public class SpringContextProcessor extends AbstractProcessor {
private final Logger logger = LoggerFactory.getLogger(SpringContextProcessor.class);
public static final PropertyDescriptor CTX_CONFIG_PATH = new PropertyDescriptor.Builder()
.name("Application Context config path")
.description("The path to the Spring Application Context configuration file relative to the classpath")
.required(true)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
.build();
public static final PropertyDescriptor CTX_LIB_PATH = new PropertyDescriptor.Builder()
.name("Application Context class path")
.description("Path to the directory with resources (i.e., JARs, configuration files etc.) required to be on "
+ "the classpath of the ApplicationContext.")
.addValidator(StandardValidators.createDirectoryExistsValidator(false, false))
.required(true)
.build();
public static final PropertyDescriptor SEND_TIMEOUT = new PropertyDescriptor.Builder()
.name("Send Timeout")
.description("Timeout for sending data to Spring Application Context. Defaults to 0.")
.required(false)
.addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
.build();
public static final PropertyDescriptor RECEIVE_TIMEOUT = new PropertyDescriptor.Builder()
.name("Receive Timeout")
.description("Timeout for receiving date from Spring context. Defaults to 0.")
.required(false)
.addValidator(StandardValidators.TIME_PERIOD_VALIDATOR)
.build();
// ====
public static final Relationship REL_SUCCESS = new Relationship.Builder().name("success")
.description(
"All FlowFiles that are successfully received from Spring Application Context are routed to this relationship")
.build();
public static final Relationship REL_FAILURE = new Relationship.Builder().name("failure")
.description(
"All FlowFiles that cannot be sent to Spring Application Context are routed to this relationship")
.build();
private final static Set<Relationship> relationships;
private final static List<PropertyDescriptor> propertyDescriptors;
// =======
private volatile String applicationContextConfigFileName;
private volatile String applicationContextLibPath;
private volatile long sendTimeout;
private volatile long receiveTimeout;
private volatile SpringDataExchanger exchanger;
static {
List<PropertyDescriptor> _propertyDescriptors = new ArrayList<>();
_propertyDescriptors.add(CTX_CONFIG_PATH);
_propertyDescriptors.add(CTX_LIB_PATH);
_propertyDescriptors.add(SEND_TIMEOUT);
_propertyDescriptors.add(RECEIVE_TIMEOUT);
propertyDescriptors = Collections.unmodifiableList(_propertyDescriptors);
Set<Relationship> _relationships = new HashSet<>();
_relationships.add(REL_SUCCESS);
_relationships.add(REL_FAILURE);
relationships = Collections.unmodifiableSet(_relationships);
}
/**
*
*/
@Override
public Set<Relationship> getRelationships() {
return relationships;
}
/**
*
*/
@OnScheduled
public void initializeSpringContext(ProcessContext processContext) {
this.applicationContextConfigFileName = processContext.getProperty(CTX_CONFIG_PATH).getValue();
this.applicationContextLibPath = processContext.getProperty(CTX_LIB_PATH).getValue();
String stStr = processContext.getProperty(SEND_TIMEOUT).getValue();
this.sendTimeout = stStr == null ? 0 : FormatUtils.getTimeDuration(stStr, TimeUnit.MILLISECONDS);
String rtStr = processContext.getProperty(RECEIVE_TIMEOUT).getValue();
this.receiveTimeout = rtStr == null ? 0 : FormatUtils.getTimeDuration(rtStr, TimeUnit.MILLISECONDS);
try {
if (logger.isDebugEnabled()) {
logger.debug(
"Initializing Spring Application Context defined in " + this.applicationContextConfigFileName);
}
this.exchanger = SpringContextFactory.createSpringContextDelegate(this.applicationContextLibPath,
this.applicationContextConfigFileName);
} catch (Exception e) {
throw new IllegalStateException("Failed while initializing Spring Application Context", e);
}
if (logger.isInfoEnabled()) {
logger.info("Successfully initialized Spring Application Context defined in "
+ this.applicationContextConfigFileName);
}
}
/**
* Will close the 'exchanger' which in turn will close both Spring
* Application Context and the ClassLoader that loaded it allowing new
* instance of Spring Application Context to be created upon the next start
* (which may have an updated classpath and functionality) without
* restarting NiFi.
*/
@OnStopped
public void closeSpringContext(ProcessContext processContext) {
if (this.exchanger != null) {
try {
if (logger.isDebugEnabled()) {
logger.debug("Closing Spring Application Context defined in " + this.applicationContextConfigFileName);
}
this.exchanger.close();
if (logger.isInfoEnabled()) {
logger.info("Successfully closed Spring Application Context defined in "
+ this.applicationContextConfigFileName);
}
} catch (IOException e) {
getLogger().warn("Failed while closing Spring Application Context", e);
}
}
}
/**
*
*/
@Override
public void onTrigger(ProcessContext context, ProcessSession processSession) throws ProcessException {
FlowFile flowFileToProcess = processSession.get();
if (flowFileToProcess != null) {
this.sendToSpring(flowFileToProcess, context, processSession);
}
this.receiveFromSpring(processSession);
}
@Override
protected Collection<ValidationResult> customValidate(final ValidationContext validationContext) {
SpringContextConfigValidator v = new SpringContextConfigValidator();
return Collections.singletonList(v.validate(CTX_CONFIG_PATH.getName(), null, validationContext));
}
/**
*
*/
@Override
protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
return propertyDescriptors;
}
/**
*
*/
private void sendToSpring(FlowFile flowFileToProcess, ProcessContext context, ProcessSession processSession) {
byte[] payload = this.extractMessage(flowFileToProcess, processSession);
boolean sent = false;
try {
sent = this.exchanger.send(payload, flowFileToProcess.getAttributes(), this.sendTimeout);
if (sent) {
processSession.getProvenanceReporter().send(flowFileToProcess, this.applicationContextConfigFileName);
processSession.remove(flowFileToProcess);
} else {
processSession.transfer(processSession.penalize(flowFileToProcess), REL_FAILURE);
this.getLogger().error("Timed out while sending FlowFile to Spring Application Context "
+ this.applicationContextConfigFileName);
context.yield();
}
} catch (Exception e) {
processSession.transfer(flowFileToProcess, REL_FAILURE);
this.getLogger().error("Failed while sending FlowFile to Spring Application Context "
+ this.applicationContextConfigFileName + "; " + e.getMessage(), e);
context.yield();
}
}
/**
*
*/
private void receiveFromSpring(ProcessSession processSession) {
final SpringResponse<?> msgFromSpring = this.exchanger.receive(this.receiveTimeout);
if (msgFromSpring != null) {
FlowFile flowFileToProcess = processSession.create();
flowFileToProcess = processSession.write(flowFileToProcess, new OutputStreamCallback() {
@Override
public void process(final OutputStream out) throws IOException {
Object payload = msgFromSpring.getPayload();
byte[] payloadBytes = payload instanceof String ? ((String) payload).getBytes() : (byte[]) payload;
out.write(payloadBytes);
}
});
flowFileToProcess = processSession.putAllAttributes(flowFileToProcess,
this.extractFlowFileAttributesFromMessageHeaders(msgFromSpring.getHeaders()));
processSession.transfer(flowFileToProcess, REL_SUCCESS);
processSession.getProvenanceReporter().receive(flowFileToProcess, this.applicationContextConfigFileName);
}
}
/**
*
*/
private Map<String, String> extractFlowFileAttributesFromMessageHeaders(Map<String, Object> messageHeaders) {
Map<String, String> attributes = new HashMap<>();
for (Entry<String, Object> entry : messageHeaders.entrySet()) {
if (entry.getValue() instanceof String) {
attributes.put(entry.getKey(), (String) entry.getValue());
}
}
return attributes;
}
/**
* Extracts contents of the {@link FlowFile} to byte array.
*/
private byte[] extractMessage(FlowFile flowFile, ProcessSession processSession) {
final byte[] messageContent = new byte[(int) flowFile.getSize()];
processSession.read(flowFile, new InputStreamCallback() {
@Override
public void process(final InputStream in) throws IOException {
StreamUtils.fillBuffer(in, messageContent, true);
}
});
return messageContent;
}
/**
*
*/
static class SpringContextConfigValidator implements Validator {
@Override
public ValidationResult validate(String subject, String input, ValidationContext context) {
String configPath = context.getProperty(CTX_CONFIG_PATH).getValue();
String libDirPath = context.getProperty(CTX_LIB_PATH).getValue();
StringBuilder invalidationMessageBuilder = new StringBuilder();
if (configPath != null && libDirPath != null) {
validateClassPath(libDirPath, invalidationMessageBuilder);
if (invalidationMessageBuilder.length() == 0 && !isConfigResolvable(configPath, new File(libDirPath))) {
invalidationMessageBuilder.append("'Application Context config path' can not be located "
+ "in the provided classpath.");
}
} else if (StringUtils.isEmpty(configPath)) {
invalidationMessageBuilder.append("'Application Context config path' must not be empty.");
} else {
if (StringUtils.isEmpty(libDirPath)) {
invalidationMessageBuilder.append("'Application Context class path' must not be empty.");
} else {
validateClassPath(libDirPath, invalidationMessageBuilder);
}
}
String invalidationMessage = invalidationMessageBuilder.toString();
ValidationResult vResult = invalidationMessage.length() == 0
? new ValidationResult.Builder().subject(subject).input(input)
.explanation("Spring configuration '" + configPath + "' is resolvable "
+ "against provided classpath '" + libDirPath + "'.").valid(true).build()
: new ValidationResult.Builder().subject(subject).input(input)
.explanation("Spring configuration '" + configPath + "' is NOT resolvable "
+ "against provided classpath '" + libDirPath + "'. Validation message: " + invalidationMessage).valid(false).build();
return vResult;
}
}
/**
*
*/
private static void validateClassPath(String libDirPath, StringBuilder invalidationMessageBuilder) {
File libDirPathFile = new File(libDirPath);
if (!libDirPathFile.exists()) {
invalidationMessageBuilder.append(
"'Application Context class path' does not exist. Was '" + libDirPathFile.getAbsolutePath() + "'.");
} else if (!libDirPathFile.isDirectory()) {
invalidationMessageBuilder.append("'Application Context class path' must point to a directory. Was '"
+ libDirPathFile.getAbsolutePath() + "'.");
}
}
/**
*
*/
private static boolean isConfigResolvable(String configPath, File libDirPathFile) {
List<URL> urls = new ArrayList<>();
URLClassLoader parentLoader = (URLClassLoader) SpringContextProcessor.class.getClassLoader();
urls.addAll(Arrays.asList(parentLoader.getURLs()));
urls.addAll(SpringContextFactory.gatherAdditionalClassPathUrls(libDirPathFile.getAbsolutePath()));
boolean resolvable = false;
try (URLClassLoader throwawayCl = new URLClassLoader(urls.toArray(new URL[] {}), null)) {
resolvable = throwawayCl.findResource(configPath) != null;
} catch (IOException e) {
// ignore since it can only happen on CL.close()
}
return resolvable;
}
}