Browse Source

Added painless execute api. (#29164)

Added an api that allows to execute an arbitrary script and a result to be returned.

```
POST /_scripts/painless/_execute
{
  "script": {
    "source": "params.var1 / params.var2",
    "params": {
      "var1": 1,
      "var2": 1
    }
  }
}
```

Relates to #27875
Martijn van Groningen 7 years ago
parent
commit
8afa7c174f

+ 53 - 0
docs/painless/painless-execute-script.asciidoc

@@ -0,0 +1,53 @@
+[[painless-execute-api]]
+=== Painless execute API
+
+The Painless execute API allows an arbitrary script to be executed and a result to be returned.
+
+[[painless-execute-api-parameters]]
+.Parameters
+[options="header"]
+|======
+| Name             | Required  | Default                | Description
+| `script`         | yes       | -                      | The script to execute
+| `context`        | no        | `execute_api_script`   | The context the script should be executed in.
+|======
+
+==== Contexts
+
+Contexts control how scripts are executed, what variables are available at runtime and what the return type is.
+
+===== Painless test script context
+
+The `painless_test` context executes scripts as is and do not add any special parameters.
+The only variable that is available is `params`, which can be used to access user defined values.
+The result of the script is always converted to a string.
+If no context is specified then this context is used by default.
+
+==== Example
+
+Request:
+
+[source,js]
+----------------------------------------------------------------
+POST /_scripts/painless/_execute
+{
+  "script": {
+    "source": "params.count / params.total",
+    "params": {
+      "count": 100.0,
+      "total": 1000.0
+    }
+  }
+}
+----------------------------------------------------------------
+// CONSOLE
+
+Response:
+
+[source,js]
+--------------------------------------------------
+{
+  "result": "0.1"
+}
+--------------------------------------------------
+// TESTRESPONSE

+ 2 - 0
docs/painless/painless-getting-started.asciidoc

@@ -389,3 +389,5 @@ dispatch *feels* like it'd add a ton of complexity which'd make maintenance and
 other improvements much more difficult.
 
 include::painless-debugging.asciidoc[]
+
+include::painless-execute-script.asciidoc[]

+ 338 - 0
modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessExecuteAction.java

@@ -0,0 +1,338 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.painless;
+
+import org.elasticsearch.action.Action;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestBuilder;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.client.ElasticsearchClient;
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.BytesRestResponse;
+import org.elasticsearch.rest.RestController;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.rest.action.RestBuilderListener;
+import org.elasticsearch.script.Script;
+import org.elasticsearch.script.ScriptContext;
+import org.elasticsearch.script.ScriptService;
+import org.elasticsearch.script.ScriptType;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+import static org.elasticsearch.rest.RestStatus.OK;
+
+public class PainlessExecuteAction extends Action<PainlessExecuteAction.Request, PainlessExecuteAction.Response,
+    PainlessExecuteAction.RequestBuilder> {
+
+    static final PainlessExecuteAction INSTANCE = new PainlessExecuteAction();
+    private static final String NAME = "cluster:admin/scripts/painless/execute";
+
+    private PainlessExecuteAction() {
+        super(NAME);
+    }
+
+    @Override
+    public RequestBuilder newRequestBuilder(ElasticsearchClient client) {
+        return new RequestBuilder(client);
+    }
+
+    @Override
+    public Response newResponse() {
+        return new Response();
+    }
+
+    public static class Request extends ActionRequest implements ToXContent {
+
+        private static final ParseField SCRIPT_FIELD = new ParseField("script");
+        private static final ParseField CONTEXT_FIELD = new ParseField("context");
+        private static final ConstructingObjectParser<Request, Void> PARSER = new ConstructingObjectParser<>(
+            "painless_execute_request", args -> new Request((Script) args[0], (SupportedContext) args[1]));
+
+        static {
+            PARSER.declareObject(ConstructingObjectParser.constructorArg(), (p, c) -> Script.parse(p), SCRIPT_FIELD);
+            PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (p, c) -> {
+                // For now only accept an empty json object:
+                XContentParser.Token token = p.nextToken();
+                assert token == XContentParser.Token.FIELD_NAME;
+                String contextType = p.currentName();
+                token = p.nextToken();
+                assert token == XContentParser.Token.START_OBJECT;
+                token = p.nextToken();
+                assert token == XContentParser.Token.END_OBJECT;
+                token = p.nextToken();
+                assert token == XContentParser.Token.END_OBJECT;
+                return SupportedContext.valueOf(contextType.toUpperCase(Locale.ROOT));
+            }, CONTEXT_FIELD);
+        }
+
+        private Script script;
+        private SupportedContext context;
+
+        static Request parse(XContentParser parser) throws IOException {
+            return PARSER.parse(parser, null);
+        }
+
+        Request(Script script, SupportedContext context) {
+            this.script = Objects.requireNonNull(script);
+            this.context = context != null ? context : SupportedContext.PAINLESS_TEST;
+        }
+
+        Request() {
+        }
+
+        public Script getScript() {
+            return script;
+        }
+
+        public SupportedContext getContext() {
+            return context;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+            if (script.getType() != ScriptType.INLINE) {
+                validationException = addValidationError("only inline scripts are supported", validationException);
+            }
+            return validationException;
+        }
+
+        @Override
+        public void readFrom(StreamInput in) throws IOException {
+            super.readFrom(in);
+            script = new Script(in);
+            context = SupportedContext.fromId(in.readByte());
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            script.writeTo(out);
+            out.writeByte(context.id);
+        }
+
+        // For testing only:
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.field(SCRIPT_FIELD.getPreferredName(), script);
+            builder.startObject(CONTEXT_FIELD.getPreferredName());
+            {
+                builder.startObject(context.name());
+                builder.endObject();
+            }
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return Objects.equals(script, request.script) &&
+                context == request.context;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(script, context);
+        }
+
+        public enum SupportedContext {
+
+            PAINLESS_TEST((byte) 0);
+
+            private final byte id;
+
+            SupportedContext(byte id) {
+                this.id = id;
+            }
+
+            public static SupportedContext fromId(byte id) {
+                switch (id) {
+                    case 0:
+                        return PAINLESS_TEST;
+                    default:
+                        throw new IllegalArgumentException("unknown context [" + id + "]");
+                }
+            }
+        }
+
+    }
+
+    public static class RequestBuilder extends ActionRequestBuilder<Request, Response, RequestBuilder> {
+
+        RequestBuilder(ElasticsearchClient client) {
+            super(client, INSTANCE, new Request());
+        }
+    }
+
+    public static class Response extends ActionResponse implements ToXContentObject {
+
+        private Object result;
+
+        Response() {}
+
+        Response(Object result) {
+            this.result = result;
+        }
+
+        public Object getResult() {
+            return result;
+        }
+
+        @Override
+        public void readFrom(StreamInput in) throws IOException {
+            super.readFrom(in);
+            result = in.readGenericValue();
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeGenericValue(result);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field("result", result);
+            return builder.endObject();
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+            return Objects.equals(result, response.result);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(result);
+        }
+    }
+
+    public abstract static class PainlessTestScript {
+
+        private final Map<String, Object> params;
+
+        public PainlessTestScript(Map<String, Object> params) {
+            this.params = params;
+        }
+
+        /** Return the parameters for this script. */
+        public Map<String, Object> getParams() {
+            return params;
+        }
+
+        public abstract Object execute();
+
+        public interface Factory {
+
+            PainlessTestScript newInstance(Map<String, Object> params);
+
+        }
+
+        public static final String[] PARAMETERS = {};
+        public static final ScriptContext<Factory> CONTEXT = new ScriptContext<>("painless_test", Factory.class);
+
+    }
+
+    public static class TransportAction extends HandledTransportAction<Request, Response> {
+
+
+        private final ScriptService scriptService;
+
+        @Inject
+        public TransportAction(Settings settings, ThreadPool threadPool, TransportService transportService,
+                               ActionFilters actionFilters, IndexNameExpressionResolver indexNameExpressionResolver,
+                               ScriptService scriptService) {
+            super(settings, NAME, threadPool, transportService, actionFilters, indexNameExpressionResolver, Request::new);
+            this.scriptService = scriptService;
+        }
+        @Override
+        protected void doExecute(Request request, ActionListener<Response> listener) {
+            switch (request.context) {
+                case PAINLESS_TEST:
+                    PainlessTestScript.Factory factory = scriptService.compile(request.script, PainlessTestScript.CONTEXT);
+                    PainlessTestScript painlessTestScript = factory.newInstance(request.script.getParams());
+                    String result = Objects.toString(painlessTestScript.execute());
+                    listener.onResponse(new Response(result));
+                    break;
+                default:
+                    throw new UnsupportedOperationException("unsupported context [" + request.context + "]");
+            }
+        }
+
+    }
+
+    static class RestAction extends BaseRestHandler {
+
+        RestAction(Settings settings, RestController controller) {
+            super(settings);
+            controller.registerHandler(GET, "/_scripts/painless/_execute", this);
+            controller.registerHandler(POST, "/_scripts/painless/_execute", this);
+        }
+
+        @Override
+        public String getName() {
+            return "_scripts_painless_execute";
+        }
+
+        @Override
+        protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) throws IOException {
+            final Request request = Request.parse(restRequest.contentOrSourceParamParser());
+            return channel -> client.executeLocally(INSTANCE, request, new RestBuilderListener<Response>(channel) {
+                @Override
+                public RestResponse buildResponse(Response response, XContentBuilder builder) throws Exception {
+                    response.toXContent(builder, ToXContent.EMPTY_PARAMS);
+                    return new BytesRestResponse(OK, builder);
+                }
+            });
+        }
+    }
+
+}

