Skip to main content

HTTP REST

Every node can serve a REST API to manage itself, the hub it is part of and the pipelines of the nodes on that hub, with plain HTTP requests and JSON. Live updates, such as pipeline changes or logs, are sent as server-sent events. The REST API offers the same functionality as the gRPC API, and every route is described in the API Reference.

This guide walks through the most common tasks in TypeScript and Python, with their client libraries. The code in it is the example program in examples of the TypeScript and Python client libraries, and both are run against real nodes by the test suite, so they work as shown. Any other HTTP client works too: see Without the client library.

Start the nodes​

The guide uses two nodes: a client node that joins a hub, and the node that hosts the hub server. Both serve the REST API and run pipelines.

Start the client node with the --rest-api flag:

np up --rest-api 127.0.0.1:10565

Start the hub server node with a config file that enables the REST API with the restApi section:

config.yaml
node:
name: My Server
http:
address: 127.0.0.1:8769
hubServer:
authenticationByRequest:
timeout: 5m
restApi:
address: 127.0.0.1:10566
pipelines: {}
np -c config.yaml up

Connect​

The client libraries are generated from the API, so every method in the reference is available on them with typed requests and responses. Their version is the NanoPing release they were made from.

The TypeScript client library has no dependencies, and runs in Node.js 22 or later and in the browser.

npm install @nanoping/api
import { NanopingClient } from "@nanoping/api";

const client = new NanopingClient({ baseUrl: "http://127.0.0.1:10565" });

A failed call throws an ApiError, whose code says why, such as Code.NotFound.

Name the node​

A node is named after the machine it runs on. Give it a name, and metadata that other nodes on the hub can filter on. A node started with a config file keeps the name and metadata of that file, and the call fails with a permission denied error.

// Names the client node and gives it metadata that other nodes on the hub can
// filter on.
export async function setNodeInformation(client: NanopingClient, out: Output) {
const { nodeId } = await client.hubClient.getLocalNodeId();

const { node } = await client.hubClient.setNode({
nodeId,
name: "My NanoPing Client",
metadata: {
items: {
type: { stringValue: "my-type" },
location: { stringValue: "Denmark, Aalborg" },
},
},
});

out(`Named node ${node?.id} "${node?.name}"`);
}

Watch the connection to the hub​

The connection state is sent when the stream opens and every time it changes. A stream is read with for await in TypeScript, where aborting its signal closes it, and with async for in Python, where cancelling the task closes it.

// Prints the connection state of the client node every time it changes, until
// the signal aborts.
export function watchConnectionState(client: NanopingClient, out: Output, signal: AbortSignal) {
const states = client.hubClient.streamConnectionState({}, { signal });
(async () => {
for await (const { state } of states) {
out(`Connection state: ${state}`);
}
})().catch(() => {});
}

Join the hub​

Joining a hub takes two sides. The client node asks to join, and waits while the request is shown on the hub server:

// Joins the client node to the hub server. The call waits until the hub server
// accepted or rejected the request. A node that already is part of the hub
// stays so.
export async function joinTheHub(client: NanopingClient, serverHttpAddress: string, out: Output) {
try {
await joinHub(client, serverHttpAddress);
out("Joined the hub");
} catch (error) {
if (error instanceof ApiError && error.code === Code.AlreadyExists) {
out("Already part of the hub");
return;
}
if (error instanceof ApiError && error.code === Code.Unauthenticated) {
throw new Error("The hub server rejected the node");
}
throw error;
}
}

On the hub server node, the request is approved or rejected in the dashboard, with np authentication-requests, or through the API:

// Accepts every node that asks to join the hub server, until the signal
// aborts.
export async function acceptJoinRequests(server: NanopingClient, signal: AbortSignal) {
await answerJoinRequests(server, () => true, { signal });
}

Once joined, the node stays part of the hub after a restart, and asking again fails with an already exists error.

Find nodes on the hub​

The hub server lists the nodes on the hub, optionally filtered by their metadata or by the services they run:

