Skip to content

Commit 91df1b8

Browse files
authored
Introduce Compatible Version plugin (#64481)
A RestCompatibilityPlugin and its xpack implementation allow to calculate a version requested on REST request. It uses accept, content-type headers to return a version. It also performs a validation of allowed combinations of versions and values provided on accept/content-type headers relates #51816
1 parent 7b4fcb6 commit 91df1b8

File tree

9 files changed

+617
-7
lines changed

9 files changed

+617
-7
lines changed

libs/x-content/src/main/java/org/elasticsearch/common/xcontent/ParsedMediaType.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
* @see MediaTypeRegistry
3434
*/
3535
public class ParsedMediaType {
36+
private final String originalHeaderValue;
3637
private final String type;
3738
private final String subType;
3839
private final Map<String, String> parameters;
3940
// tchar pattern as defined by RFC7230 section 3.2.6
4041
private static final Pattern TCHAR_PATTERN = Pattern.compile("[a-zA-z0-9!#$%&'*+\\-.\\^_`|~]+");
4142

42-
private ParsedMediaType(String type, String subType, Map<String, String> parameters) {
43+
private ParsedMediaType(String originalHeaderValue, String type, String subType, Map<String, String> parameters) {
44+
this.originalHeaderValue = originalHeaderValue;
4345
this.type = type;
4446
this.subType = subType;
4547
this.parameters = Collections.unmodifiableMap(parameters);
@@ -79,7 +81,7 @@ public static ParsedMediaType parseMediaType(String headerValue) {
7981
throw new IllegalArgumentException("invalid media-type [" + headerValue + "]");
8082
}
8183
if (elements.length == 1) {
82-
return new ParsedMediaType(splitMediaType[0].trim(), splitMediaType[1].trim(), Collections.emptyMap());
84+
return new ParsedMediaType(headerValue, splitMediaType[0].trim(), splitMediaType[1].trim(), Collections.emptyMap());
8385
} else {
8486
Map<String, String> parameters = new HashMap<>();
8587
for (int i = 1; i < elements.length; i++) {
@@ -96,7 +98,7 @@ public static ParsedMediaType parseMediaType(String headerValue) {
9698
String parameterValue = keyValueParam[1].toLowerCase(Locale.ROOT).trim();
9799
parameters.put(parameterName, parameterValue);
98100
}
99-
return new ParsedMediaType(splitMediaType[0].trim().toLowerCase(Locale.ROOT),
101+
return new ParsedMediaType(headerValue, splitMediaType[0].trim().toLowerCase(Locale.ROOT),
100102
splitMediaType[1].trim().toLowerCase(Locale.ROOT), parameters);
101103
}
102104
}
@@ -144,4 +146,9 @@ private boolean isValidParameter(String paramName, String value, Map<String, Pat
144146
//TODO undefined parameters are allowed until https://github.com/elastic/elasticsearch/issues/63080
145147
return true;
146148
}
149+
150+
@Override
151+
public String toString() {
152+
return originalHeaderValue;
153+
}
147154
}

server/src/main/java/org/elasticsearch/Version.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ public boolean isCompatible(Version version) {
397397
* Returns the minimum version that can be used for compatible REST API
398398
*/
399399
public Version minimumRestCompatibilityVersion() {
400-
return Version.CURRENT.previousMajor();
400+
return this.previousMajor();
401401
}
402402

403403
/**

server/src/main/java/org/elasticsearch/node/Node.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
import org.elasticsearch.plugins.Plugin;
145145
import org.elasticsearch.plugins.PluginsService;
146146
import org.elasticsearch.plugins.RepositoryPlugin;
147+
import org.elasticsearch.plugins.RestCompatibilityPlugin;
147148
import org.elasticsearch.plugins.ScriptPlugin;
148149
import org.elasticsearch.plugins.SearchPlugin;
149150
import org.elasticsearch.plugins.SystemIndexPlugin;
@@ -723,8 +724,16 @@ protected Node(final Environment initialEnvironment,
723724
* package scope for testing
724725
*/
725726
CompatibleVersion getRestCompatibleFunction() {
726-
// TODO PG Until compatible version plugin is implemented, return current version.
727-
return CompatibleVersion.CURRENT_VERSION;
727+
List<RestCompatibilityPlugin> restCompatibilityPlugins = pluginsService.filterPlugins(RestCompatibilityPlugin.class);
728+
final CompatibleVersion compatibleVersion;
729+
if (restCompatibilityPlugins.size() > 1) {
730+
throw new IllegalStateException("Only one RestCompatibilityPlugin is allowed");
731+
} else if (restCompatibilityPlugins.size() == 1) {
732+
compatibleVersion = restCompatibilityPlugins.get(0)::getCompatibleVersion;
733+
} else {
734+
compatibleVersion = CompatibleVersion.CURRENT_VERSION;
735+
}
736+
return compatibleVersion;
728737
}
729738

730739
protected TransportService newTransportService(Settings settings, Transport transport, ThreadPool threadPool,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.plugins;
21+
22+
import org.elasticsearch.Version;
23+
import org.elasticsearch.common.Nullable;
24+
import org.elasticsearch.common.xcontent.ParsedMediaType;
25+
26+
27+
/**
28+
* An extension point for Compatible API plugin implementation.
29+
*/
30+
public interface RestCompatibilityPlugin {
31+
/**
32+
* Returns a version which was requested on Accept and Content-Type headers
33+
*
34+
* @param acceptHeader - a ParsedMediaType parsed from Accept header
35+
* @param contentTypeHeader - a ParsedMediaType parsed from Content-Type header
36+
* @param hasContent - a flag indicating if a request has content
37+
* @return a requested Compatible API Version
38+
*/
39+
Version getCompatibleVersion(@Nullable ParsedMediaType acceptHeader, @Nullable ParsedMediaType contentTypeHeader, boolean hasContent);
40+
}

server/src/main/java/org/elasticsearch/rest/RestController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public class RestController implements HttpServerTransport.Dispatcher {
9191
/** Rest headers that are copied to internal requests made during a rest request. */
9292
private final Set<RestHeaderDefinition> headersToCopy;
9393
private final UsageService usageService;
94-
private CompatibleVersion compatibleVersion;
94+
private final CompatibleVersion compatibleVersion;
9595

9696
public RestController(Set<RestHeaderDefinition> headersToCopy, UnaryOperator<RestHandler> handlerWrapper,
9797
NodeClient client, CircuitBreakerService circuitBreakerService, UsageService usageService,

server/src/test/java/org/elasticsearch/node/NodeTests.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020

2121
import org.apache.lucene.util.LuceneTestCase;
2222
import org.apache.lucene.util.SetOnce;
23+
import org.elasticsearch.Version;
2324
import org.elasticsearch.bootstrap.BootstrapCheck;
2425
import org.elasticsearch.bootstrap.BootstrapContext;
2526
import org.elasticsearch.cluster.ClusterName;
2627
import org.elasticsearch.common.breaker.CircuitBreaker;
2728
import org.elasticsearch.common.network.NetworkModule;
2829
import org.elasticsearch.common.settings.Settings;
2930
import org.elasticsearch.common.transport.BoundTransportAddress;
31+
import org.elasticsearch.common.xcontent.ParsedMediaType;
3032
import org.elasticsearch.env.Environment;
3133
import org.elasticsearch.index.IndexService;
3234
import org.elasticsearch.index.engine.Engine.Searcher;
@@ -36,6 +38,8 @@
3638
import org.elasticsearch.indices.breaker.CircuitBreakerService;
3739
import org.elasticsearch.plugins.CircuitBreakerPlugin;
3840
import org.elasticsearch.plugins.Plugin;
41+
import org.elasticsearch.plugins.RestCompatibilityPlugin;
42+
import org.elasticsearch.rest.CompatibleVersion;
3943
import org.elasticsearch.test.ESTestCase;
4044
import org.elasticsearch.test.InternalTestCluster;
4145
import org.elasticsearch.test.MockHttpTransport;
@@ -339,4 +343,56 @@ public void setCircuitBreaker(CircuitBreaker circuitBreaker) {
339343
myCircuitBreaker.set(circuitBreaker);
340344
}
341345
}
346+
347+
public static class TestRestCompatibility1 extends Plugin implements RestCompatibilityPlugin {
348+
@Override
349+
public Version getCompatibleVersion(ParsedMediaType acceptHeader, ParsedMediaType contentTypeHeader, boolean hasContent) {
350+
return Version.CURRENT.previousMajor();
351+
}
352+
}
353+
354+
public static class TestRestCompatibility2 extends Plugin implements RestCompatibilityPlugin {
355+
@Override
356+
public Version getCompatibleVersion(ParsedMediaType acceptHeader, ParsedMediaType contentTypeHeader, boolean hasContent) {
357+
return null;
358+
}
359+
}
360+
361+
public void testLoadingMultipleRestCompatibilityPlugins() throws IOException {
362+
Settings.Builder settings = baseSettings();
363+
364+
// throw an exception when two plugins are registered
365+
List<Class<? extends Plugin>> plugins = basePlugins();
366+
plugins.add(TestRestCompatibility1.class);
367+
plugins.add(TestRestCompatibility2.class);
368+
369+
IllegalStateException e = expectThrows(IllegalStateException.class, () -> new MockNode(settings.build(), plugins));
370+
assertThat(e.getMessage(), equalTo("Only one RestCompatibilityPlugin is allowed"));
371+
}
372+
373+
public void testCorrectUsageOfRestCompatibilityPlugin() throws IOException {
374+
Settings.Builder settings = baseSettings();
375+
376+
// the correct usage expects one plugin
377+
List<Class<? extends Plugin>> plugins = basePlugins();
378+
plugins.add(TestRestCompatibility1.class);
379+
380+
try (Node node = new MockNode(settings.build(), plugins)) {
381+
CompatibleVersion restCompatibleFunction = node.getRestCompatibleFunction();
382+
assertThat(restCompatibleFunction.get(null, null, false), equalTo(Version.CURRENT.previousMajor()));
383+
}
384+
}
385+
386+
387+
public void testDefaultingRestCompatibilityPlugin() throws IOException {
388+
Settings.Builder settings = baseSettings();
389+
390+
// default to CompatibleVersion.CURRENT_VERSION when no plugins provided
391+
List<Class<? extends Plugin>> plugins = basePlugins();
392+
393+
try (Node node = new MockNode(settings.build(), plugins)) {
394+
CompatibleVersion restCompatibleFunction = node.getRestCompatibleFunction();
395+
assertThat(restCompatibleFunction.get(null, null, false), equalTo(Version.CURRENT));
396+
}
397+
}
342398
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
apply plugin: 'elasticsearch.esplugin'
21+
22+
esplugin {
23+
name 'rest-compatibility'
24+
description 'A plugin for Compatible Rest API'
25+
classname 'org.elasticsearch.compat.CompatibleVersionPlugin'
26+
extendedPlugins = ['x-pack-core']
27+
}
28+
29+
dependencies {
30+
compileOnly project(path: xpackModule('core'), configuration: 'default')
31+
testImplementation project(path: xpackModule('core'), configuration: 'testArtifacts')
32+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
package org.elasticsearch.compat;
7+
8+
import org.elasticsearch.ElasticsearchStatusException;
9+
import org.elasticsearch.Version;
10+
import org.elasticsearch.common.Nullable;
11+
import org.elasticsearch.common.xcontent.MediaType;
12+
import org.elasticsearch.common.xcontent.ParsedMediaType;
13+
import org.elasticsearch.plugins.Plugin;
14+
import org.elasticsearch.plugins.RestCompatibilityPlugin;
15+
import org.elasticsearch.rest.RestStatus;
16+
17+
public class CompatibleVersionPlugin extends Plugin implements RestCompatibilityPlugin {
18+
19+
@Override
20+
public Version getCompatibleVersion(
21+
@Nullable ParsedMediaType acceptHeader,
22+
@Nullable ParsedMediaType contentTypeHeader,
23+
boolean hasContent
24+
) {
25+
Byte aVersion = parseVersion(acceptHeader);
26+
byte acceptVersion = aVersion == null ? Version.CURRENT.major : Integer.valueOf(aVersion).byteValue();
27+
Byte cVersion = parseVersion(contentTypeHeader);
28+
byte contentTypeVersion = cVersion == null ? Version.CURRENT.major : Integer.valueOf(cVersion).byteValue();
29+
30+
// accept version must be current or prior
31+
if (acceptVersion > Version.CURRENT.major || acceptVersion < Version.CURRENT.minimumRestCompatibilityVersion().major) {
32+
throw new ElasticsearchStatusException(
33+
"Accept version must be either version {} or {}, but found {}. Accept={}",
34+
RestStatus.BAD_REQUEST,
35+
Version.CURRENT.major,
36+
Version.CURRENT.minimumRestCompatibilityVersion().major,
37+
acceptVersion,
38+
acceptHeader
39+
);
40+
}
41+
if (hasContent) {
42+
43+
// content-type version must be current or prior
44+
if (contentTypeVersion > Version.CURRENT.major
45+
|| contentTypeVersion < Version.CURRENT.minimumRestCompatibilityVersion().major) {
46+
throw new ElasticsearchStatusException(
47+
"Content-Type version must be either version {} or {}, but found {}. Content-Type={}",
48+
RestStatus.BAD_REQUEST,
49+
Version.CURRENT.major,
50+
Version.CURRENT.minimumRestCompatibilityVersion().major,
51+
contentTypeVersion,
52+
contentTypeHeader
53+
);
54+
}
55+
// if both accept and content-type are sent, the version must match
56+
if (contentTypeVersion != acceptVersion) {
57+
throw new ElasticsearchStatusException(
58+
"A compatible version is required on both Content-Type and Accept headers "
59+
+ "if either one has requested a compatible version "
60+
+ "and the compatible versions must match. Accept={}, Content-Type={}",
61+
RestStatus.BAD_REQUEST,
62+
acceptHeader,
63+
contentTypeHeader
64+
);
65+
}
66+
// both headers should be versioned or none
67+
if ((cVersion == null && aVersion != null) || (aVersion == null && cVersion != null)) {
68+
throw new ElasticsearchStatusException(
69+
"A compatible version is required on both Content-Type and Accept headers "
70+
+ "if either one has requested a compatible version. Accept={}, Content-Type={}",
71+
RestStatus.BAD_REQUEST,
72+
acceptHeader,
73+
contentTypeHeader
74+
);
75+
}
76+
if (contentTypeVersion < Version.CURRENT.major) {
77+
return Version.CURRENT.previousMajor();
78+
}
79+
}
80+
81+
if (acceptVersion < Version.CURRENT.major) {
82+
return Version.CURRENT.previousMajor();
83+
}
84+
85+
return Version.CURRENT;
86+
}
87+
88+
// scope for testing
89+
static Byte parseVersion(ParsedMediaType parsedMediaType) {
90+
if (parsedMediaType != null) {
91+
String version = parsedMediaType.getParameters().get(MediaType.COMPATIBLE_WITH_PARAMETER_NAME);
92+
return version != null ? Byte.parseByte(version) : null;
93+
}
94+
return null;
95+
}
96+
}

0 commit comments

Comments
 (0)