blob: 29c849df86b1b356f5173f8f665623d7746543bf [file] [log] [blame]
/*
* 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.sling.pipes.internal;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.AccessControlException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.request.RequestParameter;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.ServletResolverConstants;
import org.apache.sling.pipes.CommandExecutor;
import org.apache.sling.pipes.ExecutionResult;
import org.apache.sling.pipes.OutputWriter;
import org.apache.sling.pipes.Pipe;
import org.apache.sling.pipes.PipeBuilder;
import org.apache.sling.pipes.PipeExecutor;
import org.apache.sling.pipes.Plumber;
import org.apache.sling.pipes.internal.inputstream.JsonPipe;
import org.jetbrains.annotations.NotNull;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static javax.servlet.http.HttpServletResponse.SC_NOT_ACCEPTABLE;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static org.apache.commons.lang3.StringUtils.EMPTY;
import static org.apache.sling.pipes.internal.CommandUtil.keyValuesToArray;
import static org.apache.sling.pipes.internal.CommandUtil.writeToMap;
import javax.json.JsonException;
import javax.servlet.Servlet;
@Component(service = {Servlet.class, CommandExecutor.class}, property= {
ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + CommandExecutorImpl.RESOURCE_TYPE,
ServletResolverConstants.SLING_SERVLET_METHODS + "=POST",
ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json",
ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=csv"
})
public class CommandExecutorImpl extends AbstractPlumberServlet implements CommandExecutor {
final Logger log = LoggerFactory.getLogger(CommandExecutorImpl.class);
public static final String RESOURCE_TYPE = "slingPipes/exec";
static final String REQ_PARAM_FILE = "pipe_cmdfile";
static final String REQ_PARAM_CMD = "pipe_cmd";
static final String REQ_PARAM_HELP = "pipe_help";
static final String CMD_LINE_PREFIX = "cmd_line_";
static final String PN_DESCRIPTION = "commandParsed";
static final String WHITE_SPACE_SEPARATOR = "[\\s\\h]";
static final String COMMENT_PREFIX = "#";
static final String SEPARATOR = "|";
static final String PIPE_SEPARATOR = WHITE_SPACE_SEPARATOR + "*\\" + SEPARATOR + WHITE_SPACE_SEPARATOR + "*";
static final String LINE_SEPARATOR = " ";
static final String PARAMS = "@";
static final List<String> JSON_EXPR_KEYS = Arrays.asList(JsonPipe.JSON_KEY);
static final String JSON_START = "\"[{";
static final String PARAMS_SEPARATOR = WHITE_SPACE_SEPARATOR + "+" + PARAMS + WHITE_SPACE_SEPARATOR + "*";
static final Pattern SUB_TOKEN_PATTERN = Pattern.compile("(([^\"]\\S*)|\"([^\"]+)\")\\s*");
static final String KEY_NAME = "name";
static final String KEY_PATH = "path";
static final String KEY_EXPR = "expr";
private static final String HELP_START =
"\n a <pipe token> is <pipe> <expr|conf>? (<options>)?" +
"\n <options> are (@ <option>)* form with <option> being either" +
"\n\t'name pipeName' (used in bindings), " +
"\n\t'expr pipeExpression' (when not directly as <args>)" +
"\n\t'path pipePath' (when not directly as <args>)" +
"\n\t'bindings key=value ...' (for setting pipe bindings) " +
"\n\t'with key=value ...' (for setting pipe specific properties)" +
"\n\t'outputs key=value ...' (for setting outputs)" +
"\n and <pipe> is one of the following :\n";
Map<String, Method> methodMap;
Map<String, PipeExecutor> executorMap;
String help;
@Reference
Plumber plumber;
@Activate
@Modified
public void activate(){
methodMap = null;
executorMap = null;
help = null;
}
boolean isCommandCandidate(String line) {
return StringUtils.isNotBlank(line) && !line.startsWith(COMMENT_PREFIX);
}
List<String> getCommandList(SlingHttpServletRequest request) throws IOException {
List<String> cmds = new ArrayList<>();
if (request.getParameterMap().containsKey(REQ_PARAM_CMD)) {
cmds.add(request.getParameter(REQ_PARAM_CMD));
} else {
RequestParameter paramFile = request.getRequestParameter(REQ_PARAM_FILE);
if (paramFile != null) {
InputStream is = paramFile.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
String line;
StringBuilder cmdBuilder = new StringBuilder();
while ((line = reader.readLine()) != null) {
if (isCommandCandidate(line)) {
cmdBuilder.append(LINE_SEPARATOR + line.trim());
} else if (cmdBuilder.length() > 0){
cmds.add(cmdBuilder.toString().trim());
cmdBuilder = new StringBuilder();
}
}
if (cmdBuilder.length() > 0) {
cmds.add(cmdBuilder.toString().trim());
}
}
}
return cmds;
}
@Override
protected void doPost(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) throws IOException {
String currentCommand = null;
PrintWriter writer = response.getWriter();
try {
if (request.getParameter(REQ_PARAM_HELP) != null) {
writer.println(help());
} else {
ResourceResolver resolver = request.getResourceResolver();
Map<String, Object> bindings = plumber.getBindingsFromRequest(request, true);
List<String> cmds = getCommandList(request);
if (cmds.isEmpty()) {
writer.println("No command to execute!");
}
short idxLine = 0;
OutputWriter pipeWriter = getWriter(request, response);
if (pipeWriter == null) {
pipeWriter = new NopWriter();
}
pipeWriter.disableAutoClose();
pipeWriter.init(request, response);
for (String command : cmds) {
if (StringUtils.isNotBlank(command)) {
currentCommand = command;
PipeBuilder pipeBuilder = parse(resolver, command);
Pipe pipe = pipeBuilder.build();
bindings.put(CMD_LINE_PREFIX + idxLine++, pipe.getResource().getPath());
ModifiableValueMap root = pipe.getResource().adaptTo(ModifiableValueMap.class);
root.put(PN_DESCRIPTION, command);
plumber.execute(resolver, pipe, bindings, pipeWriter, true);
}
}
pipeWriter.ends();
}
writer.println("");
response.setStatus(SC_OK);
}
catch (AccessControlException e) {
response.setStatus(SC_FORBIDDEN);
response.sendError(SC_FORBIDDEN);
}
catch (IllegalAccessException | InvocationTargetException e) {
writer.println("Error executing " + currentCommand);
e.printStackTrace(writer);
response.setStatus(SC_INTERNAL_SERVER_ERROR);
response.sendError(SC_INTERNAL_SERVER_ERROR);
writer.println(help());
}
catch (IllegalArgumentException | JsonException e) {
writer.println("Error executing " + currentCommand);
e.printStackTrace(writer);
response.setStatus(SC_NOT_ACCEPTABLE);
response.sendError(SC_NOT_ACCEPTABLE);
writer.println(help());
}
}
@Override
public ExecutionResult execute(ResourceResolver resolver, String path, String... optionTokens) {
Resource resource = resolver.getResource(path);
if (resource == null){
throw new IllegalArgumentException(String.format("%s resource does not exist", path));
}
Options options = getOptions(optionTokens);
Map<String, Object> bMap = null;
if (options.with != null) {
bMap = new HashMap<>();
writeToMap(bMap, true, options.with);
}
OutputWriter writer = new NopWriter();
if (options.outputs != null){
writer = new JsonWriter();
Map<String, Object> outputs = new HashMap<>();
CommandUtil.writeToMap(outputs, true, options.outputs);
writer.setCustomOutputs(outputs);
}
writer.starts();
return plumber.execute(resolver, path, bMap, writer, true);
}
@Override
public PipeBuilder parse(ResourceResolver resolver, String...cmds) throws InvocationTargetException, IllegalAccessException {
PipeBuilder builder = plumber.newPipe(resolver);
for (Token token : parseTokens(cmds)){
Method method = getMethodMap().get(token.pipeKey);
if (method == null){
throw new IllegalArgumentException(token.pipeKey + " is not a valid pipe");
}
if (isExpressionExpected(method)){
method.invoke(builder, token.args.get(0));
} else if (isConfExpected(method)){
method.invoke(builder, (Object)keyValuesToArray(token.args));
} else if (isWithoutExpectedParameter(method)){
method.invoke(builder);
}
if (token.options != null){
token.options.writeToBuilder(builder);
}
}
return builder;
}
/**
* @param tokens array of tokens
* @return options from array
*/
protected Options getOptions(String[] tokens){
return getOptions(Arrays.asList(tokens));
}
/**
* @param tokens list of tokens
* @return options from token list
*/
protected Options getOptions(List<String> tokens){
return new Options(tokens);
}
/**
* Options for a pipe execution
*/
protected class Options {
String name;
String path;
String expr;
String[] with;
String[] bindings;
String[] outputs;
@Override
public String toString() {
return "Options{" +
"name='" + name + '\'' +
", path='" + path + '\'' +
", expr='" + expr + '\'' +
", with=" + Arrays.toString(with) +
", bindings=" + Arrays.toString(bindings) +
", outputs=" + Arrays.toString(outputs) +
'}';
}
void setOutputs(List<String> values) {
this.outputs = keyValuesToArray(values);
}
/**
* Constructor
* @param options string list from where options will be built
*/
protected Options(List<String> options){
Map<String, Object> optionMap = new HashMap<>();
for (String optionToken : options) {
String currentKey = null;
List<String> currentList = new ArrayList<>();
for (String subToken : getSpaceSeparatedTokens(optionToken)) {
if (currentKey == null) {
currentKey = subToken;
} else {
currentList.add(subToken);
}
}
finishOption(currentKey, currentList, optionMap);
}
for (Map.Entry<String, Object> entry : optionMap.entrySet()){
switch (entry.getKey()) {
case Pipe.PN_NAME :
this.name = (String)entry.getValue();
break;
case Pipe.PN_PATH :
this.path = (String)entry.getValue();
break;
case Pipe.PN_EXPR :
this.expr = (String)entry.getValue();
break;
case "with" :
this.with = keyValuesToArray((List<String>)entry.getValue());
break;
case "bindings" :
this.bindings = keyValuesToArray((List<String>) entry.getValue());
break;
case "outputs" :
setOutputs((List<String>)entry.getValue());
break;
default:
throw new IllegalArgumentException(String.format("%s is an unknown option", entry.getKey()));
}
}
}
/**
* wrap up current option
* @param currentKey option key
* @param currentList list being processed
* @param optionMap option map
*/
protected void finishOption(String currentKey, List<String> currentList, Map<String, Object> optionMap){
if (currentList != null){
if (currentKey.equals(KEY_NAME) || currentKey.equals(KEY_EXPR) || currentKey.equals(KEY_PATH)) {
optionMap.put(currentKey, currentList.get(0));
} else {
optionMap.put(currentKey, currentList);
}
}
}
/**
* write options to current builder
* @param builder current builder
* @throws IllegalAccessException
*/
void writeToBuilder(PipeBuilder builder) throws IllegalAccessException {
if (StringUtils.isNotBlank(name)){
builder.name(name);
}
if (StringUtils.isNotBlank(path)){
builder.path(path);
}
if (StringUtils.isNotBlank(expr)){
builder.expr(expr);
}
if (with != null){
builder.with(with);
}
if (bindings != null){
builder.bindings(bindings);
}
if (outputs != null) {
builder.outputs(outputs);
}
}
}
/**
* Pipe token, used to hold information of a "sub pipe" configuration
*/
protected class Token {
String pipeKey;
List<String> args;
CommandExecutorImpl.Options options;
@Override
public String toString() {
return "Token{" +
"pipeKey='" + pipeKey + '\'' +
", args=" + args +
", options=" + options +
'}';
}
}
List<String> getSpaceSeparatedTokens(String token) {
List<String> subTokens = new ArrayList<>();
Matcher matcher = SUB_TOKEN_PATTERN.matcher(token);
while (matcher.find()){
subTokens.add(matcher.group(2) != null ? matcher.group(2) : matcher.group(3));
}
return subTokens;
}
/**
* @param commands full list of command tokens
* @return Token list corresponding to the string ones
*/
protected List<CommandExecutorImpl.Token> parseTokens(String... commands) {
List<CommandExecutorImpl.Token> returnValue = new ArrayList<>();
String cat = String.join(EMPTY, commands);
for (String token : cat.split(PIPE_SEPARATOR)){
CommandExecutorImpl.Token currentToken = new CommandExecutorImpl.Token();
String[] options = token.split(PARAMS_SEPARATOR);
if (options.length > 1) {
currentToken.options = getOptions(Arrays.copyOfRange(options, 1, options.length));
}
List<String> subTokens = getSpaceSeparatedTokens(options[0]);
if (! subTokens.isEmpty()) {
currentToken.pipeKey = subTokens.get(0);
if (subTokens.size() > 1) {
currentToken.args = subTokens.subList(1, subTokens.size());
if (JSON_EXPR_KEYS.contains(currentToken.pipeKey) &&
JSON_START.indexOf(currentToken.args.get(0).getBytes(StandardCharsets.UTF_8)[0]) > 0) {
//in that case we want to concatenate all subsequent 'args' as it is a JSON expression
currentToken.args = Collections.singletonList(String.join(EMPTY, currentToken.args));
}
}
}
log.trace("generated following token {}", currentToken);
returnValue.add(currentToken);
}
return returnValue;
}
/**
* builds utility maps
*/
protected void computeMaps(){
executorMap = new HashMap<>();
methodMap = new HashMap<>();
for (Method method : PipeBuilder.class.getDeclaredMethods()) {
PipeExecutor executor = method.getAnnotation(PipeExecutor.class);
if (executor != null) {
methodMap.put(executor.command(), method);
executorMap.put(executor.command(), executor);
}
}
}
/**
* @return map of command to PB api method
*/
protected Map<String, Method> getMethodMap() {
if (methodMap == null) {
computeMaps();
}
return methodMap;
}
/**
* @return map of command to Annotation information around the PB api
*/
protected Map<String, PipeExecutor> getExecutorMap() {
if (executorMap == null) {
computeMaps();
}
return executorMap;
}
/**
* @param method corresponding PB api
* @return true if the api does expect an expression (meaning a string)
*/
protected boolean isExpressionExpected(Method method) {
return method.getParameterCount() == 1 && method.getParameterTypes()[0].equals(String.class);
}
/**
* @param method corresponding PB api
* @return true if the api does expect a configuration (meaning a list of key value pairs)
*/
protected boolean isConfExpected(Method method) {
return method.getParameterCount() == 1 && method.getParameterTypes()[0].equals(Object[].class);
}
/**
* @param method corresponding PB api
* @return true if the api does not expect parameters
*/
protected boolean isWithoutExpectedParameter(Method method){
return method.getParameterCount() == 0;
}
/**
* help command handler
*/
public String help(){
if (StringUtils.isBlank(help)) {
StringBuilder builder = new StringBuilder();
builder.append(HELP_START);
for (Map.Entry<String, PipeExecutor> entry : getExecutorMap().entrySet()) {
builder.append(String.format("\t%s\t\t:\t%s%n", entry.getKey(), entry.getValue().description()));
}
help = builder.toString();
}
return help;
}
}