// Prints the nodes on the hub: all of them, the ones with metadata "type" set
// to "my-type", and the ones running pipelines.
export async function printNodes(client: NanopingClient, out: Output) {
const all = await client.hubServer.getNodes();
out("All nodes:");
for (const node of all.nodes ?? []) {
out(` ${node.name}`);
}

const myType = await client.hubServer.getNodes({
metadataFilters: { type: { stringValue: "my-type" } },
});
out("Nodes with type my-type:");
for (const node of myType.nodes ?? []) {
out(` ${node.name}`);
}

out("Nodes running pipelines:");
for (const node of await pipelineNodes(client)) {
out(` ${node.name}`);
}
}
// Returns the nodes on the hub that run pipelines.
export async function pipelineNodes(client: NanopingClient): Promise<HubNode[]> {
const { nodes } = await client.hubServer.getNodes({ serviceFilters: ["PIPELINES"] });
return nodes ?? [];
}

Manage pipelines​

Every pipeline call names the node it acts on, so one client manages the pipelines of every node on the hub. Watch the node's pipelines first, and wait until the stream is ready: every change made after that is reported. In TypeScript the stream opens right away and its ready promise resolves then, and in Python awaiting the stream returns then.

// Prints an event every time a pipeline on the node is created, updated or
// deleted, until the signal aborts. It returns once the stream is ready, so no
// change made after it returns is missed.
export async function watchPipelines(client: NanopingClient, node: HubNode, out: Output, signal: AbortSignal) {
const events = client.pipelines.streamPipelines({ nodeId: node.id }, { signal });
await events.ready;

(async () => {
for await (const event of events) {
if (event.create) {
out(` Event on ${node.name}: created "${event.create.pipeline?.name}"`);
} else if (event.update) {
out(` Event on ${node.name}: updated "${event.update.pipeline?.name}"`);
} else if (event.delete) {
out(` Event on ${node.name}: deleted "${event.delete.pipeline?.name}"`);
}
}
})().catch(() => {});
}

Then create a pipeline, rename it, read it back and delete it. An update replaces every setting, so it sends the current ones along with the change.

// Creates a pipeline on the node, renames it, reads it back and deletes it
// again.
export async function managePipeline(client: NanopingClient, nodeId: string, out: Output) {
const { pipeline } = await client.pipelines.createPipeline({
nodeId,
name: "My First Pipeline",
jsonConfig: pipelineConfig,
restartPolicy: { onFailure: { maxRestarts: 3 } },
defaultLoggingLevel: "DEBUG",
});
out(` Created "${pipeline?.name}"`);

// An update replaces every setting, so send the current ones along with the
// new name.
const updated = await client.pipelines.updatePipeline({
nodeId,
id: pipeline?.id,
name: "My Renamed Pipeline",
jsonConfig: pipeline?.jsonConfig,
restartPolicy: pipeline?.restartPolicy,
startupPolicy: pipeline?.startupPolicy,
defaultLoggingLevel: pipeline?.defaultLoggingLevel,
instructionsTimeout: pipeline?.instructionsTimeout,
});
out(` Renamed to "${updated.pipeline?.name}"`);

const fetched = await client.pipelines.getPipelineById({ nodeId, id: pipeline?.id });
out(` Read back "${fetched.pipeline?.name}"`);

await client.pipelines.deletePipeline({ nodeId, id: pipeline?.id });
out(` Deleted "${fetched.pipeline?.name}"`);
}

The pipeline configuration is a JSON string:

// A pipeline that sends traffic from a traffic source to a traffic sink on the
// same node.
export const pipelineConfig = `{
"version": 13,
"plumr_config": {
"pipeline": {
"traffic_sink-1": {
"traffic_sink": {
"input": "[traffic_sink-1|in:0]-[uniform_traffic_source-1|out:0]",
"mtu": 1500
}
},
"uniform_traffic_source-1": {
"uniform_traffic_source": {
"output": "[traffic_sink-1|in:0]-[uniform_traffic_source-1|out:0]",
"total_packets": 5000,
"interval": 25
}
}
}
}
}`;

Putting it all together​