+ 33 - 1
modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessPlugin.java

@@ -20,28 +20,40 @@
 package org.elasticsearch.painless;
 
 
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.node.DiscoveryNodes;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.IndexScopedSettings;
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.settings.SettingsFilter;
 import org.elasticsearch.painless.spi.PainlessExtension;
 import org.elasticsearch.painless.spi.Whitelist;
+import org.elasticsearch.plugins.ActionPlugin;
 import org.elasticsearch.plugins.ExtensiblePlugin;
 import org.elasticsearch.plugins.Plugin;
 import org.elasticsearch.plugins.ScriptPlugin;
+import org.elasticsearch.rest.RestController;
+import org.elasticsearch.rest.RestHandler;
 import org.elasticsearch.script.ScriptContext;
 import org.elasticsearch.script.ScriptEngine;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.function.Supplier;
 
 /**
  * Registers Painless as a plugin.
  */
-public final class PainlessPlugin extends Plugin implements ScriptPlugin, ExtensiblePlugin {
+public final class PainlessPlugin extends Plugin implements ScriptPlugin, ExtensiblePlugin, ActionPlugin {
 
     private final Map<ScriptContext<?>, List<Whitelist>> extendedWhitelists = new HashMap<>();
 
@@ -74,4 +86,24 @@ public final class PainlessPlugin extends Plugin implements ScriptPlugin, Extens
             }
         }
     }
