diff --git a/libs/x-content/src/main/java/org/elasticsearch/common/xcontent/XContentBuilder.java b/libs/x-content/src/main/java/org/elasticsearch/common/xcontent/XContentBuilder.java index bf5984fdfde57..f1b94065d8a64 100644 --- a/libs/x-content/src/main/java/org/elasticsearch/common/xcontent/XContentBuilder.java +++ b/libs/x-content/src/main/java/org/elasticsearch/common/xcontent/XContentBuilder.java @@ -137,6 +137,10 @@ public static XContentBuilder builder(XContent xContent, Set includes, S DATE_TRANSFORMERS = Collections.unmodifiableMap(dateTransformers); } + public XContentBuilder withCompatibleMajorVersion(byte compatibleVersion) { + return this; + } + @FunctionalInterface public interface Writer { void write(XContentBuilder builder, Object value) throws IOException; diff --git a/server/src/main/java/org/elasticsearch/Version.java b/server/src/main/java/org/elasticsearch/Version.java index 84a6a00e716e5..9d74b031aea04 100644 --- a/server/src/main/java/org/elasticsearch/Version.java +++ b/server/src/main/java/org/elasticsearch/Version.java @@ -257,6 +257,7 @@ private static Version fromStringSlow(String version) { public final byte build; public final org.apache.lucene.util.Version luceneVersion; private final String toString; + public final int previousMajorId; Version(int id, org.apache.lucene.util.Version luceneVersion) { this.id = id; @@ -266,6 +267,7 @@ private static Version fromStringSlow(String version) { this.build = (byte) (id % 100); this.luceneVersion = Objects.requireNonNull(luceneVersion); this.toString = major + "." + minor + "." + revision; + this.previousMajorId = major > 0 ? (major - 1) * 1000000 + 99 : major; } public boolean after(Version version) { @@ -294,6 +296,10 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws return builder.value(toString()); } + public Version previousMajor() { + return Version.fromId(previousMajorId); + } + /* * We need the declared versions when computing the minimum compatibility version. As computing the declared versions uses reflection it * is not cheap. Since computing the minimum compatibility version can occur often, we use this holder to compute the declared versions diff --git a/server/src/main/java/org/elasticsearch/action/ActionModule.java b/server/src/main/java/org/elasticsearch/action/ActionModule.java index b96189ef69764..3956b29d13b78 100644 --- a/server/src/main/java/org/elasticsearch/action/ActionModule.java +++ b/server/src/main/java/org/elasticsearch/action/ActionModule.java @@ -259,6 +259,7 @@ import org.elasticsearch.persistent.UpdatePersistentTaskStatusAction; import org.elasticsearch.plugins.ActionPlugin; import org.elasticsearch.plugins.ActionPlugin.ActionHandler; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.rest.RestController; import org.elasticsearch.rest.RestHandler; import org.elasticsearch.rest.RestHeaderDefinition; @@ -427,7 +428,8 @@ public class ActionModule extends AbstractModule { public ActionModule(Settings settings, IndexNameExpressionResolver indexNameExpressionResolver, IndexScopedSettings indexScopedSettings, ClusterSettings clusterSettings, SettingsFilter settingsFilter, ThreadPool threadPool, List actionPlugins, NodeClient nodeClient, - CircuitBreakerService circuitBreakerService, UsageService usageService, SystemIndices systemIndices) { + CircuitBreakerService circuitBreakerService, UsageService usageService, SystemIndices systemIndices, + CompatibleVersion compatibleVersion) { this.settings = settings; this.indexNameExpressionResolver = indexNameExpressionResolver; this.indexScopedSettings = indexScopedSettings; @@ -459,10 +461,9 @@ public ActionModule(Settings settings, IndexNameExpressionResolver indexNameExpr indicesAliasesRequestRequestValidators = new RequestValidators<>( actionPlugins.stream().flatMap(p -> p.indicesAliasesRequestValidators().stream()).collect(Collectors.toList())); - restController = new RestController(headers, restWrapper, nodeClient, circuitBreakerService, usageService); + restController = new RestController(headers, restWrapper, nodeClient, circuitBreakerService, usageService, compatibleVersion); } - public Map> getActions() { return actions; } diff --git a/server/src/main/java/org/elasticsearch/node/Node.java b/server/src/main/java/org/elasticsearch/node/Node.java index f08a6ebcf24d4..15ca8e4f66bbd 100644 --- a/server/src/main/java/org/elasticsearch/node/Node.java +++ b/server/src/main/java/org/elasticsearch/node/Node.java @@ -143,6 +143,8 @@ import org.elasticsearch.plugins.Plugin; import org.elasticsearch.plugins.PluginsService; import org.elasticsearch.plugins.RepositoryPlugin; +import org.elasticsearch.plugins.RestCompatibilityPlugin; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.plugins.ScriptPlugin; import org.elasticsearch.plugins.SearchPlugin; import org.elasticsearch.plugins.SystemIndexPlugin; @@ -529,7 +531,8 @@ protected Node(final Environment initialEnvironment, ActionModule actionModule = new ActionModule(settings, clusterModule.getIndexNameExpressionResolver(), settingsModule.getIndexScopedSettings(), settingsModule.getClusterSettings(), settingsModule.getSettingsFilter(), - threadPool, pluginsService.filterPlugins(ActionPlugin.class), client, circuitBreakerService, usageService, systemIndices); + threadPool, pluginsService.filterPlugins(ActionPlugin.class), client, circuitBreakerService, usageService, + systemIndices, getRestCompatibleFunction()); modules.add(actionModule); final RestController restController = actionModule.getRestController(); @@ -704,6 +707,23 @@ protected Node(final Environment initialEnvironment, } } + /** + * @return A function that can be used to determine the requested REST compatible version + * package scope for testing + */ + CompatibleVersion getRestCompatibleFunction() { + List restCompatibilityPlugins = pluginsService.filterPlugins(RestCompatibilityPlugin.class); + final CompatibleVersion compatibleVersion; + if (restCompatibilityPlugins.size() > 1) { + throw new IllegalStateException("Only one RestCompatibilityPlugin is allowed"); + } else if (restCompatibilityPlugins.size() == 1) { + compatibleVersion = restCompatibilityPlugins.get(0)::getCompatibleVersion; + } else { + compatibleVersion = CompatibleVersion.CURRENT_VERSION; + } + return compatibleVersion; + } + protected TransportService newTransportService(Settings settings, Transport transport, ThreadPool threadPool, TransportInterceptor interceptor, Function localNodeFactory, diff --git a/server/src/main/java/org/elasticsearch/plugins/RestCompatibilityPlugin.java b/server/src/main/java/org/elasticsearch/plugins/RestCompatibilityPlugin.java new file mode 100644 index 0000000000000..9fd73a24ee87b --- /dev/null +++ b/server/src/main/java/org/elasticsearch/plugins/RestCompatibilityPlugin.java @@ -0,0 +1,39 @@ +/* + * 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.plugins; + +import org.elasticsearch.Version; +import org.elasticsearch.common.Nullable; + + +/** + * An extension point for Compatible API plugin implementation. + */ +public interface RestCompatibilityPlugin { + /** + * Returns a version which was requested on Accept and Content-Type headers + * + * @param acceptHeader - a media-type value from Accept header + * @param contentTypeHeader - a media-type value from Content-Type header + * @param hasContent - a flag indicating if a request has content + * @return a requested Compatible API Version + */ + Version getCompatibleVersion(@Nullable String acceptHeader, @Nullable String contentTypeHeader, boolean hasContent); +} diff --git a/server/src/main/java/org/elasticsearch/rest/CompatibleVersion.java b/server/src/main/java/org/elasticsearch/rest/CompatibleVersion.java new file mode 100644 index 0000000000000..48ef5a8f8a87e --- /dev/null +++ b/server/src/main/java/org/elasticsearch/rest/CompatibleVersion.java @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.rest; + +import org.elasticsearch.Version; +import org.elasticsearch.common.Nullable; + +/** + * An interface used to specify a function that returns a compatible API version + * Intended to be used in a code base instead of a plugin. + */ +@FunctionalInterface +public interface CompatibleVersion { + Version get(@Nullable String acceptHeader, @Nullable String contentTypeHeader, boolean hasContent); + + CompatibleVersion CURRENT_VERSION = (acceptHeader, contentTypeHeader, hasContent) -> Version.CURRENT; +} diff --git a/server/src/main/java/org/elasticsearch/rest/RestController.java b/server/src/main/java/org/elasticsearch/rest/RestController.java index e1ca179460794..5f404100939eb 100644 --- a/server/src/main/java/org/elasticsearch/rest/RestController.java +++ b/server/src/main/java/org/elasticsearch/rest/RestController.java @@ -23,6 +23,7 @@ import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.message.ParameterizedMessage; import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.Version; import org.elasticsearch.client.node.NodeClient; import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Strings; @@ -90,11 +91,15 @@ public class RestController implements HttpServerTransport.Dispatcher { /** Rest headers that are copied to internal requests made during a rest request. */ private final Set headersToCopy; private final UsageService usageService; + private CompatibleVersion compatibleVersion; + public RestController(Set headersToCopy, UnaryOperator handlerWrapper, - NodeClient client, CircuitBreakerService circuitBreakerService, UsageService usageService) { + NodeClient client, CircuitBreakerService circuitBreakerService, UsageService usageService, + CompatibleVersion compatibleVersion) { this.headersToCopy = headersToCopy; this.usageService = usageService; + this.compatibleVersion = compatibleVersion; if (handlerWrapper == null) { handlerWrapper = h -> h; // passthrough if no wrapper set } @@ -220,7 +225,8 @@ public void dispatchBadRequest(final RestChannel channel, final ThreadContext th } } - private void dispatchRequest(RestRequest request, RestChannel channel, RestHandler handler) throws Exception { + private void dispatchRequest(RestRequest request, RestChannel channel, RestHandler handler, Version compatibleApiVersion) + throws Exception { final int contentLength = request.contentLength(); if (contentLength > 0) { final XContentType xContentType = request.getXContentType(); @@ -242,7 +248,7 @@ private void dispatchRequest(RestRequest request, RestChannel channel, RestHandl inFlightRequestsBreaker(circuitBreakerService).addWithoutBreaking(contentLength); } // iff we could reserve bytes for the request we need to send the response also over this channel - responseChannel = new ResourceHandlingHttpChannel(channel, circuitBreakerService, contentLength); + responseChannel = new ResourceHandlingHttpChannel(channel, circuitBreakerService, contentLength, compatibleApiVersion); // TODO: Count requests double in the circuit breaker if they need copying? if (handler.allowsUnsafeBuffers() == false) { request.ensureSafeBuffers(); @@ -318,6 +324,9 @@ private void tryAllHandlers(final RestRequest request, final RestChannel channel final String rawPath = request.rawPath(); final String uri = request.uri(); final RestRequest.Method requestMethod; + //TODO: USAGE_1 now that we have a version we can implement a REST handler that accepts path, method AND version + Version version = compatibleVersion.get(request.header("Accept"), request.header("Content-Type"), request.hasContent()); + try { // Resolves the HTTP method and fails if the method is invalid requestMethod = request.method(); @@ -336,7 +345,7 @@ private void tryAllHandlers(final RestRequest request, final RestChannel channel return; } } else { - dispatchRequest(request, channel, handler); + dispatchRequest(request, channel, handler, version); return; } } @@ -454,12 +463,15 @@ private static final class ResourceHandlingHttpChannel implements RestChannel { private final RestChannel delegate; private final CircuitBreakerService circuitBreakerService; private final int contentLength; + private final Version compatibleVersion; private final AtomicBoolean closed = new AtomicBoolean(); - ResourceHandlingHttpChannel(RestChannel delegate, CircuitBreakerService circuitBreakerService, int contentLength) { + ResourceHandlingHttpChannel(RestChannel delegate, CircuitBreakerService circuitBreakerService, int contentLength, + Version compatibleVersion) { this.delegate = delegate; this.circuitBreakerService = circuitBreakerService; this.contentLength = contentLength; + this.compatibleVersion = compatibleVersion; } @Override @@ -474,13 +486,15 @@ public XContentBuilder newErrorBuilder() throws IOException { @Override public XContentBuilder newBuilder(@Nullable XContentType xContentType, boolean useFiltering) throws IOException { - return delegate.newBuilder(xContentType, useFiltering); + return delegate.newBuilder(xContentType, useFiltering) + .withCompatibleMajorVersion(compatibleVersion.major); } @Override public XContentBuilder newBuilder(XContentType xContentType, XContentType responseContentType, boolean useFiltering) throws IOException { - return delegate.newBuilder(xContentType, responseContentType, useFiltering); + return delegate.newBuilder(xContentType, responseContentType, useFiltering) + .withCompatibleMajorVersion(compatibleVersion.major); } @Override diff --git a/server/src/main/java/org/elasticsearch/rest/RestHandler.java b/server/src/main/java/org/elasticsearch/rest/RestHandler.java index 054c618876314..a5b6031553601 100644 --- a/server/src/main/java/org/elasticsearch/rest/RestHandler.java +++ b/server/src/main/java/org/elasticsearch/rest/RestHandler.java @@ -19,6 +19,7 @@ package org.elasticsearch.rest; +import org.elasticsearch.Version; import org.elasticsearch.client.node.NodeClient; import org.elasticsearch.common.xcontent.XContent; import org.elasticsearch.rest.RestRequest.Method; @@ -99,6 +100,16 @@ default boolean allowSystemIndexAccessByDefault() { return false; } + /** + * Returns the version that a handler is compatible with. + * The version is used to find a handler for a request that specified a compatible with version. + * If no version is specified, handler is assumed to be compatible with Version.CURRENT + * @return a version + */ + default Version compatibleWithVersion() { + return Version.CURRENT; + } + class Route { private final String path; diff --git a/server/src/main/java/org/elasticsearch/rest/RestRequest.java b/server/src/main/java/org/elasticsearch/rest/RestRequest.java index 512bf72e9c0d3..83f1a89798531 100644 --- a/server/src/main/java/org/elasticsearch/rest/RestRequest.java +++ b/server/src/main/java/org/elasticsearch/rest/RestRequest.java @@ -79,11 +79,18 @@ public boolean isContentConsumed() { return contentConsumed; } + // for testing protected RestRequest(NamedXContentRegistry xContentRegistry, Map params, String path, Map> headers, HttpRequest httpRequest, HttpChannel httpChannel) { this(xContentRegistry, params, path, headers, httpRequest, httpChannel, requestIdGenerator.incrementAndGet()); } + protected RestRequest(RestRequest restRequest) { + this(restRequest.getXContentRegistry(), restRequest.params(), restRequest.path(), restRequest.getHeaders(), + restRequest.getHttpRequest(), restRequest.getHttpChannel(), restRequest.getRequestId()); + } + + private RestRequest(NamedXContentRegistry xContentRegistry, Map params, String path, Map> headers, HttpRequest httpRequest, HttpChannel httpChannel, long requestId) { final XContentType xContentType; @@ -104,10 +111,7 @@ private RestRequest(NamedXContentRegistry xContentRegistry, Map this.requestId = requestId; } - protected RestRequest(RestRequest restRequest) { - this(restRequest.getXContentRegistry(), restRequest.params(), restRequest.path(), restRequest.getHeaders(), - restRequest.getHttpRequest(), restRequest.getHttpChannel(), restRequest.getRequestId()); - } + /** * Invoke {@link HttpRequest#releaseAndCopy()} on the http request in this instance and replace a pooled http request diff --git a/server/src/test/java/org/elasticsearch/VersionTests.java b/server/src/test/java/org/elasticsearch/VersionTests.java index b2f73687ebc4f..2443fa6cf8c74 100644 --- a/server/src/test/java/org/elasticsearch/VersionTests.java +++ b/server/src/test/java/org/elasticsearch/VersionTests.java @@ -369,4 +369,11 @@ public void testUnreleasedVersion() { VersionTests.assertUnknownVersion(VERSION_5_1_0_UNRELEASED); } + public void testPreviousVersion() { + Version current = Version.CURRENT; + assertThat(current.previousMajor(), equalTo(Version.fromString(Version.CURRENT.major - 1 + ".0.0"))); + assertThat(Version.fromString("7.8.1").previousMajor(), equalTo(Version.fromString("6.0.0"))); + assertThat(Version.V_EMPTY.previousMajor(), equalTo(Version.V_EMPTY)); + } + } diff --git a/server/src/test/java/org/elasticsearch/action/ActionModuleTests.java b/server/src/test/java/org/elasticsearch/action/ActionModuleTests.java index 15d2aff8d8787..023745cbe8d46 100644 --- a/server/src/test/java/org/elasticsearch/action/ActionModuleTests.java +++ b/server/src/test/java/org/elasticsearch/action/ActionModuleTests.java @@ -34,6 +34,7 @@ import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.plugins.ActionPlugin; import org.elasticsearch.plugins.ActionPlugin.ActionHandler; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.rest.RestChannel; import org.elasticsearch.rest.RestController; import org.elasticsearch.rest.RestHandler; @@ -111,7 +112,7 @@ public void testSetupRestHandlerContainsKnownBuiltin() { ActionModule actionModule = new ActionModule(settings.getSettings(), new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), settings.getIndexScopedSettings(), settings.getClusterSettings(), settings.getSettingsFilter(), null, emptyList(), null, - null, usageService, null); + null, usageService, null, CompatibleVersion.CURRENT_VERSION); actionModule.initRestHandlers(null); // At this point the easiest way to confirm that a handler is loaded is to try to register another one on top of it and to fail Exception e = expectThrows(IllegalArgumentException.class, () -> @@ -151,7 +152,7 @@ public String getName() { ActionModule actionModule = new ActionModule(settings.getSettings(), new IndexNameExpressionResolver(threadPool.getThreadContext()), settings.getIndexScopedSettings(), settings.getClusterSettings(), settings.getSettingsFilter(), threadPool, singletonList(dupsMainAction), - null, null, usageService, null); + null, null, usageService, null, CompatibleVersion.CURRENT_VERSION); Exception e = expectThrows(IllegalArgumentException.class, () -> actionModule.initRestHandlers(null)); assertThat(e.getMessage(), startsWith("Cannot replace existing handler for [/] for method: GET")); } finally { @@ -186,7 +187,7 @@ public List getRestHandlers(Settings settings, RestController restC ActionModule actionModule = new ActionModule(settings.getSettings(), new IndexNameExpressionResolver(threadPool.getThreadContext()), settings.getIndexScopedSettings(), settings.getClusterSettings(), settings.getSettingsFilter(), threadPool, singletonList(registersFakeHandler), - null, null, usageService, null); + null, null, usageService, null, CompatibleVersion.CURRENT_VERSION); actionModule.initRestHandlers(null); // At this point the easiest way to confirm that a handler is loaded is to try to register another one on top of it and to fail Exception e = expectThrows(IllegalArgumentException.class, () -> diff --git a/server/src/test/java/org/elasticsearch/node/NodeTests.java b/server/src/test/java/org/elasticsearch/node/NodeTests.java index d298a5010366e..3741b172653a1 100644 --- a/server/src/test/java/org/elasticsearch/node/NodeTests.java +++ b/server/src/test/java/org/elasticsearch/node/NodeTests.java @@ -20,6 +20,7 @@ import org.apache.lucene.util.LuceneTestCase; import org.apache.lucene.util.SetOnce; +import org.elasticsearch.Version; import org.elasticsearch.bootstrap.BootstrapCheck; import org.elasticsearch.bootstrap.BootstrapContext; import org.elasticsearch.cluster.ClusterName; @@ -36,6 +37,8 @@ import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.plugins.CircuitBreakerPlugin; import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.plugins.RestCompatibilityPlugin; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.InternalTestCluster; import org.elasticsearch.test.MockHttpTransport; @@ -153,10 +156,10 @@ public void testServerNameNodeAttribute() throws IOException { private static Settings.Builder baseSettings() { final Path tempDir = createTempDir(); return Settings.builder() - .put(ClusterName.CLUSTER_NAME_SETTING.getKey(), InternalTestCluster.clusterName("single-node-cluster", randomLong())) - .put(Environment.PATH_HOME_SETTING.getKey(), tempDir) - .put(NetworkModule.TRANSPORT_TYPE_KEY, getTestTransportType()) - .put(dataNode()); + .put(ClusterName.CLUSTER_NAME_SETTING.getKey(), InternalTestCluster.clusterName("single-node-cluster", randomLong())) + .put(Environment.PATH_HOME_SETTING.getKey(), tempDir) + .put(NetworkModule.TRANSPORT_TYPE_KEY, getTestTransportType()) + .put(dataNode()); } public void testCloseOnOutstandingTask() throws Exception { @@ -167,7 +170,7 @@ public void testCloseOnOutstandingTask() throws Exception { final CountDownLatch threadRunning = new CountDownLatch(1); threadpool.executor(ThreadPool.Names.SEARCH).execute(() -> { threadRunning.countDown(); - while (shouldRun.get()); + while (shouldRun.get()) ; }); threadRunning.await(); node.close(); @@ -190,7 +193,7 @@ public void testCloseRaceWithTaskExecution() throws Exception { } try { threadpool.executor(ThreadPool.Names.SEARCH).execute(() -> { - while (shouldRun.get()); + while (shouldRun.get()) ; }); } catch (RejectedExecutionException e) { assertThat(e.getMessage(), containsString("[Terminated,")); @@ -229,7 +232,7 @@ public void testAwaitCloseTimeoutsOnNonInterruptibleTask() throws Exception { final CountDownLatch threadRunning = new CountDownLatch(1); threadpool.executor(ThreadPool.Names.SEARCH).execute(() -> { threadRunning.countDown(); - while (shouldRun.get()); + while (shouldRun.get()) ; }); threadRunning.await(); node.close(); @@ -272,7 +275,7 @@ public void testCloseOnLeakedIndexReaderReference() throws Exception { node.start(); IndicesService indicesService = node.injector().getInstance(IndicesService.class); assertAcked(node.client().admin().indices().prepareCreate("test") - .setSettings(Settings.builder().put(SETTING_NUMBER_OF_SHARDS, 1).put(SETTING_NUMBER_OF_REPLICAS, 0))); + .setSettings(Settings.builder().put(SETTING_NUMBER_OF_SHARDS, 1).put(SETTING_NUMBER_OF_REPLICAS, 0))); IndexService indexService = indicesService.iterator().next(); IndexShard shard = indexService.getShard(0); Searcher searcher = shard.acquireSearcher("test"); @@ -288,7 +291,7 @@ public void testCloseOnLeakedStoreReference() throws Exception { node.start(); IndicesService indicesService = node.injector().getInstance(IndicesService.class); assertAcked(node.client().admin().indices().prepareCreate("test") - .setSettings(Settings.builder().put(SETTING_NUMBER_OF_SHARDS, 1).put(SETTING_NUMBER_OF_REPLICAS, 0))); + .setSettings(Settings.builder().put(SETTING_NUMBER_OF_SHARDS, 1).put(SETTING_NUMBER_OF_REPLICAS, 0))); IndexService indexService = indicesService.iterator().next(); IndexShard shard = indexService.getShard(0); shard.store().incRef(); @@ -311,7 +314,7 @@ public void testCreateWithCircuitBreakerPlugins() throws IOException { CircuitBreakerPlugin breakerPlugin = node.getPluginsService().filterPlugins(CircuitBreakerPlugin.class).get(0); assertTrue(breakerPlugin instanceof MockCircuitBreakerPlugin); assertSame("plugin circuit breaker instance is not the same as breaker service's instance", - ((MockCircuitBreakerPlugin)breakerPlugin).myCircuitBreaker.get(), + ((MockCircuitBreakerPlugin) breakerPlugin).myCircuitBreaker.get(), service.getBreaker("test_breaker")); } } @@ -339,4 +342,55 @@ public void setCircuitBreaker(CircuitBreaker circuitBreaker) { myCircuitBreaker.set(circuitBreaker); } } + + public static class TestRestCompatibility1 extends Plugin implements RestCompatibilityPlugin { + @Override + public Version getCompatibleVersion(String acceptHeader, String contentTypeHeader, boolean hasContent) { + return Version.CURRENT.previousMajor(); + } + } + + public static class TestRestCompatibility2 extends Plugin implements RestCompatibilityPlugin { + @Override + public Version getCompatibleVersion(String acceptHeader, String contentTypeHeader, boolean hasContent) { + return null; + } + } + + public void testLoadingMultipleRestCompatibilityPlugins() throws IOException { + Settings.Builder settings = baseSettings(); + + // throw an exception when two plugins are registered + List> plugins = basePlugins(); + plugins.add(TestRestCompatibility1.class); + plugins.add(TestRestCompatibility2.class); + + expectThrows(IllegalStateException.class, () -> new MockNode(settings.build(), plugins)); + } + + public void testCorrectUsageOfRestCompatibilityPlugin() throws IOException { + Settings.Builder settings = baseSettings(); + + // the correct usage expects one plugin + List> plugins = basePlugins(); + plugins.add(TestRestCompatibility1.class); + + try (Node node = new MockNode(settings.build(), plugins)) { + CompatibleVersion restCompatibleFunction = node.getRestCompatibleFunction(); + assertThat(restCompatibleFunction.get("", "", false), equalTo(Version.CURRENT.previousMajor())); + } + } + + + public void testDefaultingRestCompatibilityPlugin() throws IOException { + Settings.Builder settings = baseSettings(); + + // default to CompatibleVersion.CURRENT_VERSION when no plugins provided + List> plugins = basePlugins(); + + try (Node node = new MockNode(settings.build(), plugins)) { + CompatibleVersion restCompatibleFunction = node.getRestCompatibleFunction(); + assertThat(restCompatibleFunction.get("", "", false), equalTo(Version.CURRENT)); + } + } } diff --git a/server/src/test/java/org/elasticsearch/rest/RestControllerTests.java b/server/src/test/java/org/elasticsearch/rest/RestControllerTests.java index 0b756acb5ef86..2dd6d00b43e88 100644 --- a/server/src/test/java/org/elasticsearch/rest/RestControllerTests.java +++ b/server/src/test/java/org/elasticsearch/rest/RestControllerTests.java @@ -97,7 +97,8 @@ public void setup() { HttpServerTransport httpServerTransport = new TestHttpServerTransport(); client = new NoOpNodeClient(this.getTestName()); - restController = new RestController(Collections.emptySet(), null, client, circuitBreakerService, usageService); + restController = new RestController(Collections.emptySet(), null, client, circuitBreakerService, usageService, + , CompatibleVersion.CURRENT_VERSION); restController.registerHandler(RestRequest.Method.GET, "/", (request, channel, client) -> channel.sendResponse( new BytesRestResponse(RestStatus.OK, BytesRestResponse.TEXT_CONTENT_TYPE, BytesArray.EMPTY))); @@ -117,7 +118,8 @@ public void testApplyRelevantHeaders() throws Exception { final ThreadContext threadContext = client.threadPool().getThreadContext(); Set headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition("header.1", true), new RestHeaderDefinition("header.2", true))); - final RestController restController = new RestController(headers, null, null, circuitBreakerService, usageService); + final RestController restController = new RestController(headers, null, null, circuitBreakerService, usageService, + CompatibleVersion.CURRENT_VERSION); Map> restHeaders = new HashMap<>(); restHeaders.put("header.1", Collections.singletonList("true")); restHeaders.put("header.2", Collections.singletonList("true")); @@ -153,7 +155,8 @@ public void testRequestWithDisallowedMultiValuedHeader() { final ThreadContext threadContext = client.threadPool().getThreadContext(); Set headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition("header.1", true), new RestHeaderDefinition("header.2", false))); - final RestController restController = new RestController(headers, null, null, circuitBreakerService, usageService); + final RestController restController = new RestController(headers, null, null, circuitBreakerService, usageService, + CompatibleVersion.CURRENT_VERSION); Map> restHeaders = new HashMap<>(); restHeaders.put("header.1", Collections.singletonList("boo")); restHeaders.put("header.2", List.of("foo", "bar")); @@ -167,7 +170,8 @@ public void testRequestWithDisallowedMultiValuedHeaderButSameValues() { final ThreadContext threadContext = client.threadPool().getThreadContext(); Set headers = new HashSet<>(Arrays.asList(new RestHeaderDefinition("header.1", true), new RestHeaderDefinition("header.2", false))); - final RestController restController = new RestController(headers, null, client, circuitBreakerService, usageService); + final RestController restController = new RestController(headers, null, client, circuitBreakerService, + usageService, CompatibleVersion.CURRENT_VERSION); Map> restHeaders = new HashMap<>(); restHeaders.put("header.1", Collections.singletonList("boo")); restHeaders.put("header.2", List.of("foo", "foo")); @@ -221,7 +225,8 @@ public void testRegisterWithDeprecatedHandler() { } public void testRegisterSecondMethodWithDifferentNamedWildcard() { - final RestController restController = new RestController(null, null, null, circuitBreakerService, usageService); + final RestController restController = new RestController(null, null, null, circuitBreakerService, usageService, + CompatibleVersion.CURRENT_VERSION); RestRequest.Method firstMethod = randomFrom(RestRequest.Method.values()); RestRequest.Method secondMethod = @@ -248,7 +253,7 @@ public void testRestHandlerWrapper() throws Exception { h -> { assertSame(handler, h); return (RestRequest request, RestChannel channel, NodeClient client) -> wrapperCalled.set(true); - }, client, circuitBreakerService, usageService); + }, client, circuitBreakerService, usageService, CompatibleVersion.CURRENT_VERSION); restController.registerHandler(RestRequest.Method.GET, "/wrapped", handler); RestRequest request = testRestRequest("/wrapped", "{}", XContentType.JSON); AssertingChannel channel = new AssertingChannel(request, true, RestStatus.BAD_REQUEST); @@ -311,7 +316,8 @@ public void testDispatchRequiresContentTypeForRequestsWithContent() { String content = randomAlphaOfLength((int) Math.round(BREAKER_LIMIT.getBytes() / inFlightRequestsBreaker.getOverhead())); RestRequest request = testRestRequest("/", content, null); AssertingChannel channel = new AssertingChannel(request, true, RestStatus.NOT_ACCEPTABLE); - restController = new RestController(Collections.emptySet(), null, null, circuitBreakerService, usageService); + restController = new RestController(Collections.emptySet(), null, null, circuitBreakerService, usageService, + CompatibleVersion.CURRENT_VERSION); restController.registerHandler(RestRequest.Method.GET, "/", (r, c, client) -> c.sendResponse( new BytesRestResponse(RestStatus.OK, BytesRestResponse.TEXT_CONTENT_TYPE, BytesArray.EMPTY))); diff --git a/server/src/test/java/org/elasticsearch/rest/RestHttpResponseHeadersTests.java b/server/src/test/java/org/elasticsearch/rest/RestHttpResponseHeadersTests.java index ce54b896ef36e..90eb2288d104e 100644 --- a/server/src/test/java/org/elasticsearch/rest/RestHttpResponseHeadersTests.java +++ b/server/src/test/java/org/elasticsearch/rest/RestHttpResponseHeadersTests.java @@ -90,7 +90,7 @@ public void testUnsupportedMethodResponseHttpHeader() throws Exception { final Settings settings = Settings.EMPTY; UsageService usageService = new UsageService(); RestController restController = new RestController(Collections.emptySet(), - null, null, circuitBreakerService, usageService); + null, null, circuitBreakerService, usageService, CompatibleVersion.CURRENT_VERSION); // A basic RestHandler handles requests to the endpoint RestHandler restHandler = new RestHandler() { diff --git a/server/src/test/java/org/elasticsearch/rest/action/admin/indices/RestValidateQueryActionTests.java b/server/src/test/java/org/elasticsearch/rest/action/admin/indices/RestValidateQueryActionTests.java index 6711beb52bdca..98a21263151ec 100644 --- a/server/src/test/java/org/elasticsearch/rest/action/admin/indices/RestValidateQueryActionTests.java +++ b/server/src/test/java/org/elasticsearch/rest/action/admin/indices/RestValidateQueryActionTests.java @@ -30,6 +30,7 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.indices.breaker.NoneCircuitBreakerService; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.rest.RestController; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.search.AbstractSearchTestCase; @@ -60,7 +61,7 @@ public class RestValidateQueryActionTests extends AbstractSearchTestCase { private static UsageService usageService = new UsageService(); private static RestController controller = new RestController(emptySet(), null, client, - new NoneCircuitBreakerService(), usageService); + new NoneCircuitBreakerService(), usageService, CompatibleVersion.CURRENT_VERSION); private static RestValidateQueryAction action = new RestValidateQueryAction(); /** diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/RestActionTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/rest/RestActionTestCase.java index 0577ad0c23441..5467f71eb11ba 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/RestActionTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/RestActionTestCase.java @@ -26,6 +26,7 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.indices.breaker.NoneCircuitBreakerService; +import org.elasticsearch.rest.CompatibleVersion; import org.elasticsearch.rest.RestController; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.tasks.Task; @@ -54,7 +55,7 @@ public void setUpController() { controller = new RestController(Collections.emptySet(), null, verifyingClient, new NoneCircuitBreakerService(), - new UsageService()); + new UsageService(), CompatibleVersion.CURRENT_VERSION); } @After diff --git a/x-pack/plugin/rest-compatibility/build.gradle b/x-pack/plugin/rest-compatibility/build.gradle new file mode 100644 index 0000000000000..f7eb533865e95 --- /dev/null +++ b/x-pack/plugin/rest-compatibility/build.gradle @@ -0,0 +1,14 @@ +apply plugin: 'elasticsearch.esplugin' + +esplugin { + name 'rest-compatibility' + description 'A plugin for Compatible Rest API' + classname 'org.elasticsearch.compat.CompatibleVersionPlugin' + extendedPlugins = ['x-pack-core'] +} + +dependencies { + compileOnly project(path: xpackModule('core'), configuration: 'default') + testImplementation project(path: xpackModule('core'), configuration: 'testArtifacts') +} + diff --git a/x-pack/plugin/rest-compatibility/src/main/java/org/elasticsearch/compat/CompatibleVersionPlugin.java b/x-pack/plugin/rest-compatibility/src/main/java/org/elasticsearch/compat/CompatibleVersionPlugin.java new file mode 100644 index 0000000000000..2d4be753839eb --- /dev/null +++ b/x-pack/plugin/rest-compatibility/src/main/java/org/elasticsearch/compat/CompatibleVersionPlugin.java @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.compat; + +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.Version; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.plugins.RestCompatibilityPlugin; +import org.elasticsearch.rest.RestStatus; + +public class CompatibleVersionPlugin extends Plugin implements RestCompatibilityPlugin { + + @Override + public Version getCompatibleVersion(@Nullable String acceptHeader, @Nullable String contentTypeHeader, boolean hasContent) { + Byte aVersion = XContentType.parseVersion(acceptHeader); + byte acceptVersion = aVersion == null ? Version.CURRENT.major : Integer.valueOf(aVersion).byteValue(); + Byte cVersion = XContentType.parseVersion(contentTypeHeader); + byte contentTypeVersion = cVersion == null ? Version.CURRENT.major : Integer.valueOf(cVersion).byteValue(); + + // accept version must be current or prior + if (acceptVersion > Version.CURRENT.major || acceptVersion < Version.CURRENT.major - 1) { + throw new ElasticsearchStatusException( + "Compatible version must be equal or less then the current version. Accept={}} Content-Type={}}", + RestStatus.BAD_REQUEST, + acceptHeader, + contentTypeHeader + ); + } + if (hasContent) { + + // content-type version must be current or prior + if (contentTypeVersion > Version.CURRENT.major || contentTypeVersion < Version.CURRENT.major - 1) { + throw new ElasticsearchStatusException( + "Compatible version must be equal or less then the current version. Accept={} Content-Type={}", + RestStatus.BAD_REQUEST, + acceptHeader, + contentTypeHeader, + RestStatus.BAD_REQUEST + ); + } + // if both accept and content-type are sent, the version must match + if (contentTypeVersion != acceptVersion) { + throw new ElasticsearchStatusException( + "Content-Type and Accept version requests have to match. Accept={} Content-Type={}", + RestStatus.BAD_REQUEST, + acceptHeader, + contentTypeHeader + ); + } + // both headers should be versioned or none + if ((cVersion == null && aVersion != null) || (aVersion == null && cVersion != null)) { + throw new ElasticsearchStatusException( + "Versioning is required on both Content-Type and Accept headers. Accept={} Content-Type={}", + RestStatus.BAD_REQUEST, + acceptHeader, + contentTypeHeader + ); + } + if (contentTypeVersion < Version.CURRENT.major) { + return Version.CURRENT.previousMajor(); + } + } + + if (acceptVersion < Version.CURRENT.major) { + return Version.CURRENT.previousMajor(); + } + + return Version.CURRENT; + } +} diff --git a/x-pack/plugin/rest-compatibility/src/test/java/org/elasticsearch/compat/CompatibleVersionPluginTests.java b/x-pack/plugin/rest-compatibility/src/test/java/org/elasticsearch/compat/CompatibleVersionPluginTests.java new file mode 100644 index 0000000000000..32f792ffc2a92 --- /dev/null +++ b/x-pack/plugin/rest-compatibility/src/test/java/org/elasticsearch/compat/CompatibleVersionPluginTests.java @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.compat; + +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.Version; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.hamcrest.ElasticsearchMatchers; +import org.hamcrest.Matcher; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +public class CompatibleVersionPluginTests extends ESTestCase { + CompatibleVersionPlugin compatibleVersionPlugin = new CompatibleVersionPlugin(); + int CURRENT_VERSION = Version.CURRENT.major; + int PREVIOUS_VERSION = Version.CURRENT.major - 1; + int OBSOLETE_VERSION = Version.CURRENT.major - 2; + + public void testAcceptAndContentTypeCombinations() { + assertThat(requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(PREVIOUS_VERSION), bodyPresent()), isCompatible()); + + assertThat(requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(PREVIOUS_VERSION), bodyNotPresent()), isCompatible()); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(CURRENT_VERSION), bodyPresent()) + ); + + // no body - content-type is ignored + assertThat(requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(CURRENT_VERSION), bodyNotPresent()), isCompatible()); + // no body - content-type is ignored + assertThat(requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(PREVIOUS_VERSION), bodyNotPresent()), not(isCompatible())); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(PREVIOUS_VERSION), bodyPresent()) + ); + + assertThat(requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(CURRENT_VERSION), bodyPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(CURRENT_VERSION), bodyNotPresent()), not(isCompatible())); + + // tests when body present and one of the headers missing - versioning is required on both when body is present + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(null), bodyPresent()) + ); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(null), bodyPresent()) + ); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(null), contentTypeHeader(CURRENT_VERSION), bodyPresent()) + ); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(null), contentTypeHeader(PREVIOUS_VERSION), bodyPresent()) + ); + + // tests when body NOT present and one of the headers missing + assertThat(requestWith(acceptHeader(PREVIOUS_VERSION), contentTypeHeader(null), bodyNotPresent()), isCompatible()); + + assertThat(requestWith(acceptHeader(CURRENT_VERSION), contentTypeHeader(null), bodyNotPresent()), not(isCompatible())); + + // body not present - accept header is missing - it will default to Current version. Version on content type is ignored + assertThat(requestWith(acceptHeader(null), contentTypeHeader(PREVIOUS_VERSION), bodyNotPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader(null), contentTypeHeader(CURRENT_VERSION), bodyNotPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader(null), contentTypeHeader(null), bodyNotPresent()), not(isCompatible())); + + // Accept header = application/json means current version. If body is provided then accept and content-Type should be the same + assertThat(requestWith(acceptHeader("application/json"), contentTypeHeader(null), bodyNotPresent()), not(isCompatible())); + + assertThat( + requestWith(acceptHeader("application/json"), contentTypeHeader("application/json"), bodyPresent()), + not(isCompatible()) + ); + + assertThat(requestWith(acceptHeader(null), contentTypeHeader("application/json"), bodyPresent()), not(isCompatible())); + } + + public void testObsoleteVersion() { + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(OBSOLETE_VERSION), contentTypeHeader(OBSOLETE_VERSION), bodyPresent()) + ); + + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith(acceptHeader(OBSOLETE_VERSION), contentTypeHeader(null), bodyNotPresent()) + ); + } + + public void testMediaTypeCombinations() { + // body not present - ignore content-type + assertThat(requestWith(acceptHeader(null), contentTypeHeader(PREVIOUS_VERSION), bodyNotPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader(null), contentTypeHeader("application/json"), bodyNotPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader("*/*"), contentTypeHeader("application/json"), bodyNotPresent()), not(isCompatible())); + + // this is for instance used by SQL + assertThat( + requestWith(acceptHeader("application/json"), contentTypeHeader("application/cbor"), bodyPresent()), + not(isCompatible()) + ); + + assertThat( + requestWith( + acceptHeader("application/vnd.elasticsearch+json;compatible-with=7"), + contentTypeHeader("application/vnd.elasticsearch+cbor;compatible-with=7"), + bodyPresent() + ), + isCompatible() + ); + + // different versions on different media types + expectThrows( + ElasticsearchStatusException.class, + () -> requestWith( + acceptHeader("application/vnd.elasticsearch+json;compatible-with=7"), + contentTypeHeader("application/vnd.elasticsearch+cbor;compatible-with=8"), + bodyPresent() + ) + ); + } + + public void testTextMediaTypes() { + assertThat( + requestWith(acceptHeader("text/tab-separated-values"), contentTypeHeader("application/json"), bodyNotPresent()), + not(isCompatible()) + ); + + assertThat(requestWith(acceptHeader("text/plain"), contentTypeHeader("application/json"), bodyNotPresent()), not(isCompatible())); + + assertThat(requestWith(acceptHeader("text/csv"), contentTypeHeader("application/json"), bodyNotPresent()), not(isCompatible())); + + // versioned + // assertThat( + // requestWith( + // acceptHeader("text/vnd.elasticsearch+tab-separated-values;compatible-with=7"), + // contentTypeHeader(7), + // bodyNotPresent() + // ), + // isCompatible() + // ); + // + // assertThat( + // requestWith(acceptHeader("text/vnd.elasticsearch+plain;compatible-with=7"), contentTypeHeader(7), bodyNotPresent()), + // isCompatible() + // ); + // + // assertThat( + // requestWith(acceptHeader("text/vnd.elasticsearch+csv;compatible-with=7"), contentTypeHeader(7), bodyNotPresent()), + // isCompatible() + // ); + } + + private Matcher isCompatible() { + return requestHasVersion(PREVIOUS_VERSION); + } + + private Matcher requestHasVersion(int version) { + return ElasticsearchMatchers.HasPropertyLambdaMatcher.hasProperty(v -> (int) v.major, equalTo(version)); + } + + private String bodyNotPresent() { + return ""; + } + + private String bodyPresent() { + return "some body"; + } + + private String contentTypeHeader(int version) { + return mediaType(String.valueOf(version)); + } + + private String acceptHeader(int version) { + return mediaType(String.valueOf(version)); + } + + private String acceptHeader(String value) { + return value; + } + + private String contentTypeHeader(String value) { + return value; + } + + private String mediaType(String version) { + if (version != null) { + return "application/vnd.elasticsearch+json;compatible-with=" + version; + } + return null; + } + + private Version requestWith(String accept, String contentType, String body) { + return compatibleVersionPlugin.getCompatibleVersion(accept, contentType, body.isEmpty() == false); + } + +}