From b927cfe1de6f573f443ebd58f492b759cf939093 Mon Sep 17 00:00:00 2001 From: Chris Earle Date: Fri, 15 Apr 2016 20:58:00 -0400 Subject: [PATCH] Add DeprecationRestHandler to automatically log deprecated REST calls This adds a new proxy for RestHandlers and RestControllers so that requests made to deprecated REST APIs can be automatically logged in the ES logs via the DeprecationLogger as well as via a "Warning" header (RFC-7234) for all responses. --- .../elasticsearch/ElasticsearchException.java | 21 +- .../common/io/stream/StreamInput.java | 16 ++ .../common/io/stream/StreamOutput.java | 15 ++ .../common/logging/DeprecationLogger.java | 91 +++++++- .../common/util/concurrent/ThreadContext.java | 123 +++++++--- .../http/netty/HttpRequestHandler.java | 2 +- .../http/netty/NettyHttpChannel.java | 12 +- .../java/org/elasticsearch/node/Node.java | 6 + .../rest/DeprecationRestHandler.java | 108 +++++++++ .../elasticsearch/rest/RestController.java | 23 +- .../elasticsearch/transport/TcpTransport.java | 11 +- .../transport/local/LocalTransport.java | 7 +- .../local/LocalTransportChannel.java | 7 +- .../common/io/stream/BytesStreamsTests.java | 48 +++- .../logging/DeprecationLoggerTests.java | 155 +++++++++++++ .../util/concurrent/ThreadContextTests.java | 105 +++++++-- .../http/netty/NettyHttpChannelTests.java | 4 +- .../elasticsearch/rest/DeprecationHttpIT.java | 216 ++++++++++++++++++ .../rest/DeprecationRestHandlerTests.java | 139 +++++++++++ .../rest/RestControllerTests.java | 26 +++ .../plugins/TestDeprecatedQueryBuilder.java | 104 +++++++++ .../TestDeprecationHeaderRestAction.java | 108 +++++++++ .../rest/plugins/TestDeprecationPlugin.java | 55 +++++ 23 files changed, 1308 insertions(+), 94 deletions(-) create mode 100644 core/src/main/java/org/elasticsearch/rest/DeprecationRestHandler.java create mode 100644 core/src/test/java/org/elasticsearch/common/logging/DeprecationLoggerTests.java create mode 100644 core/src/test/java/org/elasticsearch/rest/DeprecationHttpIT.java create mode 100644 core/src/test/java/org/elasticsearch/rest/DeprecationRestHandlerTests.java create mode 100644 core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecatedQueryBuilder.java create mode 100644 core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationHeaderRestAction.java create mode 100644 core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationPlugin.java diff --git a/core/src/main/java/org/elasticsearch/ElasticsearchException.java b/core/src/main/java/org/elasticsearch/ElasticsearchException.java index 801939ba7374a..54bbfc851d207 100644 --- a/core/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/core/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -33,7 +33,6 @@ import org.elasticsearch.transport.TcpTransport; import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -102,16 +101,7 @@ public ElasticsearchException(String msg, Throwable cause, Object... args) { public ElasticsearchException(StreamInput in) throws IOException { super(in.readOptionalString(), in.readException()); readStackTrace(this, in); - int numKeys = in.readVInt(); - for (int i = 0; i < numKeys; i++) { - final String key = in.readString(); - final int numValues = in.readVInt(); - final ArrayList values = new ArrayList<>(numValues); - for (int j = 0; j < numValues; j++) { - values.add(in.readString()); - } - headers.put(key, values); - } + headers.putAll(in.readMapOfLists()); } /** @@ -206,14 +196,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeOptionalString(this.getMessage()); out.writeException(this.getCause()); writeStackTraces(this, out); - out.writeVInt(headers.size()); - for (Map.Entry> entry : headers.entrySet()) { - out.writeString(entry.getKey()); - out.writeVInt(entry.getValue().size()); - for (String v : entry.getValue()) { - out.writeString(v); - } - } + out.writeMapOfLists(headers); } public static ElasticsearchException readException(StreamInput input, int id) throws IOException { diff --git a/core/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java b/core/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java index 80ff214cbc9ef..2d3aebb320de0 100644 --- a/core/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java +++ b/core/src/main/java/org/elasticsearch/common/io/stream/StreamInput.java @@ -53,6 +53,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.NotDirectoryException; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; @@ -414,6 +415,21 @@ public Map readMap() throws IOException { return (Map) readGenericValue(); } + /** + * Read a map of strings to string lists. + */ + public Map> readMapOfLists() throws IOException { + int size = readVInt(); + if (size == 0) { + return Collections.emptyMap(); + } + Map> map = new HashMap<>(size); + for (int i = 0; i < size; ++i) { + map.put(readString(), readList(StreamInput::readString)); + } + return map; + } + @SuppressWarnings({"unchecked"}) @Nullable public Object readGenericValue() throws IOException { diff --git a/core/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java b/core/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java index dee3ea24f0055..64ed6ed390463 100644 --- a/core/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java +++ b/core/src/main/java/org/elasticsearch/common/io/stream/StreamOutput.java @@ -403,6 +403,21 @@ public void writeMap(@Nullable Map map) throws IOException { writeGenericValue(map); } + /** + * Writes a map of strings to string lists. + */ + public void writeMapOfLists(Map> map) throws IOException { + writeVInt(map.size()); + + for (Map.Entry> entry : map.entrySet()) { + writeString(entry.getKey()); + writeVInt(entry.getValue().size()); + for (String v : entry.getValue()) { + writeString(v); + } + } + } + @FunctionalInterface interface Writer { void write(StreamOutput o, Object value) throws IOException; diff --git a/core/src/main/java/org/elasticsearch/common/logging/DeprecationLogger.java b/core/src/main/java/org/elasticsearch/common/logging/DeprecationLogger.java index b792a85d34cb0..5970f9173226d 100644 --- a/core/src/main/java/org/elasticsearch/common/logging/DeprecationLogger.java +++ b/core/src/main/java/org/elasticsearch/common/logging/DeprecationLogger.java @@ -20,12 +20,70 @@ package org.elasticsearch.common.logging; import org.elasticsearch.common.SuppressLoggerChecks; +import org.elasticsearch.common.util.concurrent.ThreadContext; + +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; /** * A logger that logs deprecation notices. */ public class DeprecationLogger { + /** + * The "Warning" Header comes from RFC-7234. As the RFC describes, it's generally used for caching purposes, but it can be + * used for any warning. + * + * https://tools.ietf.org/html/rfc7234#section-5.5 + */ + public static final String DEPRECATION_HEADER = "Warning"; + + /** + * This is set once by the {@code Node} constructor, but it uses {@link CopyOnWriteArraySet} to ensure that tests can run in parallel. + *

+ * Integration tests will create separate nodes within the same classloader, thus leading to a shared, {@code static} state. + * In order for all tests to appropriately be handled, this must be able to remember all {@link ThreadContext}s that it is + * given in a thread safe manner. + *

+ * For actual usage, multiple nodes do not share the same JVM and therefore this will only be set once in practice. + */ + private static final CopyOnWriteArraySet THREAD_CONTEXT = new CopyOnWriteArraySet<>(); + + /** + * Set the {@link ThreadContext} used to add deprecation headers to network responses. + *

+ * This is expected to only be invoked by the {@code Node}'s constructor (therefore once outside of tests). + * + * @param threadContext The thread context owned by the {@code ThreadPool} (and implicitly a {@code Node}) + * @throws IllegalStateException if this {@code threadContext} has already been set + */ + public static void setThreadContext(ThreadContext threadContext) { + assert threadContext != null; + + // add returning false means it _did_ have it already + if (THREAD_CONTEXT.add(threadContext) == false) { + throw new IllegalStateException("Double-setting ThreadContext not allowed!"); + } + } + + /** + * Remove the {@link ThreadContext} used to add deprecation headers to network responses. + *

+ * This is expected to only be invoked by the {@code Node}'s {@code close} method (therefore once outside of tests). + * + * @param threadContext The thread context owned by the {@code ThreadPool} (and implicitly a {@code Node}) + * @throws IllegalStateException if this {@code threadContext} is unknown (and presumably already unset before) + */ + public static void removeThreadContext(ThreadContext threadContext) { + assert threadContext != null; + + // remove returning false means it did not have it already + if (THREAD_CONTEXT.remove(threadContext) == false) { + throw new IllegalStateException("Removing unknown ThreadContext not allowed!"); + } + } + private final ESLogger logger; /** @@ -47,8 +105,37 @@ public DeprecationLogger(ESLogger parentLogger) { /** * Logs a deprecated message. */ - @SuppressLoggerChecks(reason = "safely delegates to logger") public void deprecated(String msg, Object... params) { - logger.debug(msg, params); + deprecated(THREAD_CONTEXT, msg, params); + } + + /** + * Logs a deprecated message to the deprecation log, as well as to the local {@link ThreadContext}. + * + * @param threadContexts The node's {@link ThreadContext} (outside of concurrent tests, this should only ever have one context). + * @param msg The deprecation message. + * @param params The parameters used to fill in the message, if any exist. + */ + @SuppressLoggerChecks(reason = "safely delegates to logger") + void deprecated(Set threadContexts, String msg, Object... params) { + Iterator iterator = threadContexts.iterator(); + + if (iterator.hasNext()) { + final String formattedMsg = LoggerMessageFormat.format(msg, params); + + while (iterator.hasNext()) { + try { + iterator.next().addResponseHeader(DEPRECATION_HEADER, formattedMsg); + } catch (IllegalStateException e) { + // ignored; it should be removed shortly + } + } + + logger.debug(formattedMsg); + } else { + logger.debug(msg, params); + } + } + } diff --git a/core/src/main/java/org/elasticsearch/common/util/concurrent/ThreadContext.java b/core/src/main/java/org/elasticsearch/common/util/concurrent/ThreadContext.java index 457dcad07cffa..b816ea76dec35 100644 --- a/core/src/main/java/org/elasticsearch/common/util/concurrent/ThreadContext.java +++ b/core/src/main/java/org/elasticsearch/common/util/concurrent/ThreadContext.java @@ -28,8 +28,10 @@ import java.io.Closeable; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -64,8 +66,8 @@ public final class ThreadContext implements Closeable, Writeable { public static final String PREFIX = "request.headers"; public static final Setting DEFAULT_HEADERS_SETTING = Setting.groupSetting(PREFIX + ".", Property.NodeScope); + private static final ThreadContextStruct DEFAULT_CONTEXT = new ThreadContextStruct(); private final Map defaultHeader; - private static final ThreadContextStruct DEFAULT_CONTEXT = new ThreadContextStruct(Collections.emptyMap()); private final ContextThreadLocal threadLocal; /** @@ -110,7 +112,7 @@ public StoredContext stashContext() { public StoredContext stashAndMergeHeaders(Map headers) { final ThreadContextStruct context = threadLocal.get(); Map newHeader = new HashMap<>(headers); - newHeader.putAll(context.headers); + newHeader.putAll(context.requestHeaders); threadLocal.set(DEFAULT_CONTEXT.putHeaders(newHeader)); return () -> { threadLocal.set(context); @@ -143,7 +145,7 @@ public void readHeaders(StreamInput in) throws IOException { * Returns the header for the given key or null if not present */ public String getHeader(String key) { - String value = threadLocal.get().headers.get(key); + String value = threadLocal.get().requestHeaders.get(key); if (value == null) { return defaultHeader.get(key); } @@ -151,11 +153,27 @@ public String getHeader(String key) { } /** - * Returns all of the current contexts headers + * Returns all of the request contexts headers */ public Map getHeaders() { HashMap map = new HashMap<>(defaultHeader); - map.putAll(threadLocal.get().headers); + map.putAll(threadLocal.get().requestHeaders); + return Collections.unmodifiableMap(map); + } + + /** + * Get a copy of all response headers. + * + * @return Never {@code null}. + */ + public Map> getResponseHeaders() { + Map> responseHeaders = threadLocal.get().responseHeaders; + HashMap> map = new HashMap<>(responseHeaders.size()); + + for (Map.Entry> entry : responseHeaders.entrySet()) { + map.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + } + return Collections.unmodifiableMap(map); } @@ -170,7 +188,7 @@ public void copyHeaders(Iterable> headers) { * Puts a header into the context */ public void putHeader(String key, String value) { - threadLocal.set(threadLocal.get().putPersistent(key, value)); + threadLocal.set(threadLocal.get().putRequest(key, value)); } /** @@ -190,10 +208,20 @@ public void putTransient(String key, Object value) { /** * Returns a transient header object or null if there is no header for the given key */ + @SuppressWarnings("unchecked") // (T)object public T getTransient(String key) { return (T) threadLocal.get().transientHeaders.get(key); } + /** + * Add the unique response {@code value} for the specified {@code key}. + *

+ * Any duplicate {@code value} is ignored. + */ + public void addResponseHeader(String key, String value) { + threadLocal.set(threadLocal.get().putResponse(key, value)); + } + /** * Saves the current thread context and wraps command in a Runnable that restores that context before running command. If * command has already been passed through this method then it is returned unaltered rather than wrapped twice. @@ -234,32 +262,41 @@ default void restore() { } static final class ThreadContextStruct { - private final Map headers; + private final Map requestHeaders; private final Map transientHeaders; + private final Map> responseHeaders; private ThreadContextStruct(StreamInput in) throws IOException { - int numValues = in.readVInt(); - Map headers = numValues == 0 ? Collections.emptyMap() : new HashMap<>(numValues); - for (int i = 0; i < numValues; i++) { - headers.put(in.readString(), in.readString()); + final int numRequest = in.readVInt(); + Map requestHeaders = numRequest == 0 ? Collections.emptyMap() : new HashMap<>(numRequest); + for (int i = 0; i < numRequest; i++) { + requestHeaders.put(in.readString(), in.readString()); } - this.headers = headers; + + this.requestHeaders = requestHeaders; + this.responseHeaders = in.readMapOfLists(); this.transientHeaders = Collections.emptyMap(); } - private ThreadContextStruct(Map headers, Map transientHeaders) { - this.headers = headers; + private ThreadContextStruct(Map requestHeaders, + Map> responseHeaders, + Map transientHeaders) { + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; this.transientHeaders = transientHeaders; } - private ThreadContextStruct(Map headers) { - this(headers, Collections.emptyMap()); + /** + * This represents the default context and it should only ever be called by {@link #DEFAULT_CONTEXT}. + */ + private ThreadContextStruct() { + this(Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap()); } - private ThreadContextStruct putPersistent(String key, String value) { - Map newHeaders = new HashMap<>(this.headers); - putSingleHeader(key, value, newHeaders); - return new ThreadContextStruct(newHeaders, transientHeaders); + private ThreadContextStruct putRequest(String key, String value) { + Map newRequestHeaders = new HashMap<>(this.requestHeaders); + putSingleHeader(key, value, newRequestHeaders); + return new ThreadContextStruct(newRequestHeaders, responseHeaders, transientHeaders); } private void putSingleHeader(String key, String value, Map newHeaders) { @@ -276,24 +313,45 @@ private ThreadContextStruct putHeaders(Map headers) { for (Map.Entry entry : headers.entrySet()) { putSingleHeader(entry.getKey(), entry.getValue(), newHeaders); } - newHeaders.putAll(this.headers); - return new ThreadContextStruct(newHeaders, transientHeaders); + newHeaders.putAll(this.requestHeaders); + return new ThreadContextStruct(newHeaders, responseHeaders, transientHeaders); } } + private ThreadContextStruct putResponse(String key, String value) { + assert value != null; + + final Map> newResponseHeaders = new HashMap<>(this.responseHeaders); + final List existingValues = newResponseHeaders.get(key); + + if (existingValues != null) { + if (existingValues.contains(value)) { + return this; + } + + final List newValues = new ArrayList<>(existingValues); + newValues.add(value); + + newResponseHeaders.put(key, Collections.unmodifiableList(newValues)); + } else { + newResponseHeaders.put(key, Collections.singletonList(value)); + } + + return new ThreadContextStruct(requestHeaders, newResponseHeaders, transientHeaders); + } + private ThreadContextStruct putTransient(String key, Object value) { Map newTransient = new HashMap<>(this.transientHeaders); if (newTransient.putIfAbsent(key, value) != null) { throw new IllegalArgumentException("value for key [" + key + "] already present"); } - return new ThreadContextStruct(headers, newTransient); + return new ThreadContextStruct(requestHeaders, responseHeaders, newTransient); } boolean isEmpty() { - return headers.isEmpty() && transientHeaders.isEmpty(); + return requestHeaders.isEmpty() && responseHeaders.isEmpty() && transientHeaders.isEmpty(); } - private ThreadContextStruct copyHeaders(Iterable> headers) { Map newHeaders = new HashMap<>(); for (Map.Entry header : headers) { @@ -303,20 +361,21 @@ private ThreadContextStruct copyHeaders(Iterable> head } private void writeTo(StreamOutput out, Map defaultHeaders) throws IOException { - final Map headers; + final Map requestHeaders; if (defaultHeaders.isEmpty()) { - headers = this.headers; + requestHeaders = this.requestHeaders; } else { - headers = new HashMap<>(defaultHeaders); - headers.putAll(this.headers); + requestHeaders = new HashMap<>(defaultHeaders); + requestHeaders.putAll(this.requestHeaders); } - int keys = headers.size(); - out.writeVInt(keys); - for (Map.Entry entry : headers.entrySet()) { + out.writeVInt(requestHeaders.size()); + for (Map.Entry entry : requestHeaders.entrySet()) { out.writeString(entry.getKey()); out.writeString(entry.getValue()); } + + out.writeMapOfLists(responseHeaders); } } diff --git a/core/src/main/java/org/elasticsearch/http/netty/HttpRequestHandler.java b/core/src/main/java/org/elasticsearch/http/netty/HttpRequestHandler.java index 376ca738fabb5..859708eff717f 100644 --- a/core/src/main/java/org/elasticsearch/http/netty/HttpRequestHandler.java +++ b/core/src/main/java/org/elasticsearch/http/netty/HttpRequestHandler.java @@ -61,7 +61,7 @@ public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Ex // the netty HTTP handling always copy over the buffer to its own buffer, either in NioWorker internally // when reading, or using a cumalation buffer NettyHttpRequest httpRequest = new NettyHttpRequest(request, e.getChannel()); - NettyHttpChannel channel = new NettyHttpChannel(serverTransport, httpRequest, oue, detailedErrorsEnabled); + NettyHttpChannel channel = new NettyHttpChannel(serverTransport, httpRequest, oue, detailedErrorsEnabled, threadContext); serverTransport.dispatchRequest(httpRequest, channel); super.messageReceived(ctx, e); } diff --git a/core/src/main/java/org/elasticsearch/http/netty/NettyHttpChannel.java b/core/src/main/java/org/elasticsearch/http/netty/NettyHttpChannel.java index c4253df286004..8df31b26dc802 100644 --- a/core/src/main/java/org/elasticsearch/http/netty/NettyHttpChannel.java +++ b/core/src/main/java/org/elasticsearch/http/netty/NettyHttpChannel.java @@ -25,6 +25,7 @@ import org.elasticsearch.common.io.stream.ReleasableBytesStreamOutput; import org.elasticsearch.common.lease.Releasable; import org.elasticsearch.transport.netty.NettyUtils; +import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.http.netty.cors.CorsHandler; import org.elasticsearch.http.netty.pipelining.OrderedDownstreamChannelEvent; import org.elasticsearch.http.netty.pipelining.OrderedUpstreamMessageEvent; @@ -60,6 +61,7 @@ public final class NettyHttpChannel extends AbstractRestChannel { private final Channel channel; private final org.jboss.netty.handler.codec.http.HttpRequest nettyRequest; private final OrderedUpstreamMessageEvent orderedUpstreamMessageEvent; + private final ThreadContext threadContext; /** * @param transport The corresponding NettyHttpServerTransport where this channel belongs to. @@ -70,12 +72,13 @@ public final class NettyHttpChannel extends AbstractRestChannel { */ public NettyHttpChannel(NettyHttpServerTransport transport, NettyHttpRequest request, @Nullable OrderedUpstreamMessageEvent orderedUpstreamMessageEvent, - boolean detailedErrorsEnabled) { + boolean detailedErrorsEnabled, ThreadContext threadContext) { super(request, detailedErrorsEnabled); this.transport = transport; this.channel = request.getChannel(); this.nettyRequest = request.request(); this.orderedUpstreamMessageEvent = orderedUpstreamMessageEvent; + this.threadContext = threadContext; } @Override @@ -83,7 +86,6 @@ public BytesStreamOutput newBytesOutput() { return new ReleasableBytesStreamOutput(transport.bigArrays); } - @Override public void sendResponse(RestResponse response) { // if the response object was created upstream, then use it; @@ -99,7 +101,8 @@ public void sendResponse(RestResponse response) { } // Add all custom headers - addCustomHeaders(response, resp); + addCustomHeaders(resp, response.getHeaders()); + addCustomHeaders(resp, threadContext.getResponseHeaders()); BytesReference content = response.content(); ChannelBuffer buffer; @@ -170,8 +173,7 @@ private void addCookies(HttpResponse resp) { } } - private void addCustomHeaders(RestResponse response, HttpResponse resp) { - Map> customHeaders = response.getHeaders(); + private void addCustomHeaders(HttpResponse resp, Map> customHeaders) { if (customHeaders != null) { for (Map.Entry> headerEntry : customHeaders.entrySet()) { for (String headerValue : headerEntry.getValue()) { diff --git a/core/src/main/java/org/elasticsearch/node/Node.java b/core/src/main/java/org/elasticsearch/node/Node.java index 3d2283bf33f1f..c3c64818fb071 100644 --- a/core/src/main/java/org/elasticsearch/node/Node.java +++ b/core/src/main/java/org/elasticsearch/node/Node.java @@ -238,6 +238,10 @@ protected Node(Environment tmpEnv, Collection> classpath try { final ThreadPool threadPool = new ThreadPool(settings, executorBuilders.toArray(new ExecutorBuilder[0])); resourcesToClose.add(() -> ThreadPool.terminate(threadPool, 10, TimeUnit.SECONDS)); + // adds the context to the DeprecationLogger so that it does not need to be injected everywhere + DeprecationLogger.setThreadContext(threadPool.getThreadContext()); + resourcesToClose.add(() -> DeprecationLogger.removeThreadContext(threadPool.getThreadContext())); + final List> additionalSettings = new ArrayList<>(); final List additionalSettingsFilter = new ArrayList<>(); additionalSettings.addAll(pluginsService.getPluginSettings()); @@ -313,7 +317,9 @@ protected Node(Environment tmpEnv, Collection> classpath } ); injector = modules.createInjector(); + client.intialize(injector.getInstance(new Key>() {})); + success = true; } catch (IOException ex) { throw new ElasticsearchException("failed to bind service", ex); diff --git a/core/src/main/java/org/elasticsearch/rest/DeprecationRestHandler.java b/core/src/main/java/org/elasticsearch/rest/DeprecationRestHandler.java new file mode 100644 index 0000000000000..1a78a4c8c0452 --- /dev/null +++ b/core/src/main/java/org/elasticsearch/rest/DeprecationRestHandler.java @@ -0,0 +1,108 @@ +/* + * 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.rest; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.logging.DeprecationLogger; + +import java.util.Objects; + +/** + * {@code DeprecationRestHandler} provides a proxy for any existing {@link RestHandler} so that usage of the handler can be + * logged using the {@link DeprecationLogger}. + */ +public class DeprecationRestHandler implements RestHandler { + + private final RestHandler handler; + private final String deprecationMessage; + private final DeprecationLogger deprecationLogger; + + /** + * Create a {@link DeprecationRestHandler} that encapsulates the {@code handler} using the {@code deprecationLogger} to log + * deprecation {@code warning}. + * + * @param handler The rest handler to deprecate (it's possible that the handler is reused with a different name!) + * @param deprecationMessage The message to warn users with when they use the {@code handler} + * @param deprecationLogger The deprecation logger + * @throws NullPointerException if any parameter except {@code deprecationMessage} is {@code null} + * @throws IllegalArgumentException if {@code deprecationMessage} is not a valid header + */ + public DeprecationRestHandler(RestHandler handler, String deprecationMessage, DeprecationLogger deprecationLogger) { + this.handler = Objects.requireNonNull(handler); + this.deprecationMessage = requireValidHeader(deprecationMessage); + this.deprecationLogger = Objects.requireNonNull(deprecationLogger); + } + + /** + * {@inheritDoc} + *

+ * Usage is logged via the {@link DeprecationLogger} so that the actual response can be notified of deprecation as well. + */ + @Override + public void handleRequest(RestRequest request, RestChannel channel, NodeClient client) throws Exception { + deprecationLogger.deprecated(deprecationMessage); + + handler.handleRequest(request, channel, client); + } + + /** + * This does a very basic pass at validating that a header's value contains only expected characters according to RFC-5987, and those + * that it references. + *

+ * https://tools.ietf.org/html/rfc5987 + *

+ * This is only expected to be used for assertions. The idea is that only readable US-ASCII characters are expected; the rest must be + * encoded with percent encoding, which makes checking for a valid character range very simple. + * + * @param value The header value to check + * @return {@code true} if the {@code value} is not obviously wrong. + */ + public static boolean validHeaderValue(String value) { + if (Strings.hasText(value) == false) { + return false; + } + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + + // 32 = ' ' (31 = unit separator); 126 = '~' (127 = DEL) + if (c < 32 || c > 126) { + return false; + } + } + + return true; + } + + /** + * Throw an exception if the {@code value} is not a {@link #validHeaderValue(String) valid header}. + * + * @param value The header value to check + * @return Always {@code value}. + * @throws IllegalArgumentException if {@code value} is not a {@link #validHeaderValue(String) valid header}. + */ + public static String requireValidHeader(String value) { + if (validHeaderValue(value) == false) { + throw new IllegalArgumentException("header value must contain only US ASCII text"); + } + + return value; + } +} diff --git a/core/src/main/java/org/elasticsearch/rest/RestController.java b/core/src/main/java/org/elasticsearch/rest/RestController.java index 03d2c267c588a..4eb11700f969d 100644 --- a/core/src/main/java/org/elasticsearch/rest/RestController.java +++ b/core/src/main/java/org/elasticsearch/rest/RestController.java @@ -23,6 +23,7 @@ import org.elasticsearch.common.Nullable; import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.component.AbstractLifecycleComponent; +import org.elasticsearch.common.logging.DeprecationLogger; import org.elasticsearch.common.path.PathTrie; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -112,7 +113,27 @@ public synchronized void registerFilter(RestFilter preProcessor) { } /** - * Registers a rest handler to be executed when the provided method and path match the request. + * Registers a REST handler to be executed when the provided {@code method} and {@code path} match the request. + * + * @param method GET, POST, etc. + * @param path Path to handle (e.g., "/{index}/{type}/_bulk") + * @param handler The handler to actually execute + * @param deprecationMessage The message to log and send as a header in the response + * @param logger The existing deprecation logger to use + */ + public void registerAsDeprecatedHandler(RestRequest.Method method, String path, RestHandler handler, + String deprecationMessage, DeprecationLogger logger) { + assert (handler instanceof DeprecationRestHandler) == false; + + registerHandler(method, path, new DeprecationRestHandler(handler, deprecationMessage, logger)); + } + + /** + * Registers a REST handler to be executed when the provided method and path match the request. + * + * @param method GET, POST, etc. + * @param path Path to handle (e.g., "/{index}/{type}/_bulk") + * @param handler The handler to actually execute */ public void registerHandler(RestRequest.Method method, String path, RestHandler handler) { PathTrie handlers = getHandlersForMethod(method); diff --git a/core/src/main/java/org/elasticsearch/transport/TcpTransport.java b/core/src/main/java/org/elasticsearch/transport/TcpTransport.java index b56b91061edb1..799f6df79025b 100644 --- a/core/src/main/java/org/elasticsearch/transport/TcpTransport.java +++ b/core/src/main/java/org/elasticsearch/transport/TcpTransport.java @@ -936,10 +936,11 @@ public void sendRequest(final DiscoveryNode node, final long requestId, final St */ public void sendErrorResponse(Version nodeVersion, Channel channel, final Exception error, final long requestId, final String action) throws IOException { - try(BytesStreamOutput stream = new BytesStreamOutput()) { + try (BytesStreamOutput stream = new BytesStreamOutput()) { stream.setVersion(nodeVersion); RemoteTransportException tx = new RemoteTransportException( nodeName(), new InetSocketTransportAddress(getLocalAddress(channel)), action, error); + threadPool.getThreadContext().writeTo(stream); stream.writeException(tx); byte status = 0; status = TransportStatus.setResponse(status); @@ -972,8 +973,9 @@ public void sendResponse(Version nodeVersion, Channel channel, final TransportRe status = TransportStatus.setCompress(status); stream = CompressorFactory.COMPRESSOR.streamOutput(stream); } + threadPool.getThreadContext().writeTo(stream); stream.setVersion(nodeVersion); - BytesReference reference = buildMessage(requestId, status,nodeVersion, response, stream, bStream); + BytesReference reference = buildMessage(requestId, status, nodeVersion, response, stream, bStream); final TransportResponseOptions finalOptions = options; Runnable onRequestSent = () -> { @@ -1006,8 +1008,7 @@ public void sendResponse(Version nodeVersion, Channel channel, final TransportRe private BytesReference buildHeader(long requestId, byte status, Version protocolVersion, int length) throws IOException { try (BytesStreamOutput headerOutput = new BytesStreamOutput(TcpHeader.HEADER_SIZE)) { headerOutput.setVersion(protocolVersion); - TcpHeader.writeHeader(headerOutput, requestId, status, protocolVersion, - length); + TcpHeader.writeHeader(headerOutput, requestId, status, protocolVersion, length); final BytesReference bytes = headerOutput.bytes(); assert bytes.length() == TcpHeader.HEADER_SIZE : "header size mismatch expected: " + TcpHeader.HEADER_SIZE + " but was: " + bytes.length(); @@ -1174,8 +1175,8 @@ public final void messageReceived(BytesReference reference, Channel channel, Str } streamIn = new NamedWriteableAwareStreamInput(streamIn, namedWriteableRegistry); streamIn.setVersion(version); + threadPool.getThreadContext().readHeaders(streamIn); if (TransportStatus.isRequest(status)) { - threadPool.getThreadContext().readHeaders(streamIn); handleRequest(channel, profileName, streamIn, requestId, messageLengthBytes, version, remoteAddress); } else { final TransportResponseHandler handler = transportServiceAdapter.onResponseReceived(requestId); diff --git a/core/src/main/java/org/elasticsearch/transport/local/LocalTransport.java b/core/src/main/java/org/elasticsearch/transport/local/LocalTransport.java index f6519cbeeba1c..eba5fd5773487 100644 --- a/core/src/main/java/org/elasticsearch/transport/local/LocalTransport.java +++ b/core/src/main/java/org/elasticsearch/transport/local/LocalTransport.java @@ -259,9 +259,8 @@ protected void messageReceived(byte[] data, String action, LocalTransport source long requestId = stream.readLong(); byte status = stream.readByte(); boolean isRequest = TransportStatus.isRequest(status); + threadPool.getThreadContext().readHeaders(stream); if (isRequest) { - ThreadContext threadContext = threadPool.getThreadContext(); - threadContext.readHeaders(stream); handleRequest(stream, requestId, data.length, sourceTransport, version); } else { final TransportResponseHandler handler = transportServiceAdapter.onResponseReceived(requestId); @@ -304,7 +303,7 @@ private void handleRequest(StreamInput stream, long requestId, int messageLength inFlightRequestsBreaker().addWithoutBreaking(messageLengthBytes); } final LocalTransportChannel transportChannel = new LocalTransportChannel(this, transportServiceAdapter, sourceTransport, action, - requestId, version, messageLengthBytes); + requestId, version, messageLengthBytes, threadPool.getThreadContext()); try { if (reg == null) { throw new ActionNotFoundTransportException("Action [" + action + "] not found"); @@ -389,7 +388,7 @@ private void handleResponseError(StreamInput buffer, final TransportResponseHand private void handleException(final TransportResponseHandler handler, Exception exception) { if (!(exception instanceof RemoteTransportException)) { - exception = new RemoteTransportException("None remote transport exception", null, null, exception); + exception = new RemoteTransportException("Not a remote transport exception", null, null, exception); } final RemoteTransportException rtx = (RemoteTransportException) exception; try { diff --git a/core/src/main/java/org/elasticsearch/transport/local/LocalTransportChannel.java b/core/src/main/java/org/elasticsearch/transport/local/LocalTransportChannel.java index 81d99b8d49335..fc748b96aeaa1 100644 --- a/core/src/main/java/org/elasticsearch/transport/local/LocalTransportChannel.java +++ b/core/src/main/java/org/elasticsearch/transport/local/LocalTransportChannel.java @@ -48,10 +48,12 @@ public class LocalTransportChannel implements TransportChannel { private final long requestId; private final Version version; private final long reservedBytes; + private final ThreadContext threadContext; private final AtomicBoolean closed = new AtomicBoolean(); public LocalTransportChannel(LocalTransport sourceTransport, TransportServiceAdapter sourceTransportServiceAdapter, - LocalTransport targetTransport, String action, long requestId, Version version, long reservedBytes) { + LocalTransport targetTransport, String action, long requestId, Version version, long reservedBytes, + ThreadContext threadContext) { this.sourceTransport = sourceTransport; this.sourceTransportServiceAdapter = sourceTransportServiceAdapter; this.targetTransport = targetTransport; @@ -59,6 +61,7 @@ public LocalTransportChannel(LocalTransport sourceTransport, TransportServiceAda this.requestId = requestId; this.version = version; this.reservedBytes = reservedBytes; + this.threadContext = threadContext; } @Override @@ -84,6 +87,7 @@ public void sendResponse(TransportResponse response, TransportResponseOptions op byte status = 0; status = TransportStatus.setResponse(status); stream.writeByte(status); // 0 for request, 1 for response. + threadContext.writeTo(stream); response.writeTo(stream); sendResponseData(BytesReference.toBytes(stream.bytes())); sourceTransportServiceAdapter.onResponseSent(requestId, action, response, options); @@ -135,5 +139,6 @@ private void writeResponseExceptionHeader(BytesStreamOutput stream) throws IOExc status = TransportStatus.setResponse(status); status = TransportStatus.setError(status); stream.writeByte(status); + threadContext.writeTo(stream); } } diff --git a/core/src/test/java/org/elasticsearch/common/io/stream/BytesStreamsTests.java b/core/src/test/java/org/elasticsearch/common/io/stream/BytesStreamsTests.java index 94f07369770ea..ee16eb82a2cbf 100644 --- a/core/src/test/java/org/elasticsearch/common/io/stream/BytesStreamsTests.java +++ b/core/src/test/java/org/elasticsearch/common/io/stream/BytesStreamsTests.java @@ -30,7 +30,9 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import static org.hamcrest.Matchers.closeTo; @@ -445,7 +447,7 @@ public void testWriteStreamableList() throws IOException { final StreamInput in = StreamInput.wrap(BytesReference.toBytes(out.bytes())); - List loaded = in.readStreamableList(TestStreamable::new); + final List loaded = in.readStreamableList(TestStreamable::new); assertThat(loaded, hasSize(expected.size())); @@ -459,7 +461,49 @@ public void testWriteStreamableList() throws IOException { out.close(); } - private abstract static class BaseNamedWriteable implements NamedWriteable { + public void testWriteMapOfLists() throws IOException { + final int size = randomIntBetween(0, 5); + final Map> expected = new HashMap<>(size); + + for (int i = 0; i < size; ++i) { + int listSize = randomIntBetween(0, 5); + List list = new ArrayList<>(listSize); + + for (int j = 0; j < listSize; ++j) { + list.add(randomAsciiOfLength(5)); + } + + expected.put(randomAsciiOfLength(2), list); + } + + final BytesStreamOutput out = new BytesStreamOutput(); + out.writeMapOfLists(expected); + + final StreamInput in = StreamInput.wrap(BytesReference.toBytes(out.bytes())); + + final Map> loaded = in.readMapOfLists(); + + assertThat(loaded.size(), equalTo(expected.size())); + + for (Map.Entry> entry : expected.entrySet()) { + assertThat(loaded.containsKey(entry.getKey()), equalTo(true)); + + List loadedList = loaded.get(entry.getKey()); + + assertThat(loadedList, hasSize(entry.getValue().size())); + + for (int i = 0; i < loadedList.size(); ++i) { + assertEquals(entry.getValue().get(i), loadedList.get(i)); + } + } + + assertEquals(0, in.available()); + + in.close(); + out.close(); + } + + private static abstract class BaseNamedWriteable implements NamedWriteable { } diff --git a/core/src/test/java/org/elasticsearch/common/logging/DeprecationLoggerTests.java b/core/src/test/java/org/elasticsearch/common/logging/DeprecationLoggerTests.java new file mode 100644 index 0000000000000..f75e73ced2c8e --- /dev/null +++ b/core/src/test/java/org/elasticsearch/common/logging/DeprecationLoggerTests.java @@ -0,0 +1,155 @@ +/* + * 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.common.logging; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; + +/** + * Tests {@link DeprecationLogger} + */ +public class DeprecationLoggerTests extends ESTestCase { + + private final DeprecationLogger logger = new DeprecationLogger(Loggers.getLogger(getClass())); + + public void testAddsHeaderWithThreadContext() throws IOException { + String msg = "A simple message [{}]"; + String param = randomAsciiOfLengthBetween(1, 5); + String formatted = LoggerMessageFormat.format(msg, (Object)param); + + try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) { + Set threadContexts = Collections.singleton(threadContext); + + logger.deprecated(threadContexts, msg, param); + + Map> responseHeaders = threadContext.getResponseHeaders(); + + assertEquals(1, responseHeaders.size()); + assertEquals(formatted, responseHeaders.get(DeprecationLogger.DEPRECATION_HEADER).get(0)); + } + } + + public void testAddsCombinedHeaderWithThreadContext() throws IOException { + String msg = "A simple message [{}]"; + String param = randomAsciiOfLengthBetween(1, 5); + String formatted = LoggerMessageFormat.format(msg, (Object)param); + String formatted2 = randomAsciiOfLengthBetween(1, 10); + + try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) { + Set threadContexts = Collections.singleton(threadContext); + + logger.deprecated(threadContexts, msg, param); + logger.deprecated(threadContexts, formatted2); + + Map> responseHeaders = threadContext.getResponseHeaders(); + + assertEquals(1, responseHeaders.size()); + + List responses = responseHeaders.get(DeprecationLogger.DEPRECATION_HEADER); + + assertEquals(2, responses.size()); + assertEquals(formatted, responses.get(0)); + assertEquals(formatted2, responses.get(1)); + } + } + + public void testCanRemoveThreadContext() throws IOException { + final String expected = "testCanRemoveThreadContext"; + final String unexpected = "testCannotRemoveThreadContext"; + + try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) { + // NOTE: by adding it to the logger, we allow any concurrent test to write to it (from their own threads) + DeprecationLogger.setThreadContext(threadContext); + + logger.deprecated(expected); + + Map> responseHeaders = threadContext.getResponseHeaders(); + List responses = responseHeaders.get(DeprecationLogger.DEPRECATION_HEADER); + + // ensure it works (note: concurrent tests may be adding to it, but in different threads, so it should have no impact) + assertThat(responses, hasSize(atLeast(1))); + assertThat(responses, hasItem(equalTo(expected))); + + DeprecationLogger.removeThreadContext(threadContext); + + logger.deprecated(unexpected); + + responseHeaders = threadContext.getResponseHeaders(); + responses = responseHeaders.get(DeprecationLogger.DEPRECATION_HEADER); + + assertThat(responses, hasSize(atLeast(1))); + assertThat(responses, hasItem(expected)); + assertThat(responses, not(hasItem(unexpected))); + } + } + + public void testIgnoresClosedThreadContext() throws IOException { + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + Set threadContexts = new HashSet<>(1); + + threadContexts.add(threadContext); + + threadContext.close(); + + logger.deprecated(threadContexts, "Ignored logger message"); + + assertTrue(threadContexts.contains(threadContext)); + } + + public void testSafeWithoutThreadContext() { + logger.deprecated(Collections.emptySet(), "Ignored"); + } + + public void testFailsWithoutThreadContextSet() { + expectThrows(NullPointerException.class, () -> logger.deprecated((Set)null, "Does not explode")); + } + + public void testFailsWhenDoubleSettingSameThreadContext() throws IOException { + try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) { + DeprecationLogger.setThreadContext(threadContext); + + try { + expectThrows(IllegalStateException.class, () -> DeprecationLogger.setThreadContext(threadContext)); + } finally { + // cleanup after ourselves + DeprecationLogger.removeThreadContext(threadContext); + } + } + } + + public void testFailsWhenRemovingUnknownThreadContext() throws IOException { + try (ThreadContext threadContext = new ThreadContext(Settings.EMPTY)) { + expectThrows(IllegalStateException.class, () -> DeprecationLogger.removeThreadContext(threadContext)); + } + } + +} diff --git a/core/src/test/java/org/elasticsearch/common/util/concurrent/ThreadContextTests.java b/core/src/test/java/org/elasticsearch/common/util/concurrent/ThreadContextTests.java index e672687951310..d402f09f07ddd 100644 --- a/core/src/test/java/org/elasticsearch/common/util/concurrent/ThreadContextTests.java +++ b/core/src/test/java/org/elasticsearch/common/util/concurrent/ThreadContextTests.java @@ -19,14 +19,18 @@ package org.elasticsearch.common.util.concurrent; import org.elasticsearch.common.io.stream.BytesStreamOutput; -import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; import java.io.IOException; import java.util.Collections; import java.util.HashMap; +import java.util.List; +import java.util.Map; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.sameInstance; public class ThreadContextTests extends ESTestCase { @@ -35,7 +39,7 @@ public void testStashContext() { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); assertEquals("bar", threadContext.getHeader("foo")); assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); @@ -46,7 +50,7 @@ public void testStashContext() { } assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); } @@ -54,7 +58,7 @@ public void testStashAndMerge() { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); assertEquals("bar", threadContext.getHeader("foo")); assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); @@ -70,7 +74,7 @@ public void testStashAndMerge() { assertNull(threadContext.getHeader("simon")); assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); } @@ -78,9 +82,9 @@ public void testStoreContext() { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); ThreadContext.StoredContext storedContext = threadContext.newStoredContext(); threadContext.putHeader("foo.bar", "baz"); @@ -91,7 +95,7 @@ public void testStoreContext() { } assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); assertEquals("baz", threadContext.getHeader("foo.bar")); if (randomBoolean()) { @@ -100,11 +104,44 @@ public void testStoreContext() { storedContext.close(); } assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); assertNull(threadContext.getHeader("foo.bar")); } + public void testResponseHeaders() { + final boolean expectThird = randomBoolean(); + + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + + threadContext.addResponseHeader("foo", "bar"); + // pretend that another thread created the same response + if (randomBoolean()) { + threadContext.addResponseHeader("foo", "bar"); + } + + threadContext.addResponseHeader("Warning", "One is the loneliest number"); + threadContext.addResponseHeader("Warning", "Two can be as bad as one"); + if (expectThird) { + threadContext.addResponseHeader("Warning", "No is the saddest experience"); + } + + final Map> responseHeaders = threadContext.getResponseHeaders(); + final List foo = responseHeaders.get("foo"); + final List warnings = responseHeaders.get("Warning"); + final int expectedWarnings = expectThird ? 3 : 2; + + assertThat(foo, hasSize(1)); + assertEquals("bar", foo.get(0)); + assertThat(warnings, hasSize(expectedWarnings)); + assertThat(warnings, hasItem(equalTo("One is the loneliest number"))); + assertThat(warnings, hasItem(equalTo("Two can be as bad as one"))); + + if (expectThird) { + assertThat(warnings, hasItem(equalTo("No is the saddest experience"))); + } + } + public void testCopyHeaders() { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); @@ -117,7 +154,7 @@ public void testAccessClosed() throws IOException { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); threadContext.close(); try { @@ -146,20 +183,35 @@ public void testSerialize() throws IOException { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); + threadContext.addResponseHeader("Warning", "123456"); + if (rarely()) { + threadContext.addResponseHeader("Warning", "123456"); + } + threadContext.addResponseHeader("Warning", "234567"); + BytesStreamOutput out = new BytesStreamOutput(); threadContext.writeTo(out); try (ThreadContext.StoredContext ctx = threadContext.stashContext()) { assertNull(threadContext.getHeader("foo")); assertNull(threadContext.getTransient("ctx.foo")); + assertTrue(threadContext.getResponseHeaders().isEmpty()); assertEquals("1", threadContext.getHeader("default")); threadContext.readHeaders(out.bytes().streamInput()); assertEquals("bar", threadContext.getHeader("foo")); assertNull(threadContext.getTransient("ctx.foo")); + + final Map> responseHeaders = threadContext.getResponseHeaders(); + final List warnings = responseHeaders.get("Warning"); + + assertThat(responseHeaders.keySet(), hasSize(1)); + assertThat(warnings, hasSize(2)); + assertThat(warnings, hasItem(equalTo("123456"))); + assertThat(warnings, hasItem(equalTo("234567"))); } assertEquals("bar", threadContext.getHeader("foo")); - assertEquals(new Integer(1), threadContext.getTransient("ctx.foo")); + assertEquals(Integer.valueOf(1), threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); } @@ -169,21 +221,35 @@ public void testSerializeInDifferentContext() throws IOException { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); + threadContext.addResponseHeader("Warning", "123456"); + if (rarely()) { + threadContext.addResponseHeader("Warning", "123456"); + } + threadContext.addResponseHeader("Warning", "234567"); assertEquals("bar", threadContext.getHeader("foo")); assertNotNull(threadContext.getTransient("ctx.foo")); assertEquals("1", threadContext.getHeader("default")); + assertThat(threadContext.getResponseHeaders().keySet(), hasSize(1)); threadContext.writeTo(out); } { Settings otherSettings = Settings.builder().put("request.headers.default", "5").build(); - ThreadContext otherhreadContext = new ThreadContext(otherSettings); - otherhreadContext.readHeaders(out.bytes().streamInput()); + ThreadContext otherThreadContext = new ThreadContext(otherSettings); + otherThreadContext.readHeaders(out.bytes().streamInput()); - assertEquals("bar", otherhreadContext.getHeader("foo")); - assertNull(otherhreadContext.getTransient("ctx.foo")); - assertEquals("1", otherhreadContext.getHeader("default")); + assertEquals("bar", otherThreadContext.getHeader("foo")); + assertNull(otherThreadContext.getTransient("ctx.foo")); + assertEquals("1", otherThreadContext.getHeader("default")); + + final Map> responseHeaders = otherThreadContext.getResponseHeaders(); + final List warnings = responseHeaders.get("Warning"); + + assertThat(responseHeaders.keySet(), hasSize(1)); + assertThat(warnings, hasSize(2)); + assertThat(warnings, hasItem(equalTo("123456"))); + assertThat(warnings, hasItem(equalTo("234567"))); } } @@ -192,7 +258,7 @@ public void testSerializeInDifferentContextNoDefaults() throws IOException { { ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putHeader("foo", "bar"); - threadContext.putTransient("ctx.foo", new Integer(1)); + threadContext.putTransient("ctx.foo", 1); assertEquals("bar", threadContext.getHeader("foo")); assertNotNull(threadContext.getTransient("ctx.foo")); @@ -210,7 +276,6 @@ public void testSerializeInDifferentContextNoDefaults() throws IOException { } } - public void testCanResetDefault() { Settings build = Settings.builder().put("request.headers.default", "1").build(); ThreadContext threadContext = new ThreadContext(build); diff --git a/core/src/test/java/org/elasticsearch/http/netty/NettyHttpChannelTests.java b/core/src/test/java/org/elasticsearch/http/netty/NettyHttpChannelTests.java index a56e9993434fc..72dd6c8c28bb6 100644 --- a/core/src/test/java/org/elasticsearch/http/netty/NettyHttpChannelTests.java +++ b/core/src/test/java/org/elasticsearch/http/netty/NettyHttpChannelTests.java @@ -181,7 +181,7 @@ public void testHeadersSet() { NettyHttpRequest request = new NettyHttpRequest(httpRequest, writeCapturingChannel); // send a response - NettyHttpChannel channel = new NettyHttpChannel(httpServerTransport, request, null, randomBoolean()); + NettyHttpChannel channel = new NettyHttpChannel(httpServerTransport, request, null, randomBoolean(), threadPool.getThreadContext()); TestReponse resp = new TestReponse(); final String customHeader = "custom-header"; final String customHeaderValue = "xyz"; @@ -207,7 +207,7 @@ private HttpResponse execRequestWithCors(final Settings settings, final String o WriteCapturingChannel writeCapturingChannel = new WriteCapturingChannel(); NettyHttpRequest request = new NettyHttpRequest(httpRequest, writeCapturingChannel); - NettyHttpChannel channel = new NettyHttpChannel(httpServerTransport, request, null, randomBoolean()); + NettyHttpChannel channel = new NettyHttpChannel(httpServerTransport, request, null, randomBoolean(), threadPool.getThreadContext()); channel.sendResponse(new TestReponse()); // get the response diff --git a/core/src/test/java/org/elasticsearch/rest/DeprecationHttpIT.java b/core/src/test/java/org/elasticsearch/rest/DeprecationHttpIT.java new file mode 100644 index 0000000000000..3bb61f64e36f7 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/rest/DeprecationHttpIT.java @@ -0,0 +1,216 @@ +/* + * 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.rest; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.entity.StringEntity; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.common.logging.LoggerMessageFormat; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.rest.plugins.TestDeprecatedQueryBuilder; +import org.elasticsearch.rest.plugins.TestDeprecationHeaderRestAction; +import org.elasticsearch.rest.plugins.TestDeprecationPlugin; +import org.elasticsearch.test.ESIntegTestCase; + +import org.hamcrest.Matcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.elasticsearch.rest.RestStatus.OK; +import static org.elasticsearch.rest.plugins.TestDeprecationHeaderRestAction.TEST_DEPRECATED_SETTING_TRUE1; +import static org.elasticsearch.rest.plugins.TestDeprecationHeaderRestAction.TEST_DEPRECATED_SETTING_TRUE2; +import static org.elasticsearch.rest.plugins.TestDeprecationHeaderRestAction.TEST_NOT_DEPRECATED_SETTING; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; + +/** + * Tests {@code DeprecationLogger} uses the {@code ThreadContext} to add response headers. + */ +public class DeprecationHttpIT extends ESIntegTestCase { + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put("force.http.enabled", true) + // change values of deprecated settings so that accessing them is logged + .put(TEST_DEPRECATED_SETTING_TRUE1.getKey(), ! TEST_DEPRECATED_SETTING_TRUE1.getDefault(Settings.EMPTY)) + .put(TEST_DEPRECATED_SETTING_TRUE2.getKey(), ! TEST_DEPRECATED_SETTING_TRUE2.getDefault(Settings.EMPTY)) + // non-deprecated setting to ensure not everything is logged + .put(TEST_NOT_DEPRECATED_SETTING.getKey(), ! TEST_NOT_DEPRECATED_SETTING.getDefault(Settings.EMPTY)) + .build(); + } + + @Override + protected Collection> nodePlugins() { + return pluginList(TestDeprecationPlugin.class); + } + + /** + * Attempts to do a scatter/gather request that expects unique responses per sub-request. + */ + @AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/19222") + public void testUniqueDeprecationResponsesMergedTogether() throws IOException { + final String[] indices = new String[randomIntBetween(2, 5)]; + + // add at least one document for each index + for (int i = 0; i < indices.length; ++i) { + indices[i] = "test" + i; + + // create indices with a single shard to reduce noise; the query only deprecates uniquely by index anyway + assertTrue(prepareCreate(indices[i]).setSettings(Settings.builder().put("number_of_shards", 1)).get().isAcknowledged()); + + int randomDocCount = randomIntBetween(1, 2); + + for (int j = 0; j < randomDocCount; ++j) { + index(indices[i], "type", Integer.toString(j), "{\"field\":" + j + "}"); + } + } + + refresh(indices); + + final String commaSeparatedIndices = Stream.of(indices).collect(Collectors.joining(",")); + + final String body = + "{\"query\":{\"bool\":{\"filter\":[{\"" + TestDeprecatedQueryBuilder.NAME + "\":{}}]}}}"; + + // trigger all index deprecations + try (Response response = getRestClient().performRequest("GET", + "/" + commaSeparatedIndices + "/_search", + Collections.emptyMap(), + new StringEntity(body, RestClient.JSON_CONTENT_TYPE))) { + assertThat(response.getStatusLine().getStatusCode(), equalTo(OK.getStatus())); + + final List deprecatedWarnings = getWarningHeaders(response.getHeaders()); + final List> headerMatchers = new ArrayList<>(indices.length); + + for (String index : indices) { + headerMatchers.add(containsString(LoggerMessageFormat.format("[{}] index", (Object)index))); + } + + assertThat(deprecatedWarnings, hasSize(headerMatchers.size())); + for (Matcher headerMatcher : headerMatchers) { + assertThat(deprecatedWarnings, hasItem(headerMatcher)); + } + } + } + + public void testDeprecationWarningsAppearInHeaders() throws IOException { + doTestDeprecationWarningsAppearInHeaders(); + } + + public void testDeprecationHeadersDoNotGetStuck() throws IOException { + doTestDeprecationWarningsAppearInHeaders(); + doTestDeprecationWarningsAppearInHeaders(); + if (rarely()) { + doTestDeprecationWarningsAppearInHeaders(); + } + } + + /** + * Run a request that receives a predictably randomized number of deprecation warnings. + *

+ * Re-running this back-to-back helps to ensure that warnings are not being maintained across requests. + */ + private void doTestDeprecationWarningsAppearInHeaders() throws IOException { + final boolean useDeprecatedField = randomBoolean(); + final boolean useNonDeprecatedSetting = randomBoolean(); + + // deprecated settings should also trigger a deprecation warning + final List> settings = new ArrayList<>(3); + settings.add(TEST_DEPRECATED_SETTING_TRUE1); + + if (randomBoolean()) { + settings.add(TEST_DEPRECATED_SETTING_TRUE2); + } + + if (useNonDeprecatedSetting) { + settings.add(TEST_NOT_DEPRECATED_SETTING); + } + + Collections.shuffle(settings, random()); + + // trigger all deprecations + try (Response response = getRestClient().performRequest("GET", + "/_test_cluster/deprecated_settings", + Collections.emptyMap(), + buildSettingsRequest(settings, useDeprecatedField))) { + assertThat(response.getStatusLine().getStatusCode(), equalTo(OK.getStatus())); + + final List deprecatedWarnings = getWarningHeaders(response.getHeaders()); + final List> headerMatchers = new ArrayList<>(4); + + headerMatchers.add(equalTo(TestDeprecationHeaderRestAction.DEPRECATED_ENDPOINT)); + if (useDeprecatedField) { + headerMatchers.add(equalTo(TestDeprecationHeaderRestAction.DEPRECATED_USAGE)); + } + for (Setting setting : settings) { + if (setting.isDeprecated()) { + headerMatchers.add(containsString(LoggerMessageFormat.format("[{}] setting was deprecated", (Object)setting.getKey()))); + } + } + + assertThat(deprecatedWarnings, hasSize(headerMatchers.size())); + for (Matcher headerMatcher : headerMatchers) { + assertThat(deprecatedWarnings, hasItem(headerMatcher)); + } + } + } + + private List getWarningHeaders(Header[] headers) { + List warnings = new ArrayList<>(); + + for (Header header : headers) { + if (header.getName().equals("Warning")) { + warnings.add(header.getValue()); + } + } + + return warnings; + } + + private HttpEntity buildSettingsRequest(List> settings, boolean useDeprecatedField) throws IOException { + XContentBuilder builder = JsonXContent.contentBuilder(); + + builder.startObject().startArray(useDeprecatedField ? "deprecated_settings" : "settings"); + + for (Setting setting : settings) { + builder.value(setting.getKey()); + } + + builder.endArray().endObject(); + + return new StringEntity(builder.string(), RestClient.JSON_CONTENT_TYPE); + } + +} diff --git a/core/src/test/java/org/elasticsearch/rest/DeprecationRestHandlerTests.java b/core/src/test/java/org/elasticsearch/rest/DeprecationRestHandlerTests.java new file mode 100644 index 0000000000000..be0f3b15115b9 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/rest/DeprecationRestHandlerTests.java @@ -0,0 +1,139 @@ +/* + * 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.rest; + +import com.carrotsearch.randomizedtesting.generators.CodepointSetGenerator; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.logging.DeprecationLogger; +import org.elasticsearch.test.ESTestCase; + +import org.mockito.InOrder; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +/** + * Tests {@link DeprecationRestHandler}. + */ +public class DeprecationRestHandlerTests extends ESTestCase { + + private final RestHandler handler = mock(RestHandler.class); + /** + * Note: Headers should only use US ASCII (and this inevitably becomes one!). + */ + private final String deprecationMessage = randomAsciiOfLengthBetween(1, 30); + private final DeprecationLogger deprecationLogger = mock(DeprecationLogger.class); + + public void testNullHandler() { + expectThrows(NullPointerException.class, () -> new DeprecationRestHandler(null, deprecationMessage, deprecationLogger)); + } + + public void testInvalidDeprecationMessageThrowsException() { + String invalidDeprecationMessage = randomFrom("", null, " "); + + expectThrows(IllegalArgumentException.class, + () -> new DeprecationRestHandler(handler, invalidDeprecationMessage, deprecationLogger)); + } + + public void testNullDeprecationLogger() { + expectThrows(NullPointerException.class, () -> new DeprecationRestHandler(handler, deprecationMessage, null)); + } + + public void testHandleRequestLogsWarningThenForwards() throws Exception { + RestRequest request = mock(RestRequest.class); + RestChannel channel = mock(RestChannel.class); + NodeClient client = mock(NodeClient.class); + + DeprecationRestHandler deprecatedHandler = new DeprecationRestHandler(handler, deprecationMessage, deprecationLogger); + + // test it + deprecatedHandler.handleRequest(request, channel, client); + + InOrder inOrder = inOrder(handler, request, channel, deprecationLogger); + + // log, then forward + inOrder.verify(deprecationLogger).deprecated(deprecationMessage); + inOrder.verify(handler).handleRequest(request, channel, client); + inOrder.verifyNoMoreInteractions(); + } + + public void testValidHeaderValue() { + ASCIIHeaderGenerator generator = new ASCIIHeaderGenerator(); + String value = generator.ofCodeUnitsLength(random(), 1, 50); + + assertTrue(DeprecationRestHandler.validHeaderValue(value)); + assertSame(value, DeprecationRestHandler.requireValidHeader(value)); + } + + public void testInvalidHeaderValue() { + ASCIIHeaderGenerator generator = new ASCIIHeaderGenerator(); + String value = generator.ofCodeUnitsLength(random(), 0, 25) + + randomFrom('\t', '\0', '\n', (char)27 /* ESC */, (char)31 /* unit separator*/, (char)127 /* DEL */) + + generator.ofCodeUnitsLength(random(), 0, 25); + + assertFalse(DeprecationRestHandler.validHeaderValue(value)); + + expectThrows(IllegalArgumentException.class, () -> DeprecationRestHandler.requireValidHeader(value)); + } + + public void testInvalidHeaderValueNull() { + assertFalse(DeprecationRestHandler.validHeaderValue(null)); + + expectThrows(IllegalArgumentException.class, () -> DeprecationRestHandler.requireValidHeader(null)); + } + + public void testInvalidHeaderValueEmpty() { + String blank = randomFrom("", "\t", " "); + + assertFalse(DeprecationRestHandler.validHeaderValue(blank)); + + expectThrows(IllegalArgumentException.class, () -> DeprecationRestHandler.requireValidHeader(blank)); + } + + /** + * {@code ASCIIHeaderGenerator} only uses characters expected to be valid in headers (simplified US-ASCII). + */ + private static class ASCIIHeaderGenerator extends CodepointSetGenerator { + /** + * Create a character array for characters [{@code from}, {@code to}]. + * + * @param from Starting code point (inclusive). + * @param to Ending code point (inclusive). + * @return Never {@code null}. + */ + static char[] asciiFromTo(int from, int to) { + char[] chars = new char[to - from + 1]; + + for (int i = from; i <= to; ++i) { + chars[i - from] = (char)i; + } + + return chars; + } + + /** + * Create a generator for characters [32, 126]. + */ + public ASCIIHeaderGenerator() { + super(asciiFromTo(32, 126)); + } + } + +} diff --git a/core/src/test/java/org/elasticsearch/rest/RestControllerTests.java b/core/src/test/java/org/elasticsearch/rest/RestControllerTests.java index 834afe5d5cdd2..de4e2db6a21fd 100644 --- a/core/src/test/java/org/elasticsearch/rest/RestControllerTests.java +++ b/core/src/test/java/org/elasticsearch/rest/RestControllerTests.java @@ -20,6 +20,7 @@ package org.elasticsearch.rest; import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.logging.DeprecationLogger; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.ESTestCase; @@ -35,6 +36,11 @@ import java.util.concurrent.TimeUnit; import static org.hamcrest.CoreMatchers.equalTo; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; public class RestControllerTests extends ESTestCase { @@ -110,6 +116,26 @@ public void testCanTripCircuitBreaker() throws Exception { assertFalse(controller.canTripCircuitBreaker(new FakeRestRequest.Builder().withPath("/do-not-trip").build())); } + public void testRegisterHandlerAsDeprecationHandler() { + RestController controller = mock(RestController.class); + + RestRequest.Method method = randomFrom(RestRequest.Method.values()); + String path = "/_" + randomAsciiOfLengthBetween(1, 6); + RestHandler handler = mock(RestHandler.class); + String deprecationMessage = randomAsciiOfLengthBetween(1, 10); + DeprecationLogger logger = mock(DeprecationLogger.class); + + // don't want to test everything -- just that it actually wraps the handler + doCallRealMethod().when(controller).registerAsDeprecatedHandler(method, path, handler, deprecationMessage, logger); + + controller.registerAsDeprecatedHandler(method, path, handler, deprecationMessage, logger); + + verify(controller).registerHandler(eq(method), eq(path), any(DeprecationRestHandler.class)); + } + + /** + * Useful for testing with deprecation handler. + */ private static class FakeRestHandler implements RestHandler { private final boolean canTripCircuitBreaker; diff --git a/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecatedQueryBuilder.java b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecatedQueryBuilder.java new file mode 100644 index 0000000000000..f3b02d1f6bc47 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecatedQueryBuilder.java @@ -0,0 +1,104 @@ +/* + * 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.rest.plugins; + +import org.apache.lucene.search.Query; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.ParsingException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.logging.DeprecationLogger; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.lucene.search.Queries; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.index.query.AbstractQueryBuilder; +import org.elasticsearch.index.query.QueryParseContext; +import org.elasticsearch.index.query.QueryShardContext; + +import java.io.IOException; +import java.util.Optional; + +/** + * A query that performs a match_all query, but with each index touched getting a unique deprecation warning. + *

+ * This makes it easy to test multiple unique responses for a single request. + */ +public class TestDeprecatedQueryBuilder extends AbstractQueryBuilder { + + public static final String NAME = "deprecated_match_all"; + public static final ParseField QUERY_NAME_FIELD = new ParseField(NAME); + + private static final DeprecationLogger DEPRECATION_LOGGER = new DeprecationLogger(Loggers.getLogger(TestDeprecatedQueryBuilder.class)); + + public TestDeprecatedQueryBuilder() { + // nothing to do + } + + /** + * Read from a stream. + */ + public TestDeprecatedQueryBuilder(StreamInput in) throws IOException { + super(in); + } + + @Override + protected void doWriteTo(StreamOutput out) throws IOException { + // nothing to do + } + + @Override + protected void doXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(NAME).endObject(); + } + + public static Optional fromXContent(QueryParseContext parseContext) throws IOException, ParsingException { + XContentParser parser = parseContext.parser(); + + if (parser.nextToken() != XContentParser.Token.END_OBJECT) { + throw new ParsingException(parser.getTokenLocation(), "[{}] query does not have any fields", NAME); + } + + return Optional.of(new TestDeprecatedQueryBuilder()); + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + protected Query doToQuery(QueryShardContext context) throws IOException { + DEPRECATION_LOGGER.deprecated("[{}] query is deprecated, but used on [{}] index", NAME, context.index().getName()); + + return Queries.newMatchAllQuery(); + } + + @Override + public int doHashCode() { + return 0; + } + + @Override + protected boolean doEquals(TestDeprecatedQueryBuilder other) { + return true; + } + +} diff --git a/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationHeaderRestAction.java b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationHeaderRestAction.java new file mode 100644 index 0000000000000..da1d165b6cf29 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationHeaderRestAction.java @@ -0,0 +1,108 @@ +/* + * 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.rest.plugins; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.BytesRestResponse; +import org.elasticsearch.rest.RestChannel; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestStatus; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Enables testing {@code DeprecationRestHandler} via integration tests by guaranteeing a deprecated REST endpoint. + *

+ * This adds an endpoint named /_test_cluster/deprecated_settings that touches specified settings given their names + * and returns their values. + */ +public class TestDeprecationHeaderRestAction extends BaseRestHandler { + + public static final Setting TEST_DEPRECATED_SETTING_TRUE1 = + Setting.boolSetting("test.setting.deprecated.true1", true, + Setting.Property.NodeScope, Setting.Property.Deprecated, Setting.Property.Dynamic); + public static final Setting TEST_DEPRECATED_SETTING_TRUE2 = + Setting.boolSetting("test.setting.deprecated.true2", true, + Setting.Property.NodeScope, Setting.Property.Deprecated, Setting.Property.Dynamic); + public static final Setting TEST_NOT_DEPRECATED_SETTING = + Setting.boolSetting("test.setting.not_deprecated", false, + Setting.Property.NodeScope, Setting.Property.Dynamic); + + private static final Map> SETTINGS; + + static { + Map> settingsMap = new HashMap<>(3); + + settingsMap.put(TEST_DEPRECATED_SETTING_TRUE1.getKey(), TEST_DEPRECATED_SETTING_TRUE1); + settingsMap.put(TEST_DEPRECATED_SETTING_TRUE2.getKey(), TEST_DEPRECATED_SETTING_TRUE2); + settingsMap.put(TEST_NOT_DEPRECATED_SETTING.getKey(), TEST_NOT_DEPRECATED_SETTING); + + SETTINGS = Collections.unmodifiableMap(settingsMap); + } + + public static final String DEPRECATED_ENDPOINT = "[/_test_cluster/deprecated_settings] exists for deprecated tests"; + public static final String DEPRECATED_USAGE = "[deprecated_settings] usage is deprecated. use [settings] instead"; + + @Inject + public TestDeprecationHeaderRestAction(Settings settings, RestController controller) { + super(settings); + + controller.registerAsDeprecatedHandler(RestRequest.Method.GET, "/_test_cluster/deprecated_settings", this, + DEPRECATED_ENDPOINT, deprecationLogger); + } + + @SuppressWarnings("unchecked") // List casts + @Override + public void handleRequest(RestRequest request, RestChannel channel, NodeClient client) throws Exception { + final List settings; + + try (XContentParser parser = XContentFactory.xContent(request.content()).createParser(request.content())) { + final Map source = parser.map(); + + if (source.containsKey("deprecated_settings")) { + deprecationLogger.deprecated(DEPRECATED_USAGE); + + settings = (List)source.get("deprecated_settings"); + } else { + settings = (List)source.get("settings"); + } + } + + final XContentBuilder builder = channel.newBuilder(); + + builder.startObject().startArray("settings"); + for (String setting : settings) { + builder.startObject().field(setting, SETTINGS.get(setting).getRaw(this.settings)).endObject(); + } + builder.endArray().endObject(); + + channel.sendResponse(new BytesRestResponse(RestStatus.OK, builder)); + } +} diff --git a/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationPlugin.java b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationPlugin.java new file mode 100644 index 0000000000000..c85674a196209 --- /dev/null +++ b/core/src/test/java/org/elasticsearch/rest/plugins/TestDeprecationPlugin.java @@ -0,0 +1,55 @@ +/* + * 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.rest.plugins; + +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.plugins.ActionPlugin; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.rest.RestHandler; +import org.elasticsearch.search.SearchModule; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Adds {@link TestDeprecationHeaderRestAction} for testing deprecation requests via HTTP. + */ +public class TestDeprecationPlugin extends Plugin implements ActionPlugin { + + @Override + public List> getRestHandlers() { + return Collections.singletonList(TestDeprecationHeaderRestAction.class); + } + + @Override + public List> getSettings() { + return Arrays.asList( + TestDeprecationHeaderRestAction.TEST_DEPRECATED_SETTING_TRUE1, + TestDeprecationHeaderRestAction.TEST_DEPRECATED_SETTING_TRUE2, + TestDeprecationHeaderRestAction.TEST_NOT_DEPRECATED_SETTING); + } + + public void onModule(SearchModule module) { + module.registerQuery(TestDeprecatedQueryBuilder::new, + TestDeprecatedQueryBuilder::fromXContent, + TestDeprecatedQueryBuilder.QUERY_NAME_FIELD); + } + +}