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.javaWriteable (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: writeTo and the StreamInput constructor 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/ToXContent have moved between org.opensearch.common.* and org.opensearch.core.* across versions. If an import doesn't resolve, run: grep -rn "class StreamInput\|interface ToXContentObject" libs/ server/ | head against 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. ClusterService gives you localNode(), which proves the action ran inside the engine.
  • doExecute must be non-blocking and complete the ActionListener exactly once (onResponse or onFailure). Never throw out of it without calling onFailure.

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 | head and mirror it. The same is true for ActionHandler — confirm with find 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.opensearchplugin Gradle plugin must be resolvable. Real ecosystem plugins add the OpenSearch build-tools to the pluginManagement block in settings.gradle and set opensearch_version in gradle.properties. Mirror a known-good project such as opensearch-plugin-template-java for the exact pluginManagement and 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.version in your descriptor must exactly match the running distro's version, or opensearch-plugin install refuses to load the plugin. This strictness is on purpose: plugins run with deep access and must be compiled against the exact engine.


Implementation Requirements

  • GreetingAction is an ActionType<GreetingResponse> with a namespaced NAME and a singleton.
  • GreetingRequest implements validate() and a symmetric Writeable (read order == write order).
  • GreetingResponse implements both Writeable (transport) and ToXContentObject (JSON).
  • TransportGreetingAction extends HandledTransportAction, completes the listener exactly once, and reports the local node name from ClusterService.
  • RestGreetingAction extends BaseRestHandler, declares its route in routes(), and contains no business logic.
  • GreetingPlugin implements ActionPlugin and wires both getActions() and getRestHandlers(...).
  • The plugin builds, installs via bin/opensearch-plugin install, and the curl returns the expected JSON including the node name.

Troubleshooting

SymptomLikely causeFix
plugin [...] requires opensearch version [X] but ... on installDescriptor opensearch.version ≠ distro versionSet opensearch_version to the distro's exact version
ClassNotFoundException for GreetingPlugin at startupWrong classname in opensearchplugin {}Match the fully-qualified class name exactly
404 on /_my/greetingHandler not registered / route mismatchCheck getRestHandlers returns it and routes() path is exact
500 with serialization errorwriteTo / read constructor field order mismatchMake read order mirror write order; add a round-trip test
Plugin already exists on installReinstallingbin/opensearch-plugin remove my-greeting-plugin first
Import doesn't resolve (StreamInput, ToXContent)Package moved between common/core on your branchgrep -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

  1. Return a node count instead of a greeting. Inject ClusterService, read clusterService.state().nodes().getSize(), and return it. You'll touch ClusterState — a direct bridge to Level 4.
  2. Add a transport handler test. Use OpenSearchSingleNodeTestCase to load your plugin (getPlugins()), call client().execute(GreetingAction.INSTANCE, req).get(), and assert the response. Wire-test the request/response with AbstractWireSerializingTestCase. Full treatment in Lab 4.3 and Level 5.
  3. Route it to the cluster manager. Make a variant whose transport action extends TransportClusterManagerNodeAction and 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

  1. Where does the binding from GreetingAction.INSTANCE to TransportGreetingAction happen in your plugin, and what is the core-engine equivalent you read in Lab 3.1?
  2. Why does GreetingResponse implement both Writeable and ToXContentObject — what is each one for, and on which port/protocol is each used?
  3. Your RestGreetingAction contains no business logic. Where is the logic, and why is that separation enforced by the framework rather than just by convention?
  4. What two things must be true for bin/opensearch-plugin install to accept your zip?
  5. doExecute must complete the ActionListener exactly once. What goes wrong if you (a) complete it twice, or (b) throw without calling onFailure?
  6. If you reorder two fields in writeTo but 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.