Skip to content

Commit 8aa0a5c

Browse files
authored
Improve REST error handling when endpoint does not support HTTP verb, add OPTIONS support (#24437)
* Improved REST endpoint exception handling, see #15335 Also improved OPTIONS http method handling to better conform with the http spec. * Tidied up formatting and comments See #15335 * Tests for #15335 * Cleaned up comments, added section number * Swapped out tab indents for space indents * Test class now extends ESSingleNodeTestCase * Capture RestResponse so it can be examined in test cases Simple addition to surface the RestResponse object so we can run tests against it (see issue #15335). * Refactored class name, included feedback See #15335. * Unit test for REST error handling enhancements Randomizing unit test for enhanced REST response error handling. See issue #15335 for more details. * Cleaned up formatting * New constructor to set HTTP method Constructor added to support RestController test cases. * Refactored FakeRestRequest, streamlined test case. * Cleaned up conflicts * Tests for #15335 * Added functionality to ignore or include path wildcards See #15335 * Further enhancements to request handling Refactored executeHandler to prioritize explicit path matches. See #15335 for more information. * Cosmetic fixes * Refactored method handlers * Removed redundant import * Updated integration tests * Refactoring to address issue #17853 * Cleaned up test assertions * Fixed edge case if OPTIONS method randomly selected as invalid method In this test, an OPTIONS method request is valid, and should not return a 405 error. * Remove redundant static modifier * Hook the multiple PathTrie attempts into RestHandler.dispatchRequest * Add missing space * Correctly retrieve new handler for each Trie strategy * Only copy headers to threadcontext once * Fix test after REST header copying moved higher up * Restore original params when trying the next trie candidate * Remove OPTIONS for invalidHttpMethodArray so a 405 is guaranteed in tests * Re-add the fix I already added and got removed during merge :-/ * Add missing GET method to test * Add documentation to migration guide about breaking 404 -> 405 changes * Explain boolean response, pull into local var * fixup! Explain boolean response, pull into local var * Encapsulate multiple HTTP methods into PathTrie<MethodHandlers> * Add PathTrie.retrieveAll where all matching modes can be retrieved Then TrieMatchingMode can be package private and not leak into RestController * Include body of error with 405 responses to give hint about valid methods * Fix missing usageService handler addition I accidentally removed this :X * Initialize PathTrieIterator modes with Arrays.asList * Use "== false" instead of ! * Missing paren :-/
1 parent 1ff2c13 commit 8aa0a5c

File tree

6 files changed

+369
-19
lines changed

6 files changed

+369
-19
lines changed

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

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@
5656
import java.util.function.UnaryOperator;
5757

5858
import static org.elasticsearch.rest.RestStatus.BAD_REQUEST;
59+
import static org.elasticsearch.rest.RestStatus.METHOD_NOT_ALLOWED;
5960
import static org.elasticsearch.rest.RestStatus.FORBIDDEN;
6061
import static org.elasticsearch.rest.RestStatus.INTERNAL_SERVER_ERROR;
6162
import static org.elasticsearch.rest.RestStatus.NOT_ACCEPTABLE;
6263
import static org.elasticsearch.rest.RestStatus.OK;
64+
import static org.elasticsearch.rest.BytesRestResponse.TEXT_CONTENT_TYPE;
6365

6466
public class RestController extends AbstractComponent implements HttpServerTransport.Dispatcher {
6567

@@ -141,11 +143,11 @@ public void registerWithDeprecatedHandler(RestRequest.Method method, String path
141143
}
142144

143145
/**
144-
* Registers a REST handler to be executed when the provided method and path match the request.
146+
* Registers a REST handler to be executed when one of the provided methods and path match the request.
145147
*
146-
* @param method GET, POST, etc.
147148
* @param path Path to handle (e.g., "/{index}/{type}/_bulk")
148149
* @param handler The handler to actually execute
150+
* @param method GET, POST, etc.
149151
*/
150152
public void registerHandler(RestRequest.Method method, String path, RestHandler handler) {
151153
if (handler instanceof BaseRestHandler) {
@@ -183,11 +185,8 @@ public void dispatchRequest(RestRequest request, RestChannel channel, ThreadCont
183185
}
184186

185187
@Override
186-
public void dispatchBadRequest(
187-
final RestRequest request,
188-
final RestChannel channel,
189-
final ThreadContext threadContext,
190-
final Throwable cause) {
188+
public void dispatchBadRequest(final RestRequest request, final RestChannel channel,
189+
final ThreadContext threadContext, final Throwable cause) {
191190
try {
192191
final Exception e;
193192
if (cause == null) {
@@ -211,7 +210,7 @@ public void dispatchBadRequest(
211210
* Dispatch the request, if possible, returning true if a response was sent or false otherwise.
212211
*/
213212
boolean dispatchRequest(final RestRequest request, final RestChannel channel, final NodeClient client,
214-
ThreadContext threadContext, final Optional<RestHandler> mHandler) throws Exception {
213+
final Optional<RestHandler> mHandler) throws Exception {
215214
final int contentLength = request.hasContent() ? request.content().length() : 0;
216215

217216
RestChannel responseChannel = channel;
@@ -228,6 +227,7 @@ boolean dispatchRequest(final RestRequest request, final RestChannel channel, fi
228227
"] does not support stream parsing. Use JSON or SMILE instead"));
229228
requestHandled = true;
230229
} else if (mHandler.isPresent()) {
230+
231231
try {
232232
if (canTripCircuitBreaker(mHandler)) {
233233
inFlightRequestsBreaker(circuitBreakerService).addEstimateBytesAndMaybeBreak(contentLength, "<http_request>");
@@ -246,10 +246,19 @@ boolean dispatchRequest(final RestRequest request, final RestChannel channel, fi
246246
requestHandled = true;
247247
}
248248
} else {
249-
if (request.method() == RestRequest.Method.OPTIONS) {
250-
// when we have OPTIONS request, simply send OK by default (with the Access Control Origin header which gets automatically added)
251-
252-
channel.sendResponse(new BytesRestResponse(OK, BytesRestResponse.TEXT_CONTENT_TYPE, BytesArray.EMPTY));
249+
// Get the map of matching handlers for a request, for the full set of HTTP methods.
250+
final Set<RestRequest.Method> validMethodSet = getValidHandlerMethodSet(request);
251+
if (validMethodSet.size() > 0
252+
&& validMethodSet.contains(request.method()) == false
253+
&& request.method() != RestRequest.Method.OPTIONS) {
254+
// If an alternative handler for an explicit path is registered to a
255+
// different HTTP method than the one supplied - return a 405 Method
256+
// Not Allowed error.
257+
handleUnsupportedHttpMethod(request, channel, validMethodSet);
258+
requestHandled = true;
259+
} else if (validMethodSet.contains(request.method()) == false
260+
&& (request.method() == RestRequest.Method.OPTIONS)) {
261+
handleOptionsRequest(request, channel, validMethodSet);
253262
requestHandled = true;
254263
} else {
255264
requestHandled = false;
@@ -263,9 +272,9 @@ boolean dispatchRequest(final RestRequest request, final RestChannel channel, fi
263272
* If a request contains content, this method will return {@code true} if the {@code Content-Type} header is present, matches an
264273
* {@link XContentType} or the handler supports a content stream and the content type header is for newline delimited JSON,
265274
*/
266-
private boolean hasContentType(final RestRequest restRequest, final RestHandler restHandler) {
275+
private static boolean hasContentType(final RestRequest restRequest, final RestHandler restHandler) {
267276
if (restRequest.getXContentType() == null) {
268-
if (restHandler != null && restHandler.supportsContentStream() && restRequest.header("Content-Type") != null) {
277+
if (restHandler.supportsContentStream() && restRequest.header("Content-Type") != null) {
269278
final String lowercaseMediaType = restRequest.header("Content-Type").toLowerCase(Locale.ROOT);
270279
// we also support newline delimited JSON: http://specs.okfnlabs.org/ndjson/
271280
if (lowercaseMediaType.equals("application/x-ndjson")) {
@@ -325,7 +334,7 @@ void tryAllHandlers(final RestRequest request, final RestChannel channel, final
325334
Iterator<MethodHandlers> allHandlers = getAllHandlers(request);
326335
for (Iterator<MethodHandlers> it = allHandlers; it.hasNext(); ) {
327336
final Optional<RestHandler> mHandler = Optional.ofNullable(it.next()).flatMap(mh -> mh.getHandler(request.method()));
328-
requestHandled = dispatchRequest(request, channel, client, threadContext, mHandler);
337+
requestHandled = dispatchRequest(request, channel, client, mHandler);
329338
if (requestHandled) {
330339
break;
331340
}
@@ -349,6 +358,47 @@ Iterator<MethodHandlers> getAllHandlers(final RestRequest request) {
349358
});
350359
}
351360

361+
/**
362+
* Handle requests to a valid REST endpoint using an unsupported HTTP
363+
* method. A 405 HTTP response code is returned, and the response 'Allow'
364+
* header includes a list of valid HTTP methods for the endpoint (see
365+
* <a href="https://tools.ietf.org/html/rfc2616#section-10.4.6">HTTP/1.1 -
366+
* 10.4.6 - 405 Method Not Allowed</a>).
367+
*/
368+
private void handleUnsupportedHttpMethod(RestRequest request, RestChannel channel, Set<RestRequest.Method> validMethodSet) {
369+
try {
370+
BytesRestResponse bytesRestResponse = BytesRestResponse.createSimpleErrorResponse(channel, METHOD_NOT_ALLOWED,
371+
"Incorrect HTTP method for uri [" + request.uri() + "] and method [" + request.method() + "], allowed: " + validMethodSet);
372+
bytesRestResponse.addHeader("Allow", Strings.collectionToDelimitedString(validMethodSet, ","));
373+
channel.sendResponse(bytesRestResponse);
374+
} catch (final IOException e) {
375+
logger.warn("failed to send bad request response", e);
376+
channel.sendResponse(new BytesRestResponse(INTERNAL_SERVER_ERROR, BytesRestResponse.TEXT_CONTENT_TYPE, BytesArray.EMPTY));
377+
}
378+
}
379+
380+
/**
381+
* Handle HTTP OPTIONS requests to a valid REST endpoint. A 200 HTTP
382+
* response code is returned, and the response 'Allow' header includes a
383+
* list of valid HTTP methods for the endpoint (see
384+
* <a href="https://tools.ietf.org/html/rfc2616#section-9.2">HTTP/1.1 - 9.2
385+
* - Options</a>).
386+
*/
387+
private void handleOptionsRequest(RestRequest request, RestChannel channel, Set<RestRequest.Method> validMethodSet) {
388+
if (request.method() == RestRequest.Method.OPTIONS && validMethodSet.size() > 0) {
389+
BytesRestResponse bytesRestResponse = new BytesRestResponse(OK, TEXT_CONTENT_TYPE, BytesArray.EMPTY);
390+
bytesRestResponse.addHeader("Allow", Strings.collectionToDelimitedString(validMethodSet, ","));
391+
channel.sendResponse(bytesRestResponse);
392+
} else if (request.method() == RestRequest.Method.OPTIONS && validMethodSet.size() == 0) {
393+
/*
394+
* When we have an OPTIONS HTTP request and no valid handlers,
395+
* simply send OK by default (with the Access Control Origin header
396+
* which gets automatically added).
397+
*/
398+
channel.sendResponse(new BytesRestResponse(OK, TEXT_CONTENT_TYPE, BytesArray.EMPTY));
399+
}
400+
}
401+
352402
/**
353403
* Handle a requests with no candidate handlers (return a 400 Bad Request
354404
* error).

core/src/test/java/org/elasticsearch/rest/RestControllerTests.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,7 @@ public void testRestHandlerWrapper() throws Exception {
212212
final RestController restController = new RestController(Settings.EMPTY, Collections.emptySet(), wrapper, null,
213213
circuitBreakerService, usageService);
214214
final ThreadContext threadContext = new ThreadContext(Settings.EMPTY);
215-
restController.dispatchRequest(new FakeRestRequest.Builder(xContentRegistry()).build(),
216-
null, null, threadContext, Optional.of(handler));
215+
restController.dispatchRequest(new FakeRestRequest.Builder(xContentRegistry()).build(), null, null, Optional.of(handler));
217216
assertTrue(wrapperCalled.get());
218217
assertFalse(handlerCalled.get());
219218
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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.rest;
21+
22+
import org.elasticsearch.client.node.NodeClient;
23+
import org.elasticsearch.common.bytes.BytesReference;
24+
import org.elasticsearch.common.settings.ClusterSettings;
25+
import org.elasticsearch.common.settings.Settings;
26+
import org.elasticsearch.common.util.concurrent.ThreadContext;
27+
import org.elasticsearch.indices.breaker.CircuitBreakerService;
28+
import org.elasticsearch.indices.breaker.HierarchyCircuitBreakerService;
29+
import org.elasticsearch.test.ESTestCase;
30+
import org.elasticsearch.test.rest.FakeRestChannel;
31+
import org.elasticsearch.test.rest.FakeRestRequest;
32+
import org.elasticsearch.usage.UsageService;
33+
34+
import java.util.ArrayList;
35+
import java.util.Arrays;
36+
import java.util.Collections;
37+
import java.util.List;
38+
import java.util.stream.Collectors;
39+
40+
import static org.hamcrest.CoreMatchers.notNullValue;
41+
import static org.hamcrest.Matchers.containsInAnyOrder;
42+
import static org.hamcrest.Matchers.is;
43+
import static org.mockito.Mockito.mock;
44+
45+
public class RestHttpResponseHeadersTests extends ESTestCase {
46+
47+
/**
48+
* For requests to a valid REST endpoint using an unsupported HTTP method,
49+
* verify that a 405 HTTP response code is returned, and that the response
50+
* 'Allow' header includes a list of valid HTTP methods for the endpoint
51+
* (see
52+
* <a href="https://tools.ietf.org/html/rfc2616#section-10.4.6">HTTP/1.1 -
53+
* 10.4.6 - 405 Method Not Allowed</a>).
54+
*/
55+
public void testUnsupportedMethodResponseHttpHeader() throws Exception {
56+
57+
/*
58+
* Generate a random set of candidate valid HTTP methods to register
59+
* with the test RestController endpoint. Enums are returned in the
60+
* order they are declared, so the first step is to shuffle the HTTP
61+
* method list, passing in the RandomizedContext's Random instance,
62+
* before picking out a candidate sublist.
63+
*/
64+
List<RestRequest.Method> validHttpMethodArray = new ArrayList<RestRequest.Method>(Arrays.asList(RestRequest.Method.values()));
65+
validHttpMethodArray.remove(RestRequest.Method.OPTIONS);
66+
Collections.shuffle(validHttpMethodArray, random());
67+
68+
/*
69+
* The upper bound of the potential sublist is one less than the size of
70+
* the array, so we are guaranteed at least one invalid method to test.
71+
*/
72+
validHttpMethodArray = validHttpMethodArray.subList(0, randomIntBetween(1, validHttpMethodArray.size() - 1));
73+
assert(validHttpMethodArray.size() > 0);
74+
assert(validHttpMethodArray.size() < RestRequest.Method.values().length);
75+
76+
/*
77+
* Generate an inverse list of one or more candidate invalid HTTP
78+
* methods, so we have a candidate method to fire at the test endpoint.
79+
*/
80+
List<RestRequest.Method> invalidHttpMethodArray = new ArrayList<RestRequest.Method>(Arrays.asList(RestRequest.Method.values()));
81+
invalidHttpMethodArray.removeAll(validHttpMethodArray);
82+
// Remove OPTIONS, or else we'll get a 200 instead of 405
83+
invalidHttpMethodArray.remove(RestRequest.Method.OPTIONS);
84+
assert(invalidHttpMethodArray.size() > 0);
85+
86+
// Initialize test candidate RestController
87+
CircuitBreakerService circuitBreakerService = new HierarchyCircuitBreakerService(Settings.EMPTY,
88+
new ClusterSettings(Settings.EMPTY, ClusterSettings.BUILT_IN_CLUSTER_SETTINGS));
89+
90+
final Settings settings = Settings.EMPTY;
91+
UsageService usageService = new UsageService(settings);
92+
RestController restController = new RestController(settings, Collections.emptySet(),
93+
null, null, circuitBreakerService, usageService);
94+
95+
// A basic RestHandler handles requests to the endpoint
96+
RestHandler restHandler = new RestHandler() {
97+
98+
@Override
99+
public void handleRequest(RestRequest request, RestChannel channel, NodeClient client) throws Exception {
100+
channel.sendResponse(new TestResponse());
101+
}
102+
103+
};
104+
105+
// Register valid test handlers with test RestController
106+
for (RestRequest.Method method : validHttpMethodArray) {
107+
restController.registerHandler(method, "/", restHandler);
108+
}
109+
110+
// Generate a test request with an invalid HTTP method
111+
FakeRestRequest.Builder fakeRestRequestBuilder = new FakeRestRequest.Builder(xContentRegistry());
112+
fakeRestRequestBuilder.withMethod(invalidHttpMethodArray.get(0));
113+
RestRequest restRequest = fakeRestRequestBuilder.build();
114+
115+
// Send the request and verify the response status code
116+
FakeRestChannel restChannel = new FakeRestChannel(restRequest, false, 1);
117+
NodeClient client = mock(NodeClient.class);
118+
restController.dispatchRequest(restRequest, restChannel, new ThreadContext(Settings.EMPTY));
119+
assertThat(restChannel.capturedResponse().status().getStatus(), is(405));
120+
121+
/*
122+
* Verify the response allow header contains the valid methods for the
123+
* test endpoint
124+
*/
125+
assertThat(restChannel.capturedResponse().getHeaders().get("Allow"), notNullValue());
126+
String responseAllowHeader = restChannel.capturedResponse().getHeaders().get("Allow").get(0);
127+
List<String> responseAllowHeaderArray = Arrays.asList(responseAllowHeader.split(","));
128+
assertThat(responseAllowHeaderArray.size(), is(validHttpMethodArray.size()));
129+
assertThat(responseAllowHeaderArray, containsInAnyOrder(getMethodNameStringArray(validHttpMethodArray).toArray()));
130+
}
131+
132+
private static class TestResponse extends RestResponse {
133+
134+
@Override
135+
public String contentType() {
136+
return null;
137+
}
138+
139+
@Override
140+
public BytesReference content() {
141+
return null;
142+
}
143+
144+
@Override
145+
public RestStatus status() {
146+
return RestStatus.OK;
147+
}
148+
149+
}
150+
151+
/**
152+
* Convert an RestRequest.Method array to a String array, so it can be
153+
* compared with the expected 'Allow' header String array.
154+
*/
155+
private List<String> getMethodNameStringArray(List<RestRequest.Method> methodArray) {
156+
return methodArray.stream().map(method -> method.toString()).collect(Collectors.toList());
157+
}
158+
159+
}

docs/reference/migration/migrate_6_0/rest.asciidoc

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ In previous versions of Elasticsearch, delete by query requests without an expli
6161
were accepted, match_all was used as the default query and all documents were deleted
6262
as a result. From version 6.0.0, delete by query requests require an explicit query.
6363

64-
=== DELETE document calls now implicitly create the type
64+
==== DELETE document calls now implicitly create the type
6565

6666
Running `DELETE index/type/id` now implicitly creates `type` with a default
6767
mapping if it did not exist yet.
@@ -76,8 +76,39 @@ removed.. `GET /_all` can be used to retrieve all aliases, settings, and
7676
mappings for all indices. In order to retrieve only the mappings for an index,
7777
`GET /myindex/_mappings` (or `_aliases`, or `_settings`).
7878

79+
==== Requests to existing endpoints with incorrect HTTP verb now return 405 responses
80+
81+
Issuing a request to an endpoint that exists, but with an incorrect HTTP verb
82+
(such as a `POST` request to `/myindex/_settings`) now returns an HTTP 405
83+
response instead of a 404. An `Allow` header is added to the 405 responses
84+
containing the allowed verbs. For example:
85+
86+
[source,text]
87+
-------------------------------------------
88+
$ curl -v -XPOST 'localhost:9200/my_index/_settings'
89+
* Trying 127.0.0.1...
90+
* TCP_NODELAY set
91+
* Connected to localhost (127.0.0.1) port 9200 (#0)
92+
> POST /my_index/_settings HTTP/1.1
93+
> Host: localhost:9200
94+
> User-Agent: curl/7.51.0
95+
> Accept: */*
96+
>
97+
< HTTP/1.1 405 Method Not Allowed
98+
< Allow: PUT,GET
99+
< content-type: application/json; charset=UTF-8
100+
< content-length: 134
101+
<
102+
{
103+
"error" : "Incorrect HTTP method for uri [/my_index/_settings] and method [POST], allowed: [PUT, GET]",
104+
"status" : 405
105+
}
106+
* Curl_http_done: called premature == 0
107+
* Connection #0 to host localhost left intact
108+
--------------------------------------------
109+
79110
==== Dissallow using `_cache` and `_cache_key`
80111

81112
The `_cache` and `_cache_key` options in queries have been deprecated since version 2.0.0 and
82113
have been ignored since then, issuing a deprecation warning. These options have now been completely
83-
removed, so using them now will throw an error.
114+
removed, so using them now will throw an error.

0 commit comments

Comments
 (0)