run connects to both nodes and runs every step:

// Connects to both nodes and runs every step of the guide.
export async function run(addresses: Addresses, out: Output) {
const client = new NanopingClient({ baseUrl: addresses.client });
const server = new NanopingClient({ baseUrl: addresses.server });

const stop = new AbortController();
try {
watchConnectionState(client, out, stop.signal);
await setNodeInformation(client, out);

acceptJoinRequests(server, stop.signal).catch(() => {});
await joinTheHub(client, addresses.serverHttp, out);

await printNodes(client, out);

for (const node of await pipelineNodes(client)) {
out(`Managing a pipeline on ${node.name}`);
await watchPipelines(client, node, out, stop.signal);
await managePipeline(client, node.id!, out);
}
} finally {
stop.abort();
}
}

The main program runs it with the addresses of the two nodes, with node main.ts or python main.py:

// Runs the HTTP REST guide of the NanoPing docs against two nodes.
//
// Start the client node with
//
// np up --rest-api 127.0.0.1:10565
//
// and the hub server node with `np -c config.yaml up`, where config.yaml is
//
// node:
// name: My Server
// http:
// address: 127.0.0.1:8769
// hubServer:
// authenticationByRequest:
// timeout: 5m
// restApi:
// address: 127.0.0.1:10566
// pipelines: {}
//
// Then run `node main.ts`.
import { parseArgs } from "node:util";
import { run } from "./guide.ts";

const { values } = parseArgs({
options: {
client: { type: "string", default: "http://127.0.0.1:10565" },
server: { type: "string", default: "http://127.0.0.1:10566" },
"server-http": { type: "string", default: "http://127.0.0.1:8769" },
},
});

try {
await run({ client: values.client, server: values.server, serverHttp: values["server-http"] }, console.log);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}

Running it prints something like:

Connection state: UNAUTHENTICATED
Named node 18e2691c-e552-4512-8e68-ad09cada9476 "My NanoPing Client"
Connection state: CONNECTED
Joined the hub
All nodes:
My Server
My NanoPing Client
Nodes with type my-type:
My NanoPing Client
Nodes running pipelines:
My Server
My NanoPing Client
Managing a pipeline on My Server
Event on My Server: created "My First Pipeline"
Created "My First Pipeline"
Event on My Server: updated "My Renamed Pipeline"
Renamed to "My Renamed Pipeline"
Read back "My Renamed Pipeline"
Deleted "My Renamed Pipeline"
Managing a pipeline on My NanoPing Client
...

Without the client library​

Requests and responses​

Fields named in the path are taken from the path. The remaining fields of the request message are taken from the query string for GET and DELETE, and from the JSON body for POST and PUT. Field names are in lowerCamelCase, and 64 bit integers are strings. A body is sent with Content-Type: application/json.

The REST API has no authentication, so it refuses requests from web pages on another origin, and bodies of any other content type, which a web page could send without the browser asking the API first.

curl -X POST http://127.0.0.1:10565/v1/blueprints \
-H 'Content-Type: application/json' \
-d '{"name": "My Blueprint", "template": "..."}'

curl http://127.0.0.1:10565/v1/nodes/<node id>/pipelines

A failed request returns an HTTP error status with a body like:

{"code": 5, "message": "not found", "details": []}

Streams​

Routes under /v1/streams/ are served as server-sent events. When the stream is ready, a : ready comment is sent, and any change made after it is included in the stream. Each message is sent as a data: line holding the JSON message. If the stream fails after it is ready, an error event holding the status is sent and the response ends. A stream that fails before it is ready returns a JSON error response, as any other call.

curl -N http://127.0.0.1:10565/v1/streams/networks
: ready

data: {"create":{"network":{...}}}

Authentication by request​

/v1/streams/hub-server/authentication-requests sends and receives messages in both directions and is served over a WebSocket. Every WebSocket message from the server is a JSON object holding either a result (an AuthenticationByRequestRequest) or an error. Answer a request by sending an AuthenticationByRequestAnswer:

{"requestId": "1", "accept": true}