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:
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.
- TypeScript
- Python
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.
The Python client library needs Python 3.11 or later, and has a blocking NanopingClient and an asyncio
AsyncNanopingClient with the same methods. Requests and responses are dataclasses, and the fields of a
request are passed as keyword arguments. The guide uses the asyncio client, so it can follow streams while it
makes calls.
pip install nanoping-api
from nanoping_api import AsyncNanopingClient, NanopingClient
client = NanopingClient("http://127.0.0.1:10565")
async_client = AsyncNanopingClient("http://127.0.0.1:10565")
A failed call raises an ApiError, whose code says why, such as Code.NOT_FOUND.
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.
- TypeScript
- Python
// 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}"`);
}
async def set_node_information(client: AsyncNanopingClient, out: Output) -> None:
"""Names the client node and gives it metadata that other nodes on the hub can
filter on."""
local = await client.hub_client.get_local_node_id()
response = await client.hub_client.set_node(
node_id=local.node_id,
name="My NanoPing Client",
metadata=NodeMetadata(
items={
"type": NodeMetadataItem(string_value="my-type"),
"location": NodeMetadataItem(string_value="Denmark, Aalborg"),
}
),
)
out(f'Named node {response.node.id} "{response.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.
- TypeScript
- Python
// 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(() => {});
}
async def watch_connection_state(client: AsyncNanopingClient, out: Output) -> None:
"""Prints the connection state of the client node every time it changes, until
the task is cancelled."""
async for update in client.hub_client.stream_connection_state():
out(f"Connection state: {update.state}")
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:
- TypeScript
- Python
// 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;
}
}
async def join_the_hub(client: AsyncNanopingClient, server_http_address: str, out: Output) -> None:
"""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."""
try:
await client.join_hub(server_http_address)
out("Joined the hub")
except ApiError as error:
if error.code == Code.ALREADY_EXISTS:
out("Already part of the hub")
return
if error.code == Code.UNAUTHENTICATED:
raise RuntimeError("The hub server rejected the node") from error
raise
On the hub server node, the request is approved or rejected in the dashboard, with
np authentication-requests, or through the API:
- TypeScript
- Python
// 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 });
}
async def accept_join_requests(server: AsyncNanopingClient) -> None:
"""Accepts every node that asks to join the hub server, until the task is
cancelled."""
await server.answer_join_requests(lambda request: True)
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:
- TypeScript
- Python
// 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}`);
}
}
async def print_nodes(client: AsyncNanopingClient, out: Output) -> None:
"""Prints the nodes on the hub: all of them, the ones with metadata "type" set
to "my-type", and the ones running pipelines."""
everything = await client.hub_server.get_nodes()
out("All nodes:")
for node in everything.nodes:
out(f" {node.name}")
my_type = await client.hub_server.get_nodes(
metadata_filters={"type": NodeMetadataItem(string_value="my-type")},
)
out("Nodes with type my-type:")
for node in my_type.nodes:
out(f" {node.name}")
out("Nodes running pipelines:")
for node in await pipeline_nodes(client):
out(f" {node.name}")
- TypeScript
- Python
// 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 ?? [];
}
async def pipeline_nodes(client: AsyncNanopingClient) -> list[HubNode]:
"""Returns the nodes on the hub that run pipelines."""
response = await client.hub_server.get_nodes(service_filters=[NodeService.PIPELINES])
return response.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.
- TypeScript
- Python
// 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(() => {});
}
async def watch_pipelines(
client: AsyncNanopingClient, node: HubNode
) -> AsyncServerStream[StreamPipelinesEvent]:
"""Opens a stream of the changes to the pipelines on the node. It returns once
the stream is ready, so no change made after it returns is missed."""
return await client.pipelines.stream_pipelines(node_id=node.id)
async def print_pipeline_events(
events: AsyncServerStream[StreamPipelinesEvent], node: HubNode, out: Output
) -> None:
"""Prints an event every time a pipeline on the node is created, updated or
deleted, until the task is cancelled."""
async for event in events:
if event.create:
out(f' Event on {node.name}: created "{event.create.pipeline.name}"')
elif event.update:
out(f' Event on {node.name}: updated "{event.update.pipeline.name}"')
elif event.delete:
out(f' Event on {node.name}: deleted "{event.delete.pipeline.name}"')
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.
- TypeScript
- Python
// 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}"`);
}
async def manage_pipeline(client: AsyncNanopingClient, node_id: str, out: Output) -> None:
"""Creates a pipeline on the node, renames it, reads it back and deletes it
again."""
created = await client.pipelines.create_pipeline(
node_id=node_id,
name="My First Pipeline",
json_config=PIPELINE_CONFIG,
restart_policy=RestartPolicy(on_failure=RestartPolicyOnFailure(max_restarts=3)),
default_logging_level=Level.DEBUG,
)
pipeline = created.pipeline
out(f' Created "{pipeline.name}"')
# An update replaces every setting, so send the current ones along with the
# new name.
updated = await client.pipelines.update_pipeline(
node_id=node_id,
id=pipeline.id,
name="My Renamed Pipeline",
json_config=pipeline.json_config,
restart_policy=pipeline.restart_policy,
startup_policy=pipeline.startup_policy,
default_logging_level=pipeline.default_logging_level,
instructions_timeout=pipeline.instructions_timeout,
)
out(f' Renamed to "{updated.pipeline.name}"')
fetched = await client.pipelines.get_pipeline_by_id(node_id=node_id, id=pipeline.id)
out(f' Read back "{fetched.pipeline.name}"')
await client.pipelines.delete_pipeline(node_id=node_id, id=pipeline.id)
out(f' Deleted "{fetched.pipeline.name}"')
The pipeline configuration is a JSON string:
- TypeScript
- Python
// 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
}
}
}
}
}`;
PIPELINE_CONFIG = """{
"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:
- TypeScript
- Python
// 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();
}
}
async def run(addresses: Addresses, out: Output) -> None:
"""Connects to both nodes and runs every step of the guide."""
async with (
AsyncNanopingClient(addresses.client) as client,
AsyncNanopingClient(addresses.server) as server,
):
background: list[asyncio.Task[None]] = []
try:
background.append(asyncio.create_task(watch_connection_state(client, out)))
await set_node_information(client, out)
background.append(asyncio.create_task(accept_join_requests(server)))
await join_the_hub(client, addresses.server_http, out)
await print_nodes(client, out)
for node in await pipeline_nodes(client):
out(f"Managing a pipeline on {node.name}")
events = await watch_pipelines(client, node)
background.append(asyncio.create_task(print_pipeline_events(events, node, out)))
await manage_pipeline(client, node.id, out)
finally:
for task in background:
task.cancel()
await asyncio.gather(*background, return_exceptions=True)
The main program runs it with the addresses of the two nodes, with node main.ts or python main.py:
- TypeScript
- Python
// 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);
}
# 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 `python main.py`.
import argparse
import asyncio
import sys
from guide import Addresses, run
parser = argparse.ArgumentParser()
parser.add_argument("--client", default="http://127.0.0.1:10565")
parser.add_argument("--server", default="http://127.0.0.1:10566")
parser.add_argument("--server-http", default="http://127.0.0.1:8769")
arguments = parser.parse_args()
try:
asyncio.run(run(Addresses(arguments.client, arguments.server, arguments.server_http), print))
except Exception as error:
print(error, file=sys.stderr)
sys.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}