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
ClassRole
RestControllerThe router. Holds the trie of (method, path) → handler. Receives every HTTP request and dispatches it; renders errors.
MethodHandlersThe per-path bucket mapping each HTTP method to its handler.
RestHandlerThe base interface: handleRequest(RestRequest, RestChannel, NodeClient).
BaseRestHandlerThe class you almost always extend. Splits handling into prepareRequest(...) (parse) and a returned RestChannelConsumer (execute). Tracks consumed params.
RestRequestThe parsed HTTP request: method, path, query params, headers, body as BytesReference, and an XContent parser.
RestChannelThe reply handle: sendResponse(RestResponse).
RestResponse / BytesRestResponseThe HTTP response (status + content type + body bytes). BytesRestResponse is the common concrete one.
NodeClientThe 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:

  1. Matches the method+path to a RestHandler (via the trie / MethodHandlers).
  2. Applies cross-cutting concerns (headers, deprecation, content-type checks).
  3. Calls handler.handleRequest(request, channel, client).
  4. 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/_search and GET /_search can resolve to the same handler with the index either bound or absent. The handler reads request.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?

PhaseRuns onPurposeFailure behavior
prepareRequest (parse)the HTTP worker thread, synchronouslyValidate 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 frameworkSubmit 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. BaseRestHandler records 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
ConceptClassRole
Format-agnostic parserXContentParserToken-stream parser (nextToken(), currentName(), text(), intValue()).
Format detectionXContentType / XContentFactoryDetects JSON/YAML/CBOR/SMILE from content type or sniffing.
Polymorphic parsingNamedXContentRegistryThe 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 parsingObjectParser / ConstructingObjectParserMap 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
ListenerUse
RestActionListenerBase: 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.
RestStatusToXContentListenerLike 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:

  1. How does RestController decide which handler serves GET /a/_search? What does it return if the path matches but the method does not?
  2. Why is handling split into prepareRequest and the returned RestChannelConsumer? What guarantee does the split give about parse errors?
  3. What does BaseRestHandler do with a query parameter you accept but never consume? With one the handler does not recognize at all?
  4. What does NamedXContentRegistry enable, and how is it the XContent twin of NamedWriteableRegistry from the transport layer?
  5. Trace how a missing-index error becomes an HTTP 404. Which class maps the exception to the status?
  6. In RestSearchAction, find where the parsed SearchRequest is handed to the action framework. Which ActionType and which listener are used?

Common bugs and symptoms

SymptomRoot causeWhere to look
contains unrecognized parameter: [foo] for a param you supportParam not consumed in prepareRequestrequest.param("foo") missing; BaseRestHandler consumed-param tracking
Body silently ignoredParsed on the wrong content path, or withContentOrSourceParamParserOrNull not calledRestRequest content accessors; XContent parsing
415 Unsupported Media TypeMissing/wrong Content-Type headerRestRequest/RestController content-type checks
Plugin REST route returns 404Handler not returned from getRestHandlers, or wrong routes()ActionPlugin.getRestHandlers; the handler's routes()
Error returns 500 where 400 was rightThrew a generic exception instead of one with a RestStatususe IllegalArgumentException/*Exception with proper status; BytesRestResponse
{"query":{"customq":...}} fails to parseQuery type not in NamedXContentRegistry (plugin not installed)getNamedXContent(); query-dsl-querybuilders.md

Validation: prove you understand this

  1. Draw the path from HTTP bytes on 9200 to a NodeClient.execute call, naming every class an OpenSearch request touches.
  2. Explain the prepareRequest / RestChannelConsumer split and the exact guarantee it makes about when parse errors occur.
  3. State the rule about consuming query parameters and the two failure modes of getting it wrong.
  4. Explain NamedXContentRegistry and give a concrete request body whose parsing depends on it.
  5. Map three errors (missing index, malformed query, breaker trip) to their HTTP statuses and name the class that performs the mapping.
  6. From memory, write the getRestHandlers signature a plugin overrides to add a REST endpoint, and name what routes() on the handler must return.