/*
* 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.script;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.Version;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.logging.DeprecationLogger;
import org.elasticsearch.common.logging.ESLoggerFactory;
import org.elasticsearch.common.xcontent.ObjectParser;
import org.elasticsearch.common.xcontent.ObjectParser.ValueType;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentParser.Token;
import org.elasticsearch.common.xcontent.XContentType;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* {@link Script} represents used-defined input that can be used to
* compile and execute a script from the {@link ScriptService}
* based on the {@link ScriptType}.
*
* There are three types of scripts specified by {@link ScriptType}.
*
* The following describes the expected parameters for each type of script:
*
* <ul>
* <li> {@link ScriptType#INLINE}
* <ul>
* <li> {@link Script#lang} - specifies the language, defaults to {@link Script#DEFAULT_SCRIPT_LANG}
* <li> {@link Script#idOrCode} - specifies the code to be compiled, must not be {@code null}
* <li> {@link Script#options} - specifies the compiler options for this script; must not be {@code null},
* use an empty {@link Map} to specify no options
* <li> {@link Script#params} - {@link Map} of user-defined parameters; must not be {@code null},
* use an empty {@link Map} to specify no params
* </ul>
* <li> {@link ScriptType#STORED}
* <ul>
* <li> {@link Script#lang} - the language will be specified when storing the script, so this should
* be {@code null}; however, this can be specified to look up a stored
* script as part of the deprecated API
* <li> {@link Script#idOrCode} - specifies the id of the stored script to be looked up, must not be {@code null}
* <li> {@link Script#options} - compiler options will be specified when a stored script is stored,
* so they have no meaning here and must be {@code null}
* <li> {@link Script#params} - {@link Map} of user-defined parameters; must not be {@code null},
* use an empty {@link Map} to specify no params
* </ul>
* <li> {@link ScriptType#FILE}
* <ul>
* <li> {@link Script#lang} - specifies the language for look up, defaults to {@link Script#DEFAULT_SCRIPT_LANG}
* <li> {@link Script#idOrCode} - specifies the id of the file script to be looked up, must not be {@code null}
* <li> {@link Script#options} - compiler options will be specified when a file script is loaded,
* so they have no meaning here and must be {@code null}
* <li> {@link Script#params} - {@link Map} of user-defined parameters; must not be {@code null},
* use an empty {@link Map} to specify no params
* </ul>
* </ul>
*/
public final class Script implements ToXContentObject, Writeable {
/**
* Standard logger necessary for allocation of the deprecation logger.
*/
private static final Logger LOGGER = ESLoggerFactory.getLogger(ScriptMetaData.class);
/**
* Deprecation logger necessary for namespace changes related to stored scripts.
*/
private static final DeprecationLogger DEPRECATION_LOGGER = new DeprecationLogger(LOGGER);
/**
* The name of the of the default scripting language.
*/
public static final String DEFAULT_SCRIPT_LANG = "painless";
/**
* The name of the default template language.
*/
public static final String DEFAULT_TEMPLATE_LANG = "mustache";
/**
* The default {@link ScriptType}.
*/
public static final ScriptType DEFAULT_SCRIPT_TYPE = ScriptType.INLINE;
/**
* Compiler option for {@link XContentType} used for templates.
*/
public static final String CONTENT_TYPE_OPTION = "content_type";
/**
* Standard {@link ParseField} for outer level of script queries.
*/
public static final ParseField SCRIPT_PARSE_FIELD = new ParseField("script");
/**
* Standard {@link ParseField} for lang on the inner level.
*/
public static final ParseField LANG_PARSE_FIELD = new ParseField("lang");
/**
* Standard {@link ParseField} for options on the inner level.
*/
public static final ParseField OPTIONS_PARSE_FIELD = new ParseField("options");
/**
* Standard {@link ParseField} for params on the inner level.
*/
public static final ParseField PARAMS_PARSE_FIELD = new ParseField("params");
/**
* Helper class used by {@link ObjectParser} to store mutable {@link Script} variables and then
* construct an immutable {@link Script} object based on parsed XContent.
*/
private static final class Builder {
private ScriptType type;
private String lang;
private String idOrCode;
private Map<String, String> options;
private Map<String, Object> params;
private Builder() {
// This cannot default to an empty map because options are potentially added at multiple points.
this.options = new HashMap<>();
this.params = Collections.emptyMap();
}
/**
* Since inline scripts can accept code rather than just an id, they must also be able
* to handle template parsing, hence the need for custom parsing code. Templates can
* consist of either an {@link String} or a JSON object. If a JSON object is discovered
* then the content type option must also be saved as a compiler option.
*/
private void setInline(XContentParser parser) {
try {
if (type != null) {
throwOnlyOneOfType();
}
type = ScriptType.INLINE;
if (parser.currentToken() == Token.START_OBJECT) {
//this is really for search templates, that need to be converted to json format
XContentBuilder builder = XContentFactory.jsonBuilder();
idOrCode = builder.copyCurrentStructure(parser).string();
options.put(CONTENT_TYPE_OPTION, XContentType.JSON.mediaType());
} else {
idOrCode = parser.text();
}
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
/**
* Set both the id and the type of the stored script.
*/
private void setStored(String idOrCode) {
if (type != null) {
throwOnlyOneOfType();
}
type = ScriptType.STORED;
this.idOrCode = idOrCode;
}
/**
* Set both the id and the type of the file script.
*/
private void setFile(String idOrCode) {
if (type != null) {
throwOnlyOneOfType();
}
type = ScriptType.FILE;
this.idOrCode = idOrCode;
}
/**
* Helper method to throw an exception if more than one type of {@link Script} is specified.
*/
private void throwOnlyOneOfType() {
throw new IllegalArgumentException("must only use one of [" +
ScriptType.INLINE.getParseField().getPreferredName() + " + , " +
ScriptType.STORED.getParseField().getPreferredName() + " + , " +
ScriptType.FILE.getParseField().getPreferredName() + "]" +
" when specifying a script");
}
private void setLang(String lang) {
this.lang = lang;
}
/**
* Options may have already been added if an inline template was specified.
* Appends the user-defined compiler options with the internal compiler options.
*/
private void setOptions(Map<String, String> options) {
this.options.putAll(options);
}
private void setParams(Map<String, Object> params) {
this.params = params;
}
/**
* Validates the parameters and creates an {@link Script}.
* @param defaultLang The default lang is not a compile-time constant and must be provided
* at run-time this way in case a legacy default language is used from
* previously stored queries.
*/
private Script build(String defaultLang) {
if (type == null) {
throw new IllegalArgumentException(
"must specify either code for an [" + ScriptType.INLINE.getParseField().getPreferredName() + "] script " +
"or an id for a [" + ScriptType.STORED.getParseField().getPreferredName() + "] script " +
"or [" + ScriptType.FILE.getParseField().getPreferredName() + "] script");
}
if (type == ScriptType.INLINE) {
if (lang == null) {
lang = defaultLang;
}
if (idOrCode == null) {
throw new IllegalArgumentException(
"must specify <id> for an [" + ScriptType.INLINE.getParseField().getPreferredName() + "] script");
}
if (options.size() > 1 || options.size() == 1 && options.get(CONTENT_TYPE_OPTION) == null) {
options.remove(CONTENT_TYPE_OPTION);
throw new IllegalArgumentException("illegal compiler options [" + options + "] specified");
}
} else if (type == ScriptType.STORED) {
// Only issue this deprecation warning if we aren't using a template. Templates during
// this deprecation phase must always specify the default template language or they would
// possibly pick up a script in a different language as defined by the user under the new
// namespace unintentionally.
if (lang != null && lang.equals(DEFAULT_TEMPLATE_LANG) == false) {
DEPRECATION_LOGGER.deprecated("specifying the field [" + LANG_PARSE_FIELD.getPreferredName() + "] " +
"for executing " + ScriptType.STORED + " scripts is deprecated; use only the field " +
"[" + ScriptType.STORED.getParseField().getPreferredName() + "] to specify an <id>");
}
if (idOrCode == null) {
throw new IllegalArgumentException(
"must specify <code> for an [" + ScriptType.STORED.getParseField().getPreferredName() + "] script");
}
if (options.isEmpty()) {
options = null;
} else {
throw new IllegalArgumentException("field [" + OPTIONS_PARSE_FIELD.getPreferredName() + "] " +
"cannot be specified using a [" + ScriptType.STORED.getParseField().getPreferredName() + "] script");
}
} else if (type == ScriptType.FILE) {
if (lang == null) {
lang = defaultLang;
}
if (idOrCode == null) {
throw new IllegalArgumentException(
"must specify <code> for an [" + ScriptType.FILE.getParseField().getPreferredName() + "] script");
}
if (options.isEmpty()) {
options = null;
} else {
throw new IllegalArgumentException("field [" + OPTIONS_PARSE_FIELD.getPreferredName() + "] " +
"cannot be specified using a [" + ScriptType.FILE.getParseField().getPreferredName() + "] script");
}
}
return new Script(type, lang, idOrCode, options, params);
}
}
private static final ObjectParser<Builder, Void> PARSER = new ObjectParser<>("script", Builder::new);
static {
// Defines the fields necessary to parse a Script as XContent using an ObjectParser.
PARSER.declareField(Builder::setInline, parser -> parser, ScriptType.INLINE.getParseField(), ValueType.OBJECT_OR_STRING);
PARSER.declareString(Builder::setStored, ScriptType.STORED.getParseField());
PARSER.declareString(Builder::setFile, ScriptType.FILE.getParseField());
PARSER.declareString(Builder::setLang, LANG_PARSE_FIELD);
PARSER.declareField(Builder::setOptions, XContentParser::mapStrings, OPTIONS_PARSE_FIELD, ValueType.OBJECT);
PARSER.declareField(Builder::setParams, XContentParser::map, PARAMS_PARSE_FIELD, ValueType.OBJECT);
}
/**
* Convenience method to call {@link Script#parse(XContentParser, String)}
* using the default scripting language.
*/
public static Script parse(XContentParser parser) throws IOException {
return parse(parser, DEFAULT_SCRIPT_LANG);
}
/**
* This will parse XContent into a {@link Script}. The following formats can be parsed:
*
* The simple format defaults to an {@link ScriptType#INLINE} with no compiler options or user-defined params:
*
* Example:
* {@code
* "return Math.log(doc.popularity) * 100;"
* }
*
* The complex format where {@link ScriptType} and idOrCode are required while lang, options and params are not required.
*
* {@code
* {
* "<type (inline, stored, file)>" : "<idOrCode>",
* "lang" : "<lang>",
* "options" : {
* "option0" : "<option0>",
* "option1" : "<option1>",
* ...
* },
* "params" : {
* "param0" : "<param0>",
* "param1" : "<param1>",
* ...
* }
* }
* }
*
* Example:
* {@code
* {
* "inline" : "return Math.log(doc.popularity) * params.multiplier",
* "lang" : "painless",
* "params" : {
* "multiplier" : 100.0
* }
* }
* }
*
* This also handles templates in a special way. If a complexly formatted query is specified as another complex
* JSON object the query is assumed to be a template, and the format will be preserved.
*
* {@code
* {
* "inline" : { "query" : ... },
* "lang" : "<lang>",
* "options" : {
* "option0" : "<option0>",
* "option1" : "<option1>",
* ...
* },
* "params" : {
* "param0" : "<param0>",
* "param1" : "<param1>",
* ...
* }
* }
* }
*
* @param parser The {@link XContentParser} to be used.
* @param defaultLang The default language to use if no language is specified. The default language isn't necessarily
* the one defined by {@link Script#DEFAULT_SCRIPT_LANG} due to backwards compatibility requirements
* related to stored queries using previously default languages.
*
* @return The parsed {@link Script}.
*/
public static Script parse(XContentParser parser, String defaultLang) throws IOException {
Objects.requireNonNull(defaultLang);
Token token = parser.currentToken();
if (token == null) {
token = parser.nextToken();
}
if (token == Token.VALUE_STRING) {
return new Script(ScriptType.INLINE, defaultLang, parser.text(), Collections.emptyMap());
}
return PARSER.apply(parser, null).build(defaultLang);
}
private final ScriptType type;
private final String lang;
private final String idOrCode;
private final Map<String, String> options;
private final Map<String, Object> params;
/**
* Constructor for simple script using the default language and default type.
* @param idOrCode The id or code to use dependent on the default script type.
*/
public Script(String idOrCode) {
this(DEFAULT_SCRIPT_TYPE, DEFAULT_SCRIPT_LANG, idOrCode, Collections.emptyMap(), Collections.emptyMap());
}
/**
* Constructor for a script that does not need to use compiler options.
* @param type The {@link ScriptType}.
* @param lang The language for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE} or
* {@link ScriptType#FILE}. For {@link ScriptType#STORED} scripts this should be null, but can
* be specified to access scripts stored as part of the stored scripts deprecated API.
* @param idOrCode The id for this {@link Script} if the {@link ScriptType} is {@link ScriptType#FILE} or {@link ScriptType#STORED}.
* The code for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE}.
* @param params The user-defined params to be bound for script execution.
*/
public Script(ScriptType type, String lang, String idOrCode, Map<String, Object> params) {
this(type, lang, idOrCode, type == ScriptType.INLINE ? Collections.emptyMap() : null, params);
}
/**
* Constructor for a script that requires the use of compiler options.
* @param type The {@link ScriptType}.
* @param lang The language for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE} or
* {@link ScriptType#FILE}. For {@link ScriptType#STORED} scripts this should be null, but can
* be specified to access scripts stored as part of the stored scripts deprecated API.
* @param idOrCode The id for this {@link Script} if the {@link ScriptType} is {@link ScriptType#FILE} or {@link ScriptType#STORED}.
* The code for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE}.
* @param options The map of compiler options for this {@link Script} if the {@link ScriptType}
* is {@link ScriptType#INLINE}, {@code null} otherwise.
* @param params The user-defined params to be bound for script execution.
*/
public Script(ScriptType type, String lang, String idOrCode, Map<String, String> options, Map<String, Object> params) {
this.type = Objects.requireNonNull(type);
this.idOrCode = Objects.requireNonNull(idOrCode);
this.params = Collections.unmodifiableMap(Objects.requireNonNull(params));
if (type == ScriptType.INLINE) {
this.lang = Objects.requireNonNull(lang);
this.options = Collections.unmodifiableMap(Objects.requireNonNull(options));
} else if (type == ScriptType.STORED) {
this.lang = lang;
if (options != null) {
throw new IllegalStateException(
"options must be null for [" + ScriptType.STORED.getParseField().getPreferredName() + "] scripts");
}
this.options = null;
} else if (type == ScriptType.FILE) {
this.lang = Objects.requireNonNull(lang);
if (options != null) {
throw new IllegalStateException(
"options must be null for [" + ScriptType.FILE.getParseField().getPreferredName() + "] scripts");
}
this.options = null;
} else {
throw new IllegalStateException("unknown script type [" + type.getName() + "]");
}
}
/**
* Creates a {@link Script} read from an input stream.
*/
public Script(StreamInput in) throws IOException {
// Version 5.3 allows lang to be an optional parameter for stored scripts and expects
// options to be null for stored and file scripts.
if (in.getVersion().onOrAfter(Version.V_5_3_0_UNRELEASED)) {
this.type = ScriptType.readFrom(in);
this.lang = in.readOptionalString();
this.idOrCode = in.readString();
@SuppressWarnings("unchecked")
Map<String, String> options = (Map<String, String>)(Map)in.readMap();
this.options = options;
this.params = in.readMap();
// Version 5.1 to 5.3 (exclusive) requires all Script members to be non-null and supports the potential
// for more options than just XContentType. Reorders the read in contents to be in
// same order as the constructor.
} else if (in.getVersion().onOrAfter(Version.V_5_1_1_UNRELEASED)) {
this.type = ScriptType.readFrom(in);
this.lang = in.readString();
this.idOrCode = in.readString();
@SuppressWarnings("unchecked")
Map<String, String> options = (Map<String, String>)(Map)in.readMap();
if (this.type != ScriptType.INLINE && options.isEmpty()) {
this.options = null;
} else {
this.options = options;
}
this.params = in.readMap();
// Prior to version 5.1 the script members are read in certain cases as optional and given
// default values when necessary. Also the only option supported is for XContentType.
} else {
this.idOrCode = in.readString();
if (in.readBoolean()) {
this.type = ScriptType.readFrom(in);
} else {
this.type = DEFAULT_SCRIPT_TYPE;
}
String lang = in.readOptionalString();
if (lang == null) {
this.lang = DEFAULT_SCRIPT_LANG;
} else {
this.lang = lang;
}
Map<String, Object> params = in.readMap();
if (params == null) {
this.params = new HashMap<>();
} else {
this.params = params;
}
if (in.readBoolean()) {
this.options = new HashMap<>();
XContentType contentType = XContentType.readFrom(in);
this.options.put(CONTENT_TYPE_OPTION, contentType.mediaType());
} else if (type == ScriptType.INLINE) {
options = new HashMap<>();
} else {
this.options = null;
}
}
}
@Override
public void writeTo(StreamOutput out) throws IOException {
// Version 5.3+ allows lang to be an optional parameter for stored scripts and expects
// options to be null for stored and file scripts.
if (out.getVersion().onOrAfter(Version.V_5_3_0_UNRELEASED)) {
type.writeTo(out);
out.writeOptionalString(lang);
out.writeString(idOrCode);
@SuppressWarnings("unchecked")
Map<String, Object> options = (Map<String, Object>)(Map)this.options;
out.writeMap(options);
out.writeMap(params);
// Version 5.1 to 5.3 (exclusive) requires all Script members to be non-null and supports the potential
// for more options than just XContentType. Reorders the written out contents to be in
// same order as the constructor.
} else if (out.getVersion().onOrAfter(Version.V_5_1_1_UNRELEASED)) {
type.writeTo(out);
if (lang == null) {
out.writeString("");
} else {
out.writeString(lang);
}
out.writeString(idOrCode);
@SuppressWarnings("unchecked")
Map<String, Object> options = (Map<String, Object>)(Map)this.options;
if (options == null) {
out.writeMap(new HashMap<>());
} else {
out.writeMap(options);
}
out.writeMap(params);
// Prior to version 5.1 the Script members were possibly written as optional or null, though there is no case where a null
// value wasn't equivalent to it's default value when actually compiling/executing a script. Meaning, there are no
// backwards compatibility issues, and now there's enforced consistency. Also the only supported compiler
// option was XContentType.
} else {
out.writeString(idOrCode);
out.writeBoolean(true);
type.writeTo(out);
out.writeOptionalString(lang);
if (params.isEmpty()) {
out.writeMap(null);
} else {
out.writeMap(params);
}
if (options != null && options.containsKey(CONTENT_TYPE_OPTION)) {
XContentType contentType = XContentType.fromMediaTypeOrFormat(options.get(CONTENT_TYPE_OPTION));
out.writeBoolean(true);
contentType.writeTo(out);
} else {
out.writeBoolean(false);
}
}
}
/**
* This will build scripts into the following XContent structure:
*
* {@code
* {
* "<type (inline, stored, file)>" : "<idOrCode>",
* "lang" : "<lang>",
* "options" : {
* "option0" : "<option0>",
* "option1" : "<option1>",
* ...
* },
* "params" : {
* "param0" : "<param0>",
* "param1" : "<param1>",
* ...
* }
* }
* }
*
* Example:
* {@code
* {
* "inline" : "return Math.log(doc.popularity) * params.multiplier;",
* "lang" : "painless",
* "params" : {
* "multiplier" : 100.0
* }
* }
* }
*
* Note that lang, options, and params will only be included if there have been any specified.
*
* This also handles templates in a special way. If the {@link Script#CONTENT_TYPE_OPTION} option
* is provided and the {@link ScriptType#INLINE} is specified then the template will be preserved as a raw field.
*
* {@code
* {
* "inline" : { "query" : ... },
* "lang" : "<lang>",
* "options" : {
* "option0" : "<option0>",
* "option1" : "<option1>",
* ...
* },
* "params" : {
* "param0" : "<param0>",
* "param1" : "<param1>",
* ...
* }
* }
* }
*/
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params builderParams) throws IOException {
builder.startObject();
String contentType = options == null ? null : options.get(CONTENT_TYPE_OPTION);
if (type == ScriptType.INLINE && contentType != null && builder.contentType().mediaType().equals(contentType)) {
builder.rawField(type.getParseField().getPreferredName(), new BytesArray(idOrCode));
} else {
builder.field(type.getParseField().getPreferredName(), idOrCode);
}
if (lang != null) {
builder.field(LANG_PARSE_FIELD.getPreferredName(), lang);
}
if (options != null && !options.isEmpty()) {
builder.field(OPTIONS_PARSE_FIELD.getPreferredName(), options);
}
if (!params.isEmpty()) {
builder.field(PARAMS_PARSE_FIELD.getPreferredName(), params);
}
builder.endObject();
return builder;
}
/**
* @return The {@link ScriptType} for this {@link Script}.
*/
public ScriptType getType() {
return type;
}
/**
* @return The language for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE} or
* {@link ScriptType#FILE}. For {@link ScriptType#STORED} scripts this should be null, but can
* be specified to access scripts stored as part of the stored scripts deprecated API.
*/
public String getLang() {
return lang;
}
/**
* @return The id for this {@link Script} if the {@link ScriptType} is {@link ScriptType#FILE} or {@link ScriptType#STORED}.
* The code for this {@link Script} if the {@link ScriptType} is {@link ScriptType#INLINE}.
*/
public String getIdOrCode() {
return idOrCode;
}
/**
* @return The map of compiler options for this {@link Script} if the {@link ScriptType}
* is {@link ScriptType#INLINE}, {@code null} otherwise.
*/
public Map<String, String> getOptions() {
return options;
}
/**
* @return The map of user-defined params for this {@link Script}.
*/
public Map<String, Object> getParams() {
return params;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Script script = (Script)o;
if (type != script.type) return false;
if (lang != null ? !lang.equals(script.lang) : script.lang != null) return false;
if (!idOrCode.equals(script.idOrCode)) return false;
if (options != null ? !options.equals(script.options) : script.options != null) return false;
return params.equals(script.params);
}
@Override
public int hashCode() {
int result = type.hashCode();
result = 31 * result + (lang != null ? lang.hashCode() : 0);
result = 31 * result + idOrCode.hashCode();
result = 31 * result + (options != null ? options.hashCode() : 0);
result = 31 * result + params.hashCode();
return result;
}
@Override
public String toString() {
return "Script{" +
"type=" + type +
", lang='" + lang + '\'' +
", idOrCode='" + idOrCode + '\'' +
", options=" + options +
", params=" + params +
'}';
}
}