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).
Coding Exercises
You built the greeting plugin end to end. Now extend it — each exercise adds a real capability and a test, so the plugin grows into a small but complete feature. These build on (and go beyond) the Stretch Goals: where the stretch goal says "add a test," here you write the assertions.
-
(warm-up) Add a typed query param with a default. Extend
RestGreetingAction.prepareRequestto read?repeat=<n>viarequest.paramAsInt("repeat", 1)and a?shout=<bool>viarequest.paramAsBoolean("shout", false), carry both intoGreetingRequest, and haveTransportGreetingActionrepeat the greetingrepeattimes (uppercased ifshout). Update the request'swriteTo/read constructor symmetrically for the two new fields. Verify withcurl 'localhost:9200/_my/greeting?name=x&repeat=2&shout=true'. -
(warm-up) Validate the new param. In
GreetingRequest.validate(), add a check thatrepeatis between 1 and 10 (useaddValidationError). Curl?repeat=0and?repeat=99and confirm both return a 400 with your message. Validation is where bad input dies before it reachesdoExecute. -
(core) Add a response field proving the answering node's role. Inject
ClusterService(already present) and add anode_is_cluster_managerboolean toGreetingResponse—clusterService.state().nodes().isLocalNodeElectedClusterManager()(confirm the accessor:rg -n "isLocalNodeElectedClusterManager|getClusterManagerNode" server/src/main/java/org/opensearch/cluster/node/DiscoveryNodes.java). Extend bothwriteTo/read constructor (transport) andtoXContent(JSON) for the new field. This is the exact "Writeable + ToXContent must agree" discipline from Step 2. -
(core) A wire round-trip test for your request/response. Mirror an
AbstractWireSerializingTestCase(rg -l "extends AbstractWireSerializingTestCase" $(find / -path "*test/java*" -name "*.java" 2>/dev/null | head -1 | xargs dirname 2>/dev/null) 2>/dev/null— or copy from the OpenSearch tree) and writeGreetingResponseTeststhat round-trips a randomGreetingResponse(now withnode_is_cluster_manager) throughStreamOutput→StreamInputand asserts equality. Do the same forGreetingRequestwith all its fields. Run./gradlew test --tests "*GreetingResponseTests*". This is the test that catches the field-order bug the Step 2 Warning describes. -
(core) An end-to-end node test. Write a
GreetingPluginIT extends OpenSearchSingleNodeTestCasethat returns your plugin fromgetPlugins(), callsclient().execute(GreetingAction.INSTANCE, new GreetingRequest("contributor")).get(), and asserts the response greeting and thatnodeis non-empty. Add a negative case: an emptynamemakesclient().execute(...).get()throw anActionRequestValidationException(assert it does). This proves the action runs inside a real node, not just the REST layer. -
(advanced challenge) A cluster-manager-routed variant + a multi-node integration test. Following Stretch Goal 3, write
TransportClusterGreetingAction extends TransportClusterManagerNodeAction(find the base + its abstract methods:rg -n "class TransportClusterManagerNodeAction|clusterManagerOperation|masterOperation" server/src/main/java/org/opensearch/action/support/clustermanager/TransportClusterManagerNodeAction.java) whose operation reports the elected manager's node name. Register it under a new endpointGET /_my/manager. Then write anOpenSearchIntegTestCasewith@ClusterScope(numDataNodes = 2, numClusterManagerNodes = 1)that curls/executes against a non-manager node and asserts the response still names the elected manager — proving the base class re-routed the request over transport (cross-reference Lab 3.2's hop). This is a genuine mini-feature with distributed semantics and a real test.
Issues to Practice On
Plugins and the action framework are where most ecosystem work happens, and "add a param/field/endpoint"
issues are abundant and mergeable. Core action issues live in opensearch-project/OpenSearch; many
ecosystem plugins have their own repos under opensearch-project/*.
# Core action/plugin + first-timer issues (labels move; confirm on the tracker):
gh issue list --repo opensearch-project/OpenSearch --label "Plugins" --state open
gh issue list --repo opensearch-project/OpenSearch --label "enhancement" --search "REST endpoint" --state open
gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open
gh label list --repo opensearch-project/OpenSearch | rg -i "plugin|action|rest|api"
# Want a real plugin repo to contribute to? Pick one and list its good-first-issues:
gh issue list --repo opensearch-project/sql --label "good first issue" --state open
Representative patterns:
- "Add a query parameter / response field to endpoint X." This is literally exercises 1–3.
Reproduce the current response, find the handler + request/response classes via
rg, add the field on both the transport and XContent sides, add a round-trip + REST test, PR with a CHANGELOG entry. - "Endpoint returns wrong content / missing field on some path." Reproduce with
curl, find thetoXContentthat omits it, fix, and pin with aToXContentassertion.
Planted-bug drill. In GreetingResponse, reorder the two out.writeString(...) calls in
writeTo without reordering the reads in the StreamInput constructor. Run your exercise-4
round-trip test and watch it fail (greeting and node swap, or a length error). Then — the instructive
part — run only the REST curl on a single node: it still "works", because a same-node action may
skip the wire. This is why a serialization round-trip test catches what manual curling cannot. Revert
and keep the test.
Etiquette: claim an issue before working it, reproduce first, and every PR needs a test +
CHANGELOG.mdentry + DCOSigned-off-by(git commit -s). See community interaction and the Level 2 PR lab.
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.