From 8afa7c174f77a47f8db3dae2b6590c24d6a1ecc1 Mon Sep 17 00:00:00 2001 From: Martijn van Groningen Date: Thu, 19 Apr 2018 09:33:34 +0200 Subject: [PATCH] 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 --- .../painless/painless-execute-script.asciidoc | 53 +++ .../painless-getting-started.asciidoc | 2 + .../painless/PainlessExecuteAction.java | 338 ++++++++++++++++++ .../painless/PainlessPlugin.java | 34 +- .../painless/PainlessExecuteRequestTests.java | 61 ++++ .../PainlessExecuteResponseTests.java | 34 ++ .../painless/70_execute_painless_scripts.yml | 25 ++ .../api/scripts_painless_execute.json | 17 + 8 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 docs/painless/painless-execute-script.asciidoc create mode 100644 modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessExecuteAction.java create mode 100644 modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteRequestTests.java create mode 100644 modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteResponseTests.java create mode 100644 modules/lang-painless/src/test/resources/rest-api-spec/test/painless/70_execute_painless_scripts.yml create mode 100644 rest-api-spec/src/main/resources/rest-api-spec/api/scripts_painless_execute.json diff --git a/docs/painless/painless-execute-script.asciidoc b/docs/painless/painless-execute-script.asciidoc new file mode 100644 index 00000000000..7997c87e3e4 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/docs/painless/painless-getting-started.asciidoc b/docs/painless/painless-getting-started.asciidoc index 8cf163d55d7..2cf91666ba4 100644 --- a/docs/painless/painless-getting-started.asciidoc +++ b/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[] diff --git a/modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessExecuteAction.java b/modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessExecuteAction.java new file mode 100644 index 00000000000..aa650a37c4f --- /dev/null +++ b/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 { + + 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 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 { + + 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 params; + + public PainlessTestScript(Map params) { + this.params = params; + } + + /** Return the parameters for this script. */ + public Map getParams() { + return params; + } + + public abstract Object execute(); + + public interface Factory { + + PainlessTestScript newInstance(Map params); + + } + + public static final String[] PARAMETERS = {}; + public static final ScriptContext CONTEXT = new ScriptContext<>("painless_test", Factory.class); + + } + + public static class TransportAction extends HandledTransportAction { + + + 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 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(channel) { + @Override + public RestResponse buildResponse(Response response, XContentBuilder builder) throws Exception { + response.toXContent(builder, ToXContent.EMPTY_PARAMS); + return new BytesRestResponse(OK, builder); + } + }); + } + } + +} diff --git a/modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessPlugin.java b/modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessPlugin.java index 795d81bb6e0..0364ad667ef 100644 --- a/modules/lang-painless/src/main/java/org/elasticsearch/painless/PainlessPlugin.java +++ b/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, List> extendedWhitelists = new HashMap<>(); @@ -74,4 +86,24 @@ public final class PainlessPlugin extends Plugin implements ScriptPlugin, Extens } } } + + @SuppressWarnings("rawtypes") + public List getContexts() { + return Collections.singletonList(PainlessExecuteAction.PainlessTestScript.CONTEXT); + } + + @Override + public List> getActions() { + return Collections.singletonList( + new ActionHandler<>(PainlessExecuteAction.INSTANCE, PainlessExecuteAction.TransportAction.class) + ); + } + + @Override + public List getRestHandlers(Settings settings, RestController restController, ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster) { + return Collections.singletonList(new PainlessExecuteAction.RestAction(settings, restController)); + } } diff --git a/modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteRequestTests.java b/modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteRequestTests.java new file mode 100644 index 00000000000..488ae0e1643 --- /dev/null +++ b/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 { + + @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()); + } +} diff --git a/modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteResponseTests.java b/modules/lang-painless/src/test/java/org/elasticsearch/painless/PainlessExecuteResponseTests.java new file mode 100644 index 00000000000..20f3cf08e04 --- /dev/null +++ b/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 { + + @Override + protected PainlessExecuteAction.Response createBlankInstance() { + return new PainlessExecuteAction.Response(); + } + + @Override + protected PainlessExecuteAction.Response createTestInstance() { + return new PainlessExecuteAction.Response(randomAlphaOfLength(10)); + } +} diff --git a/modules/lang-painless/src/test/resources/rest-api-spec/test/painless/70_execute_painless_scripts.yml b/modules/lang-painless/src/test/resources/rest-api-spec/test/painless/70_execute_painless_scripts.yml new file mode 100644 index 00000000000..7b915cc38db --- /dev/null +++ b/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" } diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/scripts_painless_execute.json b/rest-api-spec/src/main/resources/rest-api-spec/api/scripts_painless_execute.json new file mode 100644 index 00000000000..c02627cfd87 --- /dev/null +++ b/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" + } + } +}