Lab 3.3: Build It — A Custom REST Action Plugin
You have traced the request path twice (Labs 3.1, 3.2). Now you build your own piece of it. In this
lab you implement a minimal OpenSearch plugin that registers a new REST endpoint and a new
transport action, builds it with the opensearch.opensearchplugin Gradle plugin, installs it into
a local distribution, and curls it. This is the smallest possible end-to-end extension, and it
exercises every concept from this level: Plugin, ActionPlugin, RestHandler, ActionType,
Writeable, ToXContent, HandledTransportAction, and NodeClient.
The endpoint you build:
GET /_my/greeting?name=<who> -> {"greeting":"hello, <who>", "node":"<node_name>"}
It greets you and reports which node answered — proving the action ran inside the engine, not in the REST layer.
Background
A plugin extends org.opensearch.plugins.Plugin and opts into extension points by implementing
interfaces. For actions you implement ActionPlugin:
Method (on ActionPlugin) | What you return |
|---|---|
getActions() | List<ActionHandler<Req,Resp>> — bind each ActionType to its TransportAction |
getRestHandlers(...) | List<RestHandler> — your REST endpoints |
The plugin is loaded by PluginsService, gets an isolated classloader, and is described by a
plugin-descriptor.properties file. See the
plugin internals deep dive and the
action framework deep dive.
Why This Lab Matters for Contributors
Most of the OpenSearch ecosystem — security, k-NN, SQL, alerting, ml-commons — is plugins built
exactly this way, in separate repos, against the published org.opensearch:opensearch artifacts.
Even in core, new features frequently arrive as a module or plugin first. Knowing how to register an
action and a handler is table stakes for the plugin labs and a real
asset when you pick up a feature issue.
Prerequisites
- OpenSearch builds locally and you can produce a distro:
./gradlew localDistro(Lab 3.1/3.2). - JDK 21 available to your IDE (the repo bundles its own JDK for the build).
- You know the version you're building against:
grep -n "^opensearch" buildSrc/version.properties 2>/dev/null || grep -rn "version" build.gradle | head
cat libs/core/src/main/java/org/opensearch/Version.java | grep -n "CURRENT\|V_3" | head
Let <OS_VERSION> below stand for that version (e.g. 3.0.0).
Step-by-Step Tasks
You will build this as a standalone plugin project (the way ecosystem plugins are built), not inside the OpenSearch tree. That keeps the dependency wiring explicit.
Step 1 (5 min) — Project skeleton
mkdir -p my-greeting-plugin/src/main/java/org/example/greeting
mkdir -p my-greeting-plugin/src/main/plugin-metadata
cd my-greeting-plugin
Layout you will create:
my-greeting-plugin/
├── build.gradle
├── settings.gradle
└── src/main/
├── java/org/example/greeting/
│ ├── GreetingPlugin.java
│ ├── GreetingAction.java
│ ├── GreetingRequest.java
│ ├── GreetingResponse.java
│ ├── TransportGreetingAction.java
│ └── RestGreetingAction.java
└── plugin-metadata/
└── plugin-descriptor.properties (generated by the gradle plugin; see Step 7)
Step 2 (8 min) — The ActionType and request/response
GreetingAction.java — the typed key. NAME is the transport action name; keep it namespaced.
package org.example.greeting;
import org.opensearch.action.ActionType;
public class GreetingAction extends ActionType<GreetingResponse> {
public static final GreetingAction INSTANCE = new GreetingAction();
public static final String NAME = "cluster:admin/greeting";
private GreetingAction() {
super(NAME, GreetingResponse::new); // reader for the response off the wire
}
}
GreetingRequest.java — a Writeable + validatable request carrying the name param.
package org.example.greeting;
import org.opensearch.action.ActionRequest;
import org.opensearch.action.ActionRequestValidationException;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;
import java.io.IOException;
import static org.opensearch.action.ValidateActions.addValidationError;
public class GreetingRequest extends ActionRequest {
private final String name;
public GreetingRequest(String name) {
this.name = name;
}
// Read constructor — order MUST mirror writeTo().
public GreetingRequest(StreamInput in) throws IOException {
super(in);
this.name = in.readString();
}
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(name);
}
@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException e = null;
if (name == null || name.isBlank()) {
e = addValidationError("name must not be empty", e);
}
return e;
}
public String getName() {
return name;
}
}
GreetingResponse.java — Writeable (transport) + ToXContentObject (JSON).
package org.example.greeting;
import org.opensearch.core.action.ActionResponse;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;
import org.opensearch.core.xcontent.ToXContentObject;
import org.opensearch.core.xcontent.XContentBuilder;
import java.io.IOException;
public class GreetingResponse extends ActionResponse implements ToXContentObject {
private final String greeting;
private final String nodeName;
public GreetingResponse(String greeting, String nodeName) {
this.greeting = greeting;
this.nodeName = nodeName;
}
public GreetingResponse(StreamInput in) throws IOException {
super(in);
this.greeting = in.readString();
this.nodeName = in.readString();
}
@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(greeting);
out.writeString(nodeName);
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject();
builder.field("greeting", greeting);
builder.field("node", nodeName);
builder.endObject();
return builder;
}
}
Warning:
writeToand theStreamInputconstructor must read/write fields in the same order. This is the single most common plugin bug and the root of countless wire-compat issues. A round-trip test (AbstractWireSerializingTestCase) catches it — see Level 5.Package paths for
StreamInput/StreamOutput/ToXContenthave moved betweenorg.opensearch.common.*andorg.opensearch.core.*across versions. If an import doesn't resolve, run:grep -rn "class StreamInput\|interface ToXContentObject" libs/ server/ | headagainst your branch.
Step 3 (10 min) — The transport action
HandledTransportAction is the base for "run on this node" actions. It registers the transport
handler for you; you implement doExecute.
package org.example.greeting;
import org.opensearch.action.support.ActionFilters;
import org.opensearch.action.support.HandledTransportAction;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.inject.Inject;
import org.opensearch.core.action.ActionListener;
import org.opensearch.tasks.Task;
import org.opensearch.transport.TransportService;
public class TransportGreetingAction extends HandledTransportAction<GreetingRequest, GreetingResponse> {
private final ClusterService clusterService;
@Inject
public TransportGreetingAction(
TransportService transportService,
ActionFilters actionFilters,
ClusterService clusterService
) {
// Registers this action under GreetingAction.NAME with the GENERIC thread pool by default;
// the request reader tells the transport layer how to deserialize incoming GreetingRequests.
super(GreetingAction.NAME, transportService, actionFilters, GreetingRequest::new);
this.clusterService = clusterService;
}
@Override
protected void doExecute(Task task, GreetingRequest request, ActionListener<GreetingResponse> listener) {
try {
String node = clusterService.localNode().getName();
GreetingResponse response = new GreetingResponse("hello, " + request.getName(), node);
listener.onResponse(response);
} catch (Exception e) {
listener.onFailure(e);
}
}
}
Key points:
@Inject— OpenSearch uses a Guice container internally; the constructor's parameters are wired for you.ClusterServicegives youlocalNode(), which proves the action ran inside the engine.doExecutemust be non-blocking and complete theActionListenerexactly once (onResponseoronFailure). Never throw out of it without callingonFailure.
Step 4 (10 min) — The REST handler
package org.example.greeting;
import org.opensearch.client.node.NodeClient;
import org.opensearch.rest.BaseRestHandler;
import org.opensearch.rest.RestRequest;
import org.opensearch.rest.action.RestToXContentListener;
import java.util.List;
import static java.util.Collections.singletonList;
import static org.opensearch.rest.RestRequest.Method.GET;
public class RestGreetingAction extends BaseRestHandler {
@Override
public String getName() {
return "greeting_action";
}
@Override
public List<Route> routes() {
return singletonList(new Route(GET, "/_my/greeting"));
}
@Override
protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) {
String name = request.param("name", "world");
GreetingRequest greetingRequest = new GreetingRequest(name);
// RestToXContentListener serializes the ToXContent response to the channel as JSON.
return channel -> client.execute(
GreetingAction.INSTANCE,
greetingRequest,
new RestToXContentListener<>(channel)
);
}
}
This is exactly the pattern you read in RestClusterHealthAction (Lab 3.1): parse params → build an
ActionRequest → return a consumer that calls client.execute(ActionType, ...). The handler holds
no business logic.
Step 5 (8 min) — The plugin class wiring it together
package org.example.greeting;
import org.opensearch.action.ActionRequest;
import org.opensearch.cluster.metadata.IndexNameExpressionResolver;
import org.opensearch.cluster.node.DiscoveryNodes;
import org.opensearch.common.settings.ClusterSettings;
import org.opensearch.common.settings.IndexScopedSettings;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.settings.SettingsFilter;
import org.opensearch.core.action.ActionResponse;
import org.opensearch.plugins.ActionPlugin;
import org.opensearch.plugins.Plugin;
import org.opensearch.rest.RestController;
import org.opensearch.rest.RestHandler;
import java.util.List;
import java.util.function.Supplier;
import static java.util.Collections.singletonList;
public class GreetingPlugin extends Plugin implements ActionPlugin {
@Override
public List<ActionHandler<? extends ActionRequest, ? extends ActionResponse>> getActions() {
// Bind the ActionType to its TransportAction. This is the plugin-side equivalent
// of ActionModule.register(...) that you read in Lab 3.1.
return singletonList(new ActionHandler<>(GreetingAction.INSTANCE, TransportGreetingAction.class));
}
@Override
public List<RestHandler> getRestHandlers(
Settings settings,
RestController restController,
ClusterSettings clusterSettings,
IndexScopedSettings indexScopedSettings,
SettingsFilter settingsFilter,
IndexNameExpressionResolver indexNameExpressionResolver,
Supplier<DiscoveryNodes> nodesInCluster
) {
return singletonList(new RestGreetingAction());
}
}
Note: The exact signature of
getRestHandlers(...)has gained/lost parameters across versions. If it doesn't match, copy the current signature from an in-tree module:grep -rn "getRestHandlers" modules/*/src/main/java | headand mirror it. The same is true forActionHandler— confirm withfind server/src/main/java -name ActionHandler.java.
Step 6 (10 min) — Gradle build files
settings.gradle:
rootProject.name = 'my-greeting-plugin'
build.gradle:
plugins {
id 'java'
// The OpenSearch-provided plugin: produces the plugin zip + descriptor + assemble tasks.
id 'opensearch.opensearchplugin'
}
opensearchplugin {
name = 'my-greeting-plugin'
description = 'Adds GET /_my/greeting'
classname = 'org.example.greeting.GreetingPlugin'
licenseFile = rootProject.file('LICENSE.txt')
noticeFile = rootProject.file('NOTICE.txt')
}
dependencies {
// Provided by the running OpenSearch node at runtime; do NOT bundle it in the zip.
compileOnly "org.opensearch:opensearch:${opensearch_version}"
testImplementation "org.opensearch.test:framework:${opensearch_version}"
}
// Skip strict precommit gates for a learning plugin (re-enable for a real one).
loggerUsageCheck.enabled = false
validateNebulaPom.enabled = false
Note: The
opensearch.opensearchpluginGradle plugin must be resolvable. Real ecosystem plugins add the OpenSearch build-tools to thepluginManagementblock insettings.gradleand setopensearch_versioningradle.properties. Mirror a known-good project such asopensearch-plugin-template-javafor the exactpluginManagementand version wiring on your target branch — the values drift, and copying a maintained template is the supported path.
The plugin produces a plugin-descriptor.properties at build time from the opensearchplugin {}
block. It looks like this (you do not hand-write it; this is what ends up in the zip):
description=Adds GET /_my/greeting
version=1.0.0
name=my-greeting-plugin
classname=org.example.greeting.GreetingPlugin
java.version=21
opensearch.version=<OS_VERSION>
Step 7 (8 min) — Build, install, run
# Build the plugin zip.
./gradlew assemble
ls build/distributions/ # -> my-greeting-plugin-1.0.0.zip
# Install it into a local OpenSearch distro (built earlier with ./gradlew localDistro in the OS tree).
DISTRO=/path/to/opensearch-<OS_VERSION> # the unpacked localDistro
"$DISTRO/bin/opensearch-plugin" install \
"file://$(pwd)/build/distributions/my-greeting-plugin-1.0.0.zip"
# Confirm it registered:
"$DISTRO/bin/opensearch-plugin" list # -> my-greeting-plugin
# Start the node and curl the endpoint:
"$DISTRO/bin/opensearch" &
sleep 20
curl -s 'localhost:9200/_my/greeting?name=contributor&pretty'
Expected:
{
"greeting" : "hello, contributor",
"node" : "<your-node-name>"
}
The name default and validation work too:
curl -s 'localhost:9200/_my/greeting?pretty' # -> "hello, world"
curl -s 'localhost:9200/_my/greeting?name=&pretty' # -> 400, validation error "name must not be empty"
Note: The
opensearch.versionin your descriptor must exactly match the running distro's version, oropensearch-plugin installrefuses to load the plugin. This strictness is on purpose: plugins run with deep access and must be compiled against the exact engine.
Implementation Requirements
-
GreetingActionis anActionType<GreetingResponse>with a namespacedNAMEand a singleton. -
GreetingRequestimplementsvalidate()and a symmetricWriteable(read order == write order). -
GreetingResponseimplements bothWriteable(transport) andToXContentObject(JSON). -
TransportGreetingAction extends HandledTransportAction, completes the listener exactly once, and reports the local node name fromClusterService. -
RestGreetingAction extends BaseRestHandler, declares its route inroutes(), and contains no business logic. -
GreetingPlugin implements ActionPluginand wires bothgetActions()andgetRestHandlers(...). -
The plugin builds, installs via
bin/opensearch-plugin install, and the curl returns the expected JSON including the node name.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
plugin [...] requires opensearch version [X] but ... on install | Descriptor opensearch.version ≠ distro version | Set opensearch_version to the distro's exact version |
ClassNotFoundException for GreetingPlugin at startup | Wrong classname in opensearchplugin {} | Match the fully-qualified class name exactly |
404 on /_my/greeting | Handler not registered / route mismatch | Check getRestHandlers returns it and routes() path is exact |
| 500 with serialization error | writeTo / read constructor field order mismatch | Make read order mirror write order; add a round-trip test |
Plugin already exists on install | Reinstalling | bin/opensearch-plugin remove my-greeting-plugin first |
Import doesn't resolve (StreamInput, ToXContent) | Package moved between common/core on your branch | grep -rn "class StreamInput" libs/ server/ and fix the import |
Expected Output
A clean install + a successful curl:
$ bin/opensearch-plugin list
my-greeting-plugin
$ curl -s 'localhost:9200/_my/greeting?name=contributor'
{"greeting":"hello, contributor","node":"node-1"}
Stretch Goals
- Return a node count instead of a greeting. Inject
ClusterService, readclusterService.state().nodes().getSize(), and return it. You'll touchClusterState— a direct bridge to Level 4. - Add a transport handler test. Use
OpenSearchSingleNodeTestCaseto load your plugin (getPlugins()), callclient().execute(GreetingAction.INSTANCE, req).get(), and assert the response. Wire-test the request/response withAbstractWireSerializingTestCase. Full treatment in Lab 4.3 and Level 5. - Route it to the cluster manager. Make a variant whose transport action extends
TransportClusterManagerNodeActionand reports which node is the elected manager. Compare how the request now hops when you curl a non-manager node (cross-reference Lab 3.2).
Validation / Self-check
- Where does the binding from
GreetingAction.INSTANCEtoTransportGreetingActionhappen in your plugin, and what is the core-engine equivalent you read in Lab 3.1? - Why does
GreetingResponseimplement bothWriteableandToXContentObject— what is each one for, and on which port/protocol is each used? - Your
RestGreetingActioncontains no business logic. Where is the logic, and why is that separation enforced by the framework rather than just by convention? - What two things must be true for
bin/opensearch-plugin installto accept your zip? doExecutemust complete theActionListenerexactly once. What goes wrong if you (a) complete it twice, or (b) throw without callingonFailure?- If you reorder two fields in
writeTobut not in the read constructor, what fails, when, and which kind of test would have caught it before merge?
When the curl returns your greeting with the answering node's name, and you can answer all six questions, you've completed Lab 3.3 — and Level 3. Continue to Level 4: Cluster Coordination and State.