The REST Layer
The REST layer is the edge of OpenSearch — the HTTP front door on port 9200
where every client request arrives. Its job is narrow and well-defined: receive
an HTTP request, route it to the right handler based on method and path, parse
its body and parameters into a typed action request, hand that to the
NodeClient, and render the eventual response (or error) back as HTTP. It does
not execute the business logic; it delegates everything to the action
framework (action-framework.md) which runs over the
transport layer (transport-layer.md). This chapter walks
RestController dispatch, the BaseRestHandler contract, request parsing via
XContent, response/error rendering, and how a handler is registered by a plugin.
After this chapter you should be able to: write a BaseRestHandler and register
it; explain the prepareRequest → RestChannelConsumer two-step and why it
exists; parse a JSON body with XContent; and trace a GET _search from HTTP
bytes to a TransportSearchAction call.
Note: Port 9200 is HTTP. Port 9300 is the transport protocol between nodes. This chapter is about 9200; the moment a REST handler calls
NodeClient.execute(...), control crosses into the action/transport world.
The classes
find server/src/main/java/org/opensearch/rest -name "RestController.java"
find server/src/main/java/org/opensearch/rest -name "BaseRestHandler.java"
grep -n "class RestController\|registerHandler\|dispatchRequest\|class MethodHandlers" \
server/src/main/java/org/opensearch/rest/RestController.java
| Class | Role |
|---|---|
RestController | The router. Holds the trie of (method, path) → handler. Receives every HTTP request and dispatches it; renders errors. |
MethodHandlers | The per-path bucket mapping each HTTP method to its handler. |
RestHandler | The base interface: handleRequest(RestRequest, RestChannel, NodeClient). |
BaseRestHandler | The class you almost always extend. Splits handling into prepareRequest(...) (parse) and a returned RestChannelConsumer (execute). Tracks consumed params. |
RestRequest | The parsed HTTP request: method, path, query params, headers, body as BytesReference, and an XContent parser. |
RestChannel | The reply handle: sendResponse(RestResponse). |
RestResponse / BytesRestResponse | The HTTP response (status + content type + body bytes). BytesRestResponse is the common concrete one. |
NodeClient | The in-process entry point to the action framework: execute(ActionType, request, listener). |
flowchart LR
HTTP["HTTP request :9200"] --> HC[HttpChannel / Netty]
HC --> RC[RestController.dispatchRequest]
RC -->|"match (method, path)"| MH[MethodHandlers]
MH --> H["BaseRestHandler.handleRequest"]
H -->|prepareRequest| PR[parse params + body]
PR --> CC[RestChannelConsumer]
CC --> NC[NodeClient.execute]
NC --> TA[TransportAction]
TA -->|ActionListener| RTL[RestToXContentListener]
RTL --> Channel[RestChannel.sendResponse]
Channel --> HTTP
Dispatch: how RestController routes
RestController holds a path trie. Each handler declares its routes() — the
(method, path) pairs it serves, including templated path segments like
/{index}/_search.
grep -n "registerHandler\|routes()\|class Route\|PathTrie" \
server/src/main/java/org/opensearch/rest/RestController.java
grep -n "public List<Route> routes" server/src/main/java/org/opensearch/rest/action/search/RestSearchAction.java
On each request, dispatchRequest:
- Matches the method+path to a
RestHandler(via the trie /MethodHandlers). - Applies cross-cutting concerns (headers, deprecation, content-type checks).
- Calls
handler.handleRequest(request, channel, client). - If nothing matches, renders a
400/405/404(e.g. method not allowed lists the allowed methods for that path).
Note: Path templating and the trie are why
GET /my-index/_searchandGET /_searchcan resolve to the same handler with the index either bound or absent. The handler readsrequest.param("index").
The prepareRequest → RestChannelConsumer two-step
BaseRestHandler deliberately splits handling into two phases:
grep -n "protected abstract RestChannelConsumer prepareRequest\|handleRequest\|unrecognized\|consumedParams\|interface RestChannelConsumer" \
server/src/main/java/org/opensearch/rest/BaseRestHandler.java
@Override
protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
// PHASE 1 (this thread): parse params + body into a typed action request.
SearchRequest searchRequest = new SearchRequest();
request.withContentOrSourceParamParserOrNull(parser -> parseSearchRequest(searchRequest, request, parser));
// PHASE 2 (returned lambda): actually execute, async.
return channel -> client.execute(SearchAction.INSTANCE, searchRequest,
new RestToXContentListener<>(channel));
}
Why two phases?
| Phase | Runs on | Purpose | Failure behavior |
|---|---|---|---|
prepareRequest (parse) | the HTTP worker thread, synchronously | Validate and build the action request. Read every param here. | A parse error throws before any execution — clean 400. |
RestChannelConsumer (execute) | typically off-thread via the action framework | Submit the action and render the result asynchronously. | Errors flow to the ActionListener and become an HTTP error response. |
This split guarantees that all request parsing happens before any execution.
If parsing fails, you get a clean client error and have done no work. It also
lets BaseRestHandler enforce that every query parameter was consumed: an
unrecognized param produces request [GET /...] contains unrecognized parameter: [...] rather than being silently ignored.
Warning: You must read (consume) every parameter you accept inside
prepareRequest.BaseRestHandlerrecords consumed params and rejects the request if any are left over. Forgetting to consume a param you support causes a spurious "unrecognized parameter" rejection; accepting a param you never read lets typos pass silently. Read them all, even ones you ignore.
Parsing the body: XContent
Request bodies are JSON (or YAML/CBOR/SMILE — XContent abstracts the format).
Parsing goes through libs/x-content:
find libs/x-content -name "XContentParser.java" -o -name "NamedXContentRegistry.java"
grep -n "withContentOrSourceParamParserOrNull\|contentParser\|requiredContent" \
server/src/main/java/org/opensearch/rest/RestRequest.java
| Concept | Class | Role |
|---|---|---|
| Format-agnostic parser | XContentParser | Token-stream parser (nextToken(), currentName(), text(), intValue()). |
| Format detection | XContentType / XContentFactory | Detects JSON/YAML/CBOR/SMILE from content type or sniffing. |
| Polymorphic parsing | NamedXContentRegistry | The XContent analog of NamedWriteableRegistry: parse a named sub-object (e.g. a query named bool) by looking up its parser. Assembled from core + plugins. |
| Declarative parsing | ObjectParser / ConstructingObjectParser | Map JSON fields to setters/constructor args declaratively, with validation. Preferred for new code. |
NamedXContentRegistry is what lets {"query": {"bool": {...}}} parse into a
BoolQueryBuilder without the parser hardcoding every query type. Plugins add
entries via getNamedXContent(); see plugin-architecture.md
and query-dsl-querybuilders.md.
Rendering the response and errors
A handler does not build HTTP directly; it gives the action framework an
ActionListener that knows how to render. The common ones:
find server -name "RestToXContentListener.java" -o -name "RestActionListener.java" -o -name "RestStatusToXContentListener.java"
grep -n "class RestToXContentListener\|buildResponse\|onResponse\|onFailure" \
server/src/main/java/org/opensearch/rest/action/RestToXContentListener.java
| Listener | Use |
|---|---|
RestActionListener | Base: turns onFailure into a rendered error response on the channel. |
RestToXContentListener<T extends ToXContent> | The workhorse: serializes a ToXContentObject response to the negotiated XContent format with the right status. |
RestStatusToXContentListener | Like above but derives the HTTP status from the response (e.g. 201 Created for index). |
Errors are rendered by BytesRestResponse, which knows how to turn an exception
into a structured JSON error with the right HTTP status (RestStatus):
grep -n "class BytesRestResponse\|build.*Exception\|status()\|RestStatus" \
server/src/main/java/org/opensearch/rest/BytesRestResponse.java
grep -n "enum RestStatus\|BAD_REQUEST\|NOT_FOUND\|INTERNAL_SERVER_ERROR\|TOO_MANY_REQUESTS" \
libs/core/src/main/java/org/opensearch/core/rest/RestStatus.java
The exception-to-status mapping is why a missing index returns 404, a malformed
query returns 400, and a circuit-breaker trip returns 429
(circuit-breakers-memory.md).
Registering a handler (the plugin seam)
Core REST handlers are registered in ActionModule. Plugin handlers are
contributed via ActionPlugin.getRestHandlers(...):
grep -n "registerHandler\|getRestHandlers" \
server/src/main/java/org/opensearch/action/ActionModule.java
grep -n "getRestHandlers" server/src/main/java/org/opensearch/plugins/ActionPlugin.java
// In your plugin:
@Override
public List<RestHandler> getRestHandlers(Settings settings, RestController restController,
ClusterSettings clusterSettings, IndexScopedSettings indexScopedSettings,
SettingsFilter settingsFilter, IndexNameExpressionResolver resolver,
Supplier<DiscoveryNodes> nodesInCluster) {
return List.of(new RestMyAction());
}
This is exactly what Level 3 lab 3.3
builds. The handler's routes() declare the paths; ActionModule/RestController
wire them into the trie at startup.
Worked example: RestSearchAction
find server -name "RestSearchAction.java"
grep -n "routes()\|prepareRequest\|parseSearchRequest\|SearchAction.INSTANCE\|RestToXContentListener\|RestStatusToXContentListener" \
server/src/main/java/org/opensearch/rest/action/search/RestSearchAction.java
The path of a GET /orders/_search:
sequenceDiagram
participant HTTP as HTTP :9200
participant RC as RestController
participant RSA as RestSearchAction
participant NC as NodeClient
participant TSA as TransportSearchAction
HTTP->>RC: GET /orders/_search {query}
RC->>RSA: handleRequest (matched route)
RSA->>RSA: prepareRequest: parseSearchRequest (XContent + params)
RSA-->>RC: RestChannelConsumer
RC->>NC: consumer.accept(channel) -> client.execute(SearchAction.INSTANCE, searchRequest, RestToXContentListener)
NC->>TSA: dispatch to TransportSearchAction
TSA-->>NC: SearchResponse (async)
NC->>RSA: listener.onResponse(SearchResponse)
RSA->>HTTP: RestToXContentListener renders JSON + 200
From here the story continues in search-execution.md.
Reading exercise
# 1. Dispatch and routing.
grep -n "dispatchRequest\|tryAllHandlers\|handleBadRequest\|PathTrie" \
server/src/main/java/org/opensearch/rest/RestController.java
# 2. The two-step contract.
grep -n "prepareRequest\|RestChannelConsumer\|unrecognized\|consumeParam" \
server/src/main/java/org/opensearch/rest/BaseRestHandler.java
# 3. A real handler end to end.
sed -n '1,120p' server/src/main/java/org/opensearch/rest/action/search/RestSearchAction.java
# 4. Error rendering.
grep -n "RestStatus\|build\|XContentBuilder" \
server/src/main/java/org/opensearch/rest/BytesRestResponse.java
# 5. Tests.
./gradlew :server:test --tests "org.opensearch.rest.RestControllerTests"
./gradlew :server:test --tests "org.opensearch.rest.action.search.RestSearchActionTests"
Answer:
- How does
RestControllerdecide which handler servesGET /a/_search? What does it return if the path matches but the method does not? - Why is handling split into
prepareRequestand the returnedRestChannelConsumer? What guarantee does the split give about parse errors? - What does
BaseRestHandlerdo with a query parameter you accept but never consume? With one the handler does not recognize at all? - What does
NamedXContentRegistryenable, and how is it the XContent twin ofNamedWriteableRegistryfrom the transport layer? - Trace how a missing-index error becomes an HTTP
404. Which class maps the exception to the status? - In
RestSearchAction, find where the parsedSearchRequestis handed to the action framework. WhichActionTypeand which listener are used?
Common bugs and symptoms
| Symptom | Root cause | Where to look |
|---|---|---|
contains unrecognized parameter: [foo] for a param you support | Param not consumed in prepareRequest | request.param("foo") missing; BaseRestHandler consumed-param tracking |
| Body silently ignored | Parsed on the wrong content path, or withContentOrSourceParamParserOrNull not called | RestRequest content accessors; XContent parsing |
415 Unsupported Media Type | Missing/wrong Content-Type header | RestRequest/RestController content-type checks |
| Plugin REST route returns 404 | Handler not returned from getRestHandlers, or wrong routes() | ActionPlugin.getRestHandlers; the handler's routes() |
| Error returns 500 where 400 was right | Threw a generic exception instead of one with a RestStatus | use IllegalArgumentException/*Exception with proper status; BytesRestResponse |
{"query":{"customq":...}} fails to parse | Query type not in NamedXContentRegistry (plugin not installed) | getNamedXContent(); query-dsl-querybuilders.md |
Validation: prove you understand this
- Draw the path from HTTP bytes on 9200 to a
NodeClient.executecall, naming every class an OpenSearch request touches. - Explain the
prepareRequest/RestChannelConsumersplit and the exact guarantee it makes about when parse errors occur. - State the rule about consuming query parameters and the two failure modes of getting it wrong.
- Explain
NamedXContentRegistryand give a concrete request body whose parsing depends on it. - Map three errors (missing index, malformed query, breaker trip) to their HTTP statuses and name the class that performs the mapping.
- From memory, write the
getRestHandlerssignature a plugin overrides to add a REST endpoint, and name whatroutes()on the handler must return.