Skip to main content

Edges

The relationships between resources, as objects in their own right. An edge is directional (fromto), typed by a relationship type, and unique per pair and type — two resources can be connected many ways, but only once each way.

Most edges are born with their nodes: POST /resources/create takes nodes and relations together and writes them in one transaction. The /edges endpoints are for everything after that — linking resources that already exist, reading an edge back, cutting one without touching its endpoints, and managing the relationship-type catalog. Create resources and relations →

The edge object

FieldTypeNotes
idnumberServer-assigned. Crosses the wire as a JSON string.
startnumberNumeric id of the from node.
endnumberNumeric id of the to node.
typestringThe relationship type name, upper-cased (CONTAINS, FLOWS_TO).
relationshipTypeIdnumberThe type's id in the catalog. JSON string on the wire.
descriptionstringProse.
metadatamap<string, string>Flat key/value.

You write fromExternalId/toExternalId and read start/end: the write side speaks in your identifiers, the read side in the graph's.

Create

POST /edges/create links resources that already exist. Both ends are resolved before anything is written; an end that isn't there is an error, not an implicit create.

POST /edges/create
{
"items": [
{
"fromExternalId": "plant_oslo",
"toExternalId": "pump_1",
"relationshipType": "contains",
"description": "Feeds the east wing",
"metadata": { "work_order": "wo-sap-12344" }
}
]
}

Name each end by external id (fromExternalId/toExternalId) or by numeric id (fromId/toId), and the relation by relationshipType or relationshipTypeId. A type name you haven't used before is created for you, so pre-registering types is only for seeding the catalog or attaching a description.

The batch is all-or-nothing — one relation the server won't take and none of them are written. Success is a 201 with the created edges under items.

RelForm contains = new RelForm();
contains.setFromExternalId("plant_oslo");
contains.setToExternalId("pump_1");
contains.setRelationshipType("CONTAINS");

DataWrapper<EdgeProxy> created = client.edges().create(List.of(contains));
System.out.println(created.getItems().iterator().next().getId());

setName("Flows To") is the alternative to setRelationshipType: it normalises the name to FLOWS_TO before it leaves the client. See naming.

When it fails

StatusMeans
400An end doesn't exist (the message names which), the relation has no type, or it breaks one of the graph rules below.
403You can't write one of the two resources. Both ends are checked, so linking something into a data set needs write access on that data set too.
409The two are already connected that way. (start, end, type) is unique — one relation per pair per type.
Edges into datasets and time-series are validated

The same two rules the graph create enforces apply here:

  • A relation to a dataset must use the BELONGS_TO relationship type — that is the relation dataset membership is built from, and anything else is a 400.
  • A dataset → time-series edge is accepted only when the series has no dataset yet, or already belongs to that very dataset. A series in a different dataset is a 400: a time-series has one dataset.

Look up

GET /edges/{id} returns a single edge; an id that doesn't exist is a 404. Older backends answered 200 with an empty items[] here, so code that has to work against both should check the count rather than the status.

POST /edges/byids takes several ids and answers with a graph: the edges under relations and the resources at both ends under nodes, so you don't need a follow-up call to resolve endpoints. Unlike the single lookup, ids that match nothing are silently omitted — compare what comes back against what you asked for.

DataWrapper<EdgeProxy> one = client.edges().findById(341);

GraphDataWrapper<Resource, EdgeProxy> many = client.edges()
.byIds(List.of(IdCollection.createFromId(341), IdCollection.createFromId(342)));

for (Resource endpoint : many.getNodes()) {
System.out.println(endpoint.getExternalId());
}

Delete

POST /edges/delete (or DELETE, the endpoint takes both) removes relationships by id and answers 204 with no body. The resources at each end are untouched — this is how you disconnect two things without losing either. Deleting a resource is the heavier move: it takes every relation the resource had with it.

Deletion is idempotent: unknown ids are silently skipped, so a successful call is not evidence the edge existed.

client.edges().delete(List.of(IdCollection.createFromId(341)));

Relationship types

Every edge carries a type, and the types are a per-tenant catalog: GET /edges/types lists them, POST /edges/types/create registers names up front. Registering is optional — a type is created the first time an edge uses its name — so reach for it when you want the catalog seeded before anyone writes, or a description/i18nCode attached to a type.

A type is { id, name, description, i18nCode }.

DataWrapper<RelationshipType> catalog = client.edges().types();

RelTypeForm form = new RelTypeForm();
form.setName("Flows To"); // normalised to FLOWS_TO by the form, client-side
client.edges().createTypes(List.of(form));

What the server does to a name

The two write paths do not normalise the same way, which is worth knowing before you create a type by accident.

POST /edges/types/create snake-upper-cases the name as it reads the request, so Flows To, flows to and FLOWS_TO all land on the one type FLOWS_TO.

POST /edges/create only upper-cases the relationshipType you give it. No underscores are inserted, so an edge created with "relationshipType": "Flows To" gets the type FLOWS TO — a different type from FLOWS_TO, silently created on the spot. Lookup is on the normalised name, so flows_to and FLOWS_TO are the same type either way.

The practical rule: write the type name the way you want it stored, FLOWS_TO, and the two paths agree. In Java, RelForm.setName("Flows To") snake-upper-cases client-side and lines the create path up with the catalog; setRelationshipType passes the string through. Python and Rust send what you give them.

A name with no letter or digit in it (blank, or symbols only) is a 400, and names registered through types/create are capped at 128 characters.

A type name that already exists fails silently

POST /edges/types/create has no find-or-create: it saves a fresh type unconditionally, so a duplicate name collides on the unique name hash at commit time — after the handler has already returned. The caller gets a 200 with an empty body, not the "existing ones returned unchanged" the endpoint advertises.

In a batch it is worse: every form is saved in one transaction, so a single duplicate rolls back the valid new types alongside it and the response still says 200. Treat a 200 with no items as "something in this batch already existed and nothing was created", and read GET /edges/types for the real state. Creating an edge with an unknown type name does not have this problem — that path is a proper find-or-create.

What each client covers

OperationJavaPythonRust
Create relationsedges().createedges.createedges.create
Get by idedges().findByIdedges.getedges.get
Look up several, with endpointsedges().byIdsedges.by_idsedges.by_ids
Deleteedges().deleteedges.deleteedges.delete
List typesedges().typesedges.typesedges.types
Create typesedges().createTypesedges.create_typesedges.create_types

All three clients cover the whole endpoint surface; what differs is how much wrapping survives. Java and Rust hand back the DataWrapper/GraphDataWrapper the API returns, so the items come out of getItems() / get_items(). Python unwraps: create and types return plain lists, get returns an EdgeProxy or None, and delete returns nothing. Python's async client exposes the same six methods on AsyncDataHubClient.edges.

The MCP server covers all of these except byids, as edge_create, edge_get, edge_delete, edge_list_types and edge_create_type.