+
+    @SuppressWarnings("rawtypes")
+    public List<ScriptContext> getContexts() {
+        return Collections.singletonList(PainlessExecuteAction.PainlessTestScript.CONTEXT);
+    }
+
+    @Override
+    public List<ActionHandler<? extends ActionRequest, ? extends ActionResponse>> getActions() {
+        return Collections.singletonList(
+            new ActionHandler<>(PainlessExecuteAction.INSTANCE, PainlessExecuteAction.TransportAction.class)
+        );
+    }
+
+    @Override
+    public List<RestHandler> getRestHandlers(Settings settings, RestController restController, ClusterSettings clusterSettings,
+                                             IndexScopedSettings indexScopedSettings, SettingsFilter settingsFilter,
+                                             IndexNameExpressionResolver indexNameExpressionResolver,
+                                             Supplier<DiscoveryNodes> nodesInCluster) {
+        return Collections.singletonList(new PainlessExecuteAction.RestAction(settings, restController));
+    }
 }

+ 61 - 0
modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteRequestTests.java

@@ -0,0 +1,61 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.painless;
+
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.script.Script;
+import org.elasticsearch.script.ScriptType;
+import org.elasticsearch.test.AbstractStreamableXContentTestCase;
+
+import java.io.IOException;
+import java.util.Collections;
+
+public class PainlessExecuteRequestTests extends AbstractStreamableXContentTestCase<PainlessExecuteAction.Request> {
+
+    @Override
+    protected PainlessExecuteAction.Request createTestInstance() {
+        Script script = new Script(randomAlphaOfLength(10));
+        PainlessExecuteAction.Request.SupportedContext context = randomBoolean() ?
+            PainlessExecuteAction.Request.SupportedContext.PAINLESS_TEST : null;
+        return new PainlessExecuteAction.Request(script, context);
+    }
+
+    @Override
+    protected PainlessExecuteAction.Request createBlankInstance() {
+        return new PainlessExecuteAction.Request();
+    }
+
+    @Override
+    protected PainlessExecuteAction.Request doParseInstance(XContentParser parser) throws IOException {
+        return PainlessExecuteAction.Request.parse(parser);
+    }
+
+    @Override
+    protected boolean supportsUnknownFields() {
+        return false;
+    }
+
+    public void testValidate() {
+        Script script = new Script(ScriptType.STORED, null, randomAlphaOfLength(10), Collections.emptyMap());
+        PainlessExecuteAction.Request request = new PainlessExecuteAction.Request(script, null);
+        Exception e = request.validate();
+        assertNotNull(e);
+        assertEquals("Validation Failed: 1: only inline scripts are supported;", e.getMessage());
+    }
+}

+ 34 - 0
modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteResponseTests.java

@@ -0,0 +1,34 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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.elasticsearch.painless;
+
+import org.elasticsearch.test.AbstractStreamableTestCase;
+
+public class PainlessExecuteResponseTests extends AbstractStreamableTestCase<PainlessExecuteAction.Response> {
+
+    @Override
+    protected PainlessExecuteAction.Response createBlankInstance() {
+        return new PainlessExecuteAction.Response();
+    }
+
+    @Override
+    protected PainlessExecuteAction.Response createTestInstance() {
+        return new PainlessExecuteAction.Response(randomAlphaOfLength(10));
+    }
+}

+ 25 - 0
modules/lang-painless/src/test/resources/rest-api-spec/test/painless/70_execute_painless_scripts.yml

@@ -0,0 +1,25 @@
+---
+"Execute with defaults":
+  - do:
+      scripts_painless_execute:
+        body:
+          script:
+            source: "params.count / params.total"
+            params:
+              count: 100.0
+              total: 1000.0
+  - match: { result: "0.1" }
+
+---
+"Execute with execute_api_script context":
+  - do:
+      scripts_painless_execute:
+        body:
+          script:
+            source: "params.var1 - params.var2"
+            params:
+              var1: 10
+              var2: 100
+          context:
+            painless_test: {}
+  - match: { result: "-90" }

+ 17 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/scripts_painless_execute.json

@@ -0,0 +1,17 @@
+{
+  "scripts_painless_execute": {
+    "documentation": "https://www.elastic.co/guide/en/elasticsearch/painless/master/painless-execute-api.html",
+    "methods": ["GET", "POST"],
+    "url": {
+      "path": "/_scripts/painless/_execute",
+      "paths": ["/_scripts/painless/_execute"],
+      "parts": {
+      },
+      "params": {
+      }
+    },
+    "body": {
+      "description": "The script to execute"
+    }
+  }
+}